From f02e428668d6467fdd1e6b74905bad6203fd26a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AF=E5=9F=BA=E9=AD=81?= <1412414664@qq.com> Date: Wed, 8 Jul 2026 08:51:48 +0800 Subject: [PATCH 1/2] fix(install): guard link-mode conflicts --- scripts/install.mjs | 19 ++++++++++++++++--- tests/install.test.mjs | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/scripts/install.mjs b/scripts/install.mjs index 8407023..180123e 100755 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -178,6 +178,15 @@ function atomicWriteJson(path, content) { function installLink() { const dryRun = values["dry-run"]; mkdirSync(target, { recursive: true }); + function prepareLinkDestination(dest) { + if (!existsSync(dest)) return; + const st = lstatSync(dest); + if (!st.isSymbolicLink() && !values.force) { + throw new UserError(`Refusing to replace existing non-symlink path during --link install: ${dest} +Use --force to replace it.`); + } + if (!dryRun) rmSync(dest, { recursive: true, force: true }); + } for (const dir of COMPONENT_DIRS) { const from = join(root, dir); if (!existsSync(from)) continue; @@ -188,8 +197,10 @@ function installLink() { const dest = join(target, "plugins", name); if (!dryRun) { mkdirSync(dirname(dest), { recursive: true }); - rmSync(dest, { recursive: true, force: true }); + prepareLinkDestination(dest); symlinkSync(src, dest); + } else { + prepareLinkDestination(dest); } console.log(`${dryRun ? "Would link" : "Linked"} ${dest} -> ${src}`); } @@ -200,8 +211,10 @@ function installLink() { const dest = join(target, dir, rel); if (!dryRun) { mkdirSync(dirname(dest), { recursive: true }); - rmSync(dest, { force: true }); + prepareLinkDestination(dest); symlinkSync(src, dest); + } else { + prepareLinkDestination(dest); } console.log(`${dryRun ? "Would link" : "Linked"} ${dest} -> ${src}`); } @@ -343,4 +356,4 @@ try { if (DEBUG) throw err; console.error(err instanceof UserError ? `Error: ${err.message}` : `Error: ${err?.message || err}`); process.exit(1); -} \ No newline at end of file +} diff --git a/tests/install.test.mjs b/tests/install.test.mjs index 152bb8d..fa1b516 100644 --- a/tests/install.test.mjs +++ b/tests/install.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, existsSync } from "node:fs"; +import { mkdtempSync, existsSync, lstatSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -24,4 +24,36 @@ describe("install.mjs", () => { assert.ok(existsSync(join(target, "agents", "goal.md"))); assert.ok(existsSync(join(target, ".goal-mode-manifest.json"))); }); -}); \ No newline at end of file + + it("link install refuses to delete existing non-symlink plugin paths", () => { + const target = join(mkdtempSync(join(tmpdir(), "goal-mode-link-conflict-")), "cfg"); + const localPluginDir = join(target, "plugins", "goal-mode"); + mkdirSync(localPluginDir, { recursive: true }); + writeFileSync(join(localPluginDir, "keep.txt"), "user-data", "utf8"); + + const r = spawnSync(process.execPath, ["scripts/install.mjs", "--target", target, "--link"], { + cwd: root, + encoding: "utf8", + }); + + assert.equal(r.status, 1); + assert.match(r.stderr, /Refusing to replace existing non-symlink path/); + assert.ok(existsSync(join(localPluginDir, "keep.txt"))); + }); + + it("link install replaces existing non-symlink paths only with force", () => { + const target = join(mkdtempSync(join(tmpdir(), "goal-mode-link-force-")), "cfg"); + const localPluginDir = join(target, "plugins", "goal-mode"); + mkdirSync(localPluginDir, { recursive: true }); + writeFileSync(join(localPluginDir, "keep.txt"), "user-data", "utf8"); + + const r = spawnSync(process.execPath, ["scripts/install.mjs", "--target", target, "--link", "--force"], { + cwd: root, + encoding: "utf8", + }); + + assert.equal(r.status, 0, r.stderr || r.stdout); + assert.equal(lstatSync(localPluginDir).isSymbolicLink(), true); + assert.equal(existsSync(join(localPluginDir, "keep.txt")), false); + }); +}); From a32d1e28ecfe669d9f880370decd5baddbda461a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AF=E5=9F=BA=E9=AD=81?= <1412414664@qq.com> Date: Wed, 8 Jul 2026 21:55:02 +0800 Subject: [PATCH 2/2] fix(install): handle dangling link targets --- scripts/install.mjs | 9 +++++++-- tests/install.test.mjs | 21 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/scripts/install.mjs b/scripts/install.mjs index 180123e..9b3a507 100755 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -179,8 +179,13 @@ function installLink() { const dryRun = values["dry-run"]; mkdirSync(target, { recursive: true }); function prepareLinkDestination(dest) { - if (!existsSync(dest)) return; - const st = lstatSync(dest); + let st; + try { + st = lstatSync(dest); + } catch (err) { + if (err?.code === "ENOENT") return; + throw err; + } if (!st.isSymbolicLink() && !values.force) { throw new UserError(`Refusing to replace existing non-symlink path during --link install: ${dest} Use --force to replace it.`); diff --git a/tests/install.test.mjs b/tests/install.test.mjs index fa1b516..a438c8f 100644 --- a/tests/install.test.mjs +++ b/tests/install.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, existsSync, lstatSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, existsSync, lstatSync, mkdirSync, readlinkSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -56,4 +56,23 @@ describe("install.mjs", () => { assert.equal(lstatSync(localPluginDir).isSymbolicLink(), true); assert.equal(existsSync(join(localPluginDir, "keep.txt")), false); }); + + it("link install with force replaces dangling symlink plugin paths", () => { + const target = join(mkdtempSync(join(tmpdir(), "goal-mode-link-dangling-")), "cfg"); + const pluginsDir = join(target, "plugins"); + const localPluginDir = join(pluginsDir, "goal-mode"); + mkdirSync(pluginsDir, { recursive: true }); + symlinkSync(join(target, "missing-plugin-dir"), localPluginDir, "dir"); + assert.equal(existsSync(localPluginDir), false); + assert.equal(lstatSync(localPluginDir).isSymbolicLink(), true); + + const r = spawnSync(process.execPath, ["scripts/install.mjs", "--target", target, "--link", "--force"], { + cwd: root, + encoding: "utf8", + }); + + assert.equal(r.status, 0, r.stderr || r.stdout); + assert.equal(lstatSync(localPluginDir).isSymbolicLink(), true); + assert.equal(readlinkSync(localPluginDir), join(root, "plugins", "goal-mode")); + }); });