nccgs 2.1.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/nccgs-adversarial-reviewer.md +1 -1
- package/.claude/agents/nccgs-engine-programmer.md +1 -1
- package/.claude/agents/nccgs-fast-implementer.md +1 -1
- package/.claude/agents/nccgs-game-designer.md +1 -1
- package/.claude/agents/nccgs-gameplay-programmer.md +1 -1
- package/.claude/agents/nccgs-qa-engineer.md +1 -1
- package/.claude/agents/nccgs-task-scout.md +1 -1
- package/.claude/agents/nccgs-ui-programmer.md +1 -1
- package/.claude/agents/nccgs-unity-build-specialist.md +1 -1
- package/.claude/agents/nccgs-unity-implementer.md +1 -1
- package/.claude/agents/nccgs-unity-systems-specialist.md +1 -1
- package/.claude/agents/nccgs-unity-ui-specialist.md +1 -1
- package/.claude/agents/nccgs-verification-engineer.md +1 -1
- package/.claude/nccgs/MEMORY.md +3 -1
- package/.claude/nccgs/VERSION +1 -1
- package/.claude/nccgs/constitution.md +187 -176
- package/.claude/nccgs/hooks/closure-guard.mjs +2 -0
- package/.claude/nccgs/hooks/unity-transport-guard.mjs +11 -0
- package/.claude/nccgs/pipeline.json +260 -260
- package/.claude/nccgs/protocols/context-packets.md +5 -0
- package/.claude/nccgs/protocols/model-routing.md +46 -46
- package/.claude/nccgs/protocols/review-handoff.md +21 -1
- package/.claude/nccgs/protocols/shared-workflow-context.md +33 -0
- package/.claude/nccgs/protocols/unity-boundary.md +20 -0
- package/.claude/nccgs/routing.generated.md +4 -4
- package/.claude/nccgs/settings.fragment.json +84 -24
- package/.claude/nccgs/studio.json +53 -53
- package/.claude/nccgs/tools/autonomy.mjs +13 -0
- package/.claude/nccgs/tools/command-tokens.mjs +22 -0
- package/.claude/nccgs/tools/hook-transport.mjs +34 -0
- package/.claude/nccgs/tools/native-completion.mjs +10 -1
- package/.claude/nccgs/tools/returned-handoff.mjs +11 -2
- package/.claude/nccgs/tools/shared-context-guard.mjs +60 -0
- package/.claude/nccgs/tools/shared-context.mjs +25 -0
- package/.claude/nccgs/tools/tool-guard.mjs +120 -131
- package/.claude/nccgs/tools/unity-transport.mjs +170 -0
- package/.claude/nccgs/workflow-catalog.json +4 -4
- package/CHANGELOG.md +235 -200
- package/CLAUDE.md +10 -8
- package/MEMORY.md +109 -60
- package/README.md +206 -191
- package/UPGRADING.md +130 -118
- package/VERSION +1 -1
- package/docs/ARCHITECTURE.md +131 -131
- package/docs/ASSET-PRODUCTION-GRAPHIFY-PLAN.md +230 -0
- package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +117 -116
- package/docs/NCCGS-1.5.md +237 -237
- package/docs/PROJECT-POLICY.md +94 -94
- package/docs/RELEASE-STATUS.md +17 -9
- package/docs/UNINSTALL.md +3 -3
- package/docs/WFK17-HOOK-REPAIR.md +71 -0
- package/docs/WORKFLOWS.md +85 -85
- package/package.json +1 -1
- package/scaffold/.nccgs/project.yaml +109 -108
- package/scripts/benchmark-v15.mjs +173 -173
- package/scripts/doctor.mjs +261 -261
- package/scripts/install.mjs +530 -529
- package/scripts/run.mjs +5 -4
- package/scripts/supervise.mjs +4 -0
- package/scripts/uninstall.mjs +292 -292
- package/scripts/validate.mjs +285 -280
- package/tests/autonomy.test.mjs +126 -113
- package/tests/design-readiness.test.mjs +199 -199
- package/tests/fixtures/compiler-shutdown.cs +67 -64
- package/tests/fixtures/native-provider.mjs +61 -0
- package/tests/framework.test.mjs +740 -740
- package/tests/hook-repair.test.mjs +106 -0
- package/tests/lifecycle-v15.test.mjs +346 -300
- package/tests/native-completion-notices.test.mjs +181 -0
- package/tests/phase1.test.mjs +427 -416
- package/tests/phase2.test.mjs +250 -250
- package/tests/policy-v15.test.mjs +234 -234
- package/tests/returned-handoff-notices.test.mjs +170 -0
- package/tests/runtime-v15.test.mjs +1197 -1191
- package/tests/task-slices.test.mjs +285 -285
- package/tests/unity-transport.test.mjs +348 -0
package/scripts/uninstall.mjs
CHANGED
|
@@ -1,292 +1,292 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import crypto from "node:crypto";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { assertProjectRoot, assertRelativePath, containedPath } from "../.claude/nccgs/tools/paths.mjs";
|
|
6
|
-
import { matchesFingerprint, equivalentBuffers, sha256 } from "../.claude/nccgs/tools/integrity.mjs";
|
|
7
|
-
import {
|
|
8
|
-
import { acquireLock, ownerState } from "../.claude/nccgs/tools/process-lock.mjs";
|
|
9
|
-
import { assertInstallationAvailable } from "../.claude/nccgs/tools/installation-guard.mjs";
|
|
10
|
-
|
|
11
|
-
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
-
const statePath = ".claude/nccgs/install-state.json";
|
|
13
|
-
const lockPath = ".nccgs/uninstall.lock";
|
|
14
|
-
const need = (ok, message) => { if (!ok) throw new Error(message); };
|
|
15
|
-
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
16
|
-
const digest = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
17
|
-
const read = file => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
18
|
-
|
|
19
|
-
function files(project, relative) {
|
|
20
|
-
const directory = containedPath(project, relative);
|
|
21
|
-
if (!fs.existsSync(directory)) return [];
|
|
22
|
-
need(fs.statSync(directory).isDirectory(), `Expected a directory: ${relative}`);
|
|
23
|
-
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
24
|
-
const name = `${relative}/${entry.name}`, file = containedPath(project, name);
|
|
25
|
-
if (entry.isDirectory()) return files(project, name);
|
|
26
|
-
need(entry.isFile() && fs.statSync(file).isFile(), `Unsupported entry: ${name}`);
|
|
27
|
-
return [name];
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Read only: do not settle/recover a job, rewrite a task, reclaim an old lock or
|
|
32
|
-
// terminate a process merely to make uninstallation possible.
|
|
33
|
-
export function assertUninstallIdle(project, ownToken = null) {
|
|
34
|
-
need(!(process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT || process.env.NCCGS_SUPERVISED_SESSION),
|
|
35
|
-
"Close Claude Code and run uninstall from an external terminal.");
|
|
36
|
-
const at = relative => containedPath(project, relative);
|
|
37
|
-
const candidates = [...[".nccgs/orchestration.lock", ".nccgs/measurement-start.lock", lockPath].flatMap(name => [name, `${name}.recovery`]),
|
|
38
|
-
...[".nccgs/tasks", ".nccgs/jobs", ".nccgs/runs", ".nccgs/sessions"].flatMap(dir => files(project, dir))];
|
|
39
|
-
for (const relative of candidates.filter(name => /\.lock(?:\.recovery)?$/.test(name))) {
|
|
40
|
-
if (!fs.existsSync(at(relative))) continue;
|
|
41
|
-
const lock = read(at(relative));
|
|
42
|
-
if (relative === lockPath && ownToken && lock.token === ownToken) continue;
|
|
43
|
-
need(ownerState(lock) === "dead", `Cannot uninstall: active or unverified process lock ${relative}. Stop/recover the owning work first.${relative.startsWith(".nccgs/sessions/") && lock.operation === "nccgs-run" ? " For a crashed launcher, see recover-session --dry-run and docs/UNINSTALL.md; never infer descendant shutdown from PID death." : ""}`);
|
|
44
|
-
}
|
|
45
|
-
const ledger = at(".nccgs/orchestration.json");
|
|
46
|
-
if (fs.existsSync(ledger)) {
|
|
47
|
-
const value = read(ledger);
|
|
48
|
-
need(object(value.dispatches), "Invalid dispatch ledger; inspect it before uninstalling.");
|
|
49
|
-
for (const dispatch of Object.values(value.dispatches)) {
|
|
50
|
-
need(object(dispatch) && ["completed", "failed", "cancelled"].includes(dispatch.status),
|
|
51
|
-
`Cannot uninstall: dispatch ${dispatch?.id ?? "unknown"} is active or unverified.`);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
for (const relative of candidates.filter(name => /^\.nccgs\/jobs\/[^/]+\/request\.json$/.test(name))) {
|
|
55
|
-
const result = at(relative.replace(/request\.json$/, "result.json"));
|
|
56
|
-
need(fs.existsSync(result) && ["PASS", "FAIL"].includes(read(result).status), `Cannot uninstall: unsettled verification job ${relative}.`);
|
|
57
|
-
}
|
|
58
|
-
for (const relative of candidates.filter(name => /^\.nccgs\/runs\/[^/]+\/run\.json$/.test(name))) {
|
|
59
|
-
const run = read(at(relative));
|
|
60
|
-
need(["ready", "settled", "blocked"].includes(run.status) && Array.isArray(run.invocations)
|
|
61
|
-
&& run.invocations.every(item => ["finished", "recovered"].includes(item.status)), `Cannot uninstall: active or unverified run ${relative}.`);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function catalog() {
|
|
66
|
-
const known = new Set(files(root, ".claude"));
|
|
67
|
-
known.add(".claude/skills/setup-vivox-voice-chat/evals/.gitignore");
|
|
68
|
-
const studio = read(path.join(root, ".claude/nccgs/studio.json"));
|
|
69
|
-
for (const agent of studio.agents) known.add(`.claude/agents/${agent.name}.md`);
|
|
70
|
-
return known;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function removeHooks(text) {
|
|
74
|
-
const settings = JSON.parse(text);
|
|
75
|
-
need(object(settings), "Claude settings must be an object.");
|
|
76
|
-
if (settings.hooks === undefined) return text;
|
|
77
|
-
need(object(settings.hooks), "Invalid Claude hooks; no files were removed.");
|
|
78
|
-
const fragment = read(path.join(root, ".claude/nccgs/settings.fragment.json"));
|
|
79
|
-
const commands = new Set(Object.values(fragment.hooks).flatMap(groups => groups.flatMap(g => g.hooks))
|
|
80
|
-
.map(
|
|
81
|
-
let changed = false;
|
|
82
|
-
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
83
|
-
need(Array.isArray(groups), `Invalid hook groups: ${event}`);
|
|
84
|
-
settings.hooks[event] = groups.flatMap(group => {
|
|
85
|
-
need(object(group) && Array.isArray(group.hooks), `Invalid hook group: ${event}`);
|
|
86
|
-
const hooks = group.hooks.filter(hook => {
|
|
87
|
-
need(object(hook), `Invalid hook: ${event}`);
|
|
88
|
-
if (hook.type !== "command" || typeof hook.command !== "string") return true;
|
|
89
|
-
if (commands.has(
|
|
90
|
-
need(
|
|
91
|
-
`Customized NCCGS hook in ${event}; separate/remove it manually before uninstalling.`);
|
|
92
|
-
return true;
|
|
93
|
-
});
|
|
94
|
-
// Keep unrelated hooks and their matcher/timeout/metadata, even when they
|
|
95
|
-
// share a group with a framework hook.
|
|
96
|
-
return hooks.length || !group.hooks.length ? [{ ...group, hooks }] : [];
|
|
97
|
-
});
|
|
98
|
-
if (!settings.hooks[event].length && groups.length) delete settings.hooks[event];
|
|
99
|
-
}
|
|
100
|
-
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
|
101
|
-
// Old installers did not record ownership of generic deny/model preferences.
|
|
102
|
-
// Preserve those shared settings instead of guessing which values to remove.
|
|
103
|
-
return changed ? `${JSON.stringify(settings, null, 2)}\n` : text;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function removeImport(buffer) {
|
|
107
|
-
if (equivalentBuffers(buffer, fs.readFileSync(path.join(root, "CLAUDE.md")))) return null;
|
|
108
|
-
const text = buffer.toString("utf8");
|
|
109
|
-
need(Buffer.from(text, "utf8").equals(buffer), "CLAUDE.md must be UTF-8 to remove the NCCGS import safely.");
|
|
110
|
-
const next = text.replace(/^<!-- NCCGS:BEGIN -->\r?\n@\.claude\/nccgs\/constitution\.md\r?\n<!-- NCCGS:END -->(?:\r?\n|$)/gm, "")
|
|
111
|
-
.replace(/^[ \t]*@\.claude\/nccgs\/constitution\.md[ \t]*(?:\r?\n|$)/gm, "");
|
|
112
|
-
need(!/@\.claude\/nccgs\//.test(next), "Customized NCCGS import in CLAUDE.md; remove it manually before uninstalling.");
|
|
113
|
-
return Buffer.from(next, "utf8");
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function planUninstall(project, ownToken = null) {
|
|
117
|
-
project = assertProjectRoot(project);
|
|
118
|
-
need(fs.realpathSync(project) !== fs.realpathSync(root), "The NCCGS package cannot uninstall itself.");
|
|
119
|
-
assertUninstallIdle(project, ownToken);
|
|
120
|
-
const at = relative => containedPath(project, relative), known = catalog(), file = at(statePath);
|
|
121
|
-
if (!fs.existsSync(file)) return { project, status: "NOT_INSTALLED", changes: [], preserved: [], warnings: [] };
|
|
122
|
-
const state = read(file);
|
|
123
|
-
need([1, 2, 3].includes(state.schemaVersion) && Array.isArray(state.files) && typeof state.frameworkVersion === "string", "Invalid install state; refuse unsafe cleanup.");
|
|
124
|
-
const managed = new Map(), aliases = new Set();
|
|
125
|
-
for (const item of state.files) {
|
|
126
|
-
const relative = assertRelativePath(item.relative);
|
|
127
|
-
need(relative.startsWith(".claude/") && ![".claude/settings.json", ".claude/settings.local.json", statePath].includes(relative), `Unsafe managed path: ${relative}`);
|
|
128
|
-
need(digest(item.sha256) && (item.canonicalSha256 === undefined || digest(item.canonicalSha256)), `Invalid managed fingerprint: ${relative}`);
|
|
129
|
-
need(!aliases.has(relative.toLowerCase()), `Duplicate managed path: ${relative}`);
|
|
130
|
-
aliases.add(relative.toLowerCase()); at(relative); managed.set(relative, item);
|
|
131
|
-
}
|
|
132
|
-
const activationPath = at(".nccgs/agent-activation.json");
|
|
133
|
-
if (fs.existsSync(activationPath)) {
|
|
134
|
-
const activation = read(activationPath);
|
|
135
|
-
need(activation.schemaVersion === 1 && object(activation.generated), "Invalid generated-agent inventory.");
|
|
136
|
-
for (const [relative, hash] of Object.entries(activation.generated)) {
|
|
137
|
-
need(known.has(relative) && /^\.claude\/agents\/nccgs-[\w-]+\.md$/.test(relative) && digest(hash), `Unsafe generated agent: ${relative}`);
|
|
138
|
-
at(relative);
|
|
139
|
-
// Profile/optional-agent configuration can be newer than install-state.
|
|
140
|
-
const canonicalSha256 = activation.generatedCanonical?.[relative];
|
|
141
|
-
need(canonicalSha256 === undefined || digest(canonicalSha256), `Invalid generated-agent fingerprint: ${relative}`);
|
|
142
|
-
managed.set(relative, { sha256: hash, ...(canonicalSha256 ? { canonicalSha256 } : {}) });
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
const changes = [], preserved = [], warnings = [];
|
|
146
|
-
const change = (relative, after) => {
|
|
147
|
-
const current = fs.readFileSync(at(relative));
|
|
148
|
-
if (after !== null && current.equals(after)) return;
|
|
149
|
-
changes.push({ path: relative, before: sha256(current), after: after === null ? null : sha256(after), bytes: current, output: after });
|
|
150
|
-
};
|
|
151
|
-
for (const [relative, fingerprint] of managed) {
|
|
152
|
-
if (!fs.existsSync(at(relative))) continue;
|
|
153
|
-
need(fs.statSync(at(relative)).isFile(), `Managed target is not a file: ${relative}`);
|
|
154
|
-
if (!known.has(relative)) { preserved.push({ path: relative, reason: "Not in this package's trusted file catalog" }); continue; }
|
|
155
|
-
if (!matchesFingerprint(at(relative), fingerprint)) { preserved.push({ path: relative, reason: "Locally modified or pre-existing override" }); continue; }
|
|
156
|
-
change(relative, null);
|
|
157
|
-
}
|
|
158
|
-
if (fs.existsSync(at(".claude/settings.json"))) change(".claude/settings.json", Buffer.from(removeHooks(fs.readFileSync(at(".claude/settings.json"), "utf8"))));
|
|
159
|
-
if (fs.existsSync(at("CLAUDE.md"))) change("CLAUDE.md", removeImport(fs.readFileSync(at("CLAUDE.md"))));
|
|
160
|
-
// Removing this marker last makes repeated uninstall a no-op and stops run.
|
|
161
|
-
change(statePath, null);
|
|
162
|
-
warnings.push("Project .nccgs history, budgets, policy, backups and generic Claude permission/model settings are retained.");
|
|
163
|
-
if (preserved.length) warnings.push("Modified/unknown managed files remain in place; inspect the preserved list before removing them manually.");
|
|
164
|
-
return { project, status: "PLANNED", frameworkVersion: state.frameworkVersion, changes, preserved, warnings };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function manifestPath(id) {
|
|
168
|
-
need(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id), "Rollback requires the printed uninstall UUID.");
|
|
169
|
-
return `.nccgs/uninstall/${id}/manifest.json`;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function writeManifest(project, relative, value) {
|
|
173
|
-
const temporary = `${relative}.${crypto.randomUUID()}.tmp`;
|
|
174
|
-
fs.writeFileSync(containedPath(project, temporary), JSON.stringify(value, null, 2) + "\n", { flag: "wx" });
|
|
175
|
-
fs.renameSync(containedPath(project, temporary), containedPath(project, relative));
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export function restoreUninstall(project, id, { dryRun = false, ownToken = null } = {}) {
|
|
179
|
-
project = assertProjectRoot(project); assertUninstallIdle(project, ownToken);
|
|
180
|
-
const relative = manifestPath(id), manifest = read(containedPath(project, relative));
|
|
181
|
-
need(manifest.schemaVersion === 1 && manifest.kind === "nccgs-uninstall" && manifest.id === id
|
|
182
|
-
&& ["prepared", "applying", "completed", "restoring", "failed"].includes(manifest.status) && Array.isArray(manifest.files), "Invalid or already restored uninstall receipt.");
|
|
183
|
-
const known = catalog(), aliases = new Set(), restores = [];
|
|
184
|
-
for (const item of manifest.files) {
|
|
185
|
-
assertRelativePath(item.path);
|
|
186
|
-
need(known.has(item.path) || [statePath, "CLAUDE.md", ".claude/settings.json"].includes(item.path), `Unsafe rollback path: ${item.path}`);
|
|
187
|
-
need(!aliases.has(item.path.toLowerCase()) && digest(item.before) && (item.after === null || digest(item.after)), "Invalid rollback record.");
|
|
188
|
-
aliases.add(item.path.toLowerCase());
|
|
189
|
-
const destination = containedPath(project, item.path), backup = containedPath(project, `.nccgs/uninstall/${id}/files/${item.path}`);
|
|
190
|
-
const bytes = fs.readFileSync(backup); need(sha256(bytes) === item.before, `Changed rollback backup: ${item.path}`);
|
|
191
|
-
const current = fs.existsSync(destination) ? sha256(fs.readFileSync(destination)) : null;
|
|
192
|
-
need(current === item.before || current === item.after, `File changed after uninstall; refusing to overwrite: ${item.path}`);
|
|
193
|
-
if (current !== item.before) restores.push({ ...item, bytes });
|
|
194
|
-
}
|
|
195
|
-
if (dryRun) return { status: "ROLLBACK_PREVIEW", id, restoredFiles: restores.map(x => x.path) };
|
|
196
|
-
manifest.status = "restoring"; writeManifest(project, relative, manifest);
|
|
197
|
-
for (const item of restores) {
|
|
198
|
-
const file = containedPath(project, item.path), current = fs.existsSync(file) ? sha256(fs.readFileSync(file)) : null;
|
|
199
|
-
need(current === item.after, `Concurrent change during rollback: ${item.path}`);
|
|
200
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
201
|
-
fs.writeFileSync(containedPath(project, item.path), item.bytes, { flag: item.after === null ? "wx" : "w" });
|
|
202
|
-
}
|
|
203
|
-
manifest.status = "restored"; manifest.restoredAt = new Date().toISOString(); writeManifest(project, relative, manifest);
|
|
204
|
-
return { status: "RESTORED", id, restoredFiles: restores.map(x => x.path) };
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function uninstall(project, { dryRun = false, rollback = null } = {}) {
|
|
208
|
-
project = assertProjectRoot(project);
|
|
209
|
-
need(fs.realpathSync(project) !== fs.realpathSync(root), "The NCCGS package cannot uninstall itself.");
|
|
210
|
-
if (!rollback) assertInstallationAvailable(project);
|
|
211
|
-
if (dryRun) return rollback ? restoreUninstall(project, rollback, { dryRun: true }) : summary(planUninstall(project));
|
|
212
|
-
assertUninstallIdle(project);
|
|
213
|
-
if (!rollback && !fs.existsSync(containedPath(project, statePath))) return summary(planUninstall(project));
|
|
214
|
-
const lock = acquireLock(containedPath(project, lockPath), { operation: "uninstall" });
|
|
215
|
-
try {
|
|
216
|
-
if (rollback) return restoreUninstall(project, rollback, { ownToken: lock.record.token });
|
|
217
|
-
// Planning is read-only, with our own lock explicitly recognized.
|
|
218
|
-
const plan = planUninstall(project, lock.record.token);
|
|
219
|
-
const id = crypto.randomUUID(), relative = manifestPath(id), directory = path.dirname(containedPath(project, relative));
|
|
220
|
-
for (const item of plan.changes) {
|
|
221
|
-
const file = containedPath(project, `.nccgs/uninstall/${id}/files/${item.path}`);
|
|
222
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
223
|
-
fs.writeFileSync(containedPath(project, `.nccgs/uninstall/${id}/files/${item.path}`), item.bytes, { flag: "wx" });
|
|
224
|
-
}
|
|
225
|
-
fs.mkdirSync(directory, { recursive: true });
|
|
226
|
-
const manifest = { schemaVersion: 1, kind: "nccgs-uninstall", id, frameworkVersion: plan.frameworkVersion,
|
|
227
|
-
createdAt: new Date().toISOString(), status: "prepared", files: plan.changes.map(({ path, before, after }) => ({ path, before, after })),
|
|
228
|
-
preserved: plan.preserved, warnings: plan.warnings };
|
|
229
|
-
writeManifest(project, relative, manifest);
|
|
230
|
-
let mutationStarted = false;
|
|
231
|
-
try {
|
|
232
|
-
assertUninstallIdle(project, lock.record.token);
|
|
233
|
-
for (const item of plan.changes) need(sha256(fs.readFileSync(containedPath(project, item.path))) === item.before, `Concurrent change before uninstall: ${item.path}`);
|
|
234
|
-
manifest.status = "applying"; writeManifest(project, relative, manifest);
|
|
235
|
-
for (const item of plan.changes) {
|
|
236
|
-
const file = containedPath(project, item.path);
|
|
237
|
-
need(sha256(fs.readFileSync(file)) === item.before, `Concurrent change during uninstall: ${item.path}`);
|
|
238
|
-
mutationStarted = true;
|
|
239
|
-
if (item.output === null) fs.unlinkSync(containedPath(project, item.path));
|
|
240
|
-
else fs.writeFileSync(containedPath(project, item.path), item.output);
|
|
241
|
-
}
|
|
242
|
-
manifest.status = "completed"; manifest.completedAt = new Date().toISOString(); writeManifest(project, relative, manifest);
|
|
243
|
-
} catch (error) {
|
|
244
|
-
if (!mutationStarted) {
|
|
245
|
-
manifest.status = "cancelled"; manifest.error = error.message; manifest.cancelledAt = new Date().toISOString();
|
|
246
|
-
writeManifest(project, relative, manifest);
|
|
247
|
-
throw new Error(`${error.message}; uninstall cancelled before changing files; receipt ${relative}`);
|
|
248
|
-
}
|
|
249
|
-
manifest.status = "failed"; manifest.error = error.message; writeManifest(project, relative, manifest);
|
|
250
|
-
try { restoreUninstall(project, id, { ownToken: lock.record.token }); }
|
|
251
|
-
catch (restoreError) { throw new Error(`${error.message}; backup ${relative}; recovery requires inspection: ${restoreError.message}`); }
|
|
252
|
-
throw new Error(`${error.message}; all planned files restored from ${relative}`);
|
|
253
|
-
}
|
|
254
|
-
// Only empty directories derived from individually checked managed paths.
|
|
255
|
-
const directories = new Set();
|
|
256
|
-
for (const item of plan.changes.filter(x => x.output === null && x.path.startsWith(".claude/"))) {
|
|
257
|
-
let dir = path.posix.dirname(item.path);
|
|
258
|
-
while (dir.startsWith(".claude/")) { directories.add(dir); dir = path.posix.dirname(dir); }
|
|
259
|
-
}
|
|
260
|
-
const cleanupWarnings = [];
|
|
261
|
-
for (const dir of [...directories].sort((a, b) => b.length - a.length)) {
|
|
262
|
-
try { fs.rmdirSync(containedPath(project, dir)); }
|
|
263
|
-
catch (error) {
|
|
264
|
-
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code))
|
|
265
|
-
cleanupWarnings.push(`Empty-directory cleanup skipped for ${dir}: ${error.code || error.message}`);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
return { ...summary(plan), warnings: [...plan.warnings, ...cleanupWarnings], status: plan.preserved.length ? "UNINSTALLED_WITH_PRESERVED_FILES" : "UNINSTALLED", id, backup: relative };
|
|
269
|
-
} finally { lock.release(); }
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function summary(plan) {
|
|
273
|
-
return { project: plan.project, status: plan.status, frameworkVersion: plan.frameworkVersion,
|
|
274
|
-
remove: plan.changes.filter(x => x.after === null).map(x => x.path),
|
|
275
|
-
edit: plan.changes.filter(x => x.after !== null).map(x => x.path), preserved: plan.preserved, warnings: plan.warnings };
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
279
|
-
try {
|
|
280
|
-
let project = process.cwd(), dryRun = false, rollback = null;
|
|
281
|
-
const args = process.argv.slice(2), seen = new Set();
|
|
282
|
-
for (let i = 0; i < args.length; i++) {
|
|
283
|
-
const arg = args[i]; need(!seen.has(arg), `Duplicate option: ${arg}`); seen.add(arg);
|
|
284
|
-
if (arg === "--dry-run") dryRun = true;
|
|
285
|
-
else if (["--project", "--rollback"].includes(arg)) {
|
|
286
|
-
const value = args[++i]; need(value && !value.startsWith("--"), `${arg} requires a value`);
|
|
287
|
-
if (arg === "--project") project = path.resolve(value); else rollback = value;
|
|
288
|
-
} else throw new Error(`Unknown uninstall option: ${arg}`);
|
|
289
|
-
}
|
|
290
|
-
console.log(JSON.stringify(uninstall(project, { dryRun, rollback }), null, 2));
|
|
291
|
-
} catch (error) { console.error(`NCCGS uninstall refused: ${error.message}`); process.exitCode = 1; }
|
|
292
|
-
}
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { assertProjectRoot, assertRelativePath, containedPath } from "../.claude/nccgs/tools/paths.mjs";
|
|
6
|
+
import { matchesFingerprint, equivalentBuffers, sha256 } from "../.claude/nccgs/tools/integrity.mjs";
|
|
7
|
+
import { managedHookKey, referencesNccgsHook } from "../.claude/nccgs/tools/hook-transport.mjs";
|
|
8
|
+
import { acquireLock, ownerState } from "../.claude/nccgs/tools/process-lock.mjs";
|
|
9
|
+
import { assertInstallationAvailable } from "../.claude/nccgs/tools/installation-guard.mjs";
|
|
10
|
+
|
|
11
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
const statePath = ".claude/nccgs/install-state.json";
|
|
13
|
+
const lockPath = ".nccgs/uninstall.lock";
|
|
14
|
+
const need = (ok, message) => { if (!ok) throw new Error(message); };
|
|
15
|
+
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
16
|
+
const digest = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
17
|
+
const read = file => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
18
|
+
|
|
19
|
+
function files(project, relative) {
|
|
20
|
+
const directory = containedPath(project, relative);
|
|
21
|
+
if (!fs.existsSync(directory)) return [];
|
|
22
|
+
need(fs.statSync(directory).isDirectory(), `Expected a directory: ${relative}`);
|
|
23
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
24
|
+
const name = `${relative}/${entry.name}`, file = containedPath(project, name);
|
|
25
|
+
if (entry.isDirectory()) return files(project, name);
|
|
26
|
+
need(entry.isFile() && fs.statSync(file).isFile(), `Unsupported entry: ${name}`);
|
|
27
|
+
return [name];
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Read only: do not settle/recover a job, rewrite a task, reclaim an old lock or
|
|
32
|
+
// terminate a process merely to make uninstallation possible.
|
|
33
|
+
export function assertUninstallIdle(project, ownToken = null) {
|
|
34
|
+
need(!(process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT || process.env.NCCGS_SUPERVISED_SESSION),
|
|
35
|
+
"Close Claude Code and run uninstall from an external terminal.");
|
|
36
|
+
const at = relative => containedPath(project, relative);
|
|
37
|
+
const candidates = [...[".nccgs/orchestration.lock", ".nccgs/measurement-start.lock", lockPath].flatMap(name => [name, `${name}.recovery`]),
|
|
38
|
+
...[".nccgs/tasks", ".nccgs/jobs", ".nccgs/runs", ".nccgs/sessions"].flatMap(dir => files(project, dir))];
|
|
39
|
+
for (const relative of candidates.filter(name => /\.lock(?:\.recovery)?$/.test(name))) {
|
|
40
|
+
if (!fs.existsSync(at(relative))) continue;
|
|
41
|
+
const lock = read(at(relative));
|
|
42
|
+
if (relative === lockPath && ownToken && lock.token === ownToken) continue;
|
|
43
|
+
need(ownerState(lock) === "dead", `Cannot uninstall: active or unverified process lock ${relative}. Stop/recover the owning work first.${relative.startsWith(".nccgs/sessions/") && lock.operation === "nccgs-run" ? " For a crashed launcher, see recover-session --dry-run and docs/UNINSTALL.md; never infer descendant shutdown from PID death." : ""}`);
|
|
44
|
+
}
|
|
45
|
+
const ledger = at(".nccgs/orchestration.json");
|
|
46
|
+
if (fs.existsSync(ledger)) {
|
|
47
|
+
const value = read(ledger);
|
|
48
|
+
need(object(value.dispatches), "Invalid dispatch ledger; inspect it before uninstalling.");
|
|
49
|
+
for (const dispatch of Object.values(value.dispatches)) {
|
|
50
|
+
need(object(dispatch) && ["completed", "failed", "cancelled"].includes(dispatch.status),
|
|
51
|
+
`Cannot uninstall: dispatch ${dispatch?.id ?? "unknown"} is active or unverified.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const relative of candidates.filter(name => /^\.nccgs\/jobs\/[^/]+\/request\.json$/.test(name))) {
|
|
55
|
+
const result = at(relative.replace(/request\.json$/, "result.json"));
|
|
56
|
+
need(fs.existsSync(result) && ["PASS", "FAIL"].includes(read(result).status), `Cannot uninstall: unsettled verification job ${relative}.`);
|
|
57
|
+
}
|
|
58
|
+
for (const relative of candidates.filter(name => /^\.nccgs\/runs\/[^/]+\/run\.json$/.test(name))) {
|
|
59
|
+
const run = read(at(relative));
|
|
60
|
+
need(["ready", "settled", "blocked"].includes(run.status) && Array.isArray(run.invocations)
|
|
61
|
+
&& run.invocations.every(item => ["finished", "recovered"].includes(item.status)), `Cannot uninstall: active or unverified run ${relative}.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function catalog() {
|
|
66
|
+
const known = new Set(files(root, ".claude"));
|
|
67
|
+
known.add(".claude/skills/setup-vivox-voice-chat/evals/.gitignore");
|
|
68
|
+
const studio = read(path.join(root, ".claude/nccgs/studio.json"));
|
|
69
|
+
for (const agent of studio.agents) known.add(`.claude/agents/${agent.name}.md`);
|
|
70
|
+
return known;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function removeHooks(text) {
|
|
74
|
+
const settings = JSON.parse(text);
|
|
75
|
+
need(object(settings), "Claude settings must be an object.");
|
|
76
|
+
if (settings.hooks === undefined) return text;
|
|
77
|
+
need(object(settings.hooks), "Invalid Claude hooks; no files were removed.");
|
|
78
|
+
const fragment = read(path.join(root, ".claude/nccgs/settings.fragment.json"));
|
|
79
|
+
const commands = new Set(Object.values(fragment.hooks).flatMap(groups => groups.flatMap(g => g.hooks))
|
|
80
|
+
.map(managedHookKey).filter(Boolean));
|
|
81
|
+
let changed = false;
|
|
82
|
+
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
83
|
+
need(Array.isArray(groups), `Invalid hook groups: ${event}`);
|
|
84
|
+
settings.hooks[event] = groups.flatMap(group => {
|
|
85
|
+
need(object(group) && Array.isArray(group.hooks), `Invalid hook group: ${event}`);
|
|
86
|
+
const hooks = group.hooks.filter(hook => {
|
|
87
|
+
need(object(hook), `Invalid hook: ${event}`);
|
|
88
|
+
if (hook.type !== "command" || typeof hook.command !== "string") return true;
|
|
89
|
+
if (commands.has(managedHookKey(hook))) { changed = true; return false; }
|
|
90
|
+
need(!referencesNccgsHook(hook),
|
|
91
|
+
`Customized NCCGS hook in ${event}; separate/remove it manually before uninstalling.`);
|
|
92
|
+
return true;
|
|
93
|
+
});
|
|
94
|
+
// Keep unrelated hooks and their matcher/timeout/metadata, even when they
|
|
95
|
+
// share a group with a framework hook.
|
|
96
|
+
return hooks.length || !group.hooks.length ? [{ ...group, hooks }] : [];
|
|
97
|
+
});
|
|
98
|
+
if (!settings.hooks[event].length && groups.length) delete settings.hooks[event];
|
|
99
|
+
}
|
|
100
|
+
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
|
101
|
+
// Old installers did not record ownership of generic deny/model preferences.
|
|
102
|
+
// Preserve those shared settings instead of guessing which values to remove.
|
|
103
|
+
return changed ? `${JSON.stringify(settings, null, 2)}\n` : text;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function removeImport(buffer) {
|
|
107
|
+
if (equivalentBuffers(buffer, fs.readFileSync(path.join(root, "CLAUDE.md")))) return null;
|
|
108
|
+
const text = buffer.toString("utf8");
|
|
109
|
+
need(Buffer.from(text, "utf8").equals(buffer), "CLAUDE.md must be UTF-8 to remove the NCCGS import safely.");
|
|
110
|
+
const next = text.replace(/^<!-- NCCGS:BEGIN -->\r?\n@\.claude\/nccgs\/constitution\.md\r?\n<!-- NCCGS:END -->(?:\r?\n|$)/gm, "")
|
|
111
|
+
.replace(/^[ \t]*@\.claude\/nccgs\/constitution\.md[ \t]*(?:\r?\n|$)/gm, "");
|
|
112
|
+
need(!/@\.claude\/nccgs\//.test(next), "Customized NCCGS import in CLAUDE.md; remove it manually before uninstalling.");
|
|
113
|
+
return Buffer.from(next, "utf8");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function planUninstall(project, ownToken = null) {
|
|
117
|
+
project = assertProjectRoot(project);
|
|
118
|
+
need(fs.realpathSync(project) !== fs.realpathSync(root), "The NCCGS package cannot uninstall itself.");
|
|
119
|
+
assertUninstallIdle(project, ownToken);
|
|
120
|
+
const at = relative => containedPath(project, relative), known = catalog(), file = at(statePath);
|
|
121
|
+
if (!fs.existsSync(file)) return { project, status: "NOT_INSTALLED", changes: [], preserved: [], warnings: [] };
|
|
122
|
+
const state = read(file);
|
|
123
|
+
need([1, 2, 3].includes(state.schemaVersion) && Array.isArray(state.files) && typeof state.frameworkVersion === "string", "Invalid install state; refuse unsafe cleanup.");
|
|
124
|
+
const managed = new Map(), aliases = new Set();
|
|
125
|
+
for (const item of state.files) {
|
|
126
|
+
const relative = assertRelativePath(item.relative);
|
|
127
|
+
need(relative.startsWith(".claude/") && ![".claude/settings.json", ".claude/settings.local.json", statePath].includes(relative), `Unsafe managed path: ${relative}`);
|
|
128
|
+
need(digest(item.sha256) && (item.canonicalSha256 === undefined || digest(item.canonicalSha256)), `Invalid managed fingerprint: ${relative}`);
|
|
129
|
+
need(!aliases.has(relative.toLowerCase()), `Duplicate managed path: ${relative}`);
|
|
130
|
+
aliases.add(relative.toLowerCase()); at(relative); managed.set(relative, item);
|
|
131
|
+
}
|
|
132
|
+
const activationPath = at(".nccgs/agent-activation.json");
|
|
133
|
+
if (fs.existsSync(activationPath)) {
|
|
134
|
+
const activation = read(activationPath);
|
|
135
|
+
need(activation.schemaVersion === 1 && object(activation.generated), "Invalid generated-agent inventory.");
|
|
136
|
+
for (const [relative, hash] of Object.entries(activation.generated)) {
|
|
137
|
+
need(known.has(relative) && /^\.claude\/agents\/nccgs-[\w-]+\.md$/.test(relative) && digest(hash), `Unsafe generated agent: ${relative}`);
|
|
138
|
+
at(relative);
|
|
139
|
+
// Profile/optional-agent configuration can be newer than install-state.
|
|
140
|
+
const canonicalSha256 = activation.generatedCanonical?.[relative];
|
|
141
|
+
need(canonicalSha256 === undefined || digest(canonicalSha256), `Invalid generated-agent fingerprint: ${relative}`);
|
|
142
|
+
managed.set(relative, { sha256: hash, ...(canonicalSha256 ? { canonicalSha256 } : {}) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const changes = [], preserved = [], warnings = [];
|
|
146
|
+
const change = (relative, after) => {
|
|
147
|
+
const current = fs.readFileSync(at(relative));
|
|
148
|
+
if (after !== null && current.equals(after)) return;
|
|
149
|
+
changes.push({ path: relative, before: sha256(current), after: after === null ? null : sha256(after), bytes: current, output: after });
|
|
150
|
+
};
|
|
151
|
+
for (const [relative, fingerprint] of managed) {
|
|
152
|
+
if (!fs.existsSync(at(relative))) continue;
|
|
153
|
+
need(fs.statSync(at(relative)).isFile(), `Managed target is not a file: ${relative}`);
|
|
154
|
+
if (!known.has(relative)) { preserved.push({ path: relative, reason: "Not in this package's trusted file catalog" }); continue; }
|
|
155
|
+
if (!matchesFingerprint(at(relative), fingerprint)) { preserved.push({ path: relative, reason: "Locally modified or pre-existing override" }); continue; }
|
|
156
|
+
change(relative, null);
|
|
157
|
+
}
|
|
158
|
+
if (fs.existsSync(at(".claude/settings.json"))) change(".claude/settings.json", Buffer.from(removeHooks(fs.readFileSync(at(".claude/settings.json"), "utf8"))));
|
|
159
|
+
if (fs.existsSync(at("CLAUDE.md"))) change("CLAUDE.md", removeImport(fs.readFileSync(at("CLAUDE.md"))));
|
|
160
|
+
// Removing this marker last makes repeated uninstall a no-op and stops run.
|
|
161
|
+
change(statePath, null);
|
|
162
|
+
warnings.push("Project .nccgs history, budgets, policy, backups and generic Claude permission/model settings are retained.");
|
|
163
|
+
if (preserved.length) warnings.push("Modified/unknown managed files remain in place; inspect the preserved list before removing them manually.");
|
|
164
|
+
return { project, status: "PLANNED", frameworkVersion: state.frameworkVersion, changes, preserved, warnings };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function manifestPath(id) {
|
|
168
|
+
need(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id), "Rollback requires the printed uninstall UUID.");
|
|
169
|
+
return `.nccgs/uninstall/${id}/manifest.json`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function writeManifest(project, relative, value) {
|
|
173
|
+
const temporary = `${relative}.${crypto.randomUUID()}.tmp`;
|
|
174
|
+
fs.writeFileSync(containedPath(project, temporary), JSON.stringify(value, null, 2) + "\n", { flag: "wx" });
|
|
175
|
+
fs.renameSync(containedPath(project, temporary), containedPath(project, relative));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function restoreUninstall(project, id, { dryRun = false, ownToken = null } = {}) {
|
|
179
|
+
project = assertProjectRoot(project); assertUninstallIdle(project, ownToken);
|
|
180
|
+
const relative = manifestPath(id), manifest = read(containedPath(project, relative));
|
|
181
|
+
need(manifest.schemaVersion === 1 && manifest.kind === "nccgs-uninstall" && manifest.id === id
|
|
182
|
+
&& ["prepared", "applying", "completed", "restoring", "failed"].includes(manifest.status) && Array.isArray(manifest.files), "Invalid or already restored uninstall receipt.");
|
|
183
|
+
const known = catalog(), aliases = new Set(), restores = [];
|
|
184
|
+
for (const item of manifest.files) {
|
|
185
|
+
assertRelativePath(item.path);
|
|
186
|
+
need(known.has(item.path) || [statePath, "CLAUDE.md", ".claude/settings.json"].includes(item.path), `Unsafe rollback path: ${item.path}`);
|
|
187
|
+
need(!aliases.has(item.path.toLowerCase()) && digest(item.before) && (item.after === null || digest(item.after)), "Invalid rollback record.");
|
|
188
|
+
aliases.add(item.path.toLowerCase());
|
|
189
|
+
const destination = containedPath(project, item.path), backup = containedPath(project, `.nccgs/uninstall/${id}/files/${item.path}`);
|
|
190
|
+
const bytes = fs.readFileSync(backup); need(sha256(bytes) === item.before, `Changed rollback backup: ${item.path}`);
|
|
191
|
+
const current = fs.existsSync(destination) ? sha256(fs.readFileSync(destination)) : null;
|
|
192
|
+
need(current === item.before || current === item.after, `File changed after uninstall; refusing to overwrite: ${item.path}`);
|
|
193
|
+
if (current !== item.before) restores.push({ ...item, bytes });
|
|
194
|
+
}
|
|
195
|
+
if (dryRun) return { status: "ROLLBACK_PREVIEW", id, restoredFiles: restores.map(x => x.path) };
|
|
196
|
+
manifest.status = "restoring"; writeManifest(project, relative, manifest);
|
|
197
|
+
for (const item of restores) {
|
|
198
|
+
const file = containedPath(project, item.path), current = fs.existsSync(file) ? sha256(fs.readFileSync(file)) : null;
|
|
199
|
+
need(current === item.after, `Concurrent change during rollback: ${item.path}`);
|
|
200
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
201
|
+
fs.writeFileSync(containedPath(project, item.path), item.bytes, { flag: item.after === null ? "wx" : "w" });
|
|
202
|
+
}
|
|
203
|
+
manifest.status = "restored"; manifest.restoredAt = new Date().toISOString(); writeManifest(project, relative, manifest);
|
|
204
|
+
return { status: "RESTORED", id, restoredFiles: restores.map(x => x.path) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function uninstall(project, { dryRun = false, rollback = null } = {}) {
|
|
208
|
+
project = assertProjectRoot(project);
|
|
209
|
+
need(fs.realpathSync(project) !== fs.realpathSync(root), "The NCCGS package cannot uninstall itself.");
|
|
210
|
+
if (!rollback) assertInstallationAvailable(project);
|
|
211
|
+
if (dryRun) return rollback ? restoreUninstall(project, rollback, { dryRun: true }) : summary(planUninstall(project));
|
|
212
|
+
assertUninstallIdle(project);
|
|
213
|
+
if (!rollback && !fs.existsSync(containedPath(project, statePath))) return summary(planUninstall(project));
|
|
214
|
+
const lock = acquireLock(containedPath(project, lockPath), { operation: "uninstall" });
|
|
215
|
+
try {
|
|
216
|
+
if (rollback) return restoreUninstall(project, rollback, { ownToken: lock.record.token });
|
|
217
|
+
// Planning is read-only, with our own lock explicitly recognized.
|
|
218
|
+
const plan = planUninstall(project, lock.record.token);
|
|
219
|
+
const id = crypto.randomUUID(), relative = manifestPath(id), directory = path.dirname(containedPath(project, relative));
|
|
220
|
+
for (const item of plan.changes) {
|
|
221
|
+
const file = containedPath(project, `.nccgs/uninstall/${id}/files/${item.path}`);
|
|
222
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
223
|
+
fs.writeFileSync(containedPath(project, `.nccgs/uninstall/${id}/files/${item.path}`), item.bytes, { flag: "wx" });
|
|
224
|
+
}
|
|
225
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
226
|
+
const manifest = { schemaVersion: 1, kind: "nccgs-uninstall", id, frameworkVersion: plan.frameworkVersion,
|
|
227
|
+
createdAt: new Date().toISOString(), status: "prepared", files: plan.changes.map(({ path, before, after }) => ({ path, before, after })),
|
|
228
|
+
preserved: plan.preserved, warnings: plan.warnings };
|
|
229
|
+
writeManifest(project, relative, manifest);
|
|
230
|
+
let mutationStarted = false;
|
|
231
|
+
try {
|
|
232
|
+
assertUninstallIdle(project, lock.record.token);
|
|
233
|
+
for (const item of plan.changes) need(sha256(fs.readFileSync(containedPath(project, item.path))) === item.before, `Concurrent change before uninstall: ${item.path}`);
|
|
234
|
+
manifest.status = "applying"; writeManifest(project, relative, manifest);
|
|
235
|
+
for (const item of plan.changes) {
|
|
236
|
+
const file = containedPath(project, item.path);
|
|
237
|
+
need(sha256(fs.readFileSync(file)) === item.before, `Concurrent change during uninstall: ${item.path}`);
|
|
238
|
+
mutationStarted = true;
|
|
239
|
+
if (item.output === null) fs.unlinkSync(containedPath(project, item.path));
|
|
240
|
+
else fs.writeFileSync(containedPath(project, item.path), item.output);
|
|
241
|
+
}
|
|
242
|
+
manifest.status = "completed"; manifest.completedAt = new Date().toISOString(); writeManifest(project, relative, manifest);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (!mutationStarted) {
|
|
245
|
+
manifest.status = "cancelled"; manifest.error = error.message; manifest.cancelledAt = new Date().toISOString();
|
|
246
|
+
writeManifest(project, relative, manifest);
|
|
247
|
+
throw new Error(`${error.message}; uninstall cancelled before changing files; receipt ${relative}`);
|
|
248
|
+
}
|
|
249
|
+
manifest.status = "failed"; manifest.error = error.message; writeManifest(project, relative, manifest);
|
|
250
|
+
try { restoreUninstall(project, id, { ownToken: lock.record.token }); }
|
|
251
|
+
catch (restoreError) { throw new Error(`${error.message}; backup ${relative}; recovery requires inspection: ${restoreError.message}`); }
|
|
252
|
+
throw new Error(`${error.message}; all planned files restored from ${relative}`);
|
|
253
|
+
}
|
|
254
|
+
// Only empty directories derived from individually checked managed paths.
|
|
255
|
+
const directories = new Set();
|
|
256
|
+
for (const item of plan.changes.filter(x => x.output === null && x.path.startsWith(".claude/"))) {
|
|
257
|
+
let dir = path.posix.dirname(item.path);
|
|
258
|
+
while (dir.startsWith(".claude/")) { directories.add(dir); dir = path.posix.dirname(dir); }
|
|
259
|
+
}
|
|
260
|
+
const cleanupWarnings = [];
|
|
261
|
+
for (const dir of [...directories].sort((a, b) => b.length - a.length)) {
|
|
262
|
+
try { fs.rmdirSync(containedPath(project, dir)); }
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code))
|
|
265
|
+
cleanupWarnings.push(`Empty-directory cleanup skipped for ${dir}: ${error.code || error.message}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { ...summary(plan), warnings: [...plan.warnings, ...cleanupWarnings], status: plan.preserved.length ? "UNINSTALLED_WITH_PRESERVED_FILES" : "UNINSTALLED", id, backup: relative };
|
|
269
|
+
} finally { lock.release(); }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function summary(plan) {
|
|
273
|
+
return { project: plan.project, status: plan.status, frameworkVersion: plan.frameworkVersion,
|
|
274
|
+
remove: plan.changes.filter(x => x.after === null).map(x => x.path),
|
|
275
|
+
edit: plan.changes.filter(x => x.after !== null).map(x => x.path), preserved: plan.preserved, warnings: plan.warnings };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
279
|
+
try {
|
|
280
|
+
let project = process.cwd(), dryRun = false, rollback = null;
|
|
281
|
+
const args = process.argv.slice(2), seen = new Set();
|
|
282
|
+
for (let i = 0; i < args.length; i++) {
|
|
283
|
+
const arg = args[i]; need(!seen.has(arg), `Duplicate option: ${arg}`); seen.add(arg);
|
|
284
|
+
if (arg === "--dry-run") dryRun = true;
|
|
285
|
+
else if (["--project", "--rollback"].includes(arg)) {
|
|
286
|
+
const value = args[++i]; need(value && !value.startsWith("--"), `${arg} requires a value`);
|
|
287
|
+
if (arg === "--project") project = path.resolve(value); else rollback = value;
|
|
288
|
+
} else throw new Error(`Unknown uninstall option: ${arg}`);
|
|
289
|
+
}
|
|
290
|
+
console.log(JSON.stringify(uninstall(project, { dryRun, rollback }), null, 2));
|
|
291
|
+
} catch (error) { console.error(`NCCGS uninstall refused: ${error.message}`); process.exitCode = 1; }
|
|
292
|
+
}
|