mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
feat(shadow): round-trip plugin binaries for BRAT / non-marketplace (#429b)
Phase B/2. The enabled-plugins LIST round-trips (#434) and the marketplace installer fetches binaries for registry plugins (#439, Phase B/1) — but a BRAT / sideloaded plugin isn't on the marketplace, so its code never reached another machine. ShadowVaultBootstrap.pull/pushPluginBinaries round-trip the plugin *code* (manifest.json / main.js / styles.css; never data.json) through the remote vault's canonical `.obsidian/plugins/<id>/`. Wired into runAutoConnect AFTER the marketplace installer so the plugins it fetched live aren't re-pulled (pull only stages what's still missing = the non-marketplace ones, preserving the installer's live load and acting as a fallback when a marketplace fetch fails). The push makes the remote `.obsidian/plugins/` a complete vault. A pulled binary loads on the next vault open (Obsidian scans the plugins dir at startup). - pull never overwrites a local file (local install authoritative) - push is diff-gated (no churn) and skips remote-ssh (self-managed) - per-file SSH errors are swallowed, never abort the batch Tests: 7 unit (in-memory fakes) + 3 integration (real docker sshd: cross-session round-trip, push→fresh-pull, local-authoritative). Refs #429 #342 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2b7fcd1c87
commit
57def8564e
4 changed files with 288 additions and 0 deletions
|
|
@ -626,6 +626,23 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
logger.warn(`runAutoConnect(${tag}): post-pull plugin install failed: ${errorMessage(e)}`);
|
||||
}
|
||||
|
||||
// #429b binary round-trip: the marketplace installer above can't
|
||||
// fetch a BRAT / sideloaded plugin (it isn't on the registry). Run
|
||||
// AFTER the installer so the plugins it just fetched are on disk —
|
||||
// the pull then only stages what's STILL missing (the non-market
|
||||
// ones), which keeps the installer's live load intact and also
|
||||
// acts as a fallback if a marketplace fetch failed. The push makes
|
||||
// the remote `.obsidian/plugins/` a complete vault so every machine
|
||||
// can pull. A pulled binary loads on the next vault open (Obsidian
|
||||
// scans the plugins dir at startup).
|
||||
try {
|
||||
const enabledIds = ShadowVaultBootstrap.readEnabledPluginIds(localConfigDir);
|
||||
await ShadowVaultBootstrap.pullPluginBinaries(da, remoteConfigDir, localConfigDir, enabledIds);
|
||||
await ShadowVaultBootstrap.pushPluginBinaries(da, remoteConfigDir, localConfigDir, enabledIds);
|
||||
} catch (e) {
|
||||
logger.warn(`runAutoConnect(${tag}): plugin-binary round-trip failed: ${errorMessage(e)}`);
|
||||
}
|
||||
|
||||
// #342 push half: pull only brought remote→local. Without this,
|
||||
// a settings change made HERE never reaches the remote, so the
|
||||
// next session's pull finds nothing and the change evaporates.
|
||||
|
|
|
|||
|
|
@ -530,6 +530,104 @@ export class ShadowVaultBootstrap {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── plugin binary round-trip (#429b — BRAT / non-marketplace) ──────────
|
||||
//
|
||||
// The enabled-plugins LIST round-trips (above) and the marketplace
|
||||
// installer fetches binaries for plugins on Obsidian's registry. But a
|
||||
// BRAT / sideloaded plugin isn't on the marketplace, so its binary
|
||||
// would never reach another machine. These methods round-trip the
|
||||
// plugin *code* through the remote vault's `.obsidian/plugins/<id>/`
|
||||
// (the canonical store) so such plugins load everywhere. Code only —
|
||||
// the plugin's own `data.json` (settings, sometimes secrets) is left
|
||||
// alone.
|
||||
|
||||
/** Plugin code files synced cross-machine (text; binary assets are out of scope). */
|
||||
static readonly PLUGIN_BINARY_FILES = ['manifest.json', 'main.js', 'styles.css'] as const;
|
||||
|
||||
/**
|
||||
* Pull each plugin's code from the remote into the local shadow when
|
||||
* it's MISSING locally — so a plugin enabled on another machine (incl.
|
||||
* BRAT / non-marketplace) has its code staged here and loads on the
|
||||
* next vault open. Never overwrites a local file (the local install is
|
||||
* authoritative). `remote-ssh` is skipped — the plugin manages its own
|
||||
* install.
|
||||
*/
|
||||
static async pullPluginBinaries(
|
||||
reader: SharedConfigReader,
|
||||
remoteConfigDir: string,
|
||||
localConfigDir: string,
|
||||
pluginIds: readonly string[],
|
||||
): Promise<{ pulled: string[] }> {
|
||||
const pulled: string[] = [];
|
||||
for (const id of pluginIds) {
|
||||
if (id === ShadowVaultBootstrap.SELF_PLUGIN_ID) continue;
|
||||
const localPluginDir = path.join(localConfigDir, 'plugins', id);
|
||||
for (const file of ShadowVaultBootstrap.PLUGIN_BINARY_FILES) {
|
||||
const localFile = path.join(localPluginDir, file);
|
||||
if (fs.existsSync(localFile)) continue; // local authoritative
|
||||
const remoteRel = `${remoteConfigDir}/plugins/${id}/${file}`;
|
||||
try {
|
||||
if (!(await reader.exists(remoteRel))) continue;
|
||||
const content = await reader.read(remoteRel);
|
||||
fs.mkdirSync(localPluginDir, { recursive: true });
|
||||
ShadowVaultBootstrap.writeFileAtomic(localFile, content);
|
||||
if (!pulled.includes(id)) pulled.push(id);
|
||||
} catch (e) {
|
||||
logger.warn(`pullPluginBinaries: ${id}/${file} skipped (${errorMessage(e)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pulled.length) logger.info(`pullPluginBinaries: staged [${pulled.join(', ')}]`);
|
||||
return { pulled };
|
||||
}
|
||||
|
||||
/**
|
||||
* Push each plugin's local code to the remote when the remote copy is
|
||||
* missing or differs — so other machines can pull it. Code only; never
|
||||
* the plugin's `data.json`.
|
||||
*/
|
||||
static async pushPluginBinaries(
|
||||
rw: SharedConfigReader & SharedConfigWriter,
|
||||
remoteConfigDir: string,
|
||||
localConfigDir: string,
|
||||
pluginIds: readonly string[],
|
||||
): Promise<{ pushed: string[] }> {
|
||||
const pushed: string[] = [];
|
||||
for (const id of pluginIds) {
|
||||
if (id === ShadowVaultBootstrap.SELF_PLUGIN_ID) continue;
|
||||
const localPluginDir = path.join(localConfigDir, 'plugins', id);
|
||||
for (const file of ShadowVaultBootstrap.PLUGIN_BINARY_FILES) {
|
||||
const localFile = path.join(localPluginDir, file);
|
||||
if (!fs.existsSync(localFile)) continue;
|
||||
const remoteRel = `${remoteConfigDir}/plugins/${id}/${file}`;
|
||||
try {
|
||||
const content = fs.readFileSync(localFile, 'utf-8');
|
||||
let remoteContent: string | null = null;
|
||||
if (await rw.exists(remoteRel)) remoteContent = await rw.read(remoteRel);
|
||||
if (remoteContent === content) continue; // up to date — avoid churn
|
||||
await rw.write(remoteRel, content);
|
||||
if (!pushed.includes(id)) pushed.push(id);
|
||||
} catch (e) {
|
||||
logger.warn(`pushPluginBinaries: ${id}/${file} skipped (${errorMessage(e)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pushed.length) logger.info(`pushPluginBinaries: pushed [${pushed.join(', ')}]`);
|
||||
return { pushed };
|
||||
}
|
||||
|
||||
/** Atomic (tmp + rename) write of arbitrary file content. */
|
||||
private static writeFileAtomic(localFile: string, content: string): void {
|
||||
const tmp = `${localFile}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, content, 'utf-8');
|
||||
try {
|
||||
fs.renameSync(tmp, localFile);
|
||||
} catch (e) {
|
||||
try { fs.unlinkSync(tmp); } catch { /* best effort */ }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a community-plugins.json body into a string-id array, or null if malformed. */
|
||||
private static parsePluginIdList(content: string): string[] | null {
|
||||
try {
|
||||
|
|
@ -541,6 +639,15 @@ export class ShadowVaultBootstrap {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled-plugin ids from a local `.obsidian/community-plugins.json`
|
||||
* ([] when absent/malformed) — the set whose binaries the connect flow
|
||||
* round-trips via {@link pullPluginBinaries}/{@link pushPluginBinaries}.
|
||||
*/
|
||||
static readEnabledPluginIds(localConfigDir: string): string[] {
|
||||
return ShadowVaultBootstrap.readPluginIdList(path.join(localConfigDir, 'community-plugins.json'));
|
||||
}
|
||||
|
||||
/** Read a local community-plugins.json into an id array ([] when absent/malformed). */
|
||||
private static readPluginIdList(localPath: string): string[] {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1106,3 +1106,112 @@ describe('ShadowVaultBootstrap community-plugins round-trip (#429 / #342)', () =
|
|||
expect(store['.obsidian/community-plugins.json']).toBe('{ not : json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ShadowVaultBootstrap plugin-binary round-trip (#429b — BRAT / non-marketplace)', () => {
|
||||
function makeLocalConfigDir(): string {
|
||||
const dir = path.join(
|
||||
os.tmpdir(),
|
||||
`bin-roundtrip-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
'.obsidian',
|
||||
);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
const pluginFile = (cfg: string, id: string, file: string) => path.join(cfg, 'plugins', id, file);
|
||||
function seedLocal(cfg: string, id: string, file: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(pluginFile(cfg, id, file)), { recursive: true });
|
||||
fs.writeFileSync(pluginFile(cfg, id, file), content, 'utf-8');
|
||||
}
|
||||
|
||||
// Reader+writer fake over an in-memory store keyed by remote rel path.
|
||||
function makeRW(seed: Record<string, string> = {}): {
|
||||
rw: SharedConfigReader & SharedConfigWriter;
|
||||
store: Record<string, string>;
|
||||
} {
|
||||
const store: Record<string, string> = { ...seed };
|
||||
const rw: SharedConfigReader & SharedConfigWriter = {
|
||||
exists: (p) => Promise.resolve(p in store),
|
||||
read: (p) => Promise.resolve(store[p]),
|
||||
write: (p, c) => { store[p] = c; return Promise.resolve(); },
|
||||
};
|
||||
return { rw, store };
|
||||
}
|
||||
|
||||
it('pulls a plugin enabled on another machine into the shadow (code staged on disk)', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
const { rw } = makeRW({
|
||||
'.obsidian/plugins/brat-x/manifest.json': '{"id":"brat-x"}',
|
||||
'.obsidian/plugins/brat-x/main.js': '/* brat code */\n',
|
||||
});
|
||||
|
||||
const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['brat-x']);
|
||||
|
||||
expect(pulled).toContain('brat-x');
|
||||
expect(fs.readFileSync(pluginFile(cfg, 'brat-x', 'main.js'), 'utf-8')).toBe('/* brat code */\n');
|
||||
expect(fs.existsSync(pluginFile(cfg, 'brat-x', 'manifest.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('never overwrites a local plugin file (local install is authoritative)', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
seedLocal(cfg, 'p', 'main.js', '/* LOCAL */\n');
|
||||
const { rw } = makeRW({ '.obsidian/plugins/p/main.js': '/* REMOTE */\n' });
|
||||
|
||||
await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['p']);
|
||||
|
||||
expect(fs.readFileSync(pluginFile(cfg, 'p', 'main.js'), 'utf-8'), 'local file must win')
|
||||
.toBe('/* LOCAL */\n');
|
||||
});
|
||||
|
||||
it('skips remote-ssh on pull — the plugin manages its own install', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
const { rw } = makeRW({ '.obsidian/plugins/remote-ssh/main.js': '/* ignore me */\n' });
|
||||
|
||||
const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['remote-ssh']);
|
||||
|
||||
expect(pulled).not.toContain('remote-ssh');
|
||||
expect(fs.existsSync(pluginFile(cfg, 'remote-ssh', 'main.js'))).toBe(false);
|
||||
});
|
||||
|
||||
it('pushes a locally-installed plugin so other machines can pull it', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
seedLocal(cfg, 'q', 'main.js', '/* q code */\n');
|
||||
seedLocal(cfg, 'q', 'manifest.json', '{"id":"q"}');
|
||||
const { rw, store } = makeRW();
|
||||
|
||||
const { pushed } = await ShadowVaultBootstrap.pushPluginBinaries(rw, '.obsidian', cfg, ['q']);
|
||||
|
||||
expect(pushed).toContain('q');
|
||||
expect(store['.obsidian/plugins/q/main.js']).toBe('/* q code */\n');
|
||||
expect(store['.obsidian/plugins/q/manifest.json']).toBe('{"id":"q"}');
|
||||
});
|
||||
|
||||
it('push is a no-op when the remote copy is already identical (avoid churn)', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
seedLocal(cfg, 'r', 'main.js', '/* same */\n');
|
||||
const { rw } = makeRW({ '.obsidian/plugins/r/main.js': '/* same */\n' });
|
||||
let writes = 0;
|
||||
const origWrite = rw.write;
|
||||
rw.write = (p, c) => { writes++; return origWrite(p, c); };
|
||||
|
||||
const { pushed } = await ShadowVaultBootstrap.pushPluginBinaries(rw, '.obsidian', cfg, ['r']);
|
||||
|
||||
expect(pushed).not.toContain('r');
|
||||
expect(writes, 'identical content must not be re-written').toBe(0);
|
||||
});
|
||||
|
||||
it('a per-file SSH error is swallowed and does not abort the rest', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
const { rw } = makeRW({
|
||||
'.obsidian/plugins/x/manifest.json': '{"id":"x"}',
|
||||
'.obsidian/plugins/x/main.js': '/* x */\n',
|
||||
});
|
||||
const origRead = rw.read;
|
||||
rw.read = (p) => (p.endsWith('manifest.json') ? Promise.reject(new Error('SSH hiccup')) : origRead(p));
|
||||
|
||||
const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['x']);
|
||||
|
||||
expect(pulled, 'main.js still pulls after manifest read fails').toContain('x');
|
||||
expect(fs.readFileSync(pluginFile(cfg, 'x', 'main.js'), 'utf-8')).toBe('/* x */\n');
|
||||
expect(fs.existsSync(pluginFile(cfg, 'x', 'manifest.json')), 'failed file is skipped').toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -150,4 +150,59 @@ describe('Config consistency across connect cycles (#429 / #342)', () => {
|
|||
const local2 = JSON.parse(fs.readFileSync(path.join(s2.layout.configDir, 'app.json'), 'utf-8'));
|
||||
expect(local2, 'the setting changed in session 1 must be consistent in session 2').toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('a sideloaded plugin binary enabled on another machine is staged into a fresh shadow (#429b)', async () => {
|
||||
const id = 'sideloaded-plugin';
|
||||
await remoteClient.adapter.write(`${REMOTE_CFG}/plugins/${id}/manifest.json`,
|
||||
JSON.stringify({ id, version: '1.0.0', name: 'Sideloaded', minAppVersion: '1.5.0' }));
|
||||
await remoteClient.adapter.write(`${REMOTE_CFG}/plugins/${id}/main.js`, '/* sideloaded code */\n');
|
||||
|
||||
const profile = profileFor('bin-pull');
|
||||
const { layout } = await freshSession().bootstrap(profile, [profile]);
|
||||
const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(
|
||||
remoteClient.adapter, REMOTE_CFG, layout.configDir, [id]);
|
||||
|
||||
expect(pulled).toContain(id);
|
||||
expect(fs.readFileSync(path.join(layout.configDir, 'plugins', id, 'main.js'), 'utf-8'))
|
||||
.toBe('/* sideloaded code */\n');
|
||||
expect(fs.existsSync(path.join(layout.configDir, 'plugins', id, 'manifest.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('a sideloaded plugin installed in one session round-trips into the next via the remote', async () => {
|
||||
const id = 'local-only-plugin';
|
||||
// Session 1 (machine A): code present locally, pushed to the remote.
|
||||
const pA = profileFor('bin-push');
|
||||
const sA = await freshSession().bootstrap(pA, [pA]);
|
||||
const dirA = path.join(sA.layout.configDir, 'plugins', id);
|
||||
fs.mkdirSync(dirA, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirA, 'manifest.json'),
|
||||
JSON.stringify({ id, version: '2.0.0', name: 'LocalOnly', minAppVersion: '1.5.0' }));
|
||||
fs.writeFileSync(path.join(dirA, 'main.js'), '/* local-only code v2 */\n');
|
||||
await ShadowVaultBootstrap.pushPluginBinaries(remoteClient.adapter, REMOTE_CFG, sA.layout.configDir, [id]);
|
||||
|
||||
// Session 2 (machine B): a fresh shadow pulls the pushed binary.
|
||||
const pB = profileFor('bin-push-2');
|
||||
const sB = await freshSession().bootstrap(pB, [pB]);
|
||||
await ShadowVaultBootstrap.pullPluginBinaries(remoteClient.adapter, REMOTE_CFG, sB.layout.configDir, [id]);
|
||||
|
||||
expect(fs.readFileSync(path.join(sB.layout.configDir, 'plugins', id, 'main.js'), 'utf-8'),
|
||||
'a sideloaded plugin installed in session 1 must reach session 2')
|
||||
.toBe('/* local-only code v2 */\n');
|
||||
});
|
||||
|
||||
it('pullPluginBinaries never overwrites a local plugin file (local is authoritative, real SSH)', async () => {
|
||||
const id = 'conflict-plugin';
|
||||
await remoteClient.adapter.write(`${REMOTE_CFG}/plugins/${id}/main.js`, '/* REMOTE code */\n');
|
||||
|
||||
const profile = profileFor('bin-conflict');
|
||||
const { layout } = await freshSession().bootstrap(profile, [profile]);
|
||||
const dir = path.join(layout.configDir, 'plugins', id);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'main.js'), '/* LOCAL code */\n');
|
||||
|
||||
await ShadowVaultBootstrap.pullPluginBinaries(remoteClient.adapter, REMOTE_CFG, layout.configDir, [id]);
|
||||
|
||||
expect(fs.readFileSync(path.join(dir, 'main.js'), 'utf-8'), 'local file must not be clobbered by pull')
|
||||
.toBe('/* LOCAL code */\n');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue