create-agent-rig 0.9.1 → 0.10.0
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/CHANGELOG.md +419 -3
- package/README.md +267 -305
- package/package.json +8 -14
- package/packages/cli/dist/commands/create.js +56 -88
- package/packages/cli/dist/commands/doctor.js +213 -0
- package/packages/cli/dist/commands/init.js +111 -50
- package/packages/cli/dist/commands/integrations.js +468 -0
- package/packages/cli/dist/commands/setup-wizard.js +61 -0
- package/packages/cli/dist/commands/uninstall.js +1223 -0
- package/packages/cli/dist/commands/upgrade.js +472 -90
- package/packages/cli/dist/index.js +651 -46
- package/packages/cli/dist/integrations/declaration.js +158 -0
- package/packages/cli/dist/integrations/doctor-guards.js +126 -0
- package/packages/cli/dist/integrations/doctor-workflow.js +25 -0
- package/packages/cli/dist/integrations/mcp-json.js +107 -0
- package/packages/cli/dist/integrations/memory-doctor.js +124 -0
- package/packages/cli/dist/integrations/registry.js +22 -0
- package/packages/cli/dist/integrations/spawn.js +228 -0
- package/packages/cli/dist/integrations/spec-kit.js +280 -0
- package/packages/cli/dist/integrations/verify.js +193 -0
- package/packages/cli/dist/integrations/windows-job.js +275 -0
- package/packages/cli/dist/lib/elevated-paths.js +74 -0
- package/packages/cli/dist/lib/install-set.js +1 -44
- package/packages/cli/dist/lib/manifest.js +64 -5
- package/packages/cli/dist/lib/prompts.js +0 -23
- package/packages/cli/dist/lib/safe-path.js +193 -2
- package/packages/cli/dist/lib/safe-text.js +78 -0
- package/packages/cli/dist/lib/substitute.js +1 -52
- package/packages/cli/dist/lib/summary.js +5 -5
- package/packages/cli/dist/templates.js +4 -14
- package/scripts/prepare.mjs +28 -12
- package/templates/agent-os/subagent-routing.json +4 -4
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +13 -9
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +31 -6
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +5 -5
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +3 -3
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -6
- package/templates/agent-os/universal/.claude/agents/implementation-agent.md +41 -0
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -4
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +4 -2
- package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -2
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +83 -4
- package/templates/agent-os/universal/.claude/rules/autonomy.md +55 -27
- package/templates/agent-os/universal/.claude/rules/invariants.md +9 -8
- package/templates/agent-os/universal/.claude/rules/workflow.md +47 -22
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -1
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +12 -9
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +100 -2
- package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +236 -6
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +2 -2
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +4 -3
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +16 -0
- package/templates/agent-os/universal/.claude/settings.json +0 -8
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +13 -9
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +31 -6
- package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +5 -5
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
- package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +3 -3
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -2
- package/templates/agent-os/universal/.codex/agents/implementation-agent.toml +6 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -2
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +0 -10
- package/templates/agent-os/universal/AGENTS.md +208 -107
- package/templates/agent-os/universal/CLAUDE.md +15 -162
- package/templates/agent-os/universal/PLAN.md +17 -10
- package/templates/agent-os/universal/docs/decisions/agents-md-canonical.md +313 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +15 -10
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +12 -8
- package/templates/agent-os/universal/docs/decisions/session-start-wire-format.md +206 -0
- package/templates/agent-os/universal/docs/decisions/subagent-routing.md +6 -3
- package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +235 -0
- package/templates/agent-os/universal/layers.json +25 -30
- package/templates/hash-history.json +8 -4
- package/templates/release-ledger.json +2 -1
- package/packages/cli/dist/lib/composition.js +0 -20
- package/packages/cli/dist/lib/targets.js +0 -28
- package/packages/cli/dist/policy/benchmark/corpus.js +0 -165
- package/packages/cli/dist/policy/core/adapter.js +0 -18
- package/packages/cli/dist/policy/core/coverage.js +0 -253
- package/packages/cli/dist/policy/core/decision-record.js +0 -287
- package/packages/cli/dist/policy/core/declaration.js +0 -127
- package/packages/cli/dist/policy/core/evidence-matrix.js +0 -94
- package/packages/cli/dist/policy/core/probe.js +0 -442
- package/packages/cli/dist/policy/core/registry.js +0 -115
- package/packages/cli/dist/policy/core/validation.js +0 -275
- package/packages/cli/dist/policy/core/vocabulary.js +0 -123
- package/packages/cli/dist/policy/harness/claude.js +0 -47
- package/packages/cli/dist/policy/harness/codex.js +0 -87
- package/packages/cli/dist/policy/harness/index.js +0 -15
- package/packages/cli/dist/policy/harness/shared-hooks.js +0 -28
- package/packages/cli/dist/policy/index.js +0 -17
- package/templates/agent-os/init/AGENTS.md +0 -201
- package/templates/agent-os/init/CLAUDE.md +0 -201
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +0 -105
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +0 -117
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +0 -89
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +0 -105
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +0 -105
- package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +0 -117
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +0 -6
- package/templates/agent-os/stack/node-ts/.claude/hooks/dod-checks.json +0 -1
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +0 -85
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +0 -89
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +0 -68
- package/templates/agent-os/universal/.claude/rules/architecture.md +0 -81
- package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +0 -28
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +0 -90
- package/templates/skeleton/aws-serverless/README.md +0 -181
- package/templates/skeleton/aws-serverless/apps/web/next.config.mjs +0 -17
- package/templates/skeleton/aws-serverless/apps/web/package.json +0 -19
- package/templates/skeleton/aws-serverless/apps/web/src/app/layout.tsx +0 -17
- package/templates/skeleton/aws-serverless/apps/web/src/app/page.tsx +0 -96
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +0 -36
- package/templates/skeleton/aws-serverless/apps/web/src/lib/validate.ts +0 -23
- package/templates/skeleton/aws-serverless/apps/web/test/shared-validation.test.ts +0 -38
- package/templates/skeleton/aws-serverless/apps/web/tsconfig.json +0 -14
- package/templates/skeleton/aws-serverless/eslint.config.mjs +0 -20
- package/templates/skeleton/aws-serverless/gitignore +0 -56
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +0 -79
- package/templates/skeleton/aws-serverless/infra/cdk.json +0 -3
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +0 -248
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +0 -45
- package/templates/skeleton/aws-serverless/infra/package.json +0 -18
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +0 -301
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +0 -137
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +0 -120
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +0 -44
- package/templates/skeleton/aws-serverless/package.json +0 -30
- package/templates/skeleton/aws-serverless/packages/core/package.json +0 -11
- package/templates/skeleton/aws-serverless/packages/core/src/events.ts +0 -14
- package/templates/skeleton/aws-serverless/packages/core/src/index.ts +0 -15
- package/templates/skeleton/aws-serverless/packages/core/src/note.ts +0 -69
- package/templates/skeleton/aws-serverless/packages/core/test/events.test.ts +0 -23
- package/templates/skeleton/aws-serverless/packages/core/test/note.test.ts +0 -101
- package/templates/skeleton/aws-serverless/packages/db/package.json +0 -14
- package/templates/skeleton/aws-serverless/packages/db/src/client.ts +0 -17
- package/templates/skeleton/aws-serverless/packages/db/src/index.ts +0 -2
- package/templates/skeleton/aws-serverless/packages/db/src/note-model.ts +0 -52
- package/templates/skeleton/aws-serverless/packages/db/test/note-model.test.ts +0 -91
- package/templates/skeleton/aws-serverless/packages/shared/package.json +0 -11
- package/templates/skeleton/aws-serverless/packages/shared/src/env.ts +0 -17
- package/templates/skeleton/aws-serverless/packages/shared/src/errors.ts +0 -33
- package/templates/skeleton/aws-serverless/packages/shared/src/index.ts +0 -3
- package/templates/skeleton/aws-serverless/packages/shared/src/logger.ts +0 -20
- package/templates/skeleton/aws-serverless/packages/shared/test/env.test.ts +0 -26
- package/templates/skeleton/aws-serverless/packages/shared/test/errors.test.ts +0 -28
- package/templates/skeleton/aws-serverless/packages/shared/test/logger.test.ts +0 -19
- package/templates/skeleton/aws-serverless/pnpm-lock.yaml +0 -2855
- package/templates/skeleton/aws-serverless/pnpm-workspace.yaml +0 -14
- package/templates/skeleton/aws-serverless/services/api/package.json +0 -15
- package/templates/skeleton/aws-serverless/services/api/src/adapters/sqs-publisher.ts +0 -26
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +0 -69
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +0 -37
- package/templates/skeleton/aws-serverless/services/api/src/list-main.ts +0 -12
- package/templates/skeleton/aws-serverless/services/api/src/main.ts +0 -21
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +0 -41
- package/templates/skeleton/aws-serverless/services/api/src/usecases/list-notes.ts +0 -14
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +0 -211
- package/templates/skeleton/aws-serverless/services/api/test/create-note.usecase.test.ts +0 -45
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +0 -122
- package/templates/skeleton/aws-serverless/services/api/test/sqs-publisher.test.ts +0 -22
- package/templates/skeleton/aws-serverless/services/worker/package.json +0 -12
- package/templates/skeleton/aws-serverless/services/worker/src/handlers/note-created.ts +0 -15
- package/templates/skeleton/aws-serverless/services/worker/src/main.ts +0 -7
- package/templates/skeleton/aws-serverless/services/worker/src/usecases/process-note-created.ts +0 -37
- package/templates/skeleton/aws-serverless/services/worker/test/note-created.test.ts +0 -61
- package/templates/skeleton/aws-serverless/tsconfig.base.json +0 -15
- package/templates/skeleton/aws-serverless/tsconfig.json +0 -16
- package/templates/skeleton/aws-serverless/vitest.config.ts +0 -14
- package/templates/skeleton/node-service/.github/workflows/ci.yml +0 -27
- package/templates/skeleton/node-service/.github/workflows/deploy.yml +0 -29
- package/templates/skeleton/node-service/README.md +0 -104
- package/templates/skeleton/node-service/apps/web/next.config.mjs +0 -17
- package/templates/skeleton/node-service/apps/web/package.json +0 -19
- package/templates/skeleton/node-service/apps/web/src/app/layout.tsx +0 -17
- package/templates/skeleton/node-service/apps/web/src/app/page.tsx +0 -96
- package/templates/skeleton/node-service/apps/web/src/lib/api.ts +0 -29
- package/templates/skeleton/node-service/apps/web/src/lib/validate.ts +0 -23
- package/templates/skeleton/node-service/apps/web/test/shared-validation.test.ts +0 -38
- package/templates/skeleton/node-service/apps/web/tsconfig.json +0 -14
- package/templates/skeleton/node-service/eslint.config.mjs +0 -20
- package/templates/skeleton/node-service/gitignore +0 -54
- package/templates/skeleton/node-service/package.json +0 -30
- package/templates/skeleton/node-service/packages/core/package.json +0 -11
- package/templates/skeleton/node-service/packages/core/src/events.ts +0 -14
- package/templates/skeleton/node-service/packages/core/src/index.ts +0 -15
- package/templates/skeleton/node-service/packages/core/src/note.ts +0 -69
- package/templates/skeleton/node-service/packages/core/test/events.test.ts +0 -23
- package/templates/skeleton/node-service/packages/core/test/note.test.ts +0 -101
- package/templates/skeleton/node-service/packages/db/package.json +0 -12
- package/templates/skeleton/node-service/packages/db/src/index.ts +0 -1
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +0 -100
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +0 -100
- package/templates/skeleton/node-service/packages/shared/package.json +0 -11
- package/templates/skeleton/node-service/packages/shared/src/env.ts +0 -17
- package/templates/skeleton/node-service/packages/shared/src/errors.ts +0 -33
- package/templates/skeleton/node-service/packages/shared/src/index.ts +0 -3
- package/templates/skeleton/node-service/packages/shared/src/logger.ts +0 -20
- package/templates/skeleton/node-service/packages/shared/test/env.test.ts +0 -26
- package/templates/skeleton/node-service/packages/shared/test/errors.test.ts +0 -28
- package/templates/skeleton/node-service/packages/shared/test/logger.test.ts +0 -19
- package/templates/skeleton/node-service/pnpm-lock.yaml +0 -2402
- package/templates/skeleton/node-service/pnpm-workspace.yaml +0 -13
- package/templates/skeleton/node-service/scripts/build-artifact.mjs +0 -34
- package/templates/skeleton/node-service/services/api/package.json +0 -17
- package/templates/skeleton/node-service/services/api/src/adapters/spool-publisher.ts +0 -23
- package/templates/skeleton/node-service/services/api/src/handlers/create-note.ts +0 -40
- package/templates/skeleton/node-service/services/api/src/handlers/list-notes.ts +0 -23
- package/templates/skeleton/node-service/services/api/src/main.ts +0 -40
- package/templates/skeleton/node-service/services/api/src/server.ts +0 -172
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +0 -20
- package/templates/skeleton/node-service/services/api/src/usecases/create-note.ts +0 -30
- package/templates/skeleton/node-service/services/api/src/usecases/list-notes.ts +0 -14
- package/templates/skeleton/node-service/services/api/test/artifact.test.ts +0 -92
- package/templates/skeleton/node-service/services/api/test/create-note.handler.test.ts +0 -64
- package/templates/skeleton/node-service/services/api/test/create-note.usecase.test.ts +0 -43
- package/templates/skeleton/node-service/services/api/test/list-notes.test.ts +0 -48
- package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +0 -40
- package/templates/skeleton/node-service/services/api/test/package-manager.ts +0 -51
- package/templates/skeleton/node-service/services/api/test/server.test.ts +0 -208
- package/templates/skeleton/node-service/services/api/test/spool-publisher.test.ts +0 -32
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +0 -29
- package/templates/skeleton/node-service/services/worker/package.json +0 -16
- package/templates/skeleton/node-service/services/worker/src/main.ts +0 -28
- package/templates/skeleton/node-service/services/worker/src/spool.ts +0 -60
- package/templates/skeleton/node-service/services/worker/src/usecases/process-note-created.ts +0 -38
- package/templates/skeleton/node-service/services/worker/test/process-note-created.test.ts +0 -34
- package/templates/skeleton/node-service/services/worker/test/spool.test.ts +0 -76
- package/templates/skeleton/node-service/tsconfig.base.json +0 -15
- package/templates/skeleton/node-service/tsconfig.json +0 -13
- package/templates/skeleton/node-service/vitest.config.ts +0 -12
|
@@ -0,0 +1,1223 @@
|
|
|
1
|
+
import { lstat, readFile, readdir, realpath, rmdir, unlink } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { initManifest } from './init.js';
|
|
4
|
+
import { AGENTS_MD_RESCUE, renderedAgentsMd } from './upgrade.js';
|
|
5
|
+
import { hookFilesReferencedIn } from '../lib/init-settings.js';
|
|
6
|
+
import { ALL_LAYERS, MANIFEST_REL, parseManifest, sha256 } from '../lib/manifest.js';
|
|
7
|
+
import { MAX_PATH_SEGMENTS, exceedsMaxPathSegments, resolveInside } from '../lib/safe-path.js';
|
|
8
|
+
/** A user-facing failure: message is printed as-is, no stack trace. */
|
|
9
|
+
export class UninstallError extends Error {
|
|
10
|
+
}
|
|
11
|
+
/** The reason named for a path discovered changed at apply time, never at plan time. */
|
|
12
|
+
export const CHANGED_SINCE_PLANNING_REASON = 'changed since planning — its bytes no longer match what was planned to be removed';
|
|
13
|
+
const WIRING_PATHS = new Set(['.claude/settings.json', '.codex/hooks.json']);
|
|
14
|
+
/**
|
|
15
|
+
* `.rig/` holds evidence (claims, run state) this command has no ownership
|
|
16
|
+
* evidence for, so {@link removeEmptyParents}'s walk stops here rather than
|
|
17
|
+
* reading or emptying the DIRECTORY itself. That is not a claim that nothing
|
|
18
|
+
* under `.rig/` is ever removed: a FILE under it can still be one of the
|
|
19
|
+
* exact paths {@link rigOwnedPaths} admits (`.rig/revalidation.json`, which
|
|
20
|
+
* `init` installs) and is removed like any other manifest-owned file when its
|
|
21
|
+
* hash matches — this constant only ever gates the directory's own removal.
|
|
22
|
+
*/
|
|
23
|
+
const RIG_DIR = '.rig';
|
|
24
|
+
/**
|
|
25
|
+
* Where `rel` lives inside the rig — refused outright if it lands anywhere
|
|
26
|
+
* else. The manifest is committed, so it arrives in pull requests like any
|
|
27
|
+
* other file, and a path it names has to be checked exactly as `upgrade`
|
|
28
|
+
* checks one before writing.
|
|
29
|
+
*/
|
|
30
|
+
function onDisk(repoDir, rel) {
|
|
31
|
+
const dest = resolveInside(repoDir, rel);
|
|
32
|
+
if (dest === null) {
|
|
33
|
+
throw new UninstallError(`Refusing to touch "${rel}" — it resolves outside ${repoDir}.`);
|
|
34
|
+
}
|
|
35
|
+
return dest;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Why a path that resolves lexically inside the repo is still refused.
|
|
39
|
+
* Deliberately does NOT say "(symlink)" — `regularFileStatus`'s `'unsafe'`
|
|
40
|
+
* verdict also covers a directory sitting where the manifest expects a
|
|
41
|
+
* plain file, a non-directory ancestor blocking the walk, and a segment
|
|
42
|
+
* whose `realpath` escapes the repository regardless of how `lstat`
|
|
43
|
+
* classifies it (the Windows-junction case above). A symlink is the common
|
|
44
|
+
* case, not the only one this reason is used for, and naming a kind the
|
|
45
|
+
* code has not actually confirmed is the same mistake `hookStillReferencedReason`
|
|
46
|
+
* made hardcoding "edited" (cycle-5 review, security lens advisory 2).
|
|
47
|
+
*/
|
|
48
|
+
export const NOT_A_REGULAR_FILE_REASON = 'not a regular file inside the repository — a symlink, a directory (or other non-file entry) ' +
|
|
49
|
+
'sitting where a plain file belongs, or an ancestor whose real path leaves the repository';
|
|
50
|
+
/**
|
|
51
|
+
* `'ok'`, `'absent'`, or `'unsafe'` — decided with `lstat`, one path segment at
|
|
52
|
+
* a time from the repository root down, so a symlink is caught wherever it
|
|
53
|
+
* sits and never followed to answer the question.
|
|
54
|
+
*
|
|
55
|
+
* `resolveInside` is purely lexical: it refuses `..` and an absolute path, but
|
|
56
|
+
* a manifest path that lands inside the repo lexically can still leave it at
|
|
57
|
+
* runtime if an ANCESTOR directory is a symlink out — `.claude/rules` pointing
|
|
58
|
+
* outside the repo makes `.claude/rules/workflow.md` resolve outside it too,
|
|
59
|
+
* even though the string never left. Reading such a path hashes bytes this
|
|
60
|
+
* command has no evidence for; removing it deletes something outside the repo
|
|
61
|
+
* entirely. So every segment down to the file itself is checked with `lstat`,
|
|
62
|
+
* which — unlike `stat` or a plain `readFile`/`unlink` — never follows the
|
|
63
|
+
* final symlink component, and the file itself must be a regular file too: a
|
|
64
|
+
* symlink sitting exactly at the manifest path is exactly as unsafe to read
|
|
65
|
+
* through and to report as owned.
|
|
66
|
+
*
|
|
67
|
+
* `'absent'` covers a missing ancestor as well as a missing file — both mean
|
|
68
|
+
* "nothing here to remove", which is what the existing `absent` verdict
|
|
69
|
+
* already says.
|
|
70
|
+
*
|
|
71
|
+
* Two INDEPENDENT checks run at every segment, not one: the `lstat`
|
|
72
|
+
* classification (`isSymbolicLink()` / `isDirectory()` / `isFile()`) refuses
|
|
73
|
+
* anything not confirmed to be a plain directory or file, AND, separately,
|
|
74
|
+
* `realpath` on that same segment must still resolve inside `repoDir`. The
|
|
75
|
+
* second check does not read the classification at all — it is what makes
|
|
76
|
+
* the containment guarantee hold even for a reparse-point kind `lstat` does
|
|
77
|
+
* not report as a symlink, whatever that kind turns out to be, rather than
|
|
78
|
+
* resting on one interpretation of one field.
|
|
79
|
+
*
|
|
80
|
+
* ⚠ **What is and is not measured here.** This repository's own development
|
|
81
|
+
* environment cannot create a Windows directory junction, so the claim above
|
|
82
|
+
* — that the containment check does not depend on how `lstat` classifies
|
|
83
|
+
* one — is a property of the CODE (realpath resolution is classification-
|
|
84
|
+
* independent by construction), not a measurement taken on a junction.
|
|
85
|
+
* `packages/cli/test/uninstall.test.ts`'s Windows-only junction tests
|
|
86
|
+
* (`onlyOnWindows`) DO measure the symlink-classification branch, in the
|
|
87
|
+
* `windows-e2e` CI lane only: they pin that Node/libuv reports a junction
|
|
88
|
+
* through `Stats.isSymbolicLink()` on Windows the same way a real symlink is
|
|
89
|
+
* — the same behaviour this repository's own ancestor-escape fixtures
|
|
90
|
+
* elsewhere already rely on (`test/template/*.test.ts`'s
|
|
91
|
+
* `process.platform === 'win32' ? 'junction' : 'dir'` pattern). Neither this
|
|
92
|
+
* repository's tests nor its CI have ever exercised a reparse-point kind
|
|
93
|
+
* `lstat().isSymbolicLink()` reports `false` for while still pointing outside
|
|
94
|
+
* the repository, so no claim is made about that case beyond "the realpath
|
|
95
|
+
* check would still catch it, by construction, if the target actually
|
|
96
|
+
* resolves outside `repoDir`".
|
|
97
|
+
*/
|
|
98
|
+
async function regularFileStatus(repoDir, rel) {
|
|
99
|
+
// Lexical containment first, unchanged from before this check existed: a
|
|
100
|
+
// manifest path with `..` or an absolute segment refuses the whole run,
|
|
101
|
+
// exactly as it did when this was `onDisk`'s job alone. Only a path that
|
|
102
|
+
// passes this can even reach the lstat walk below.
|
|
103
|
+
onDisk(repoDir, rel);
|
|
104
|
+
let base;
|
|
105
|
+
try {
|
|
106
|
+
base = await realpath(repoDir);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error.code === 'ENOENT')
|
|
110
|
+
return 'absent';
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
const segments = rel.split('/');
|
|
114
|
+
let current = repoDir;
|
|
115
|
+
for (const [index, segment] of segments.entries()) {
|
|
116
|
+
current = path.join(current, segment);
|
|
117
|
+
let info;
|
|
118
|
+
try {
|
|
119
|
+
info = await lstat(current);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (error.code === 'ENOENT')
|
|
123
|
+
return 'absent';
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
const isLast = index === segments.length - 1;
|
|
127
|
+
const classifiedSafe = isLast
|
|
128
|
+
? !info.isSymbolicLink() && info.isFile()
|
|
129
|
+
: !info.isSymbolicLink() && info.isDirectory();
|
|
130
|
+
if (!classifiedSafe)
|
|
131
|
+
return 'unsafe';
|
|
132
|
+
// Classification-independent: whatever this segment reports itself as,
|
|
133
|
+
// its actual target must still resolve inside the repository root.
|
|
134
|
+
let resolved;
|
|
135
|
+
try {
|
|
136
|
+
resolved = await realpath(current);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return 'unsafe';
|
|
140
|
+
}
|
|
141
|
+
if (resolved !== base && !resolved.startsWith(base + path.sep))
|
|
142
|
+
return 'unsafe';
|
|
143
|
+
}
|
|
144
|
+
return 'ok';
|
|
145
|
+
// Unreachable in practice: `segments` always has at least one element
|
|
146
|
+
// (`''.split('/')` is `['']`), and every branch inside the loop above
|
|
147
|
+
// returns before falling through. No trailing throw is needed here — the
|
|
148
|
+
// loop's own `return 'ok'` covers every path that reaches the end of it.
|
|
149
|
+
}
|
|
150
|
+
const normalizeToLF = (content) => content.replace(/\r\n/g, '\n');
|
|
151
|
+
const normalizeToCRLF = (content) => normalizeToLF(content).replace(/\n/g, '\r\n');
|
|
152
|
+
/**
|
|
153
|
+
* True when `current`'s only difference from the recorded hash is its line
|
|
154
|
+
* endings — checked both directions, since the recorded hash was taken of
|
|
155
|
+
* whatever the rig actually wrote, and this command has no other record of
|
|
156
|
+
* those original bytes to compare against.
|
|
157
|
+
*
|
|
158
|
+
* A binary file — one whose bytes do not round-trip through UTF-8 without
|
|
159
|
+
* loss — can never take this branch: decoding it would hash a
|
|
160
|
+
* replacement-character string standing in for bytes that were never text,
|
|
161
|
+
* exactly what ADR-RP-003 forbids. Its own raw-byte hash was already checked
|
|
162
|
+
* and did not match, so it falls through to `modified` instead, the same as
|
|
163
|
+
* `upgrade`'s `isReleasedVersion` treats an invalid-UTF-8 candidate.
|
|
164
|
+
*/
|
|
165
|
+
function isLineEndingOnlyMatch(current, recordedHash) {
|
|
166
|
+
const decoded = current.toString('utf8');
|
|
167
|
+
if (!Buffer.from(decoded, 'utf8').equals(current))
|
|
168
|
+
return false;
|
|
169
|
+
return (sha256(normalizeToLF(decoded)) === recordedHash ||
|
|
170
|
+
sha256(normalizeToCRLF(decoded)) === recordedHash);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* `segment`, folded to the one spelling every alias of `.git` collapses to:
|
|
174
|
+
* lowercased, an alternate-data-stream suffix (`name::$DATA`, and any other
|
|
175
|
+
* `:stream`) stripped, then trailing dots and spaces stripped — the two
|
|
176
|
+
* characters Windows itself silently drops when it resolves a path segment
|
|
177
|
+
* on disk, so `.git`, `.git.`, `.git ` and `.GIT` all name the same entry
|
|
178
|
+
* there even though they are different strings here.
|
|
179
|
+
*/
|
|
180
|
+
function normalizeGitLikeSegment(segment) {
|
|
181
|
+
const withoutStream = segment.split(':')[0] ?? segment;
|
|
182
|
+
// A linear scan, not a regex: `/[. ]+$/` backtracks quadratically on a long
|
|
183
|
+
// run of dots on some engines/inputs (measured: 400,000 dots cost 83s of
|
|
184
|
+
// CPU) — availability only, nothing is removed, but a manifest is
|
|
185
|
+
// committed input and a hostile one must not be able to hang a `--dry-run`.
|
|
186
|
+
let end = withoutStream.length;
|
|
187
|
+
while (end > 0 && (withoutStream[end - 1] === '.' || withoutStream[end - 1] === ' '))
|
|
188
|
+
end--;
|
|
189
|
+
return withoutStream.slice(0, end).toLowerCase();
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Refuses the whole run outright when any manifest path — `files` or `kept` —
|
|
193
|
+
* names something under `.git`, checked on EVERY segment (not only the
|
|
194
|
+
* first), so a nested `.git` inside an owned directory is refused exactly as
|
|
195
|
+
* a top-level one is. A manifest is committed, so it is untrusted input the
|
|
196
|
+
* same way a pull request is; a manifest that pairs `.git/hooks/pre-commit`
|
|
197
|
+
* — or an alias {@link normalizeGitLikeSegment} folds to the same thing —
|
|
198
|
+
* with that file's true on-disk hash would otherwise make a confirmed
|
|
199
|
+
* `uninstall` remove the repository's own git state. Checked once, over
|
|
200
|
+
* every key, before anything else runs.
|
|
201
|
+
*/
|
|
202
|
+
function refuseGitPaths(manifest) {
|
|
203
|
+
const all = [...Object.keys(manifest.files), ...Object.keys(manifest.kept ?? {})];
|
|
204
|
+
const gitPath = all.find((rel) => rel.split('/').some((s) => normalizeGitLikeSegment(s) === '.git'));
|
|
205
|
+
if (gitPath !== undefined) {
|
|
206
|
+
throw new UninstallError(`${MANIFEST_REL} names "${gitPath}", which resolves to a path under .git. A rig manifest ` +
|
|
207
|
+
'never legitimately owns anything there — refusing to run.');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Refuses the whole run when a manifest lists the same path under both
|
|
212
|
+
* `files` and `kept`. Nothing this rig ever writes produces that overlap —
|
|
213
|
+
* `planUpgrade` explicitly drops a `kept` path the moment a release vouches
|
|
214
|
+
* for it as one of `files` — so a manifest that has it is corrupt or
|
|
215
|
+
* hand-edited, and letting the reader silently pick a winner (rather than
|
|
216
|
+
* refusing) is exactly how a path ends up removed on one line of the plan
|
|
217
|
+
* while the same plan reports it `preserved` on another.
|
|
218
|
+
*/
|
|
219
|
+
function refuseFilesKeptOverlap(manifest) {
|
|
220
|
+
const keptKeys = new Set(Object.keys(manifest.kept ?? {}));
|
|
221
|
+
const overlap = Object.keys(manifest.files).find((rel) => keptKeys.has(rel));
|
|
222
|
+
if (overlap !== undefined) {
|
|
223
|
+
throw new UninstallError(`${MANIFEST_REL} names "${overlap}" under both "files" and "kept" — a manifest this tool ` +
|
|
224
|
+
'ever wrote never overlaps the two; refusing to run.');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The exact set of paths this release actually installs — the ownership
|
|
229
|
+
* boundary a `remove` verdict is held to, beyond the manifest's own say-so.
|
|
230
|
+
* Read from the same install-set generator `init`/`upgrade` use, never
|
|
231
|
+
* hand-listed: a hand-written list drifts the day the install set changes
|
|
232
|
+
* shape, and a manifest naming a path outside it (a tampered entry, or simply
|
|
233
|
+
* a stale one from a release that installed something this one does not) is
|
|
234
|
+
* not evidence this command can act on.
|
|
235
|
+
*
|
|
236
|
+
* Deliberately the exact `rel`, not its top-level segment: a boundary drawn
|
|
237
|
+
* at the top level (`.claude`, `.rig`, `docs`, `journal`, …) would let a
|
|
238
|
+
* manifest pair almost any path under an owned DIRECTORY with its true hash
|
|
239
|
+
* and have it removed, even though this release never installs that exact
|
|
240
|
+
* path. It also means a path spelled with a Windows alternate-data-stream
|
|
241
|
+
* suffix (`name::$DATA`) is never mistaken for the plain name it addresses on
|
|
242
|
+
* disk — the two are different strings, so the suffixed one is simply absent
|
|
243
|
+
* from this set and falls to `preserved` on that ground alone.
|
|
244
|
+
*
|
|
245
|
+
* Reads `initManifest()` alone — the bare `{ rel, source }` list — never
|
|
246
|
+
* `initInstallSet`, which also RENDERS every template's substituted content.
|
|
247
|
+
* Ownership is a question about which PATHS this release installs, not about
|
|
248
|
+
* what bytes it would write there; coupling it to content rendering means a
|
|
249
|
+
* destructive command's plan can fail on a template error that has nothing
|
|
250
|
+
* to do with what is being deleted.
|
|
251
|
+
*
|
|
252
|
+
* Passes {@link ALL_LAYERS} explicitly (RP-180) rather than taking
|
|
253
|
+
* `initManifest`'s own default (`DEFAULT_LAYERS`, Core only): this set is the
|
|
254
|
+
* ownership BOUNDARY a `remove` verdict is held to, not a description of what
|
|
255
|
+
* a plain `init` would install today. A rig that opted into the workflow
|
|
256
|
+
* layer has those paths in `manifest.files` exactly like any Core path, and
|
|
257
|
+
* this set has to include them or `uninstall` would report every one of them
|
|
258
|
+
* "not a path this release installs" and refuse to ever remove it — a rig
|
|
259
|
+
* that opted in would never be able to fully uninstall. Which layers a GIVEN
|
|
260
|
+
* rig actually has is a manifest question (`manifest.files`/`manifest.kept`,
|
|
261
|
+
* see `docs/decisions/workflow-layer-split.md`, "Interaction with
|
|
262
|
+
* `uninstall`"), not a question this boundary answers.
|
|
263
|
+
*/
|
|
264
|
+
async function rigOwnedPaths() {
|
|
265
|
+
const files = await initManifest(ALL_LAYERS);
|
|
266
|
+
return new Set(files.map((f) => f.rel));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The manifest's raw bytes, or `null` when it is genuinely absent — read the
|
|
270
|
+
* same symlink-safe way any other manifest-owned path is: every ancestor
|
|
271
|
+
* segment down to the manifest itself is checked with {@link regularFileStatus}
|
|
272
|
+
* before anything is read, so a symlinked `.claude` cannot make this command
|
|
273
|
+
* read a plausible-looking manifest that sits outside the repository, and
|
|
274
|
+
* cannot make it silently report `noManifest` for a repository that actually
|
|
275
|
+
* has one behind the link either. Refuses rather than guesses either way.
|
|
276
|
+
*/
|
|
277
|
+
async function readManifestBytes(repoDir) {
|
|
278
|
+
const status = await regularFileStatus(repoDir, MANIFEST_REL);
|
|
279
|
+
if (status === 'absent')
|
|
280
|
+
return null;
|
|
281
|
+
if (status === 'unsafe') {
|
|
282
|
+
throw new UninstallError(`Refusing to read "${MANIFEST_REL}" — an ancestor directory is a symlink (or another ` +
|
|
283
|
+
'non-regular entry), so it cannot be trusted.');
|
|
284
|
+
}
|
|
285
|
+
return readFile(onDisk(repoDir, MANIFEST_REL));
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Matches any `.mjs` file anywhere under `.claude/hooks/` — deliberately not
|
|
289
|
+
* anchored to the top level, so it also matches `.claude/hooks/lib/hook-input.mjs`.
|
|
290
|
+
* Used only in the `unsafe`-wiring branch below, to seed the closure walk from
|
|
291
|
+
* every owned hook path, not only the ones a readable wiring file happens to
|
|
292
|
+
* name directly.
|
|
293
|
+
*/
|
|
294
|
+
const HOOK_REL_PATTERN = /^\.claude\/hooks\/.+\.mjs$/;
|
|
295
|
+
/**
|
|
296
|
+
* A relative ESM import target inside a `.mjs` file — `from './x.mjs'` or
|
|
297
|
+
* `from '../y/z.mjs'`. Only a RELATIVE specifier is ever matched (a bare one
|
|
298
|
+
* names a package, not a file this rig owns), and only one ending in `.mjs` —
|
|
299
|
+
* the shape every hook and lib module in this fleet's own tree uses.
|
|
300
|
+
*/
|
|
301
|
+
const RELATIVE_MJS_IMPORT = /from\s+['"](\.\.?\/[^'"]+\.mjs)['"]/g;
|
|
302
|
+
/**
|
|
303
|
+
* Reason named when a hook file is preserved because the wiring file that
|
|
304
|
+
* still calls it is itself preserved. `kind` is not decoration: a hook a
|
|
305
|
+
* `kept` wiring file references was never "preserved as edited" — nobody
|
|
306
|
+
* edited it, the rig never wrote it, and reusing the `'edited'` wording for
|
|
307
|
+
* every kind produced a report that told two contradictory stories about the
|
|
308
|
+
* SAME repository state (`.claude/settings.json` → "user-owned (kept by
|
|
309
|
+
* init)"; two lines away, `guard-bash.mjs` → "referenced by
|
|
310
|
+
* .claude/settings.json, which was preserved as edited"). Reproduced by
|
|
311
|
+
* hand-authoring a wiring `.claude/settings.json`, running `init` over it
|
|
312
|
+
* (which records it under `kept`, never `files`), then `uninstall --yes`.
|
|
313
|
+
*/
|
|
314
|
+
export function hookStillReferencedReason(wiringRel, kind) {
|
|
315
|
+
// Exhaustiveness is enforced at COMPILE time via `default`'s
|
|
316
|
+
// `kind satisfies never` — not by leaving `default` off, which was tried
|
|
317
|
+
// and measured to NOT work: a pre-switch runtime guard that narrows
|
|
318
|
+
// `kind` down to the three known members (`if (kind !== 'edited' && …)`)
|
|
319
|
+
// makes the switch exhaustive over the NARROWED type, so a fourth
|
|
320
|
+
// `WiringPreservedKind` member compiled clean under `tsc --strict` with
|
|
321
|
+
// that guard in place — the opposite of what its own comment claimed
|
|
322
|
+
// (code-lens and security-lens review, RP-181, cycle 8). `satisfies never`
|
|
323
|
+
// inside `default` narrows nothing upstream (there is nothing upstream of
|
|
324
|
+
// `default` left to narrow) and fails typecheck the moment a fourth member
|
|
325
|
+
// exists — verified in a sandbox built from this exact file: three
|
|
326
|
+
// members compiles clean, four members raises `TS1360: Type "..." does
|
|
327
|
+
// not satisfy the expected type 'never'`. The `default` RETURNS a named
|
|
328
|
+
// fallback rather than throwing: this function runs from `index.ts`
|
|
329
|
+
// inside `uninstallPayload`, AFTER `applyUninstall` has already deleted
|
|
330
|
+
// files — a throw reachable from here would turn an unreachable-today
|
|
331
|
+
// inconsistency into a post-deletion stack trace with no JSON on stdout,
|
|
332
|
+
// the worst point in the whole command for that to happen. Verified in
|
|
333
|
+
// the same sandbox: an unexpected `kind` at runtime returns the fallback
|
|
334
|
+
// string, never throws, and never interpolates a literal `undefined`
|
|
335
|
+
// into the sentence.
|
|
336
|
+
let why;
|
|
337
|
+
switch (kind) {
|
|
338
|
+
case 'edited':
|
|
339
|
+
why = 'which was preserved as edited';
|
|
340
|
+
break;
|
|
341
|
+
case 'kept':
|
|
342
|
+
why = 'which init found already in place and never took ownership of';
|
|
343
|
+
break;
|
|
344
|
+
case 'unsafe':
|
|
345
|
+
why = 'which could not be safely read (itself a symlink, or reached through one)';
|
|
346
|
+
break;
|
|
347
|
+
default: {
|
|
348
|
+
kind;
|
|
349
|
+
return (`still referenced by ${wiringRel}, for a reason this version does not name — removing ` +
|
|
350
|
+
'this file would leave it pointing at nothing');
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return `still referenced by ${wiringRel}, ${why} — removing this file would leave it pointing at nothing`;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Reason named when a file is preserved not because a wiring file names it
|
|
357
|
+
* directly, but because a hook file that IS (directly or, itself,
|
|
358
|
+
* transitively) needed by that wiring imports it. Deliberately a different
|
|
359
|
+
* sentence from {@link hookStillReferencedReason}, naming the IMMEDIATE
|
|
360
|
+
* importer rather than only the ultimate wiring path:
|
|
361
|
+
* `.claude/scripts/lib/secrets.mjs` is never mentioned by
|
|
362
|
+
* `.claude/settings.json` at all — only `.claude/hooks/guard-secret-file.mjs`
|
|
363
|
+
* is — so an operator grepping `settings.json` for `secrets.mjs` to
|
|
364
|
+
* understand why it survived would find nothing if this said only "still
|
|
365
|
+
* referenced by .claude/settings.json"; grepping it for
|
|
366
|
+
* `guard-secret-file.mjs` (this reason's `importedByRel`) finds the real
|
|
367
|
+
* connection.
|
|
368
|
+
*
|
|
369
|
+
* Deliberately does NOT say `importedByRel` is itself directly referenced by
|
|
370
|
+
* `wiringRel` — it says only that `wiringRel` needs it "directly or through
|
|
371
|
+
* further imports", which stays true whether `importedByRel` is the hook a
|
|
372
|
+
* wiring file names outright or itself another import one or more hops
|
|
373
|
+
* removed (`.claude/hooks/lib/edit-input.mjs` imports
|
|
374
|
+
* `.claude/scripts/git-env.mjs`, and neither is named in
|
|
375
|
+
* `.claude/settings.json` at all). A reader who needs the next hop finds it
|
|
376
|
+
* on `importedByRel`'s OWN `preserved` entry, which names what needed IT.
|
|
377
|
+
*/
|
|
378
|
+
export function hookImportedByReason(importedByRel, wiringRel) {
|
|
379
|
+
return (`imported by ${importedByRel}, itself needed — directly or through further imports — by ` +
|
|
380
|
+
`the still-preserved ${wiringRel}, which is why it survives too. Removing this file would ` +
|
|
381
|
+
`leave ${importedByRel} unable to load`);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Reason named when a file was protected not because it was traced — as a
|
|
385
|
+
* direct reference or as a genuine import — but because SOME OTHER file the
|
|
386
|
+
* walk needed to read in order to keep tracing could not be read at all.
|
|
387
|
+
* `unreadableRel` names that file, not `rel` itself: this file's own need
|
|
388
|
+
* was never confirmed one way or the other, so it is kept purely as a
|
|
389
|
+
* precaution.
|
|
390
|
+
*
|
|
391
|
+
* Deliberately its own, fifth wording rather than reusing
|
|
392
|
+
* {@link hookStillReferencedReason}'s "still referenced by … which was
|
|
393
|
+
* preserved as edited" — that sentence claims a specific, confirmed
|
|
394
|
+
* connection this file does not have. Reusing it made the wording that
|
|
395
|
+
* sounds MOST certain describe the files the command is LEAST sure about: in
|
|
396
|
+
* one reproduced run, 84 owned paths in one preserved-wiring scenario, ~7
|
|
397
|
+
* genuinely referenced by the wiring file (which the command HAD read and
|
|
398
|
+
* could enumerate exactly), ~30 swept in by this precaution and reported
|
|
399
|
+
* with the identical unqualified "still referenced by" sentence — including
|
|
400
|
+
* a TEST FIXTURE (`.agents/skills/new-invariant/guard-invariant.example.test.mjs`)
|
|
401
|
+
* no hook could ever import (UX-lens review, RP-181).
|
|
402
|
+
*/
|
|
403
|
+
/**
|
|
404
|
+
* The one fixed prefix every {@link hookUnverifiedReason} string starts
|
|
405
|
+
* with — exported so a caller building a roll-up summary (how many
|
|
406
|
+
* `preserved` paths are genuinely traced versus kept only as a precaution)
|
|
407
|
+
* can tell the two apart from the rendered reason text without a second,
|
|
408
|
+
* independent guess at the wording. One string, defined once; both this
|
|
409
|
+
* module and {@link isUnverifiedReason} read the SAME constant, so they
|
|
410
|
+
* cannot drift apart the way two copies of a fact always eventually do.
|
|
411
|
+
*/
|
|
412
|
+
const UNVERIFIED_REASON_PREFIX = 'protected because ';
|
|
413
|
+
export function hookUnverifiedReason(unreadableRel) {
|
|
414
|
+
return (`${UNVERIFIED_REASON_PREFIX}${unreadableRel} could not be read, so every file this release ` +
|
|
415
|
+
'installs is being kept rather than risk removing one it needs');
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* True when `reason` is exactly the shape {@link hookUnverifiedReason}
|
|
419
|
+
* produces — used only to build a roll-up summary in the CLI (how many
|
|
420
|
+
* `preserved` paths were genuinely traced versus kept only as a
|
|
421
|
+
* precaution), never to decide protection itself, which has already
|
|
422
|
+
* happened by the time anything calls this.
|
|
423
|
+
*/
|
|
424
|
+
export function isUnverifiedReason(reason) {
|
|
425
|
+
return reason.startsWith(UNVERIFIED_REASON_PREFIX);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* The ONE reason a protected hook or dependency reports, decided by which
|
|
429
|
+
* evidence for its protection actually exists — centralised so
|
|
430
|
+
* `planUninstall` and the apply-time re-check (through `index.ts`, which
|
|
431
|
+
* carries the same three pieces of evidence across the process boundary in
|
|
432
|
+
* `ApplyUninstallResult.protectedHooksAtApply`) cannot describe the same
|
|
433
|
+
* fact two different ways. Precedence, strongest evidence first: a genuine
|
|
434
|
+
* import trace (`importedByRel`) beats mere caution (`unverifiedBecause`)
|
|
435
|
+
* beats a bare direct reference — a file can end up with more than one kind
|
|
436
|
+
* of evidence over the life of a walk (a precaution sweep can catch a path
|
|
437
|
+
* before a LATER, genuine import trace reaches the same one), and the
|
|
438
|
+
* strongest one is what gets reported.
|
|
439
|
+
*/
|
|
440
|
+
export function protectedFileReason(wiringRel, wiringKind, importedByRel, unverifiedBecause) {
|
|
441
|
+
if (importedByRel !== undefined)
|
|
442
|
+
return hookImportedByReason(importedByRel, wiringRel);
|
|
443
|
+
if (unverifiedBecause !== undefined)
|
|
444
|
+
return hookUnverifiedReason(unverifiedBecause);
|
|
445
|
+
return hookStillReferencedReason(wiringRel, wiringKind);
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* `seedRel` plus everything it (transitively) imports that is itself one of
|
|
449
|
+
* `ownedPaths` — each one added to `protectedHooks` under `wiringRel`, and
|
|
450
|
+
* (for everything except `seedRel` itself) to `importedBy` under whichever
|
|
451
|
+
* file's import target resolved to it, mutating all four maps in place.
|
|
452
|
+
* `unverified` maps a path this pass protected WITHOUT tracing it — see
|
|
453
|
+
* {@link hookUnverifiedReason} — to the file whose own unreadability
|
|
454
|
+
* triggered the sweep that caught it.
|
|
455
|
+
*
|
|
456
|
+
* A directly-wired hook file (`.claude/hooks/guard-bash.mjs`) is not
|
|
457
|
+
* self-contained: it imports `.claude/hooks/lib/hook-input.mjs`,
|
|
458
|
+
* `.claude/scripts/lib/shell-tools.mjs`, `.claude/scripts/stop-flag.mjs` — its
|
|
459
|
+
* own dependencies, which are themselves owned paths a manifest can mark
|
|
460
|
+
* `remove` on their own account. `hookFilesReferencedIn`/{@link HOOK_REL_PATTERN}
|
|
461
|
+
* only ever name the hook files a wiring file calls DIRECTLY; without this
|
|
462
|
+
* walk, a preserved `.claude/settings.json` protects `guard-bash.mjs` itself
|
|
463
|
+
* but not what it imports, and the removal loop deletes `hook-input.mjs` out
|
|
464
|
+
* from under it. `guard-bash.mjs` then dies at module resolution with exit 1
|
|
465
|
+
* on every call — a `PreToolUse` hook that exits non-2 is non-blocking, so
|
|
466
|
+
* the Never tier, the credential guard, `block-no-verify` and the kill switch
|
|
467
|
+
* all go silently inert while `.claude/settings.json` still wires them. This
|
|
468
|
+
* is `safe-path.ts`'s "a brake that looks installed and is not".
|
|
469
|
+
*
|
|
470
|
+
* **Every real READ is gated by {@link regularFileStatus}**, the identical
|
|
471
|
+
* symlink-safe check every other read in this file gets — `onDisk` alone is
|
|
472
|
+
* purely lexical and does not stop a `readFile` from opening whatever the
|
|
473
|
+
* path resolves to. A `.claude/hooks/guard-bash.mjs` swapped for a symlink to
|
|
474
|
+
* `/dev/zero` (or any other unbounded or blocking special file), with the
|
|
475
|
+
* wiring that references it also modified, would otherwise make `readFile`
|
|
476
|
+
* itself hang or exhaust the heap — reachable from a hostile committed
|
|
477
|
+
* manifest through nothing worse than `git clone`, before consent, and
|
|
478
|
+
* fatal to the "`--json` prints exactly one object" promise either way.
|
|
479
|
+
* `protectedHooks.set` already ran for `rel` a line above, so THAT file
|
|
480
|
+
* stays protected regardless of its own status. What that alone cannot do
|
|
481
|
+
* is discover what an unreadable `rel` itself imports — `visited` has
|
|
482
|
+
* already been marked, so nothing else in the walk will try again either.
|
|
483
|
+
*
|
|
484
|
+
* ⚠ **Only `status === 'unsafe'` triggers the superset sweep below —
|
|
485
|
+
* `'absent'` does not, and that distinction is load-bearing, not an
|
|
486
|
+
* oversight.** An earlier version of this function swept on ANY
|
|
487
|
+
* `!== 'ok'` status, `'absent'` included — measured cost: deleting one
|
|
488
|
+
* ordinary, already-uninstalled-by-hand hook file (`block-no-verify.mjs`),
|
|
489
|
+
* with the wiring referencing it preserved, turned 83 planned removals into
|
|
490
|
+
* 40 planned / 43 preserved, on a repository that had done nothing more
|
|
491
|
+
* hostile than turn a hook off. And nothing is bought by sweeping on
|
|
492
|
+
* absence: a file that is not there cannot fail module resolution over an
|
|
493
|
+
* import it cannot make, because the module that would need that import is
|
|
494
|
+
* itself gone. The whole justification for the sweep — a file we cannot
|
|
495
|
+
* READ might import something we cannot discover — simply does not apply to
|
|
496
|
+
* a file that does not exist (code-lens review, RP-181).
|
|
497
|
+
*
|
|
498
|
+
* An earlier version of the `'unsafe'` branch stopped at protecting `rel`
|
|
499
|
+
* alone, on the reasoning that every `.claude/scripts/` dependency the walk
|
|
500
|
+
* needs to reach is also imported by at least one OTHER, ordinarily-readable
|
|
501
|
+
* hook. That reasoning was checked against the shipped import graph and
|
|
502
|
+
* found false: `.claude/scripts/lib/secrets.mjs` has exactly ONE seeder
|
|
503
|
+
* (`guard-secret-file.mjs`), and `.claude/scripts/unattended-flag.mjs` has
|
|
504
|
+
* exactly one (`guard-rulebook.mjs`) — reproduced end to end through a real
|
|
505
|
+
* `git commit` (mode `120000`) and a fresh `git clone`: symlink the sole
|
|
506
|
+
* seeder, preserve the wiring that references it, and `secrets.mjs` survived
|
|
507
|
+
* the uninstall while its only importer died at module resolution, silently
|
|
508
|
+
* inert (security-lens review, RP-181). So the `'unsafe'` branch below does
|
|
509
|
+
* not stop at `rel` alone: it protects the conservative superset — every
|
|
510
|
+
* owned `.mjs` path — the same move `protectedHooksFor` already makes one
|
|
511
|
+
* level up for an unreadable WIRING file, recording each newly-swept path
|
|
512
|
+
* into `unverified` rather than claiming it as confirmed. "Protecting too
|
|
513
|
+
* many is the safe direction" is the same doctrine either way; only where
|
|
514
|
+
* it gets applied, and how honestly it gets reported, changed.
|
|
515
|
+
*
|
|
516
|
+
* Bounded, per `.claude/rules/invariants.md`'s fail-open rule, but precisely:
|
|
517
|
+
* no recursion, and every REAL read happens at most once per owned path —
|
|
518
|
+
* `visited` is checked before reading, and only a resolved import target
|
|
519
|
+
* already IN `ownedPaths` is ever pushed, so a file this release does not
|
|
520
|
+
* ship is never opened. What is NOT bounded by `|ownedPaths|` is transient
|
|
521
|
+
* `queue` length: `!visited.has(resolved)` is checked at PUSH time, and two
|
|
522
|
+
* different files can each name the same not-yet-popped dependency before
|
|
523
|
+
* either is processed, queuing it more than once. Each duplicate costs one
|
|
524
|
+
* cheap pop-and-skip, never a second read — pinned, not merely asserted (see
|
|
525
|
+
* `.claude/rules/invariants.md`, "State the limits — and test them"), by
|
|
526
|
+
* `packages/cli/test/uninstall.test.ts` › "processes 400,000 duplicate
|
|
527
|
+
* import matches to the same owned dependency in bounded time".
|
|
528
|
+
*/
|
|
529
|
+
async function protectHookAndDeps(repoDir, ownedPaths, seedRel, wiringRel, protectedHooks, visited, importedBy, unverified) {
|
|
530
|
+
// `[rel, parent]` — `parent` is the file whose import target resolved to
|
|
531
|
+
// `rel`, or `undefined` for `seedRel` itself (a wiring file names it
|
|
532
|
+
// directly; nothing imported it to get here). Recorded into `importedBy`
|
|
533
|
+
// only the FIRST time `rel` is reached, mirroring `protectedHooks`'s own
|
|
534
|
+
// `!has` guard just below — so a file reachable two different ways keeps
|
|
535
|
+
// whichever path found it first, for a reason string that names one real
|
|
536
|
+
// connection rather than every one that happens to exist.
|
|
537
|
+
const queue = [[seedRel, undefined]];
|
|
538
|
+
while (queue.length > 0) {
|
|
539
|
+
const [rel, parent] = queue.pop();
|
|
540
|
+
if (visited.has(rel))
|
|
541
|
+
continue;
|
|
542
|
+
visited.add(rel);
|
|
543
|
+
// `rel` was REACHED — named by a wiring file, or resolved from another
|
|
544
|
+
// file's import — so it is traced, whatever its own status turns out to
|
|
545
|
+
// be, and is never merely "unverified". Together with the sweep's own
|
|
546
|
+
// `!visited.has(owned)` below this makes the wording independent of the
|
|
547
|
+
// order the seeds arrive in: a sweep that ran BEFORE `rel`'s turn is
|
|
548
|
+
// undone here, and one that runs AFTER it never marks `rel` at all.
|
|
549
|
+
// Clearing only on a readable pop (the cycle-8 fix) closed the first
|
|
550
|
+
// half alone — an unreadable seed anywhere but first in the wiring
|
|
551
|
+
// file's text order still re-marked every hook already confirmed
|
|
552
|
+
// (code-, security- and UX-lens review, RP-181, cycle 9).
|
|
553
|
+
unverified.delete(rel);
|
|
554
|
+
if (!protectedHooks.has(rel))
|
|
555
|
+
protectedHooks.set(rel, wiringRel);
|
|
556
|
+
if (parent !== undefined && !importedBy.has(rel))
|
|
557
|
+
importedBy.set(rel, parent);
|
|
558
|
+
const status = await regularFileStatus(repoDir, rel);
|
|
559
|
+
// `'absent'` buys the sweep below nothing: a file that is not there has
|
|
560
|
+
// no imports that can fail to resolve, because the module that would
|
|
561
|
+
// make them is itself gone (code-lens review, RP-181). Only `'unsafe'`
|
|
562
|
+
// — unreadable, not merely missing — triggers it.
|
|
563
|
+
if (status === 'absent')
|
|
564
|
+
continue;
|
|
565
|
+
if (status === 'unsafe') {
|
|
566
|
+
for (const owned of ownedPaths) {
|
|
567
|
+
if (!owned.endsWith('.mjs'))
|
|
568
|
+
continue;
|
|
569
|
+
if (!protectedHooks.has(owned))
|
|
570
|
+
protectedHooks.set(owned, wiringRel);
|
|
571
|
+
// First unreadable file to sweep a given path names the reason;
|
|
572
|
+
// never overwritten by a later sweep, never planted on a path some
|
|
573
|
+
// walk already reached, and cleared the moment one does (above).
|
|
574
|
+
if (!visited.has(owned) && !importedBy.has(owned) && !unverified.has(owned)) {
|
|
575
|
+
unverified.set(owned, rel);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
let text;
|
|
581
|
+
try {
|
|
582
|
+
text = await readFile(onDisk(repoDir, rel), 'utf8');
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
continue; // gone, or unreadable — nothing further to walk from here
|
|
586
|
+
}
|
|
587
|
+
const dir = path.posix.dirname(rel);
|
|
588
|
+
for (const match of text.matchAll(RELATIVE_MJS_IMPORT)) {
|
|
589
|
+
const resolved = path.posix.normalize(path.posix.join(dir, match[1]));
|
|
590
|
+
if (ownedPaths.has(resolved) && !visited.has(resolved))
|
|
591
|
+
queue.push([resolved, rel]);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
async function protectedHooksFor(repoDir, ownedPaths, trackingFor) {
|
|
596
|
+
const protectedHooks = new Map();
|
|
597
|
+
const importedBy = new Map();
|
|
598
|
+
const unverified = new Map();
|
|
599
|
+
const wiringKind = new Map();
|
|
600
|
+
const wiringBytes = new Map();
|
|
601
|
+
const visited = new Set();
|
|
602
|
+
for (const wiringRel of WIRING_PATHS) {
|
|
603
|
+
const tracking = trackingFor(wiringRel);
|
|
604
|
+
if (!tracking.tracked || !ownedPaths.has(wiringRel))
|
|
605
|
+
continue;
|
|
606
|
+
const status = await regularFileStatus(repoDir, wiringRel);
|
|
607
|
+
if (status === 'absent')
|
|
608
|
+
continue;
|
|
609
|
+
if (status === 'unsafe') {
|
|
610
|
+
wiringKind.set(wiringRel, 'unsafe');
|
|
611
|
+
for (const rel of ownedPaths) {
|
|
612
|
+
if (HOOK_REL_PATTERN.test(rel)) {
|
|
613
|
+
await protectHookAndDeps(repoDir, ownedPaths, rel, wiringRel, protectedHooks, visited, importedBy, unverified);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const current = await readFile(onDisk(repoDir, wiringRel));
|
|
619
|
+
wiringBytes.set(wiringRel, current);
|
|
620
|
+
// pristine and NOT always-preserved — it is about to be removed, so
|
|
621
|
+
// nothing downstream needs protecting on its account. A `kept` wiring
|
|
622
|
+
// path never takes this branch: `alwaysPreserved` is true for it
|
|
623
|
+
// unconditionally, hash or no hash.
|
|
624
|
+
if (!tracking.alwaysPreserved && sha256(current) === tracking.recordedHash)
|
|
625
|
+
continue;
|
|
626
|
+
wiringKind.set(wiringRel, tracking.alwaysPreserved ? 'kept' : 'edited');
|
|
627
|
+
for (const hook of hookFilesReferencedIn(current.toString('utf8'))) {
|
|
628
|
+
await protectHookAndDeps(repoDir, ownedPaths, hook, wiringRel, protectedHooks, visited, importedBy, unverified);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return { protectedHooks, importedBy, unverified, wiringKind, wiringBytes };
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* What an uninstall would do, decided per file, removing nothing.
|
|
635
|
+
*
|
|
636
|
+
* Ownership evidence is the manifest alone: a path's recorded hash is the only
|
|
637
|
+
* thing that can mark it for removal, and only when the bytes on disk still
|
|
638
|
+
* match it exactly AND the path is one of the EXACT paths this release
|
|
639
|
+
* actually installs. Everything else — absent, edited, kept by init, wiring
|
|
640
|
+
* this rig no longer recognises, a hook a preserved wiring file still calls, a
|
|
641
|
+
* path outside the current install set, or the CRLF/LF twin of what it wrote
|
|
642
|
+
* — is reported and left alone.
|
|
643
|
+
*/
|
|
644
|
+
export async function planUninstall(repoDir) {
|
|
645
|
+
const raw = await readManifestBytes(repoDir);
|
|
646
|
+
if (raw === null)
|
|
647
|
+
return { noManifest: true, actions: [], manifestHash: null };
|
|
648
|
+
const manifestHash = sha256(raw);
|
|
649
|
+
const manifest = parseManifest(raw.toString('utf8'));
|
|
650
|
+
if (manifest === null) {
|
|
651
|
+
throw new UninstallError(`${MANIFEST_REL} exists but could not be read as a rig manifest. Refusing to remove anything.`);
|
|
652
|
+
}
|
|
653
|
+
refuseGitPaths(manifest);
|
|
654
|
+
refuseFilesKeptOverlap(manifest);
|
|
655
|
+
const ownedPaths = await rigOwnedPaths();
|
|
656
|
+
// A `kept` wiring path is tracked too, not just a `files` one —
|
|
657
|
+
// `refuseFilesKeptOverlap` above already guarantees the same `rel` never
|
|
658
|
+
// appears in both, so there is no ambiguity about which case applies.
|
|
659
|
+
const trackingFor = (wiringRel) => {
|
|
660
|
+
if (Object.hasOwn(manifest.kept ?? {}, wiringRel)) {
|
|
661
|
+
return { tracked: true, alwaysPreserved: true };
|
|
662
|
+
}
|
|
663
|
+
const recordedHash = manifest.files[wiringRel];
|
|
664
|
+
return recordedHash === undefined
|
|
665
|
+
? { tracked: false }
|
|
666
|
+
: { tracked: true, alwaysPreserved: false, recordedHash };
|
|
667
|
+
};
|
|
668
|
+
const { protectedHooks, importedBy, unverified, wiringKind, wiringBytes } = await protectedHooksFor(repoDir, ownedPaths, trackingFor);
|
|
669
|
+
const actions = [];
|
|
670
|
+
for (const rel of Object.keys(manifest.files).sort()) {
|
|
671
|
+
const recorded = manifest.files[rel];
|
|
672
|
+
// Checked BEFORE `onDisk` — and so before `onDisk`'s own message, which
|
|
673
|
+
// reads "resolves outside <repoDir>" and would be FALSE for a path this
|
|
674
|
+
// deep, since it never leaves the repository lexically at all. Every
|
|
675
|
+
// path this release actually owns is nowhere near this deep (pinned in
|
|
676
|
+
// `safe-path.test.ts`), so a key past the cap can never have been one of
|
|
677
|
+
// them regardless — reported the same honest, non-aborting way as any
|
|
678
|
+
// other unowned path, with a reason that names the real limit instead of
|
|
679
|
+
// a false one. This does not weaken the `..`/absolute escape check right
|
|
680
|
+
// below: THAT check still aborts the whole run for a genuinely escaping
|
|
681
|
+
// key, exactly as before; a too-deep key is refused by never reaching
|
|
682
|
+
// that check at all, and it is never read, hashed, or written either way
|
|
683
|
+
// — the depth cap and the escape check are independent safety nets, not
|
|
684
|
+
// substitutes for each other.
|
|
685
|
+
if (exceedsMaxPathSegments(rel)) {
|
|
686
|
+
actions.push({
|
|
687
|
+
rel,
|
|
688
|
+
verdict: 'preserved',
|
|
689
|
+
reason: `more than ${MAX_PATH_SEGMENTS} path segments — deeper than any path this release ` +
|
|
690
|
+
'installs, so it is refused without being resolved on disk at all; if this key is ' +
|
|
691
|
+
`not one you recognise, remove it from ${MANIFEST_REL} by hand`,
|
|
692
|
+
});
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
// Lexical containment unconditionally, before the ownership check below —
|
|
696
|
+
// a `..`-escaping path is refused outright regardless of whether it
|
|
697
|
+
// happens to be one of the exact paths this release installs.
|
|
698
|
+
onDisk(repoDir, rel);
|
|
699
|
+
if (!ownedPaths.has(rel)) {
|
|
700
|
+
actions.push({
|
|
701
|
+
rel,
|
|
702
|
+
verdict: 'preserved',
|
|
703
|
+
reason: 'not a path this release installs',
|
|
704
|
+
});
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const status = await regularFileStatus(repoDir, rel);
|
|
708
|
+
if (status === 'absent') {
|
|
709
|
+
actions.push({ rel, verdict: 'absent' });
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (status === 'unsafe') {
|
|
713
|
+
actions.push({ rel, verdict: 'preserved', reason: NOT_A_REGULAR_FILE_REASON });
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
// status === 'ok': every ancestor is a real directory and the path itself
|
|
717
|
+
// is a regular file — safe to read and to hash. A wiring file's bytes may
|
|
718
|
+
// already have been read by `protectedHooksFor` above; reuse them rather
|
|
719
|
+
// than reading the same file twice.
|
|
720
|
+
const current = wiringBytes.get(rel) ?? (await readFile(onDisk(repoDir, rel)));
|
|
721
|
+
if (WIRING_PATHS.has(rel)) {
|
|
722
|
+
if (sha256(current) === recorded) {
|
|
723
|
+
actions.push({ rel, verdict: 'remove', recordedHash: recorded });
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
const hooks = [...hookFilesReferencedIn(current.toString('utf8'))].sort();
|
|
727
|
+
actions.push({
|
|
728
|
+
rel,
|
|
729
|
+
verdict: 'preserved',
|
|
730
|
+
reason: "wiring-modified — remove the rig's hook entries by hand" +
|
|
731
|
+
(hooks.length > 0 ? ` (still referenced: ${hooks.join(', ')})` : ''),
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
const protectingWiring = protectedHooks.get(rel);
|
|
737
|
+
if (protectingWiring !== undefined) {
|
|
738
|
+
actions.push({
|
|
739
|
+
rel,
|
|
740
|
+
verdict: 'preserved',
|
|
741
|
+
// `wiringKind` is set for `protectingWiring` in the SAME iteration
|
|
742
|
+
// of `protectedHooksFor` that populated `protectedHooks` with
|
|
743
|
+
// `rel` — never independently, so this can only be `undefined` if
|
|
744
|
+
// the two maps disagreed with each other, which would itself be
|
|
745
|
+
// the bug to fix.
|
|
746
|
+
reason: protectedFileReason(protectingWiring, wiringKind.get(protectingWiring), importedBy.get(rel), unverified.get(rel)),
|
|
747
|
+
});
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
const currentHash = sha256(current);
|
|
751
|
+
if (currentHash === recorded) {
|
|
752
|
+
actions.push({ rel, verdict: 'remove', recordedHash: recorded });
|
|
753
|
+
}
|
|
754
|
+
else if (isLineEndingOnlyMatch(current, recorded)) {
|
|
755
|
+
actions.push({ rel, verdict: 'preserved', reason: 'line-endings-only' });
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
actions.push({ rel, verdict: 'preserved', reason: 'modified' });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
for (const rel of Object.keys(manifest.kept ?? {}).sort()) {
|
|
762
|
+
actions.push({ rel, verdict: 'preserved', reason: 'user-owned (kept by init)' });
|
|
763
|
+
}
|
|
764
|
+
// Round 4, blocker 2 (CLI-UX): removing one of CLAUDE.md/AGENTS.md while
|
|
765
|
+
// the other is not a clean removal — preserved as the user's own edit, or
|
|
766
|
+
// already gone — is exactly the moment the rulebook could end up with no
|
|
767
|
+
// readable copy left at all. `upgrade` already narrates the held-back half
|
|
768
|
+
// of this same situation (`heldBack`); `uninstall` did not narrate its
|
|
769
|
+
// own half at all. `init` refuses outright over a pre-existing CLAUDE.md
|
|
770
|
+
// or AGENTS.md (the `MAPS` special case), so neither ever appears under
|
|
771
|
+
// `manifest.kept` — both are always decided by the loop above, never here.
|
|
772
|
+
const claudeAction = actions.find((a) => a.rel === 'CLAUDE.md');
|
|
773
|
+
const agentsAction = actions.find((a) => a.rel === 'AGENTS.md');
|
|
774
|
+
// Round 5 advisory: the `undefined` case previously said "is not tracked
|
|
775
|
+
// by this rig", which reads as "is absent" — it is not. `undefined` here
|
|
776
|
+
// only ever means the manifest never named this path (an old, pre-RP-186
|
|
777
|
+
// manifest missing an entry the current install set always has); the file
|
|
778
|
+
// itself may well be sitting right there. Checked directly rather than
|
|
779
|
+
// guessed at, so the wording says what is actually true.
|
|
780
|
+
const siblingState = async (a, rel) => {
|
|
781
|
+
if (a !== undefined) {
|
|
782
|
+
return a.verdict === 'absent'
|
|
783
|
+
? 'is already gone'
|
|
784
|
+
: `stays as yours (${a.reason ?? 'edited'})`;
|
|
785
|
+
}
|
|
786
|
+
const status = await regularFileStatus(repoDir, rel);
|
|
787
|
+
return status === 'absent' ? 'is already gone' : 'exists and is yours (untracked by this rig)';
|
|
788
|
+
};
|
|
789
|
+
const notACleanRemoval = (a) => a === undefined || a.verdict === 'preserved' || a.verdict === 'absent';
|
|
790
|
+
if (claudeAction?.verdict === 'remove' && notACleanRemoval(agentsAction)) {
|
|
791
|
+
claudeAction.note =
|
|
792
|
+
`this is the rig's own CLAUDE.md — removing it leaves AGENTS.md, which ` +
|
|
793
|
+
`${await siblingState(agentsAction, 'AGENTS.md')}, as the only rulebook copy`;
|
|
794
|
+
}
|
|
795
|
+
if (agentsAction?.verdict === 'remove' && notACleanRemoval(claudeAction)) {
|
|
796
|
+
agentsAction.note =
|
|
797
|
+
`this is the rig's own AGENTS.md — removing it leaves CLAUDE.md, which ` +
|
|
798
|
+
`${await siblingState(claudeAction, 'CLAUDE.md')}, as the only rulebook copy`;
|
|
799
|
+
}
|
|
800
|
+
// Round 4, blocker 1 (round 5: shares `renderedAgentsMd` with `upgrade.ts`
|
|
801
|
+
// — one implementation of "is this the current rendering", not two that
|
|
802
|
+
// could drift apart): the sibling `upgrade` writes when CLAUDE.md's shim
|
|
803
|
+
// is genuinely held back (`AGENTS_MD_RESCUE`, never recorded in the
|
|
804
|
+
// manifest) is invisible to the loop above — it only ever walks
|
|
805
|
+
// `manifest.files`/`manifest.kept`. Decided the same way as everything
|
|
806
|
+
// else here: rig-owned (removable) only when its bytes are EXACTLY what
|
|
807
|
+
// this release would render for THIS project right now; anything else is
|
|
808
|
+
// the user's, left alone and reported as preserved. Round 5 advisory:
|
|
809
|
+
// annotated in every state, not only `preserved` — a bare `remove` line
|
|
810
|
+
// said nothing about why removing an UNTRACKED path was safe.
|
|
811
|
+
const rescueStatus = await regularFileStatus(repoDir, AGENTS_MD_RESCUE);
|
|
812
|
+
if (rescueStatus === 'unsafe') {
|
|
813
|
+
actions.push({
|
|
814
|
+
rel: AGENTS_MD_RESCUE,
|
|
815
|
+
verdict: 'preserved',
|
|
816
|
+
reason: NOT_A_REGULAR_FILE_REASON,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
else if (rescueStatus === 'ok') {
|
|
820
|
+
const rescueBytes = await readFile(onDisk(repoDir, AGENTS_MD_RESCUE));
|
|
821
|
+
const rendered = await renderedAgentsMd(manifest.project, manifest.layers ?? ALL_LAYERS);
|
|
822
|
+
if (rendered !== null && sha256(rescueBytes) === sha256(Buffer.from(rendered, 'utf8'))) {
|
|
823
|
+
actions.push({
|
|
824
|
+
rel: AGENTS_MD_RESCUE,
|
|
825
|
+
verdict: 'remove',
|
|
826
|
+
recordedHash: sha256(rescueBytes),
|
|
827
|
+
note: 'byte-identical to what this release renders for AGENTS.md right now — safe to remove, and never recorded in the manifest as rig-owned',
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
actions.push({
|
|
832
|
+
rel: AGENTS_MD_RESCUE,
|
|
833
|
+
verdict: 'preserved',
|
|
834
|
+
reason: "not this release's current rendered AGENTS.md — treated as yours",
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// 'absent': nothing to report — this path was never rig-owned, so "not
|
|
839
|
+
// there" is simply not there, same as any other path this release never
|
|
840
|
+
// installs.
|
|
841
|
+
return { noManifest: false, actions, manifestHash };
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* True when every segment of `relDir`, walked one at a time from `repoDir`,
|
|
845
|
+
* `lstat`s as a real (non-symlink) directory.
|
|
846
|
+
*
|
|
847
|
+
* Mirrors {@link regularFileStatus}'s own per-segment walk, for the same
|
|
848
|
+
* reason: a single `lstat` on the whole joined path has the OS resolve every
|
|
849
|
+
* INTERMEDIATE component transparently — only the final component is left
|
|
850
|
+
* unfollowed — so it would happily walk through a symlinked ancestor to
|
|
851
|
+
* answer "is the last segment a directory". Checking one segment at a time
|
|
852
|
+
* means each `lstat` only ever extends a prefix the previous iteration has
|
|
853
|
+
* already proven is a real directory, so no call in the chain can be resolved
|
|
854
|
+
* through a symlink it did not itself just reject.
|
|
855
|
+
*/
|
|
856
|
+
async function isPlainDirectoryChain(repoDir, relDir) {
|
|
857
|
+
let base;
|
|
858
|
+
try {
|
|
859
|
+
base = await realpath(repoDir);
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
let current = repoDir;
|
|
865
|
+
for (const segment of relDir.split('/')) {
|
|
866
|
+
current = path.join(current, segment);
|
|
867
|
+
let info;
|
|
868
|
+
try {
|
|
869
|
+
info = await lstat(current);
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
875
|
+
return false;
|
|
876
|
+
// Classification-independent, the same second check `regularFileStatus`
|
|
877
|
+
// makes and for the same reason: whatever `lstat` classifies this
|
|
878
|
+
// segment as, its actual target must still resolve inside the
|
|
879
|
+
// repository root.
|
|
880
|
+
let resolved;
|
|
881
|
+
try {
|
|
882
|
+
resolved = await realpath(current);
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
if (resolved !== base && !resolved.startsWith(base + path.sep))
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
return true;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Removes `rel`'s parent directories while they are empty, stopping at the
|
|
894
|
+
* repository root and never removing `.rig` itself — evidence lives there,
|
|
895
|
+
* and the directory staying in place (even empty) is the rig's own record
|
|
896
|
+
* that it once ran here.
|
|
897
|
+
*
|
|
898
|
+
* Symlink-safe the same way removal itself is: before any directory is read
|
|
899
|
+
* or emptied, {@link isPlainDirectoryChain} re-walks it segment by segment, so
|
|
900
|
+
* an ancestor swapped for a symlink after the file itself was removed is left
|
|
901
|
+
* alone rather than read or emptied through.
|
|
902
|
+
*/
|
|
903
|
+
async function removeEmptyParents(repoDir, rel) {
|
|
904
|
+
let dir = path.posix.dirname(rel.split(path.sep).join('/'));
|
|
905
|
+
while (dir !== '.' && dir !== '' && dir !== RIG_DIR) {
|
|
906
|
+
if (!(await isPlainDirectoryChain(repoDir, dir)))
|
|
907
|
+
return;
|
|
908
|
+
const abs = path.join(repoDir, ...dir.split('/'));
|
|
909
|
+
let entries;
|
|
910
|
+
try {
|
|
911
|
+
entries = await readdir(abs);
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
if (entries.length > 0)
|
|
917
|
+
return;
|
|
918
|
+
try {
|
|
919
|
+
await rmdir(abs);
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
dir = path.posix.dirname(dir);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* `null` when the manifest still matches `expectedHash` exactly; otherwise the
|
|
929
|
+
* one sentence explaining why it does not — gone, behind a symlinked ancestor
|
|
930
|
+
* (or another non-regular entry), or simply different bytes now. Never
|
|
931
|
+
* throws: a symlinked ancestor is exactly one of the reasons this reports
|
|
932
|
+
* rather than the caller having to catch {@link UninstallError} itself.
|
|
933
|
+
*/
|
|
934
|
+
async function manifestMismatchReason(repoDir, expectedHash) {
|
|
935
|
+
let raw;
|
|
936
|
+
try {
|
|
937
|
+
raw = await readManifestBytes(repoDir);
|
|
938
|
+
}
|
|
939
|
+
catch (error) {
|
|
940
|
+
return error.message;
|
|
941
|
+
}
|
|
942
|
+
if (raw === null)
|
|
943
|
+
return `"${MANIFEST_REL}" is gone — changed since planning`;
|
|
944
|
+
if (sha256(raw) !== expectedHash) {
|
|
945
|
+
return `"${MANIFEST_REL}" changed since planning — its bytes no longer match the plan`;
|
|
946
|
+
}
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Removes the `remove`-verdict paths in `plan`, then the manifest — but only
|
|
951
|
+
* once every one of them succeeded AND (outside `--detach`) nothing else in
|
|
952
|
+
* the plan is `preserved`, nor turned out to have changed since planning. A
|
|
953
|
+
* `preserved` action, or a path caught changed at apply time, means the rig
|
|
954
|
+
* still owns bytes it did not remove; deleting the manifest anyway would
|
|
955
|
+
* discard the only evidence naming what it still owns, blinding a later
|
|
956
|
+
* `upgrade` — unless `options.detach` says to do exactly that on purpose,
|
|
957
|
+
* leaving those paths for the user instead.
|
|
958
|
+
*
|
|
959
|
+
* Two kinds of "this is not the plan I made" are both re-checked here, never
|
|
960
|
+
* trusted from `plan`, because the window between the plan being shown and
|
|
961
|
+
* this call — a confirmation prompt sits in it — is exactly where either can
|
|
962
|
+
* happen: the manifest's own bytes ({@link manifestMismatchReason}, checked
|
|
963
|
+
* once before the first removal and again immediately before the manifest's
|
|
964
|
+
* own deletion) and each `remove`-verdict file's own bytes (checked
|
|
965
|
+
* immediately before its removal, via `recordedHash`). A symlink appearing
|
|
966
|
+
* where a plain file was planned is a THIRD kind, and gets a different
|
|
967
|
+
* response on purpose: it aborts the whole run rather than skipping one path,
|
|
968
|
+
* because it is the one shape suspicious enough that continuing is the wrong
|
|
969
|
+
* default.
|
|
970
|
+
*/
|
|
971
|
+
export async function applyUninstall(repoDir, plan, options = {}) {
|
|
972
|
+
if (plan.noManifest) {
|
|
973
|
+
// `outcome` names one of three end states a REAL run reached; `--dry-run`
|
|
974
|
+
// never reaches one, including here — "nothing installed" is only an end
|
|
975
|
+
// state once a real run has (not) acted on it. See `UninstallOutcome`.
|
|
976
|
+
return options.dryRun === true
|
|
977
|
+
? { removed: [], manifestRemoved: false }
|
|
978
|
+
: { removed: [], manifestRemoved: false, outcome: 'uninstalled' };
|
|
979
|
+
}
|
|
980
|
+
const toRemove = plan.actions.filter((a) => a.verdict === 'remove');
|
|
981
|
+
if (options.dryRun === true)
|
|
982
|
+
return { removed: [], manifestRemoved: false };
|
|
983
|
+
const detach = options.detach === true;
|
|
984
|
+
// Whether this run, absent any hard failure, would go on to delete the
|
|
985
|
+
// manifest: always true under `--detach` (that is the point of it), and
|
|
986
|
+
// otherwise only when nothing in the plan is `preserved`. A `changed since
|
|
987
|
+
// planning` discovery below can still turn this off for an ordinary run —
|
|
988
|
+
// detach is the only thing that overrides it.
|
|
989
|
+
const wouldDeleteManifest = detach || !plan.actions.some((a) => a.verdict === 'preserved');
|
|
990
|
+
// Checkpoint 1: the manifest itself, before anything is touched at all. A
|
|
991
|
+
// plan built from bytes that no longer exist is not evidence for what
|
|
992
|
+
// follows, so nothing is removed — not even the files a fresh plan would
|
|
993
|
+
// still agree to remove.
|
|
994
|
+
if (plan.manifestHash !== null) {
|
|
995
|
+
const mismatch = await manifestMismatchReason(repoDir, plan.manifestHash);
|
|
996
|
+
if (mismatch !== null) {
|
|
997
|
+
return {
|
|
998
|
+
removed: [],
|
|
999
|
+
manifestRemoved: false,
|
|
1000
|
+
completed: [],
|
|
1001
|
+
remaining: [...toRemove.map((a) => a.rel), ...(wouldDeleteManifest ? [MANIFEST_REL] : [])],
|
|
1002
|
+
error: `Refusing to apply a stale plan: ${mismatch}`,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
// Re-derived here, not trusted from the plan's own (already-baked-in)
|
|
1007
|
+
// per-file verdicts: a wiring file that was still pristine when
|
|
1008
|
+
// `planUninstall` ran can have been edited, or replaced with a symlink, in
|
|
1009
|
+
// the window since — the confirmation prompt sits in exactly that window —
|
|
1010
|
+
// and hook files sort ahead of the wiring that references them, so the
|
|
1011
|
+
// removal loop below would otherwise unlink them before ever re-examining
|
|
1012
|
+
// it. `toRemove` is where a wiring path's recorded hash comes from here:
|
|
1013
|
+
// present only when the PLAN called it `remove` (pristine, `files`-tracked,
|
|
1014
|
+
// at plan time), which is exactly the case this re-check exists to catch.
|
|
1015
|
+
// A `kept` wiring path is never in `toRemove` — `planUninstall`'s own
|
|
1016
|
+
// `kept` loop always calls it `preserved`, unconditionally, so its hooks
|
|
1017
|
+
// were already protected by the PLAN-TIME call to `protectedHooksFor`
|
|
1018
|
+
// above, and this second pass has nothing further to do on its account.
|
|
1019
|
+
//
|
|
1020
|
+
// `wiringBytes` is deliberately NOT taken from this call: it is the read
|
|
1021
|
+
// this pass itself performs of a wiring file's bytes, at the SAME
|
|
1022
|
+
// too-early point in time the comment below explains — reusing it at the
|
|
1023
|
+
// removal site would just reintroduce the bug this apply-time re-check
|
|
1024
|
+
// exists to close.
|
|
1025
|
+
const ownedPaths = await rigOwnedPaths();
|
|
1026
|
+
const applyTimeTrackingFor = (wiringRel) => {
|
|
1027
|
+
const recordedHash = toRemove.find((a) => a.rel === wiringRel)?.recordedHash;
|
|
1028
|
+
return recordedHash === undefined
|
|
1029
|
+
? { tracked: false }
|
|
1030
|
+
: { tracked: true, alwaysPreserved: false, recordedHash };
|
|
1031
|
+
};
|
|
1032
|
+
const { protectedHooks: applyTimeProtectedHooks, importedBy: applyTimeImportedBy, unverified: applyTimeUnverified, wiringKind: applyTimeWiringKind, } = await protectedHooksFor(repoDir, ownedPaths, applyTimeTrackingFor);
|
|
1033
|
+
// ⚠ Boundary that `index.ts`'s own outer try/catch around this whole
|
|
1034
|
+
// function relies on: nothing above this line has removed anything, and
|
|
1035
|
+
// nothing above this line is inside a try/catch of its own — an exception
|
|
1036
|
+
// from `rigOwnedPaths()` or the apply-time `protectedHooksFor` pass above
|
|
1037
|
+
// propagates uncaught, and the caller is entitled to assume `removed: []`
|
|
1038
|
+
// when that happens. From here down, every per-file removal is wrapped in
|
|
1039
|
+
// ITS OWN try/catch (immediately below) and this function always RETURNS a
|
|
1040
|
+
// result — real `removed` so far included — rather than throwing. Adding a
|
|
1041
|
+
// throwing call inside or after this loop without also updating it to
|
|
1042
|
+
// return, not throw, would make that caller's `removed: []` a lie.
|
|
1043
|
+
const removeFile = options.removeFile ?? ((absolutePath) => unlink(absolutePath));
|
|
1044
|
+
const removed = [];
|
|
1045
|
+
const changedSincePlanning = [];
|
|
1046
|
+
const protectedHooksAtApply = [];
|
|
1047
|
+
for (let i = 0; i < toRemove.length; i++) {
|
|
1048
|
+
const { rel, recordedHash } = toRemove[i];
|
|
1049
|
+
const protectingWiring = applyTimeProtectedHooks.get(rel);
|
|
1050
|
+
if (protectingWiring !== undefined) {
|
|
1051
|
+
// Discovered only now: the plan said `remove`, but the wiring file
|
|
1052
|
+
// that still calls this hook has since been edited or become a
|
|
1053
|
+
// symlink. Skipped, never removed, reported loudly — this is not a
|
|
1054
|
+
// silent exit 0. Never `'kept'` here specifically: `applyTimeTrackingFor`
|
|
1055
|
+
// above only ever tracks a path that was a plan-time `remove` verdict,
|
|
1056
|
+
// and a `kept` path is never one — ordinarily its hooks were already
|
|
1057
|
+
// protected by the PLAN-TIME call, using the manifest's OWN `kept`
|
|
1058
|
+
// membership, which this re-check does not consult at all. ⚠ Not an
|
|
1059
|
+
// invariant in the one case this re-check exists FOR, though: a `kept`
|
|
1060
|
+
// wiring path that was genuinely ABSENT at plan time (nothing to read,
|
|
1061
|
+
// so the plan-time pass protected nothing on its account) and then
|
|
1062
|
+
// reappears, rewired, in the confirmation-prompt window is exactly the
|
|
1063
|
+
// "changed since planning" shape this whole apply-time pass was built
|
|
1064
|
+
// to catch — and it is not caught here, because this pass is keyed off
|
|
1065
|
+
// `toRemove`, which a `kept` path is never in. Narrow, and no worse
|
|
1066
|
+
// than the pre-existing state (a hook this rig never removes anyway
|
|
1067
|
+
// stays exactly as absent-or-present as it already was), but stated
|
|
1068
|
+
// here rather than left implied.
|
|
1069
|
+
//
|
|
1070
|
+
// ⚠ A second, sibling gap, this one for an EDITED (not `kept`) wiring
|
|
1071
|
+
// file specifically (security-lens review, RP-181, cycle 8): if a
|
|
1072
|
+
// hook a preserved-as-edited wiring file names is genuinely ABSENT at
|
|
1073
|
+
// plan time, its single-seeded dependency is never swept (correctly — an absent
|
|
1074
|
+
// file has nothing to protect a dependency on behalf of), and that
|
|
1075
|
+
// dependency gets an ordinary `remove` verdict. If the hook then
|
|
1076
|
+
// REAPPEARS — as a symlink, or as a legitimate working file that
|
|
1077
|
+
// needs it — in the confirmation-prompt window before apply, nothing
|
|
1078
|
+
// re-examines it: this whole pass only re-derives protection for a
|
|
1079
|
+
// wiring path that was itself a plan-time `remove` verdict, and an
|
|
1080
|
+
// EDITED wiring file never is one. The dependency is removed on
|
|
1081
|
+
// schedule. Judged materially weaker than the hole this apply-time
|
|
1082
|
+
// re-check exists to close (that one needed only a symlink committed
|
|
1083
|
+
// and surviving `git clone`; this one needs WRITE ACCESS to the
|
|
1084
|
+
// working tree in the narrow window between the plan being shown and
|
|
1085
|
+
// `--yes` being answered) and left as a documented limitation rather
|
|
1086
|
+
// than grown into this change: closing it would mean re-deriving
|
|
1087
|
+
// apply-time protection for every wiring path unconditionally, not
|
|
1088
|
+
// only ones already known to be plan-time `remove` verdicts, which is
|
|
1089
|
+
// a wider change than this cycle's fix earns. The same end state also
|
|
1090
|
+
// needs no window at all — hand-delete the hook, uninstall, restore the
|
|
1091
|
+
// hook from git — and `docs/command-contract.md` says so beside this
|
|
1092
|
+
// limitation rather than letting "needs write access" read as its bound.
|
|
1093
|
+
const importer = applyTimeImportedBy.get(rel);
|
|
1094
|
+
const unverifiedBecause = applyTimeUnverified.get(rel);
|
|
1095
|
+
protectedHooksAtApply.push({
|
|
1096
|
+
rel,
|
|
1097
|
+
wiringRel: protectingWiring,
|
|
1098
|
+
// `applyTimeWiringKind` is set for `protectingWiring` in the SAME
|
|
1099
|
+
// iteration of `protectedHooksFor` that populated
|
|
1100
|
+
// `applyTimeProtectedHooks` with `rel` — never independently, so
|
|
1101
|
+
// this can only be `undefined` if the two maps disagreed with each
|
|
1102
|
+
// other, which would itself be the bug to fix (the same reasoning
|
|
1103
|
+
// as the plan-time assertion above).
|
|
1104
|
+
wiringKind: applyTimeWiringKind.get(protectingWiring),
|
|
1105
|
+
...(importer !== undefined ? { importedBy: importer } : {}),
|
|
1106
|
+
...(unverifiedBecause !== undefined ? { unverifiedBecause } : {}),
|
|
1107
|
+
});
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
try {
|
|
1111
|
+
// Re-checked here, not trusted from the plan: the plan can be stale by
|
|
1112
|
+
// the time this runs, and a symlink swapped in after planning is
|
|
1113
|
+
// exactly the case the plan-time check cannot see.
|
|
1114
|
+
const status = await regularFileStatus(repoDir, rel);
|
|
1115
|
+
if (status !== 'ok') {
|
|
1116
|
+
throw new Error(`refusing to remove "${rel}": it is no longer a plain file inside the repository ` +
|
|
1117
|
+
'(a symlink appeared since planning)');
|
|
1118
|
+
}
|
|
1119
|
+
// Content re-checked too, not only the file's TYPE: the symlink check
|
|
1120
|
+
// above cannot see a plain edit, and the confirmation prompt between
|
|
1121
|
+
// the plan and this call is exactly the window one could happen in. A
|
|
1122
|
+
// mismatch is not suspicious the way a symlink is — it is skipped, not
|
|
1123
|
+
// aborted, and the run keeps going.
|
|
1124
|
+
//
|
|
1125
|
+
// Read FRESH here, never from `wiringBytes` — that cache was filled by
|
|
1126
|
+
// `protectedHooksFor` BEFORE this removal loop started, so for
|
|
1127
|
+
// `.claude/settings.json` / `.codex/hooks.json` it is not "immediately
|
|
1128
|
+
// before removal" the way `UninstallAction.recordedHash`'s own contract
|
|
1129
|
+
// promises: it is the whole loop above this file's turn (on the order
|
|
1130
|
+
// of a hundred unlinks plus directory cleanups). An edit landing in
|
|
1131
|
+
// that span would be missed and the modified wiring file deleted —
|
|
1132
|
+
// exactly what this re-check, and `changedSincePlanning`, exist to
|
|
1133
|
+
// prevent. (`wiringBytes` is still used at plan time, in `planUninstall`
|
|
1134
|
+
// above — there it IS the immediate read, since nothing runs between it
|
|
1135
|
+
// and that file's own verdict.)
|
|
1136
|
+
if (recordedHash !== undefined) {
|
|
1137
|
+
const current = await readFile(onDisk(repoDir, rel));
|
|
1138
|
+
if (sha256(current) !== recordedHash) {
|
|
1139
|
+
changedSincePlanning.push(rel);
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
await removeFile(onDisk(repoDir, rel));
|
|
1144
|
+
}
|
|
1145
|
+
catch (error) {
|
|
1146
|
+
const stillOwed = toRemove.slice(i).map((a) => a.rel);
|
|
1147
|
+
return {
|
|
1148
|
+
removed,
|
|
1149
|
+
manifestRemoved: false,
|
|
1150
|
+
completed: [...removed],
|
|
1151
|
+
remaining: wouldDeleteManifest ? [...stillOwed, MANIFEST_REL] : stillOwed,
|
|
1152
|
+
error: error.message,
|
|
1153
|
+
...(changedSincePlanning.length > 0 ? { changedSincePlanning } : {}),
|
|
1154
|
+
...(protectedHooksAtApply.length > 0 ? { protectedHooksAtApply } : {}),
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
removed.push(rel);
|
|
1158
|
+
await removeEmptyParents(repoDir, rel);
|
|
1159
|
+
}
|
|
1160
|
+
const stillPreserved = !wouldDeleteManifest || changedSincePlanning.length > 0 || protectedHooksAtApply.length > 0;
|
|
1161
|
+
// Something is still preserved and this is not a detach — the rig remains
|
|
1162
|
+
// installed, on purpose. The manifest is the only record naming what it
|
|
1163
|
+
// still owns, so it is kept even though every removal that WAS planned
|
|
1164
|
+
// (and still matched its recorded hash) just succeeded.
|
|
1165
|
+
if (!detach && stillPreserved) {
|
|
1166
|
+
return {
|
|
1167
|
+
removed,
|
|
1168
|
+
manifestRemoved: false,
|
|
1169
|
+
outcome: 'partial',
|
|
1170
|
+
...(changedSincePlanning.length > 0 ? { changedSincePlanning } : {}),
|
|
1171
|
+
...(protectedHooksAtApply.length > 0 ? { protectedHooksAtApply } : {}),
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
// Checkpoint 2: the manifest again, immediately before deleting it — the
|
|
1175
|
+
// window every removal above could have used. Re-checked the same
|
|
1176
|
+
// symlink-safe, content-verified way as checkpoint 1, not trusted from the
|
|
1177
|
+
// first check: this narrows the window a swap can exploit; it does not
|
|
1178
|
+
// close it entirely — a concurrent swap in the instant between THIS check
|
|
1179
|
+
// and the `unlink` call below is a residual race no check-then-act sequence
|
|
1180
|
+
// over the filesystem can rule out (docs/command-contract.md, "## uninstall
|
|
1181
|
+
// (RP-181)"). On a mismatch here the manifest is kept, never deleted, and
|
|
1182
|
+
// the result is an honest partial one: `completed` names every file that
|
|
1183
|
+
// really was removed, `remaining` names only the manifest.
|
|
1184
|
+
if (plan.manifestHash !== null) {
|
|
1185
|
+
const mismatch = await manifestMismatchReason(repoDir, plan.manifestHash);
|
|
1186
|
+
if (mismatch !== null) {
|
|
1187
|
+
return {
|
|
1188
|
+
removed,
|
|
1189
|
+
manifestRemoved: false,
|
|
1190
|
+
completed: [...removed],
|
|
1191
|
+
remaining: [MANIFEST_REL],
|
|
1192
|
+
error: mismatch,
|
|
1193
|
+
...(changedSincePlanning.length > 0 ? { changedSincePlanning } : {}),
|
|
1194
|
+
...(protectedHooksAtApply.length > 0 ? { protectedHooksAtApply } : {}),
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
try {
|
|
1199
|
+
await unlink(onDisk(repoDir, MANIFEST_REL));
|
|
1200
|
+
}
|
|
1201
|
+
catch (error) {
|
|
1202
|
+
return {
|
|
1203
|
+
removed,
|
|
1204
|
+
manifestRemoved: false,
|
|
1205
|
+
completed: [...removed],
|
|
1206
|
+
remaining: [MANIFEST_REL],
|
|
1207
|
+
error: error.message,
|
|
1208
|
+
...(changedSincePlanning.length > 0 ? { changedSincePlanning } : {}),
|
|
1209
|
+
...(protectedHooksAtApply.length > 0 ? { protectedHooksAtApply } : {}),
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
// The manifest is often the last file left in `.claude/` — its own removal
|
|
1213
|
+
// is what can finally empty that directory, so the same cleanup runs again
|
|
1214
|
+
// for it.
|
|
1215
|
+
await removeEmptyParents(repoDir, MANIFEST_REL);
|
|
1216
|
+
return {
|
|
1217
|
+
removed,
|
|
1218
|
+
manifestRemoved: true,
|
|
1219
|
+
outcome: detach ? 'detached' : 'uninstalled',
|
|
1220
|
+
...(changedSincePlanning.length > 0 ? { changedSincePlanning } : {}),
|
|
1221
|
+
...(protectedHooksAtApply.length > 0 ? { protectedHooksAtApply } : {}),
|
|
1222
|
+
};
|
|
1223
|
+
}
|