@crewhaus/tool-lifecycle 0.7.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.
@@ -0,0 +1,92 @@
1
+ import type { RetirementSteps, StepOutcome } from "@crewhaus/harness-lifecycle";
2
+ import type { Tree } from "./tree";
3
+ /** Where a harness keeps its durable state, by convention. */
4
+ export declare const STATE_RELDIR = ".crewhaus";
5
+ /**
6
+ * Where `@crewhaus/harness-lifecycle` moves the live state dir inside the
7
+ * archive. The package does not export this name, so it is mirrored here —
8
+ * and asserted at run time rather than trusted: if the archived tree is not
9
+ * found at this path the tool reports that it could not verify the archive,
10
+ * never that it verified one. `index.test.ts` pins the coupling so a rename
11
+ * upstream fails a test here instead of silently skipping verification.
12
+ */
13
+ export declare const ARCHIVED_STATE_DIRNAME = "crewhaus-state";
14
+ /** The manifest this tool writes into the archive before anything moves. */
15
+ export declare const STATE_MANIFEST_FILENAME = "state-manifest.json";
16
+ /** Default registry root, matching `@crewhaus/spec-registry`'s file backend. */
17
+ export declare const DEFAULT_REGISTRY_RELDIR = ".crewhaus/specs";
18
+ export declare const AUDIT_VERIFY_UNAVAILABLE: string;
19
+ export declare const COMPLIANCE_EVIDENCE_UNAVAILABLE: string;
20
+ /**
21
+ * What is known about a spec's registry entry.
22
+ *
23
+ * `unknown` exists because the alternative is unthinkable: a manifest that
24
+ * could not be parsed, reported as "no pins", retires a harness that an
25
+ * environment is still pointing at. An unreadable manifest and an absent one
26
+ * are different answers and are kept different all the way to the result.
27
+ */
28
+ export type PinState = {
29
+ readonly state: "absent";
30
+ } | {
31
+ readonly state: "known";
32
+ readonly pins: Record<string, string>;
33
+ readonly versions: ReadonlyArray<string>;
34
+ } | {
35
+ readonly state: "unknown";
36
+ readonly reason: string;
37
+ };
38
+ /** A spec name is a directory component: never a path, never a traversal. */
39
+ export declare function specNameIsSafe(name: string): boolean;
40
+ /**
41
+ * Read `<registryDir>/<spec>/manifest.json` — the file-backed
42
+ * `@crewhaus/spec-registry` layout (`{ versions: [...], pins: { env: version } }`).
43
+ *
44
+ * Only the FILE is read here; the rule that active pins block a retirement
45
+ * belongs to `buildRetirementPlan` in `@crewhaus/harness-lifecycle` and is
46
+ * not restated. Anything that is not a well-formed manifest resolves to
47
+ * `unknown`, never to an empty pin set.
48
+ */
49
+ export declare function readRegistryPins(registryDirAbs: string, spec: string): PinState;
50
+ export type StateManifest = {
51
+ readonly writtenAt: string;
52
+ readonly harnessDir: string;
53
+ readonly fileCount: number;
54
+ readonly totalBytes: number;
55
+ readonly complete: boolean;
56
+ readonly unreadable: Tree["unreadable"];
57
+ readonly entries: Tree["entries"];
58
+ };
59
+ export type BuildStepsOptions = {
60
+ /** The fingerprint of `.crewhaus` taken BEFORE anything moves. */
61
+ readonly stateTree: Tree | undefined;
62
+ readonly harnessDir: string;
63
+ readonly pinState: PinState;
64
+ readonly now: () => Date;
65
+ };
66
+ /**
67
+ * The `RetirementSteps` this build can honestly supply.
68
+ *
69
+ * `backupState` does not make a second copy of the state: the package's own
70
+ * archive step MOVES the live directory into the archive, so a copy beside it
71
+ * would double the bytes and prove nothing. What it writes instead is the
72
+ * per-file sha256 manifest taken before the move, which is what makes the
73
+ * move checkable afterwards — and it refuses (ok: false, which aborts before
74
+ * anything is destroyed) when that fingerprint is incomplete.
75
+ */
76
+ export declare function buildRetireSteps(opts: BuildStepsOptions): RetirementSteps;
77
+ /**
78
+ * Wrap a step set so every outcome is kept.
79
+ *
80
+ * `runRetirement` THROWS on the step that fails, and the retirement log it
81
+ * writes into the archive only exists for the abort paths that get that far —
82
+ * so a caller of the tool would otherwise be told "the tombstone failed" with
83
+ * no record of the three steps that succeeded before it. Recording here means
84
+ * a refused retirement reports the same per-step outcomes a completed one
85
+ * does, which is what makes it possible to tell how far it got.
86
+ */
87
+ export declare function recordSteps(steps: RetirementSteps): {
88
+ readonly steps: RetirementSteps;
89
+ readonly recorded: StepOutcome[];
90
+ };
91
+ /** Read back a state manifest written by `backupState`. */
92
+ export declare function readStateManifest(archiveDir: string): StateManifest | undefined;
@@ -0,0 +1,269 @@
1
+ /**
2
+ * `HarnessRetire`'s half of the retirement: the facts the tool must establish
3
+ * before `@crewhaus/harness-lifecycle` is allowed to move anything, and the
4
+ * `RetirementSteps` seam the package asks its host to supply.
5
+ *
6
+ * The package owns the orchestration — the order of the steps, the refusal on
7
+ * an active deployment pin, the abort-before-destruction gate, and the
8
+ * `retirement.json` written into the archive. None of that is re-decided
9
+ * here. What lives here is what a HOST has to answer: where are the pins,
10
+ * what can this build actually do, and did the bytes arrive.
11
+ *
12
+ * WHAT THIS BUILD CANNOT DO, AND SAYS SO. Three of the five steps the CLI
13
+ * wires need packages `@crewhaus/tool-lifecycle` does not depend on. Rather
14
+ * than reporting them as successes (a step that returns `ok: true` for work
15
+ * nobody did is the exact "could not determine reported as fine" failure this
16
+ * repository keeps paying for), they return `ok: false` with the reason. The
17
+ * package then aborts before the destructive move, which is the correct
18
+ * outcome: the audit chain has not been verified, so nothing should be moved
19
+ * on the strength of it.
20
+ */
21
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
22
+ import * as path from "node:path";
23
+ import { renderPath } from "./result";
24
+ /** Where a harness keeps its durable state, by convention. */
25
+ export const STATE_RELDIR = ".crewhaus";
26
+ /**
27
+ * Where `@crewhaus/harness-lifecycle` moves the live state dir inside the
28
+ * archive. The package does not export this name, so it is mirrored here —
29
+ * and asserted at run time rather than trusted: if the archived tree is not
30
+ * found at this path the tool reports that it could not verify the archive,
31
+ * never that it verified one. `index.test.ts` pins the coupling so a rename
32
+ * upstream fails a test here instead of silently skipping verification.
33
+ */
34
+ export const ARCHIVED_STATE_DIRNAME = "crewhaus-state";
35
+ /** The manifest this tool writes into the archive before anything moves. */
36
+ export const STATE_MANIFEST_FILENAME = "state-manifest.json";
37
+ /** Default registry root, matching `@crewhaus/spec-registry`'s file backend. */
38
+ export const DEFAULT_REGISTRY_RELDIR = ".crewhaus/specs";
39
+ export const AUDIT_VERIFY_UNAVAILABLE = "not performed — @crewhaus/audit-log is not a dependency of @crewhaus/tool-lifecycle, so this " +
40
+ "tool cannot verify the harness's audit chain. Run the AuditVerify tool (@crewhaus/tool-crewhaus) " +
41
+ "against the same directory first; it walks the real chain.";
42
+ export const COMPLIANCE_EVIDENCE_UNAVAILABLE = "not performed — @crewhaus/compliance-controls is not a dependency of @crewhaus/tool-lifecycle, " +
43
+ "so no final compliance-evidence bundle was collected. Collect it with the CLI " +
44
+ "(`crewhaus compliance evidence`) before retiring if your framework requires one.";
45
+ /** A spec name is a directory component: never a path, never a traversal. */
46
+ export function specNameIsSafe(name) {
47
+ return (name.length > 0 &&
48
+ !name.includes("/") &&
49
+ !name.includes("\\") &&
50
+ !name.includes("\u0000") &&
51
+ name !== "." &&
52
+ name !== ".." &&
53
+ !name.split(/[/\\]/).includes(".."));
54
+ }
55
+ /**
56
+ * Read `<registryDir>/<spec>/manifest.json` — the file-backed
57
+ * `@crewhaus/spec-registry` layout (`{ versions: [...], pins: { env: version } }`).
58
+ *
59
+ * Only the FILE is read here; the rule that active pins block a retirement
60
+ * belongs to `buildRetirementPlan` in `@crewhaus/harness-lifecycle` and is
61
+ * not restated. Anything that is not a well-formed manifest resolves to
62
+ * `unknown`, never to an empty pin set.
63
+ */
64
+ export function readRegistryPins(registryDirAbs, spec) {
65
+ const manifestPath = path.join(registryDirAbs, spec, "manifest.json");
66
+ let raw;
67
+ try {
68
+ raw = readFileSync(manifestPath, "utf8");
69
+ }
70
+ catch (err) {
71
+ const code = err.code;
72
+ if (code === "ENOENT")
73
+ return { state: "absent" };
74
+ return {
75
+ state: "unknown",
76
+ reason: `the registry manifest for "${renderPath(spec)}" could not be read (${code ?? "unknown error"})`,
77
+ };
78
+ }
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(raw);
82
+ }
83
+ catch (err) {
84
+ return {
85
+ state: "unknown",
86
+ reason: `the registry manifest for "${renderPath(spec)}" is not valid JSON (${err.message})`,
87
+ };
88
+ }
89
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
90
+ return {
91
+ state: "unknown",
92
+ reason: `the registry manifest for "${renderPath(spec)}" is not a JSON object`,
93
+ };
94
+ }
95
+ const record = parsed;
96
+ const rawPins = record["pins"];
97
+ const rawVersions = record["versions"];
98
+ const pins = {};
99
+ if (rawPins !== undefined) {
100
+ if (typeof rawPins !== "object" || rawPins === null || Array.isArray(rawPins)) {
101
+ return {
102
+ state: "unknown",
103
+ reason: `the registry manifest for "${renderPath(spec)}" has a "pins" field that is not an object`,
104
+ };
105
+ }
106
+ for (const [env, version] of Object.entries(rawPins)) {
107
+ if (typeof version !== "string") {
108
+ return {
109
+ state: "unknown",
110
+ reason: `the registry manifest for "${renderPath(spec)}" pins "${renderPath(env)}" to something that is not a version string`,
111
+ };
112
+ }
113
+ pins[env] = version;
114
+ }
115
+ }
116
+ let versions = [];
117
+ if (rawVersions !== undefined) {
118
+ if (!Array.isArray(rawVersions) || rawVersions.some((v) => typeof v !== "string")) {
119
+ return {
120
+ state: "unknown",
121
+ reason: `the registry manifest for "${renderPath(spec)}" has a "versions" field that is not an array of strings`,
122
+ };
123
+ }
124
+ versions = [...rawVersions].sort();
125
+ }
126
+ return { state: "known", pins, versions };
127
+ }
128
+ /**
129
+ * The `RetirementSteps` this build can honestly supply.
130
+ *
131
+ * `backupState` does not make a second copy of the state: the package's own
132
+ * archive step MOVES the live directory into the archive, so a copy beside it
133
+ * would double the bytes and prove nothing. What it writes instead is the
134
+ * per-file sha256 manifest taken before the move, which is what makes the
135
+ * move checkable afterwards — and it refuses (ok: false, which aborts before
136
+ * anything is destroyed) when that fingerprint is incomplete.
137
+ */
138
+ export function buildRetireSteps(opts) {
139
+ return {
140
+ async backupState(archiveDir) {
141
+ const tree = opts.stateTree;
142
+ if (tree === undefined) {
143
+ return {
144
+ step: "backupState",
145
+ ok: true,
146
+ detail: `no ${STATE_RELDIR} directory under ${renderPath(opts.harnessDir)} — nothing to fingerprint`,
147
+ };
148
+ }
149
+ if (!tree.complete) {
150
+ return {
151
+ step: "backupState",
152
+ ok: false,
153
+ detail: `${tree.unreadable.length} entry(ies) under ${STATE_RELDIR} could not be read, so the archive could not be fingerprinted: ${tree.unreadable
154
+ .slice(0, 3)
155
+ .map((u) => `${u.rel} (${u.reason})`)
156
+ .join("; ")}`,
157
+ };
158
+ }
159
+ const manifest = {
160
+ writtenAt: opts.now().toISOString(),
161
+ harnessDir: opts.harnessDir,
162
+ fileCount: tree.fileCount,
163
+ totalBytes: tree.totalBytes,
164
+ complete: tree.complete,
165
+ unreadable: tree.unreadable,
166
+ entries: tree.entries,
167
+ };
168
+ const manifestPath = path.join(archiveDir, STATE_MANIFEST_FILENAME);
169
+ try {
170
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
171
+ }
172
+ catch (err) {
173
+ return {
174
+ step: "backupState",
175
+ ok: false,
176
+ detail: `the state manifest could not be written to the archive: ${err.message}`,
177
+ };
178
+ }
179
+ return {
180
+ step: "backupState",
181
+ ok: true,
182
+ detail: `fingerprinted ${tree.fileCount} file(s), ${tree.totalBytes} byte(s) → ${STATE_MANIFEST_FILENAME}`,
183
+ tarball: manifestPath,
184
+ };
185
+ },
186
+ async complianceEvidence() {
187
+ return { step: "complianceEvidence", ok: false, detail: COMPLIANCE_EVIDENCE_UNAVAILABLE };
188
+ },
189
+ async auditVerify() {
190
+ return { step: "auditVerify", ok: false, detail: AUDIT_VERIFY_UNAVAILABLE };
191
+ },
192
+ async pushKnowledge() {
193
+ // Never planned by this tool: knowledge sharing is the KnowledgeSync
194
+ // tool's job, and doing it here would push a harness's memories on the
195
+ // way out of a call whose subject is deletion.
196
+ return {
197
+ step: "pushKnowledge",
198
+ ok: true,
199
+ detail: "skipped — run the KnowledgeSync tool before retiring if the lessons should outlive the harness",
200
+ };
201
+ },
202
+ async tombstoneRegistry() {
203
+ const pin = opts.pinState;
204
+ if (pin.state === "absent") {
205
+ // True, and the only case this build can honestly report as done:
206
+ // there is no registry entry, so there is nothing to tombstone.
207
+ return {
208
+ step: "tombstoneRegistry",
209
+ ok: true,
210
+ detail: "no registry entry for this spec — nothing to tombstone",
211
+ };
212
+ }
213
+ if (pin.state === "unknown") {
214
+ return { step: "tombstoneRegistry", ok: false, detail: pin.reason };
215
+ }
216
+ if (pin.versions.length === 0 && Object.keys(pin.pins).length === 0) {
217
+ return {
218
+ step: "tombstoneRegistry",
219
+ ok: true,
220
+ detail: "the registry entry holds no versions and no pins — nothing to tombstone",
221
+ };
222
+ }
223
+ return {
224
+ step: "tombstoneRegistry",
225
+ ok: false,
226
+ detail: `${pin.versions.length} registered version(s) and ${Object.keys(pin.pins).length} pin(s) remain, and @crewhaus/spec-registry is not a dependency of @crewhaus/tool-lifecycle, so this tool cannot delete them. Remove them first (crewhaus registry delete), then retire — retiring now would leave a registry entry pointing at state that no longer exists.`,
227
+ };
228
+ },
229
+ };
230
+ }
231
+ /**
232
+ * Wrap a step set so every outcome is kept.
233
+ *
234
+ * `runRetirement` THROWS on the step that fails, and the retirement log it
235
+ * writes into the archive only exists for the abort paths that get that far —
236
+ * so a caller of the tool would otherwise be told "the tombstone failed" with
237
+ * no record of the three steps that succeeded before it. Recording here means
238
+ * a refused retirement reports the same per-step outcomes a completed one
239
+ * does, which is what makes it possible to tell how far it got.
240
+ */
241
+ export function recordSteps(steps) {
242
+ const recorded = [];
243
+ const keep = (outcome) => {
244
+ recorded.push({ step: outcome.step, ok: outcome.ok, detail: outcome.detail });
245
+ return outcome;
246
+ };
247
+ return {
248
+ recorded,
249
+ steps: {
250
+ backupState: async (archiveDir) => keep(await steps.backupState(archiveDir)),
251
+ complianceEvidence: async (archiveDir) => keep(await steps.complianceEvidence(archiveDir)),
252
+ auditVerify: async () => keep(await steps.auditVerify()),
253
+ pushKnowledge: async (archiveDir) => keep(await steps.pushKnowledge(archiveDir)),
254
+ tombstoneRegistry: async () => keep(await steps.tombstoneRegistry()),
255
+ },
256
+ };
257
+ }
258
+ /** Read back a state manifest written by `backupState`. */
259
+ export function readStateManifest(archiveDir) {
260
+ const p = path.join(archiveDir, STATE_MANIFEST_FILENAME);
261
+ if (!existsSync(p))
262
+ return undefined;
263
+ try {
264
+ return JSON.parse(readFileSync(p, "utf8"));
265
+ }
266
+ catch {
267
+ return undefined;
268
+ }
269
+ }
@@ -0,0 +1,74 @@
1
+ import { type Loaded } from "./result";
2
+ export type TreeEntry = {
3
+ /** Path relative to the walk root, with `/` separators on every OS. */
4
+ readonly rel: string;
5
+ readonly kind: "file" | "symlink" | "other";
6
+ readonly bytes: number;
7
+ /** sha256 of the file's bytes. Absent for a symlink or an unhashed entry. */
8
+ readonly sha256?: string;
9
+ /** A symlink's target, verbatim. */
10
+ readonly target?: string;
11
+ };
12
+ export type TreeUnreadable = {
13
+ readonly rel: string;
14
+ readonly reason: string;
15
+ };
16
+ export type Tree = {
17
+ readonly root: string;
18
+ readonly entries: ReadonlyArray<TreeEntry>;
19
+ readonly fileCount: number;
20
+ readonly totalBytes: number;
21
+ /** Oldest / newest mtime seen, epoch ms. Absent for an empty tree. */
22
+ readonly oldestMtimeMs?: number;
23
+ readonly newestMtimeMs?: number;
24
+ /** Entries that could not be read, with the reason each one failed. */
25
+ readonly unreadable: ReadonlyArray<TreeUnreadable>;
26
+ /**
27
+ * True only when every entry under the root was visited AND (when hashing
28
+ * was requested) hashed. False the moment anything was skipped, refused or
29
+ * truncated — the caller must not treat such a tree as a verified one.
30
+ */
31
+ readonly complete: boolean;
32
+ };
33
+ export type WalkOptions = {
34
+ readonly maxFiles: number;
35
+ readonly maxBytes: number;
36
+ /** Compute sha256 per file. Off for a cheap inventory. */
37
+ readonly hash: boolean;
38
+ };
39
+ /**
40
+ * Walk `root` depth-first in sorted order.
41
+ *
42
+ * Sorted because a result that depends on `readdir` order is a result that
43
+ * differs between two machines looking at the same directory, and because the
44
+ * manifest this produces is compared byte-for-byte against a later walk.
45
+ */
46
+ export declare function walkTree(root: string, opts: WalkOptions): Loaded<Tree>;
47
+ export type VerifyMismatch = {
48
+ readonly rel: string;
49
+ readonly problem: "missing" | "size" | "sha256" | "unreadable" | "kind";
50
+ readonly detail: string;
51
+ };
52
+ export type VerifyResult = {
53
+ readonly checked: number;
54
+ readonly verified: number;
55
+ readonly mismatched: ReadonlyArray<VerifyMismatch>;
56
+ /**
57
+ * True only when every entry of the manifest was found and matched. A
58
+ * manifest that was itself incomplete can never verify, because the
59
+ * entries it is missing are exactly the ones nothing is being checked
60
+ * against.
61
+ */
62
+ readonly ok: boolean;
63
+ };
64
+ /**
65
+ * Check a tree against a manifest taken earlier.
66
+ *
67
+ * Used twice: after `HarnessRetire` moves `.crewhaus` into the archive (did
68
+ * every byte arrive?), and after `StoreMigrate` copies a store (is the
69
+ * destination really the source?). An entry the manifest never hashed is
70
+ * reported as unverifiable rather than counted as verified.
71
+ */
72
+ export declare function verifyAgainst(destRoot: string, manifest: ReadonlyArray<TreeEntry>, manifestComplete?: boolean): VerifyResult;
73
+ /** sha256 of one file, or the reason it could not be hashed. */
74
+ export declare function hashFile(abs: string): Loaded<string>;