diff --git a/manifest-beta.json b/manifest-beta.json index 523fe02..93483f7 100644 --- a/manifest-beta.json +++ b/manifest-beta.json @@ -1,7 +1,7 @@ { "id": "remote-ssh", "name": "Remote SSH", - "version": "1.1.6", + "version": "1.1.7-beta.0", "minAppVersion": "1.5.0", "description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.", "author": "souta shimozono", diff --git a/plugin/manifest.json b/plugin/manifest.json index 523fe02..93483f7 100644 --- a/plugin/manifest.json +++ b/plugin/manifest.json @@ -1,7 +1,7 @@ { "id": "remote-ssh", "name": "Remote SSH", - "version": "1.1.6", + "version": "1.1.7-beta.0", "minAppVersion": "1.5.0", "description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.", "author": "souta shimozono", diff --git a/plugin/package-lock.json b/plugin/package-lock.json index 4e9311c..20d9390 100644 --- a/plugin/package-lock.json +++ b/plugin/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-remote-ssh", - "version": "1.1.6", + "version": "1.1.7-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-remote-ssh", - "version": "1.1.6", + "version": "1.1.7-beta.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/plugin/package.json b/plugin/package.json index a61d765..3df3378 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-remote-ssh", - "version": "1.1.6", + "version": "1.1.7-beta.0", "description": "VS Code Remote SSH-like experience for Obsidian", "main": "main.js", "scripts": { diff --git a/plugin/src/main.ts b/plugin/src/main.ts index 267ba2c..3789dbd 100644 --- a/plugin/src/main.ts +++ b/plugin/src/main.ts @@ -1043,6 +1043,18 @@ export default class RemoteSshPlugin extends Plugin { * shadow window (Phase 4). */ async openShadowVaultFor(profile: SshProfile): Promise { + // Connect clicked from INSIDE the shadow window for this same + // profile (Settings row button / connect modal). This vault IS the + // shadow — there is no second window to spawn, and re-running the + // bootstrap from here would make installPlugin's rm+symlink cycle + // target its own files (src == dst), turning main.js/manifest.json + // into self-referential symlinks that brick the install on the + // next start. Reconnect in place instead. + if (this.settings.autoConnectProfileId === profile.id) { + await this.runAutoConnect('reconnect'); + return; + } + // Source dir: where this running plugin lives, so the shadow // vault's plugin install symlinks the same bundle. const sourcePluginDir = this.pluginDir(); diff --git a/plugin/src/shadow/ShadowVaultBootstrap.ts b/plugin/src/shadow/ShadowVaultBootstrap.ts index f60d969..e03ba8a 100644 --- a/plugin/src/shadow/ShadowVaultBootstrap.ts +++ b/plugin/src/shadow/ShadowVaultBootstrap.ts @@ -60,8 +60,12 @@ export interface BootstrapResult { * surfaces the one-time "restart Obsidian" notice. */ migrated: boolean; - /** How the plugin source landed in the shadow vault. */ - pluginInstallMethod: 'symlink' | 'copy'; + /** + * How the plugin source landed in the shadow vault. `in-place` means + * source and target were the same directory (bootstrap re-run from + * inside the shadow window) so nothing needed installing. + */ + pluginInstallMethod: 'symlink' | 'copy' | 'in-place'; } /** @@ -1164,7 +1168,7 @@ export class ShadowVaultBootstrap { * writes the per-vault data.json into pluginDir as a real file, * leaving the source vault's data.json untouched. */ - private installPlugin(pluginDir: string): 'symlink' | 'copy' { + private installPlugin(pluginDir: string): 'symlink' | 'copy' | 'in-place' { // If pluginDir is a stale whole-dir symlink from an older build // (or a previous run of this same code on an older version), // unlink it — DO NOT rmSync, that would follow the link and @@ -1178,6 +1182,23 @@ export class ShadowVaultBootstrap { // Doesn't exist yet, fine. } + // Self-install guard: when the bootstrap runs from INSIDE the + // shadow window it targets, sourcePluginDir IS pluginDir. The + // rm+symlink cycle below would then delete each real plugin file + // and replace it with a symlink pointing at its own path — a + // self-referential link Obsidian can't resolve, so the plugin + // "disappears" on the next start. The bundle is already here; + // there is nothing to install. + try { + if (fs.realpathSync(this.sourcePluginDir) === fs.realpathSync(pluginDir)) { + return 'in-place'; + } + } catch { + // Either side unresolvable (e.g. pluginDir doesn't exist yet, or + // the stale whole-dir symlink was just unlinked) → not the same + // dir; proceed with a normal install. + } + fs.mkdirSync(pluginDir, { recursive: true }); const sharedFiles = ['main.js', 'manifest.json', 'styles.css']; diff --git a/plugin/tests/ShadowVaultBootstrap.test.ts b/plugin/tests/ShadowVaultBootstrap.test.ts index a260e52..9ba2f76 100644 --- a/plugin/tests/ShadowVaultBootstrap.test.ts +++ b/plugin/tests/ShadowVaultBootstrap.test.ts @@ -438,6 +438,28 @@ describe('ShadowVaultBootstrap.bootstrap', () => { expect(stat.isDirectory()).toBe(true); }); + it('regression: re-bootstrap from INSIDE the shadow window (source == target plugin dir) must not self-symlink the plugin files', async () => { + // First bootstrap from a normal source vault. + const r1 = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath)); + const profile = makeProfile({ id: 'p1', name: 'P', remotePath: '/p1' }); + const first = await r1.bootstrap(profile, [profile]); + + // Simulate the plugin now RUNNING inside that shadow window with + // the user clicking Connect again: the running plugin's own dir + // (the bootstrap source) IS the shadow's plugin dir. Before the + // self-install guard, installPlugin rm'd each real file and left a + // symlink pointing at its own path — the plugin then failed to + // load ("disappeared") on the next Obsidian start. + const r2 = new ShadowVaultBootstrap(scratch.baseDir, first.layout.pluginDir, new ObsidianRegistry(scratch.configPath)); + const second = await r2.bootstrap(profile, [profile]); + + expect(second.layout.pluginDir).toBe(first.layout.pluginDir); + expect(second.pluginInstallMethod).toBe('in-place'); + // main.js must still resolve to the staged bundle content. + const mainJs = path.join(second.layout.pluginDir, 'main.js'); + expect(fs.readFileSync(mainJs, 'utf-8')).toContain('fake bundled plugin'); + }); + it('writes [remote-ssh] only into shadow community-plugins.json on first bootstrap (does not auto-install source plugins)', async () => { fs.writeFileSync( path.join(scratch.sourceConfigDir, 'community-plugins.json'),