fix: honor --dry-run in update and uninstall
This commit is contained in:
parent
6229538d29
commit
744cb36d74
9 changed files with 113 additions and 23 deletions
6
.changeset/dry-run-update-uninstall.md
Normal file
6
.changeset/dry-run-update-uninstall.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
"designpaca": patch
|
||||||
|
"@designpaca/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
`update` 와 `uninstall` 이 `--dry-run` 을 무시하고 실제로 파일과 매니페스트를 바꾸던 문제를 고쳤다. 두 명령 모두 계획만 출력하며, CI 스모크에서 `--dry-run` 이 아무것도 쓰지 않는지 검사한다.
|
||||||
|
|
@ -83,6 +83,14 @@ jobs:
|
||||||
env DESIGNPACA_STATE_DIR="$state_dir" npx --no-install designpaca --version
|
env DESIGNPACA_STATE_DIR="$state_dir" npx --no-install designpaca --version
|
||||||
env DESIGNPACA_STATE_DIR="$state_dir" npx --no-install designpaca install --target claude-code --scope project --yes
|
env DESIGNPACA_STATE_DIR="$state_dir" npx --no-install designpaca install --target claude-code --scope project --yes
|
||||||
test -f .claude/skills/designpaca/SKILL.md || { echo "설치 후 스킬이 없다" >&2; exit 1; }
|
test -f .claude/skills/designpaca/SKILL.md || { echo "설치 후 스킬이 없다" >&2; exit 1; }
|
||||||
|
|
||||||
|
# --dry-run 은 어느 명령에서도 쓰지 않아야 한다 (v0.12.0 에서 update 가 무시했다)
|
||||||
|
cp "$state_dir/manifest.json" "$sandbox/manifest.before.json"
|
||||||
|
env DESIGNPACA_STATE_DIR="$state_dir" npx --no-install designpaca update --dry-run --force
|
||||||
|
cmp -s "$state_dir/manifest.json" "$sandbox/manifest.before.json" || { echo "update --dry-run 이 매니페스트를 바꿨다" >&2; exit 1; }
|
||||||
|
env DESIGNPACA_STATE_DIR="$state_dir" npx --no-install designpaca uninstall --dry-run --yes
|
||||||
|
test -f .claude/skills/designpaca/SKILL.md || { echo "uninstall --dry-run 이 파일을 지웠다" >&2; exit 1; }
|
||||||
|
|
||||||
env DESIGNPACA_STATE_DIR="$state_dir" npx --no-install designpaca doctor
|
env DESIGNPACA_STATE_DIR="$state_dir" npx --no-install designpaca doctor
|
||||||
env DESIGNPACA_STATE_DIR="$state_dir" npx --no-install designpaca uninstall --yes
|
env DESIGNPACA_STATE_DIR="$state_dir" npx --no-install designpaca uninstall --yes
|
||||||
test ! -f .claude/skills/designpaca/SKILL.md || { echo "제거 후에도 파일이 남았다" >&2; exit 1; }
|
test ! -f .claude/skills/designpaca/SKILL.md || { echo "제거 후에도 파일이 남았다" >&2; exit 1; }
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ npx designpaca update --force
|
||||||
| `-s, --scope <범위>` | `user` (전역) \| `project` |
|
| `-s, --scope <범위>` | `user` (전역) \| `project` |
|
||||||
| `-y, --yes` | 확인 없이 진행 |
|
| `-y, --yes` | 확인 없이 진행 |
|
||||||
| `-f, --force` | 직접 수정한 파일도 덮어쓴다 (`.orig` 백업) |
|
| `-f, --force` | 직접 수정한 파일도 덮어쓴다 (`.orig` 백업) |
|
||||||
| `--dry-run` | 쓰지 않고 계획만 출력 |
|
| `--dry-run` | 쓰지 않고 계획만 출력 (install · update · uninstall 공통) |
|
||||||
| `--no-update-check` | 새 버전 확인 생략 |
|
| `--no-update-check` | 새 버전 확인 생략 |
|
||||||
|
|
||||||
## 요구사항
|
## 요구사항
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { dim, heading, ok, warn } from "../ui.ts";
|
||||||
export async function runUninstall(opts: {
|
export async function runUninstall(opts: {
|
||||||
targets?: TargetId[];
|
targets?: TargetId[];
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
dryRun?: boolean;
|
||||||
}): Promise<number> {
|
}): Promise<number> {
|
||||||
const manifest = await readManifest();
|
const manifest = await readManifest();
|
||||||
const targets = manifest.installs.filter(
|
const targets = manifest.installs.filter(
|
||||||
|
|
@ -15,13 +16,15 @@ export async function runUninstall(opts: {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(heading("제거"));
|
console.log(heading(opts.dryRun ? "제거 계획" : "제거"));
|
||||||
for (const rec of targets) {
|
for (const rec of targets) {
|
||||||
const res = await removeInstall(rec, { force: opts.force });
|
const res = await removeInstall(rec, { force: opts.force, dryRun: opts.dryRun });
|
||||||
console.log(ok(`${rec.target} (${rec.scope}) — ${res.removed.length}개 제거 ${dim(tildify(rec.root))}`));
|
const verb = opts.dryRun ? "제거할 파일" : "제거";
|
||||||
|
console.log(ok(`${rec.target} (${rec.scope}) — ${verb} ${res.removed.length}개 ${dim(tildify(rec.root))}`));
|
||||||
if (res.keptModified.length > 0) {
|
if (res.keptModified.length > 0) {
|
||||||
|
const tense = opts.dryRun ? "남긴다" : "남겼다";
|
||||||
console.log(
|
console.log(
|
||||||
warn(` 직접 수정한 파일 ${res.keptModified.length}개는 남겼다 (--force 로 함께 지운다)`),
|
warn(` 직접 수정한 파일 ${res.keptModified.length}개는 ${tense} (--force 로 함께 지운다)`),
|
||||||
);
|
);
|
||||||
for (const p of res.keptModified.slice(0, 5)) console.log(dim(` ${tildify(p)}`));
|
for (const p of res.keptModified.slice(0, 5)) console.log(dim(` ${tildify(p)}`));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { dim, heading, info, ok, warn } from "../ui.ts";
|
||||||
* 설치 기록을 그대로 따라가며 재설치한다.
|
* 설치 기록을 그대로 따라가며 재설치한다.
|
||||||
* 사용자가 고친 파일은 기본적으로 보존된다(applyPlan 이 판단) — 강제로 맞추려면 --force.
|
* 사용자가 고친 파일은 기본적으로 보존된다(applyPlan 이 판단) — 강제로 맞추려면 --force.
|
||||||
*/
|
*/
|
||||||
export async function runUpdate(opts: { force?: boolean } = {}): Promise<number> {
|
export async function runUpdate(opts: { force?: boolean; dryRun?: boolean } = {}): Promise<number> {
|
||||||
const version = await getVersion();
|
const version = await getVersion();
|
||||||
const manifest = await readManifest();
|
const manifest = await readManifest();
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@ export async function runUpdate(opts: { force?: boolean } = {}): Promise<number>
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(heading(`업데이트 → v${version}`));
|
console.log(heading(opts.dryRun ? `업데이트 계획 → v${version}` : `업데이트 → v${version}`));
|
||||||
const stale = manifest.installs.filter((i) => i.version !== version);
|
const stale = manifest.installs.filter((i) => i.version !== version);
|
||||||
|
|
||||||
if (stale.length === 0) {
|
if (stale.length === 0) {
|
||||||
|
|
@ -48,8 +48,9 @@ export async function runUpdate(opts: { force?: boolean } = {}): Promise<number>
|
||||||
targets: [rec.target],
|
targets: [rec.target],
|
||||||
scope: rec.scope,
|
scope: rec.scope,
|
||||||
force: opts.force,
|
force: opts.force,
|
||||||
|
dryRun: opts.dryRun,
|
||||||
});
|
});
|
||||||
printOutcomes(outcomes);
|
printOutcomes(outcomes, opts.dryRun);
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ ${bold("옵션")}
|
||||||
-s, --scope <범위> user | project ${dim("(기본: user)")}
|
-s, --scope <범위> user | project ${dim("(기본: user)")}
|
||||||
-y, --yes 확인 없이 진행합니다
|
-y, --yes 확인 없이 진행합니다
|
||||||
-f, --force 직접 수정한 파일도 덮어씁니다 ${dim("(.orig 로 백업)")}
|
-f, --force 직접 수정한 파일도 덮어씁니다 ${dim("(.orig 로 백업)")}
|
||||||
--dry-run 쓰지 않고 계획만 보여줍니다
|
--dry-run 쓰지 않고 계획만 보여줍니다 (install · update · uninstall)
|
||||||
--no-update-check 새 버전 확인을 건너뜁니다
|
--no-update-check 새 버전 확인을 건너뜁니다
|
||||||
-h, --help 이 도움말
|
-h, --help 이 도움말
|
||||||
-v, --version 버전
|
-v, --version 버전
|
||||||
|
|
@ -87,6 +87,7 @@ ${bold("예시")}
|
||||||
${dim("npx designpaca install -t claude-code,codex -s user -y")}
|
${dim("npx designpaca install -t claude-code,codex -s user -y")}
|
||||||
${dim("npx designpaca install -t cursor -s project")}
|
${dim("npx designpaca install -t cursor -s project")}
|
||||||
${dim("npx designpaca update --force")}
|
${dim("npx designpaca update --force")}
|
||||||
|
${dim("npx designpaca update --dry-run")}
|
||||||
${dim("npx designpaca tools --yes")}
|
${dim("npx designpaca tools --yes")}
|
||||||
`.trimStart();
|
`.trimStart();
|
||||||
}
|
}
|
||||||
|
|
@ -140,11 +141,11 @@ async function main(): Promise<number> {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "update":
|
case "update":
|
||||||
code = await runUpdate({ force: args.force });
|
code = await runUpdate({ force: args.force, dryRun: args.dryRun });
|
||||||
break;
|
break;
|
||||||
case "uninstall":
|
case "uninstall":
|
||||||
case "remove":
|
case "remove":
|
||||||
code = await runUninstall({ targets: args.targets, force: args.force });
|
code = await runUninstall({ targets: args.targets, force: args.force, dryRun: args.dryRun });
|
||||||
break;
|
break;
|
||||||
case "doctor":
|
case "doctor":
|
||||||
code = await runDoctor();
|
code = await runDoctor();
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,13 @@ after(async () => {
|
||||||
function run(args: string[]): string {
|
function run(args: string[]): string {
|
||||||
return execFileSync(process.execPath, [CLI, ...args, "--no-update-check"], {
|
return execFileSync(process.execPath, [CLI, ...args, "--no-update-check"], {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
env: { ...process.env, HOME: home, USERPROFILE: home, FORCE_COLOR: "0" },
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOME: home,
|
||||||
|
USERPROFILE: home,
|
||||||
|
FORCE_COLOR: "0",
|
||||||
|
DESIGNPACA_STATE_DIR: path.join(home, ".designpaca"),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,6 +64,52 @@ test("알 수 없는 대상은 종료 코드 2 로 거부한다", { skip: !built
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("update --dry-run 은 파일도 매니페스트도 건드리지 않는다", { skip: !built }, async () => {
|
||||||
|
run(["install", "-t", "claude-code", "-s", "user", "-y"]);
|
||||||
|
const manifestFile = path.join(home, ".designpaca", "manifest.json");
|
||||||
|
const skillMd = path.join(home, ".claude", "skills", "designpaca", "SKILL.md");
|
||||||
|
|
||||||
|
// 업데이트 대상이 있는 상태를 만든다
|
||||||
|
const manifest = JSON.parse(await fs.readFile(manifestFile, "utf8"));
|
||||||
|
manifest.installs[0].version = "0.0.1";
|
||||||
|
await fs.writeFile(manifestFile, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
||||||
|
|
||||||
|
const manifestBefore = await fs.readFile(manifestFile, "utf8");
|
||||||
|
const skillBefore = await fs.readFile(skillMd, "utf8");
|
||||||
|
const statBefore = await fs.stat(skillMd);
|
||||||
|
|
||||||
|
const out = run(["update", "--dry-run"]);
|
||||||
|
assert.ok(out.includes("업데이트 계획"));
|
||||||
|
assert.ok(out.includes("설치 계획"));
|
||||||
|
|
||||||
|
assert.equal(await fs.readFile(manifestFile, "utf8"), manifestBefore);
|
||||||
|
assert.equal(await fs.readFile(skillMd, "utf8"), skillBefore);
|
||||||
|
const statAfter = await fs.stat(skillMd);
|
||||||
|
assert.equal(statAfter.mtimeMs, statBefore.mtimeMs);
|
||||||
|
|
||||||
|
// 대조군: 실제 update 는 반영돼야 한다
|
||||||
|
run(["update"]);
|
||||||
|
const manifestAfter = JSON.parse(await fs.readFile(manifestFile, "utf8"));
|
||||||
|
assert.equal(manifestAfter.installs[0].version, run(["--version"]).trim());
|
||||||
|
|
||||||
|
run(["uninstall", "-y"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uninstall --dry-run 은 아무것도 지우지 않는다", { skip: !built }, async () => {
|
||||||
|
run(["install", "-t", "claude-code", "-s", "user", "-y"]);
|
||||||
|
const skillMd = path.join(home, ".claude", "skills", "designpaca", "SKILL.md");
|
||||||
|
const manifestFile = path.join(home, ".designpaca", "manifest.json");
|
||||||
|
|
||||||
|
const out = run(["uninstall", "--dry-run"]);
|
||||||
|
assert.ok(out.includes("제거 계획"));
|
||||||
|
await fs.access(skillMd);
|
||||||
|
const manifest = JSON.parse(await fs.readFile(manifestFile, "utf8"));
|
||||||
|
assert.equal(manifest.installs.length, 1);
|
||||||
|
|
||||||
|
run(["uninstall", "-y"]);
|
||||||
|
await assert.rejects(() => fs.access(skillMd));
|
||||||
|
});
|
||||||
|
|
||||||
test("설치 → doctor → 제거 왕복", { skip: !built }, async () => {
|
test("설치 → doctor → 제거 왕복", { skip: !built }, async () => {
|
||||||
run(["install", "-t", "claude-code", "-s", "user", "-y"]);
|
run(["install", "-t", "claude-code", "-s", "user", "-y"]);
|
||||||
const skillMd = path.join(home, ".claude", "skills", "designpaca", "SKILL.md");
|
const skillMd = path.join(home, ".claude", "skills", "designpaca", "SKILL.md");
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@ export interface RemoveResult {
|
||||||
/** 매니페스트에 기록된 것만 되돌린다. 기록에 없는 파일은 손대지 않는다. */
|
/** 매니페스트에 기록된 것만 되돌린다. 기록에 없는 파일은 손대지 않는다. */
|
||||||
export async function removeInstall(
|
export async function removeInstall(
|
||||||
record: InstallRecord,
|
record: InstallRecord,
|
||||||
opts: { force?: boolean; env?: EnvOptions } = {},
|
opts: { force?: boolean; dryRun?: boolean; env?: EnvOptions } = {},
|
||||||
): Promise<RemoveResult> {
|
): Promise<RemoveResult> {
|
||||||
const drift = await inspectDrift(record);
|
const drift = await inspectDrift(record);
|
||||||
const dirty = new Set(drift.modified);
|
const dirty = new Set(drift.modified);
|
||||||
|
|
@ -164,22 +164,26 @@ export async function removeInstall(
|
||||||
if (f.marker) {
|
if (f.marker) {
|
||||||
const cur = await readIfExists(f.path);
|
const cur = await readIfExists(f.path);
|
||||||
if (cur === null) continue;
|
if (cur === null) continue;
|
||||||
|
if (!opts.dryRun) {
|
||||||
const next = removeBlock(cur, f.marker);
|
const next = removeBlock(cur, f.marker);
|
||||||
// 블록만 남아 있던 문서라면 파일째 지운다
|
// 블록만 남아 있던 문서라면 파일째 지운다
|
||||||
if (next.trim().length === 0) await removeFileAndPrune(f.path, path.dirname(f.path));
|
if (next.trim().length === 0) await removeFileAndPrune(f.path, path.dirname(f.path));
|
||||||
else await writeAtomic(f.path, next);
|
else await writeAtomic(f.path, next);
|
||||||
} else {
|
}
|
||||||
|
} else if (!opts.dryRun) {
|
||||||
await removeFileAndPrune(f.path, record.root);
|
await removeFileAndPrune(f.path, record.root);
|
||||||
}
|
}
|
||||||
removed.push(f.path);
|
removed.push(f.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!opts.dryRun) {
|
||||||
if (keptModified.length === 0) {
|
if (keptModified.length === 0) {
|
||||||
await dropInstall(record.target, record.scope, record.root, opts.env?.home);
|
await dropInstall(record.target, record.scope, record.root, opts.env?.home);
|
||||||
} else {
|
} else {
|
||||||
// 일부만 남았으면 기록도 남은 것만 유지한다
|
// 일부만 남았으면 기록도 남은 것만 유지한다
|
||||||
await upsertInstall({ ...record, files: record.files.filter((f) => dirty.has(f.path)) }, opts.env?.home);
|
await upsertInstall({ ...record, files: record.files.filter((f) => dirty.has(f.path)) }, opts.env?.home);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { removed, keptModified };
|
return { removed, keptModified };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,21 @@ test("claude-code: 설치 → 드리프트 없음 → 제거", async () => {
|
||||||
await assert.rejects(() => fs.access(plan.root));
|
await assert.rejects(() => fs.access(plan.root));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("removeInstall dryRun 은 파일과 매니페스트를 건드리지 않는다", async () => {
|
||||||
|
const plan = await planInstall("claude-code", "user", skill, env);
|
||||||
|
const res = await applyPlan(plan, skill.version, { env });
|
||||||
|
|
||||||
|
const dry = await removeInstall(res.record, { env, dryRun: true });
|
||||||
|
assert.equal(dry.removed.length, 3);
|
||||||
|
assert.equal(dry.keptModified.length, 0);
|
||||||
|
await fs.access(path.join(plan.root, "SKILL.md"));
|
||||||
|
assert.equal((await readManifest(home)).installs.length, 1);
|
||||||
|
|
||||||
|
const real = await removeInstall(res.record, { env });
|
||||||
|
assert.equal(real.removed.length, 3);
|
||||||
|
assert.equal((await readManifest(home)).installs.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test("사용자가 고친 파일은 update 가 건너뛴다", async () => {
|
test("사용자가 고친 파일은 update 가 건너뛴다", async () => {
|
||||||
const plan = await planInstall("claude-code", "user", skill, env);
|
const plan = await planInstall("claude-code", "user", skill, env);
|
||||||
const first = await applyPlan(plan, skill.version, { env });
|
const first = await applyPlan(plan, skill.version, { env });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue