@orkestrel/scaffold 0.0.22 → 0.0.24

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.
Files changed (39) hide show
  1. package/README.md +84 -99
  2. package/dist/bin/main.js +1094 -0
  3. package/dist/bin/main.js.map +1 -0
  4. package/dist/host/CLAUDE.md +3 -1
  5. package/dist/host/agents/orchestration.md +61 -4
  6. package/dist/host/agents/skills/orkestrel-align-packages/SKILL.md +1 -1
  7. package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +7 -5
  8. package/dist/host/agents/skills/orkestrel-harden-package/SKILL.md +1 -1
  9. package/dist/host/agents/skills/orkestrel-harden-package/references/contract.md +1 -1
  10. package/dist/host/claude/agents/orkestrel.md +9 -9
  11. package/dist/host/claude/rules/architecture.md +45 -3
  12. package/dist/host/claude/rules/quality.md +4 -0
  13. package/dist/host/claude/rules/tests.md +57 -1
  14. package/dist/host/claude/rules/workspace.md +50 -17
  15. package/dist/host/codex/agents/orkestrel.toml +1 -1
  16. package/dist/host/configs/helpers.ts +762 -0
  17. package/dist/host/dotfiles/oxlintrc.json +2 -1
  18. package/dist/host/guides/scaffold.md +862 -0
  19. package/dist/host/manifest.json +40 -33
  20. package/dist/host/tests/config.test.ts +544 -0
  21. package/dist/host/tests/policy.test.ts +46 -0
  22. package/dist/host/tests/setupPolicy.ts +557 -602
  23. package/dist/src/core/index.cjs +3569 -10510
  24. package/dist/src/core/index.cjs.map +1 -1
  25. package/dist/src/core/index.d.cts +2361 -2789
  26. package/dist/src/core/index.d.ts +2361 -2789
  27. package/dist/src/core/index.js +3513 -10374
  28. package/dist/src/core/index.js.map +1 -1
  29. package/dist/src/server/index.cjs +2855 -3765
  30. package/dist/src/server/index.cjs.map +1 -1
  31. package/dist/src/server/index.d.cts +1920 -1335
  32. package/dist/src/server/index.d.ts +1920 -1335
  33. package/dist/src/server/index.js +2812 -3680
  34. package/dist/src/server/index.js.map +1 -1
  35. package/package.json +16 -23
  36. package/dist/bin/scaffold.js +0 -1896
  37. package/dist/bin/scaffold.js.map +0 -1
  38. package/dist/host/guides/src/scaffold.md +0 -2886
  39. /package/dist/host/guides/{src/guide.md → guide.md} +0 -0
@@ -1,1896 +0,0 @@
1
- #!/usr/bin/env node
2
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
- import { basename, dirname, join, relative } from "node:path";
4
- import * as tls from "node:tls";
5
- import { CATALOG_AGENT_PATH, DEPENDENCY_NAME_PATTERN, ENVIRONMENTS, GROUPS, MAX_ARTIFACT_BYTES, NAME_PATTERN, SERVICE_SCRIPT_PATH, ScaffoldError, blueprint, catalogNames, catalogToBlock, createCompiler, dependency, diffPlan, isScaffoldError, manifestToDependencies, manifestToName, ownDataValue, planToSummary } from "../src/core/index.js";
6
- import { WriteTransaction, catalogPackages, commitWriteTransaction, createMaterializer, createSync, deriveBlueprint, digestFile, digestText, discardWriteTransaction, discoverPackages, hostRoot, hydratePlan, isFilesystemPath, isRealDirectory, isTerminalText, isVacant, locateHostSource, parseSyncOptions, pruneTargets, readFileText, readHostManifest, readManifest, readTarget, resolvePhysicalPath, validateWriteDirectories } from "../src/server/index.js";
7
- import { createReporter, createSpinner, createStyler } from "@orkestrel/console";
8
- import { createServerSink } from "@orkestrel/console/server";
9
- import { attempt, isRecord, parseJSON } from "@orkestrel/contract";
10
- import { isTerminalError } from "@orkestrel/terminal";
11
- import { createTerminal } from "@orkestrel/terminal/server";
12
- import { parseArgs } from "node:util";
13
- //#region src/bin/constants.ts
14
- /** The command-line interface's closed command vocabulary. */
15
- var KNOWN_VERBS = Object.freeze([
16
- "new",
17
- "pull",
18
- "mirror",
19
- "audit",
20
- "repair",
21
- "fleet",
22
- "catalog"
23
- ]);
24
- /** Internal artifact origins translated into user-facing labels. */
25
- var ORIGIN_LABEL = Object.freeze({
26
- host: "host-owned",
27
- template: "starter",
28
- computed: "generated"
29
- });
30
- /** Internal drift states translated into user-facing labels. */
31
- var DRIFT_LABEL = Object.freeze({
32
- aligned: "unchanged",
33
- stale: "drifted",
34
- missing: "missing",
35
- foreign: "unexpected file"
36
- });
37
- /** Dependency freshness states translated into user-facing labels. */
38
- var FRESHNESS_LABEL = Object.freeze({
39
- current: "unchanged",
40
- behind: "behind",
41
- missing: "missing upstream",
42
- failed: "fetch failed"
43
- });
44
- /** Materializer actions translated into user-facing labels. */
45
- var ACTION_LABEL = Object.freeze({
46
- written: "wrote",
47
- copied: "wrote",
48
- skipped: "unchanged",
49
- removed: "removed"
50
- });
51
- /** The deliberately limited ownership boundary every write verb repairs within. */
52
- var REPAIR_SCOPE = "shared host-owned artifacts and absent service seams — missing files are restored; drifted files change only with --replace, which discards local changes; present starter and generated files are never touched";
53
- /** The opt-in generated-canon ownership boundary `--generated` widens that write to. */
54
- var REPAIR_GENERATED_SCOPE = "shared host-owned and generated artifacts plus service manifest scripts — missing files are restored; drifted files change only with --replace, which discards local changes; present starter files and package publication metadata are never touched";
55
- /** The dry-run note for `new`. */
56
- var NEW_DRY_RUN_NOTE = "dry run — pass --apply to write";
57
- /** The fallback message for a malformed command line without an error message. */
58
- var INVALID_ARGUMENTS_MESSAGE = "invalid arguments";
59
- /** Shared prompt-cancellation message. */
60
- var CANCELLED_MESSAGE = "cancelled — nothing written";
61
- /** Terminal choices for a new workspace's src and app environments. */
62
- var ENVIRONMENT_CHOICES = Object.freeze([
63
- {
64
- name: "core",
65
- value: "core",
66
- description: "the pure engine"
67
- },
68
- {
69
- name: "browser",
70
- value: "browser",
71
- description: "DOM-facing environment"
72
- },
73
- {
74
- name: "server",
75
- value: "server",
76
- description: "node-facing environment"
77
- }
78
- ]);
79
- /** The safety model included in full help. */
80
- var SAFETY_BANNER = [
81
- "safety: every verb is a dry run by default.",
82
- "on a terminal, a write prompts for confirmation; in a script, pass --apply (and --yes to skip the confirm).",
83
- "every write is confined to the current working directory — cd there first.",
84
- "when Node exposes system-CA controls, TLS adds the OS certificate store; earlier supported Node 22 releases use default roots. NODE_EXTRA_CA_CERTS adds custom PEMs."
85
- ].join("\n");
86
- /** Stable command exit-code meanings. */
87
- var EXIT_CODES = Object.freeze([
88
- ["0", "clean / success"],
89
- ["1", "drift or failure"],
90
- ["2", "usage error"]
91
- ]);
92
- /** One-line command summaries. */
93
- var VERB_SUMMARY = Object.freeze({
94
- new: "scaffold a workspace into ./<name>",
95
- pull: "refresh vendored guides/versions, report drift",
96
- mirror: "refresh every published Orkestrel package guide",
97
- audit: "whole-plan conformance report",
98
- repair: "restore missing host-owned files; replace drifted bytes only with --replace",
99
- fleet: "audit/repair every workspace under the cwd's immediate children",
100
- catalog: "regenerate the fleet package-catalog table"
101
- });
102
- /** Compact command flag references. */
103
- var VERB_FLAGS = Object.freeze({
104
- new: "--src a,b --app a,b --deps x,y --apply --yes --target <path> --from <path>",
105
- pull: "--target . --deps x,y --apply --yes --strict",
106
- mirror: "--target . --apply --yes --strict",
107
- audit: "--target . --live --generated --replace --from <path> --groups a,b",
108
- repair: "--target . --generated --replace --apply --yes --prune --from <path>",
109
- fleet: "--generated --replace --apply --yes --prune --from <path>",
110
- catalog: "--from <path> ... --target <repo> --offline --apply --yes"
111
- });
112
- /** Plain-language command flag descriptions. */
113
- var VERB_FLAG_HELP = Object.freeze({
114
- new: [
115
- ["--src a,b", "which src environments to include (core, browser, server)"],
116
- ["--app a,b", "which app environments to include (core, browser, server)"],
117
- ["--deps x,y", "@orkestrel/* dependencies to add (installed as dependencies)"],
118
- ["--apply", "write the files (default is a dry run)"],
119
- ["--yes", "skip the confirmation question"],
120
- ["--target <path>", "destination directory (default: ./<name>)"],
121
- ["--from <path>", "read the template from a local path instead of the bundled one"]
122
- ],
123
- pull: [
124
- ["--target .", "directory to refresh (default: current directory)"],
125
- ["--deps x,y", "limit the refresh to these dependencies"],
126
- ["--apply", "write the refreshed files (default is a dry run)"],
127
- ["--yes", "skip the confirmation question"],
128
- ["--strict", "fail (exit 1) on any drift, even non-fatal"]
129
- ],
130
- mirror: [
131
- ["--target .", "directory whose guide mirror is refreshed (default: current directory)"],
132
- ["--apply", "write the refreshed guides (default is a dry run)"],
133
- ["--yes", "skip the confirmation question"],
134
- ["--strict", "fail immediately when an upstream guide cannot be fetched"]
135
- ],
136
- audit: [
137
- ["--target .", "directory to audit (default: current directory)"],
138
- ["--live", "also check upstream freshness over the network"],
139
- ["--generated", "widen an accepted repair hand-off to generated files"],
140
- ["--replace", "let the repair hand-off discard local changes in the drifted files it names"],
141
- ["--from <path>", "read the template from a local path instead of the bundled one"],
142
- ["--groups a,b", "limit the audit to these artifact groups"]
143
- ],
144
- repair: [
145
- ["--target .", "directory to repair (default: current directory)"],
146
- ["--generated", "widen the scope to generated files, except package.json"],
147
- ["--replace", "discard local changes in the drifted files named by the repair report"],
148
- ["--apply", "write the fixes (default is a dry run)"],
149
- ["--yes", "skip the confirmation question"],
150
- ["--prune", "also DELETE unexpected files under .claude/agents, .codex/agents, and scripts"],
151
- ["--from <path>", "read the template from a local path instead of the bundled one"]
152
- ],
153
- fleet: [
154
- ["--generated", "widen the scope to generated files in every package, except package.json"],
155
- ["--replace", "discard local changes in the drifted files named in each package"],
156
- ["--apply", "write fixes across every package (default is a dry run)"],
157
- ["--yes", "skip the confirmation question"],
158
- ["--prune", "also DELETE unexpected files under .claude/agents, .codex/agents, and scripts, per package"],
159
- ["--from <path>", "read the template from a local path instead of the bundled one"]
160
- ],
161
- catalog: [
162
- ["--from <path> ...", "one or more local package paths to include"],
163
- ["--target <repo>", "the repository whose Orkestrel agent catalog table gets updated"],
164
- ["--offline", "skip network lookups (npm registry) for package descriptions"],
165
- ["--apply", "write the updated table (default is a dry run)"],
166
- ["--yes", "skip the confirmation question"]
167
- ]
168
- });
169
- /** Dry-run and confirmation notes per command. */
170
- var VERB_DRY_RUN_NOTE = Object.freeze({
171
- new: "dry run by default — add --apply to write the files, --yes to skip the question",
172
- pull: "dry run by default — add --apply to write the refreshed files, --yes to skip the question",
173
- mirror: "dry run by default — add --apply to write every published package guide, --yes to skip the question",
174
- audit: "read-only — audit never writes; pass --live to also check upstream freshness",
175
- repair: "dry run by default — add --apply to write, --yes to skip the question",
176
- fleet: "dry run by default — add --apply to write across every package, --yes to skip the question",
177
- catalog: "dry run by default — add --apply to write, --yes to skip the question"
178
- });
179
- /** One concrete invocation per command. */
180
- var VERB_EXAMPLE = Object.freeze({
181
- new: "example: scaffold new widget --src core,server --app core,browser --apply",
182
- pull: "example: scaffold pull --apply",
183
- mirror: "example: scaffold mirror --apply --yes",
184
- audit: "example: scaffold audit --live",
185
- repair: "example: scaffold repair --apply",
186
- fleet: "example: scaffold fleet --apply --yes",
187
- catalog: "example: scaffold catalog --apply"
188
- });
189
- /** Message used when a prune scan has no candidates. */
190
- var PRUNE_EMPTY = "no unexpected files to delete";
191
- /** Guidance for unexpected files outside a non-pruning repair handoff. */
192
- var FOREIGN_HINT = "unexpected files are never deleted by default — run 'scaffold repair --prune --apply' to delete them; a file you added yourself is unexpected too, so check the paths above first";
193
- /** Interactive dependency prompt. */
194
- var ORKESTREL_DEPS_PROMPT = "@orkestrel dependencies (comma-separated short names, e.g. contract, emitter — installed as dependencies)";
195
- /** Catalog-degraded validation note. */
196
- var CATALOG_UNRESOLVED_NOTE = "couldn't resolve the vendored @orkestrel catalog — validating names by shape only";
197
- /** Opening marker for the generated package catalog block. */
198
- var CATALOG_START_MARKER = "<!-- catalog:start -->";
199
- /** Closing marker for the generated package catalog block. */
200
- var CATALOG_END_MARKER = "<!-- catalog:end -->";
201
- /** Non-terminal prune safety note. */
202
- var PRUNE_SKIPPED = "prune skipped — pass --apply to authorize deletion; --yes only skips confirmation";
203
- /** Degraded unexpected-file scan note. */
204
- var SCAN_SKIPPED = "unexpected-file scanning skipped — couldn't establish the template source";
205
- //#endregion
206
- //#region src/bin/errors.ts
207
- /** Internal sentinel that unwinds command dispatch without bypassing cleanup. */
208
- var CLIExitError = class extends Error {
209
- code;
210
- constructor(code) {
211
- super(`cli-exit:${String(code)}`);
212
- this.code = code;
213
- }
214
- };
215
- //#endregion
216
- //#region src/bin/shapers.ts
217
- /** Partition findings by whether repair owns their artifact origin. The `owned` and `generated` buckets carry no foreign findings by construction. */
218
- function partitionFindings(findings, plan) {
219
- const origins = new Map(plan.artifacts.map((artifact) => [artifact.path, artifact.origin]));
220
- let ownedDrifted = 0;
221
- let ownedMissing = 0;
222
- let generatedDrifted = 0;
223
- let generatedMissing = 0;
224
- let foreign = 0;
225
- for (const finding of findings) {
226
- const origin = origins.get(finding.path);
227
- const owned = origin === "host" || origin === "template";
228
- if (finding.drift === "aligned") continue;
229
- if (finding.drift === "foreign") {
230
- foreign += 1;
231
- continue;
232
- }
233
- if (finding.drift === "stale") {
234
- if (owned) ownedDrifted += 1;
235
- else generatedDrifted += 1;
236
- } else if (finding.drift === "missing") {
237
- if (owned) ownedMissing += 1;
238
- else generatedMissing += 1;
239
- }
240
- }
241
- return {
242
- owned: {
243
- drifted: ownedDrifted,
244
- missing: ownedMissing,
245
- foreign: 0
246
- },
247
- generated: {
248
- drifted: generatedDrifted,
249
- missing: generatedMissing,
250
- foreign: 0
251
- },
252
- foreign: {
253
- drifted: 0,
254
- missing: 0,
255
- foreign
256
- }
257
- };
258
- }
259
- /** Create one deterministic fleet result entry. */
260
- function fleetEntryOf(name, counts, failed, outside = 0) {
261
- return {
262
- name,
263
- drifted: counts?.drifted ?? 0,
264
- missing: counts?.missing ?? 0,
265
- foreign: counts?.foreign ?? 0,
266
- failed,
267
- outside
268
- };
269
- }
270
- /** Create the command-line interface's machine-readable failure envelope. */
271
- function errorEnvelopeOf(code, message) {
272
- return { error: {
273
- code,
274
- message
275
- } };
276
- }
277
- /** Project a plan summary to the stable machine-readable `new` result. */
278
- function summaryToNewResult(summary, applied) {
279
- return {
280
- name: summary.name,
281
- src: summary.src,
282
- app: summary.app,
283
- host: summary.host,
284
- template: summary.template,
285
- computed: summary.computed,
286
- applied
287
- };
288
- }
289
- /** Add repair's outside-scope count to its selected audit. */
290
- function repairAuditOf(audit, outside) {
291
- return {
292
- ...audit,
293
- outside
294
- };
295
- }
296
- /** Add a materialization result to a repair audit. */
297
- function auditToRepairResult(audit, result) {
298
- return {
299
- ...audit,
300
- result
301
- };
302
- }
303
- /** Create the stable machine-readable catalog result. */
304
- function catalogResultOf(entries, drift, shrink) {
305
- return shrink === void 0 ? {
306
- entries,
307
- drift
308
- } : {
309
- entries,
310
- drift,
311
- shrink
312
- };
313
- }
314
- //#endregion
315
- //#region src/bin/helpers.ts
316
- /** Render one pluralized count. */
317
- function countPart(count, label) {
318
- return `${count} ${label}${count === 1 ? "" : "s"}`;
319
- }
320
- /** Render nonzero audit buckets or `clean`. */
321
- function bucketText(counts) {
322
- const parts = [];
323
- if (counts.drifted > 0) parts.push(`${counts.drifted} drifted`);
324
- if (counts.missing > 0) parts.push(`${counts.missing} missing`);
325
- if (counts.foreign > 0) parts.push(`${counts.foreign} unexpected`);
326
- return parts.length > 0 ? parts.join(", ") : "clean";
327
- }
328
- /** Render audit's origin-aware verdict. */
329
- function auditVerdict(audit, plan) {
330
- const count = audit.findings.length;
331
- if (audit.clean) return `audit: ${countPart(count, "artifact")} — clean`;
332
- const split = partitionFindings(audit.findings, plan);
333
- const owned = split.owned.drifted === 0 && split.owned.missing === 0;
334
- const generated = bucketText(split.generated);
335
- const foreign = bucketText(split.foreign);
336
- if (owned && generated === "clean") return `audit: ${countPart(count, "artifact")} — host-owned clean; ${foreign}`;
337
- const suffix = foreign === "clean" ? "" : `; unexpected: ${foreign}`;
338
- return owned ? `audit: ${countPart(count, "artifact")} — host-owned clean; ${generated} (generated)${suffix}` : `audit: ${countPart(count, "artifact")} — host-owned: ${bucketText(split.owned)}; generated: ${generated}${suffix}`;
339
- }
340
- /** Render non-aligned audit findings as terminal table rows. */
341
- function findingRows(findings, plan) {
342
- const origins = new Map(plan.artifacts.map((artifact) => [artifact.path, artifact.origin]));
343
- return findings.filter((finding) => finding.drift !== "aligned").map((finding) => {
344
- const origin = origins.get(finding.path);
345
- const category = origin === void 0 ? "unexpected file" : ORIGIN_LABEL[origin];
346
- return [
347
- DRIFT_LABEL[finding.drift],
348
- category,
349
- finding.path
350
- ];
351
- });
352
- }
353
- /** Create audit's terminal table. */
354
- function auditTable(audit, plan) {
355
- return {
356
- columns: [
357
- { label: "Status" },
358
- { label: "Kind" },
359
- { label: "Path" }
360
- ],
361
- rows: findingRows(audit.findings, plan)
362
- };
363
- }
364
- /**
365
- * Count the files one authorized write will create or overwrite.
366
- *
367
- * @param counts - The drift tallies of every target the write covers.
368
- * @param replace - Whether stale byte replacement was explicitly authorized.
369
- * @returns Every missing file, plus drifted files only under `replace`.
370
- * @remarks
371
- * The one figure every write confirmation asks about, so `repair` and `fleet`
372
- * cannot drift apart on it. An unexpected file is never counted here: only
373
- * `--prune` deletes one, and only after its own separate question.
374
- */
375
- function countWrites(counts, replace) {
376
- let total = 0;
377
- for (const count of counts) total += count.missing + (replace ? count.drifted : 0);
378
- return total;
379
- }
380
- /**
381
- * Test whether a reported repository remains dirty inside or outside the selected scope.
382
- *
383
- * @param audit - The selected-scope audit.
384
- * @param outside - Findings outside that scope.
385
- * @returns Whether either source reports drift.
386
- */
387
- function hasFindings(audit, outside) {
388
- return !audit.clean || outside > 0;
389
- }
390
- /**
391
- * Render one write verb's ownership boundary in that verb's own voice.
392
- *
393
- * @param verb - The command whose scope this is.
394
- * @param generated - Whether generated canon was included in the scope.
395
- * @param repos - The number of repositories the write covers, absent for a single target.
396
- * @returns The scope line naming what the command restores, what it replaces only with `--replace`, and what it never touches.
397
- */
398
- function scopeLine(verb, generated, repos) {
399
- return `${verb} scope${repos === void 0 ? "" : ` across ${countPart(repos, "repo")}`}: ${generated ? REPAIR_GENERATED_SCOPE : REPAIR_SCOPE}`;
400
- }
401
- /**
402
- * Render drift outside repair's selected ownership boundary.
403
- *
404
- * @param count - The number of findings outside the selected scope.
405
- * @param generated - Whether generated canon was included in the repair scope.
406
- * @returns The scope guidance line, or `undefined` when no findings remain outside scope.
407
- */
408
- function scopeNote(count, generated) {
409
- if (count === 0) return void 0;
410
- return generated ? `note: ${countPart(count, "finding")} outside host-owned and generated repair scope — run 'audit' for the list; present starter files and package publication metadata remain protected` : `note: ${countPart(count, "finding")} outside host-owned repair scope — run 'audit' for the list`;
411
- }
412
- /**
413
- * Render repair's dry-run verdict.
414
- *
415
- * @param audit - The audit over the selected repair plan.
416
- * @param options - The selected scope and write authorizations.
417
- * @returns The scope-aware clean or drifted verdict.
418
- */
419
- function repairVerdict(audit, options) {
420
- const scope = options.generated ? "host-owned and generated" : "host-owned";
421
- if (audit.clean) return `repair: ${countPart(audit.findings.length, `${scope} artifact`)} aligned — nothing to write`;
422
- const head = `repair: ${scope}: ${bucketText(audit)}`;
423
- if (audit.drifted === 0) return options.apply ? `${head} — missing files will be restored` : `${head} — pass --apply to write`;
424
- if (audit.missing === 0) return options.replace ? `${head} — --apply overwrites drifted files, discarding local changes` : `${head} — drifted files change only with --replace, which discards local changes`;
425
- return options.replace ? `${head} — --apply restores missing files and overwrites drifted ones, discarding local changes` : `${head} — --apply restores missing files; drifted files change only with --replace, which discards local changes`;
426
- }
427
- /**
428
- * Merge only generated service scripts into an existing manifest.
429
- *
430
- * @param current - The existing consumer manifest text.
431
- * @param generated - The canonical manifest text for the derived service blueprint.
432
- * @param services - The declared service vendor names.
433
- * @returns Formatter-stable manifest text preserving publication metadata and unrelated scripts.
434
- */
435
- function mergeServiceManifest(current, generated, services) {
436
- const currentManifest = parseJSON(current);
437
- const generatedManifest = parseJSON(generated);
438
- if (!isRecord(currentManifest) || !isRecord(generatedManifest)) throw new ScaffoldError("INVALID", "Service adoption requires object package manifests");
439
- const currentScriptsValue = ownDataValue(currentManifest, "scripts");
440
- const generatedScriptsValue = ownDataValue(generatedManifest, "scripts");
441
- if (!isRecord(currentScriptsValue) || !isRecord(generatedScriptsValue)) throw new ScaffoldError("INVALID", "Service adoption requires object package scripts");
442
- const serviceKeys = ["test:service", ...services.map((service) => `test:service:${service}`)];
443
- const serviceNames = new Set(serviceKeys);
444
- const currentPublish = ownDataValue(currentScriptsValue, "prepublishOnly");
445
- const generatedPublish = ownDataValue(generatedScriptsValue, "prepublishOnly");
446
- if (typeof currentPublish !== "string" || typeof generatedPublish !== "string") throw new ScaffoldError("INVALID", "Service adoption requires a prepublishOnly script");
447
- const suffix = " && npm run test:service";
448
- const publish = currentPublish.endsWith(suffix) ? currentPublish : `${currentPublish}${suffix}`;
449
- const scripts = {};
450
- for (const name of Object.keys(generatedScriptsValue)) {
451
- if (serviceNames.has(name)) {
452
- const value = ownDataValue(generatedScriptsValue, name);
453
- if (typeof value !== "string") throw new ScaffoldError("INVALID", `Generated service script is missing at ${name}`);
454
- scripts[name] = value;
455
- continue;
456
- }
457
- if (name === "prepublishOnly") {
458
- scripts[name] = publish;
459
- continue;
460
- }
461
- const value = ownDataValue(currentScriptsValue, name);
462
- if (value !== void 0) scripts[name] = value;
463
- }
464
- for (const name of Object.keys(currentScriptsValue)) {
465
- if (Object.hasOwn(scripts, name)) continue;
466
- if (name === "test:service" || name.startsWith("test:service:")) continue;
467
- const value = ownDataValue(currentScriptsValue, name);
468
- if (value !== void 0) scripts[name] = value;
469
- }
470
- return `${JSON.stringify({
471
- ...currentManifest,
472
- scripts
473
- }, void 0, " ")}\n`;
474
- }
475
- /**
476
- * Render repair's closing tally.
477
- *
478
- * @param tally - The displayed counts of one repair run.
479
- * @returns The tally line, in the same words the audit table above it used.
480
- * @remarks
481
- * A drifted file repair was not authorized to overwrite is counted on its own
482
- * rather than folded in with the aligned files, because `unchanged` is already
483
- * the audit table's word for a file that matches canon.
484
- */
485
- function repairTally(tally) {
486
- const left = tally.drifted === 0 ? "" : `, ${countPart(tally.drifted, `${DRIFT_LABEL.stale} file`)} left alone`;
487
- return `${ACTION_LABEL.written} ${tally.written}, ${ACTION_LABEL.skipped} ${tally.unchanged}${left}, ${ACTION_LABEL.removed} ${tally.removed}`;
488
- }
489
- /** Render synchronization freshness as table rows. */
490
- function syncRows(report) {
491
- const guides = report.guides.map((guide) => [
492
- guide.name,
493
- "guide",
494
- FRESHNESS_LABEL[guide.freshness] ?? guide.freshness
495
- ]);
496
- const versions = report.versions.map((version) => [
497
- version.name,
498
- "version",
499
- FRESHNESS_LABEL[version.freshness] ?? version.freshness
500
- ]);
501
- return [...guides, ...versions];
502
- }
503
- /** Create a synchronization terminal table. */
504
- function syncTable(report) {
505
- return {
506
- columns: [
507
- { label: "Name" },
508
- { label: "Kind" },
509
- { label: "Freshness" }
510
- ],
511
- rows: syncRows(report)
512
- };
513
- }
514
- /** Render cause notes from non-current synchronization entries. */
515
- function syncCauseNotes(report) {
516
- return [...report.guides, ...report.versions].filter((entry) => entry.note !== void 0).map((entry) => ` ${entry.name}: ${FRESHNESS_LABEL[entry.freshness] ?? entry.freshness} — ${entry.note}`);
517
- }
518
- /** Render a command-specific synchronization tally. */
519
- function syncVerdict(report, action) {
520
- const count = report.guides.length + report.versions.length;
521
- return `${action}: ${String(count)} ${count === 1 ? "entry" : "entries"} — ${String(report.failed)} failed`;
522
- }
523
- /** Render a synchronization success tally. */
524
- function syncSuccess(count) {
525
- return `wrote ${countPart(count, "guide")}`;
526
- }
527
- /** Render one fleet repository outcome. */
528
- function fleetRepoLine(name, outcome) {
529
- if (outcome.state === "clean") return `${name}: clean`;
530
- if (outcome.state === "drifted") return `${name}: ${bucketText(outcome)}`;
531
- if (outcome.state === "repaired") return `${name}: repaired (${countPart(outcome.remaining, "finding")} remaining)`;
532
- return `${name}: ${outcome.message}`;
533
- }
534
- /** Render fleet's repository totals. */
535
- function fleetTotals(drifted, failed) {
536
- return `total: ${countPart(drifted, "dirty repo")}, ${failed} failed`;
537
- }
538
- /** Create catalog's terminal table. */
539
- function catalogTable(entries) {
540
- return {
541
- columns: [{ label: "Package" }, { label: "Version" }],
542
- rows: entries.map((entry) => [entry.name, entry.version])
543
- };
544
- }
545
- /** Render catalog shrink risk when present. */
546
- function catalogShrinkWarning(oldRows, newRows) {
547
- if (newRows >= oldRows) return void 0;
548
- return `warning: catalog shrinks from ${countPart(oldRows, "row")} to ${newRows}`;
549
- }
550
- /** Render catalog source tallies. */
551
- function catalogCounts(published, local) {
552
- return `catalog: ${countPart(published, "published package")}, ${countPart(local, "local-only")}`;
553
- }
554
- /** Create `new`'s plan summary table. */
555
- function newPlanTable(summary) {
556
- return {
557
- columns: [{ label: "Origin" }, {
558
- label: "Count",
559
- align: "right"
560
- }],
561
- rows: [
562
- ["host-owned", String(summary.host)],
563
- ["starter", String(summary.template)],
564
- ["generated", String(summary.computed)]
565
- ]
566
- };
567
- }
568
- /**
569
- * Render one contained write destination as the operator's own path to it.
570
- *
571
- * @param root - The invocation directory every write is confined beneath.
572
- * @param destination - The contained physical destination.
573
- * @returns The destination relative to the invocation directory, or the absolute path when it is not beneath it.
574
- */
575
- function describeDestination(root, destination) {
576
- const path = relative(root, destination).replaceAll("\\", "/");
577
- if (path === "") return ".";
578
- return path.startsWith("..") ? destination : `./${path}`;
579
- }
580
- /** Render `new`'s dry-run destination. */
581
- function newPlanPreview(destination) {
582
- return `will write into ${destination}`;
583
- }
584
- /** Render `new`'s write result. */
585
- function newApplySuccess(count, destination) {
586
- return `wrote ${countPart(count, "file")} into ${destination}`;
587
- }
588
- /** Render catalog's write result. */
589
- function catalogApplySuccess(path) {
590
- return `wrote ${path}`;
591
- }
592
- /** Render the shared write confirmation. */
593
- function applyConfirmMessage(files, repos) {
594
- const scope = repos === void 0 ? "" : ` across ${countPart(repos, "repo")}`;
595
- return `Apply — write ${countPart(files, "file")}${scope}? `;
596
- }
597
- /** Render the separate prune confirmation. */
598
- function pruneConfirmMessage(count) {
599
- return `Also delete ${countPart(count, "unexpected file")} under .claude/agents, .codex/agents, and scripts? `;
600
- }
601
- /**
602
- * Render the interactive audit-to-repair handoff as the list of actions it authorizes.
603
- *
604
- * @param missing - The number of missing host-owned files the inherited repair would restore.
605
- * @param drifted - The number of drifted host-owned files it would overwrite, zero unless `--replace` authorized replacement.
606
- * @param foreign - The number of unexpected files found.
607
- * @param prune - Whether `--prune` authorized deletion, without which no unexpected file is touched.
608
- * @returns The confirmation question, naming each authorized action and the cost of overwriting.
609
- */
610
- function repairHandoff(missing, drifted, foreign, prune) {
611
- const parts = [];
612
- if (missing > 0) parts.push(`restore ${countPart(missing, "missing host-owned file")}`);
613
- if (drifted > 0) parts.push(`overwrite ${countPart(drifted, "drifted host-owned file")}, discarding local changes`);
614
- if (prune && foreign > 0) parts.push(`delete ${countPart(foreign, "unexpected file")}`);
615
- return `${parts.join("; ")} — run repair now? `;
616
- }
617
- /** Render one unresolved Orkestrel dependency token. */
618
- function unknownOrkestrelToken(token, suggestion) {
619
- const message = `"${token}" is not a published @orkestrel package`;
620
- return suggestion === void 0 ? `${message} — try again` : `${message} — did you mean "${suggestion}"? try again`;
621
- }
622
- /** Render compact command usage. */
623
- function shortUsage() {
624
- return [
625
- "scaffold <verb> [options]",
626
- "",
627
- ...KNOWN_VERBS.map((verb) => ` ${verb.padEnd(8)}${VERB_SUMMARY[verb]}`),
628
- "",
629
- "run 'scaffold <verb> --help' for a verb's full reference"
630
- ].join("\n");
631
- }
632
- /** Render the full command reference. */
633
- function fullHelp() {
634
- const verbs = KNOWN_VERBS.map((verb) => ` ${verb} ${VERB_FLAGS[verb]}\n ${VERB_SUMMARY[verb]}`);
635
- const exits = EXIT_CODES.map(([code, meaning]) => ` ${code} ${meaning}`);
636
- return [
637
- "scaffold <verb> [options]",
638
- "",
639
- ...verbs,
640
- "",
641
- SAFETY_BANNER,
642
- "",
643
- "exit codes:",
644
- ...exits
645
- ].join("\n");
646
- }
647
- /** Render one command's help reference. */
648
- function verbHelp(verb) {
649
- const flags = VERB_FLAG_HELP[verb].map(([flag, meaning]) => ` ${flag.padEnd(20)}${meaning}`);
650
- return [
651
- `scaffold ${verb} ${VERB_FLAGS[verb]}`,
652
- "",
653
- VERB_SUMMARY[verb],
654
- VERB_DRY_RUN_NOTE[verb],
655
- "",
656
- ...flags,
657
- "",
658
- VERB_EXAMPLE[verb]
659
- ].join("\n");
660
- }
661
- /** Compute Levenshtein edit distance. */
662
- function editDistance(left, right) {
663
- const rows = left.length + 1;
664
- const columns = right.length + 1;
665
- const table = new Uint32Array(rows * columns);
666
- for (let row = 0; row < rows; row += 1) table[row * columns] = row;
667
- for (let column = 0; column < columns; column += 1) table[column] = column;
668
- for (let row = 1; row < rows; row += 1) for (let column = 1; column < columns; column += 1) {
669
- const cost = left[row - 1] === right[column - 1] ? 0 : 1;
670
- const index = row * columns + column;
671
- table[index] = Math.min((table[index - columns] ?? 0) + 1, (table[index - 1] ?? 0) + 1, (table[index - columns - 1] ?? 0) + cost);
672
- }
673
- return table[rows * columns - 1] ?? 0;
674
- }
675
- /** Find the nearest string by edit distance. */
676
- function nearest(input, set) {
677
- let best;
678
- let distance = Number.POSITIVE_INFINITY;
679
- for (const candidate of set) {
680
- const candidateDistance = editDistance(input, candidate);
681
- if (candidateDistance < distance) {
682
- distance = candidateDistance;
683
- best = candidate;
684
- }
685
- }
686
- return best;
687
- }
688
- /** Render an unknown command with a nearest-command hint. */
689
- function didYouMean(command) {
690
- const suggestion = nearest(command, KNOWN_VERBS);
691
- return suggestion === void 0 ? `unknown command "${command}"` : `unknown command "${command}" — did you mean "${suggestion}"?`;
692
- }
693
- /** Render one prune preview line per exact path. */
694
- function prunePreview(paths) {
695
- return paths.map((path) => ` delete ${path}`);
696
- }
697
- /** Render non-terminal guidance for missing input. */
698
- function missingInput(input, verb) {
699
- return `missing ${input} — pass it as a flag/argument, or run 'scaffold ${verb}' on a terminal to be guided`;
700
- }
701
- /** Render an invalid package name. */
702
- function invalidName(name, pattern) {
703
- return `Package name "${name}" must match ${pattern}`;
704
- }
705
- /** Render dependencies whose latest versions could not be resolved. */
706
- function unresolvedVersion(names) {
707
- return `could not resolve the latest version for ${names.map((name) => `"${name}"`).join(", ")} — check the name or pass name@range`;
708
- }
709
- /**
710
- * Render drift that belongs to generated artifacts.
711
- *
712
- * @param count - The number of findings on generated artifacts.
713
- * @returns The guidance line, stating what each repair scope does rather than how the files differ.
714
- */
715
- function generatedNote(count) {
716
- return `${countPart(count, "finding")} in generated files — run 'scaffold repair --generated' to restore missing ones; add --replace to overwrite drifted ones, discarding local changes`;
717
- }
718
- /**
719
- * Render the explicit destructive opt-in for stale host-owned files.
720
- *
721
- * @param count - The number of stale host-owned files.
722
- * @returns The replacement guidance line, naming the safe default before its destructive opt-in.
723
- */
724
- function replacementNote(count) {
725
- return `${countPart(count, "drifted host-owned file")} — repair leaves drifted files alone; 'scaffold repair --replace' overwrites them, discarding local changes`;
726
- }
727
- /**
728
- * Render honest repair guidance for computed and protected manifest drift.
729
- *
730
- * @param findings - The audit findings to classify.
731
- * @param plan - The plan that owns each computed artifact.
732
- * @returns One line for repairable computed drift and one for protected manifest drift when present.
733
- */
734
- function renderComputedNotes(findings, plan) {
735
- const origins = new Map(plan.artifacts.map((artifact) => [artifact.path, artifact.origin]));
736
- let computed = 0;
737
- let manifest = 0;
738
- for (const finding of findings) {
739
- if (finding.drift === "aligned" || origins.get(finding.path) !== "computed") continue;
740
- if (finding.path === "package.json") manifest += 1;
741
- else computed += 1;
742
- }
743
- const notes = [];
744
- if (computed > 0) notes.push(generatedNote(computed));
745
- if (manifest > 0 && plan.blueprint.services.length > 0) notes.push(`${countPart(manifest, "finding")} in package.json — 'scaffold repair --generated' repairs generated service scripts; review any remaining publication metadata directly`);
746
- else if (manifest > 0) notes.push(`${countPart(manifest, "finding")} in package.json — repair does not rewrite protected publication metadata; review and edit it directly`);
747
- return notes;
748
- }
749
- /** Render live dependency freshness tallies. */
750
- function auditLiveNote(current, behind, failed) {
751
- return `live: ${current} current, ${behind} behind, ${failed} failed`;
752
- }
753
- /** Render whether an audit compared content or presence. */
754
- function comparisonLine(compared, presence) {
755
- if (presence === 0) return "comparing: file contents for host-owned files";
756
- if (compared === 0) return `comparing: file presence for ${countPart(presence, "presence-owned file")}`;
757
- return `comparing: file contents for ${countPart(compared, "host-owned file")}; presence for ${countPart(presence, "presence-owned file")}`;
758
- }
759
- /** Render catalog's final verdict. */
760
- function catalogVerdict(clean) {
761
- return clean ? "catalog: clean" : "catalog: drifted — pass --apply to write";
762
- }
763
- /** Render a caught error without leaking implementation detail. */
764
- function describeError(error) {
765
- if (isScaffoldError(error)) return `[${error.code}] ${error.message}`;
766
- return error instanceof Error ? error.message : "unknown error";
767
- }
768
- /** Resolve and confine one write destination to its invocation root. */
769
- function containDestination(root, candidate) {
770
- if (!isFilesystemPath(root) || !isFilesystemPath(candidate)) throw new ScaffoldError("INVALID", "Target paths must not contain control characters");
771
- const contained = attempt(() => resolvePhysicalPath(root, candidate, "INVALID", "working directory"));
772
- if (contained.success) return contained.value;
773
- if (isScaffoldError(contained.error) && contained.error.code === "INVALID") throw new ScaffoldError("INVALID", `Target "${candidate}" is outside or traverses a linked parent of the working directory — run scaffold from the physical directory you want to write beneath.`, { path: candidate });
774
- throw contained.error;
775
- }
776
- /** Return one dependency-token issue, or absence when it is valid. */
777
- function orkestrelTokenIssue(normalized, catalog) {
778
- if (catalog === void 0) return DEPENDENCY_NAME_PATTERN.test(normalized) ? void 0 : unknownOrkestrelToken(normalized, void 0);
779
- if (catalog.includes(normalized)) return void 0;
780
- return unknownOrkestrelToken(normalized, nearest(normalized, catalog));
781
- }
782
- //#endregion
783
- //#region src/bin/parsers.ts
784
- /** Parse a strict command-line argument vector. */
785
- function parseArguments(argv) {
786
- if (argv.some((argument) => !isTerminalText(argument))) throw new ScaffoldError("INVALID", "Command arguments must not contain control characters");
787
- const args = argv[0] === "--" ? argv.slice(1) : [...argv];
788
- return parseArgs({
789
- args,
790
- allowPositionals: true,
791
- options: {
792
- src: { type: "string" },
793
- app: { type: "string" },
794
- deps: { type: "string" },
795
- groups: { type: "string" },
796
- target: { type: "string" },
797
- from: {
798
- type: "string",
799
- multiple: true
800
- },
801
- apply: {
802
- type: "boolean",
803
- default: false
804
- },
805
- yes: {
806
- type: "boolean",
807
- default: false
808
- },
809
- json: {
810
- type: "boolean",
811
- default: false
812
- },
813
- prune: {
814
- type: "boolean",
815
- default: false
816
- },
817
- generated: {
818
- type: "boolean",
819
- default: false
820
- },
821
- replace: {
822
- type: "boolean",
823
- default: false
824
- },
825
- strict: {
826
- type: "boolean",
827
- default: false
828
- },
829
- live: {
830
- type: "boolean",
831
- default: false
832
- },
833
- offline: {
834
- type: "boolean",
835
- default: false
836
- },
837
- help: {
838
- type: "boolean",
839
- default: false,
840
- short: "h"
841
- }
842
- }
843
- });
844
- }
845
- /** Split a comma-separated token list, trimming and dropping empty entries. */
846
- function splitTokens(raw) {
847
- return raw.split(",").map((token) => token.trim()).filter((token) => token.length > 0);
848
- }
849
- /** Normalize an Orkestrel dependency token to its full package name. */
850
- function normalizeOrkestrelToken(token) {
851
- return token.startsWith("@orkestrel/") ? token : `@orkestrel/${token}`;
852
- }
853
- /**
854
- * Parse and resolve a pull dependency selection against the target manifest.
855
- *
856
- * @param raw - The comma-separated full package names, or `undefined` for all.
857
- * @param declared - The target's declared Orkestrel dependencies.
858
- * @returns `undefined` for all dependencies, otherwise the exact selected records.
859
- * @throws `ScaffoldError('INVALID')` for empty, malformed, repeated, or undeclared names.
860
- */
861
- function parsePullDependencies(raw, declared) {
862
- if (raw === void 0) return void 0;
863
- const names = raw.split(",").map((name) => name.trim());
864
- if (names.length === 0 || names.some((name) => !DEPENDENCY_NAME_PATTERN.test(name)) || new Set(names).size !== names.length) throw new ScaffoldError("INVALID", "Pull dependencies must be unique, comma-separated @orkestrel/* package names");
865
- const selected = declared.filter((dependency) => names.includes(dependency.name));
866
- const found = new Set(selected.map((dependency) => dependency.name));
867
- const missing = names.filter((name) => !found.has(name));
868
- if (missing.length > 0) throw new ScaffoldError("INVALID", `Pull dependencies are not declared by the target: ${missing.join(", ")}`, { dependencies: missing });
869
- return selected;
870
- }
871
- //#endregion
872
- //#region src/bin/validators.ts
873
- /** Narrow a positional command to the command-line interface's vocabulary. */
874
- function isVerb(value) {
875
- return KNOWN_VERBS.some((verb) => verb === value);
876
- }
877
- //#endregion
878
- //#region src/bin/CLI.ts
879
- /** Stateful command-line orchestration and its process boundary. */
880
- var CLI = class {
881
- #sync;
882
- #sink = createServerSink();
883
- #tty = process.stdout.isTTY === true;
884
- #styler = createStyler({ enabled: process.env.NO_COLOR === void 0 && this.#tty });
885
- #reporter = createReporter({
886
- sink: this.#sink,
887
- width: this.#sink.columns,
888
- styler: this.#styler
889
- });
890
- #json = false;
891
- /**
892
- * Create command-line orchestration with optional upstream endpoint settings.
893
- *
894
- * @param sync - Sync settings used by every live dependency operation.
895
- */
896
- constructor(sync) {
897
- this.#sync = parseSyncOptions(sync);
898
- }
899
- /** Execute one command-line argument vector. */
900
- async run(argv) {
901
- this.#trust();
902
- try {
903
- await this.#dispatch(argv);
904
- } catch (error) {
905
- if (error instanceof CLIExitError) process.exitCode = error.code;
906
- else if (this.#json) {
907
- const code = isScaffoldError(error) ? error.code : "ERROR";
908
- const message = isScaffoldError(error) ? error.message : describeError(error);
909
- this.#write(errorEnvelopeOf(code, message));
910
- process.exitCode = 1;
911
- } else {
912
- this.#reporter.status("error", describeError(error));
913
- process.exitCode = 1;
914
- }
915
- }
916
- }
917
- /**
918
- * Widen Node's default trusted-issuer set to include the OS certificate
919
- * store, so `fetch` behind a corporate TLS-inspecting proxy behaves like npm
920
- * (`cafile`) and browsers (OS trust store) instead of failing with
921
- * `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` against Node's bundled CA list alone.
922
- * Feature-detected (`tls.getCACertificates` / `tls.setDefaultCACertificates`
923
- * ship on Node ≈22.16+/24.5+; this package's floor is `>=22.12.0`) and captured
924
- * through `attempt` — any failure is a silent no-op, never a crash. This only ADDS
925
- * trusted issuers; it never touches `rejectUnauthorized` or
926
- * `NODE_TLS_REJECT_UNAUTHORIZED`, so certificate verification stays on.
927
- */
928
- #trust() {
929
- if (typeof tls.getCACertificates !== "function" || typeof tls.setDefaultCACertificates !== "function") return;
930
- attempt(() => {
931
- const merged = /* @__PURE__ */ new Set([...tls.getCACertificates("default"), ...tls.getCACertificates("system")]);
932
- tls.setDefaultCACertificates([...merged]);
933
- });
934
- }
935
- /** Write ONE machine-readable JSON value to stdout — the entire `--json` output contract. */
936
- #write(value) {
937
- process.stdout.write(`${JSON.stringify(value)}\n`);
938
- }
939
- /** Report a general operation failure with exit 1 as prose or one JSON error envelope. */
940
- #fail(message, json) {
941
- if (json) this.#write(errorEnvelopeOf("ERROR", message));
942
- else this.#reporter.status("error", message);
943
- throw new CLIExitError(1);
944
- }
945
- /**
946
- * A general operation failure from a caught error — the real
947
- * `ScaffoldError` code when available ('ERROR' last resort), prose (via
948
- * `describe`, which still carries the bracketed code for a human reader) or
949
- * the one JSON error envelope under `--json` (code and message kept
950
- * SEPARATE — never double-encoding the code into the message text).
951
- */
952
- #error(error, json) {
953
- if (json) {
954
- const code = isScaffoldError(error) ? error.code : "ERROR";
955
- const message = isScaffoldError(error) ? error.message : describeError(error);
956
- this.#write(errorEnvelopeOf(code, message));
957
- } else this.#reporter.status("error", describeError(error));
958
- throw new CLIExitError(1);
959
- }
960
- /** A usage error (bad flag value, unknown verb — exit 2) — stderr prose, or the one JSON error envelope under `--json`. */
961
- #usage(message, json) {
962
- if (json) this.#write(errorEnvelopeOf("USAGE", message));
963
- else process.stderr.write(`${message}\n`);
964
- throw new CLIExitError(2);
965
- }
966
- /** Contain a destination and report any coded escape through the shared CLI error path. */
967
- #contain(candidate, json) {
968
- const contained = attempt(() => containDestination(process.cwd(), candidate));
969
- if (contained.success) return contained.value;
970
- this.#error(contained.error, json);
971
- }
972
- /** Compile a spec and report unresolved blocking questions through the shared CLI error path. */
973
- #compile(spec, json, groups) {
974
- const compiler = createCompiler();
975
- try {
976
- const scaffolding = compiler.compile(spec, groups);
977
- if (!scaffolding.plan) {
978
- const blocking = scaffolding.questions.filter((question) => question.blocking);
979
- const message = (blocking.length > 0 ? blocking : scaffolding.questions).map((question) => question.text).join("; ");
980
- this.#fail(message, json);
981
- }
982
- return [scaffolding.plan, scaffolding.questions];
983
- } finally {
984
- compiler.destroy();
985
- }
986
- }
987
- #prunePaths(target, host, services) {
988
- const seam = services.length > 0 ? SERVICE_SCRIPT_PATH : void 0;
989
- return pruneTargets(target, host).filter((path) => path !== seam);
990
- }
991
- /**
992
- * Merge the physical prune scan into `audit` as `foreign` findings — pure
993
- * object-spread composition in the BIN only (`src/core`'s `diffPlan` is never
994
- * modified/reimplemented). Every unexpected path except the declared service
995
- * workspace's exact `SERVICE_SCRIPT_PATH` becomes one `'orchestration'`-group
996
- * `foreign` finding (the `Group` every `PRUNE_DIRECTORIES` entry—`.claude/agents`,
997
- * `.codex/agents`, `scripts`—belongs to); any scan hit makes the merged audit
998
- * unclean, so an "unexpected file" is honestly counted as drift (exit 1)
999
- * instead of the structurally-always-zero `diffPlan.foreign`.
1000
- */
1001
- #scan(audit, target, host, services) {
1002
- const paths = this.#prunePaths(target, host, services);
1003
- if (paths.length === 0) return audit;
1004
- const findings = paths.map((path) => ({
1005
- path,
1006
- group: "orchestration",
1007
- drift: "foreign"
1008
- }));
1009
- return {
1010
- ...audit,
1011
- clean: false,
1012
- foreign: audit.foreign + paths.length,
1013
- findings: [...audit.findings, ...findings]
1014
- };
1015
- }
1016
- /**
1017
- * Add unexpected-file findings when the vendored allowlist can be established.
1018
- * When fail-closed allowlist discovery raises a coded `TARGET` failure, retain
1019
- * the existing findings and mark the audit incomplete instead of crashing.
1020
- */
1021
- #scanSafe(audit, target, host, services) {
1022
- const scanned = attempt(() => this.#scan(audit, target, host, services));
1023
- if (scanned.success) return {
1024
- audit: scanned.value,
1025
- skipped: false
1026
- };
1027
- if (isScaffoldError(scanned.error) && scanned.error.code === "TARGET") return {
1028
- audit: {
1029
- ...audit,
1030
- clean: false,
1031
- complete: false,
1032
- questions: [...audit.questions, {
1033
- field: "host",
1034
- text: scanned.error.message,
1035
- blocking: true
1036
- }]
1037
- },
1038
- skipped: true
1039
- };
1040
- throw scanned.error;
1041
- }
1042
- /**
1043
- * `repair` scopes to host-owned artifacts by default and optionally generated canon, but the
1044
- * caller compiles the full plan anyway. Diff it too so a clean scoped verdict can point at drift
1045
- * outside the selected boundary. The count feeds the shared `scopeNote` renderer.
1046
- */
1047
- #outside(compiled, selected, target, host) {
1048
- const full = hydratePlan(compiled, host);
1049
- const selectedPaths = new Set(selected.artifacts.map((artifact) => artifact.path));
1050
- return diffPlan(full, readTarget(target, full.artifacts.map((artifact) => artifact.path))).findings.filter((finding) => finding.drift !== "aligned" && !selectedPaths.has(finding.path)).length;
1051
- }
1052
- #emitRepair(audit, generated, json, result) {
1053
- if (json) {
1054
- this.#write(result === void 0 ? audit : auditToRepairResult(audit, result));
1055
- return;
1056
- }
1057
- const note = scopeNote(audit.outside, generated);
1058
- if (note !== void 0) this.#reporter.line(note);
1059
- }
1060
- /** Promote only absent service-owned starter seams into repairable missing artifacts. */
1061
- #promoteServices(plan, target) {
1062
- if (plan.blueprint.services.length === 0) return plan;
1063
- const paths = [SERVICE_SCRIPT_PATH, "tests/config/services.test.ts"];
1064
- const current = readTarget(target, paths);
1065
- return {
1066
- ...plan,
1067
- artifacts: plan.artifacts.map((artifact) => artifact.origin === "template" && paths.includes(artifact.path) && !Object.hasOwn(current, artifact.path) ? {
1068
- ...artifact,
1069
- origin: "computed"
1070
- } : artifact)
1071
- };
1072
- }
1073
- /** Select repair ownership and merge only the manifest's generated service-script keys. */
1074
- #scopeRepair(compiled, target, generated) {
1075
- const promoted = this.#promoteServices(compiled, target);
1076
- const currentManifest = generated && promoted.blueprint.services.length > 0 ? readManifest(target) : void 0;
1077
- const artifacts = [];
1078
- for (const artifact of promoted.artifacts) {
1079
- if (artifact.origin === "host" || artifact.path === SERVICE_SCRIPT_PATH || artifact.path === "tests/config/services.test.ts" || generated && artifact.origin === "computed" && artifact.path !== "package.json") {
1080
- artifacts.push(artifact);
1081
- continue;
1082
- }
1083
- if (currentManifest !== void 0 && artifact.origin === "computed" && artifact.path === "package.json") {
1084
- const content = mergeServiceManifest(currentManifest, artifact.content, promoted.blueprint.services);
1085
- if (content !== currentManifest) artifacts.push({
1086
- ...artifact,
1087
- content
1088
- });
1089
- }
1090
- }
1091
- return {
1092
- ...promoted,
1093
- blueprint: {
1094
- ...promoted.blueprint,
1095
- overrides: []
1096
- },
1097
- artifacts
1098
- };
1099
- }
1100
- /** Compile and audit the selected repair ownership boundary for one structural snapshot. */
1101
- #prepareRepair(spec, target, host, generated, json) {
1102
- const [compiled] = this.#compile(spec, json);
1103
- const scoped = this.#scopeRepair(compiled, target, generated);
1104
- let plan;
1105
- try {
1106
- plan = hydratePlan(scoped, host);
1107
- } catch (error) {
1108
- this.#error(error, json);
1109
- }
1110
- try {
1111
- const audit = diffPlan(plan, readTarget(target, plan.artifacts.map((artifact) => artifact.path)));
1112
- return [
1113
- compiled,
1114
- plan,
1115
- audit
1116
- ];
1117
- } catch (error) {
1118
- this.#error(error, json);
1119
- }
1120
- }
1121
- /** Reject a cancelled prompt (ctrl-c) with the shared `CANCELLED_MESSAGE` — exit 1, nothing written. */
1122
- async #guard(promise) {
1123
- try {
1124
- return await promise;
1125
- } catch (error) {
1126
- if (isTerminalError(error) && error.code === "CANCEL") {
1127
- this.#reporter.line(CANCELLED_MESSAGE);
1128
- throw new CLIExitError(1);
1129
- }
1130
- throw error;
1131
- }
1132
- }
1133
- /**
1134
- * The shared write-confirmation gate every verb calls before it touches disk.
1135
- * `--apply` is the sole write authorization. JSON and non-terminal calls do
1136
- * not prompt once authorized; `--yes` skips the terminal confirmation.
1137
- */
1138
- async #apply(terminal, message, values, json) {
1139
- if (!values.apply) return false;
1140
- if (json || values.yes || !this.#tty) return true;
1141
- return this.#guard(terminal.confirm({
1142
- message,
1143
- default: false
1144
- }));
1145
- }
1146
- /**
1147
- * The SECOND, separate confirm for `--prune`-eligible deletions — never
1148
- * bundled into the write question. Both `--prune` and `--apply` are required;
1149
- * `--yes` only skips this confirmation and never authorizes deletion.
1150
- */
1151
- async #prune(terminal, message, values, json) {
1152
- if (!values.prune) return false;
1153
- if (!values.apply) {
1154
- if (!json && !this.#tty) this.#reporter.line(PRUNE_SKIPPED);
1155
- return false;
1156
- }
1157
- if (json || values.yes || !this.#tty) return true;
1158
- return this.#guard(terminal.confirm({
1159
- message,
1160
- default: false
1161
- }));
1162
- }
1163
- /** A spinner for a long-running write step, absent under `--json` or off a TTY sink. */
1164
- #spinner(message, json) {
1165
- return json || !this.#tty ? void 0 : createSpinner({
1166
- message,
1167
- sink: this.#sink,
1168
- styler: this.#styler
1169
- });
1170
- }
1171
- /** Announce a successful write — the spinner's own success line, or a plain `reporter.status` without one (never under `--json`). */
1172
- #succeed(spinner, json, message) {
1173
- if (spinner) spinner.success(message);
1174
- else if (!json) this.#reporter.status("success", message);
1175
- }
1176
- /** Announce a failed write and halt(1) — the spinner's own failure line (if any), then the shared `fail`. */
1177
- #reject(spinner, json, error) {
1178
- const message = describeError(error);
1179
- if (spinner) spinner.failure(message);
1180
- this.#fail(message, json);
1181
- }
1182
- /**
1183
- * Best-effort vendored `@orkestrel` catalog names, resolved via `host`
1184
- * (`hostRoot()`, or the active `--from` override) through the host manifest
1185
- * — `undefined` when the catalog cannot be established (a missing/unreadable
1186
- * manifest, no `CATALOG_AGENT_PATH` entry, or any other failure),
1187
- * degrading the dependency prompt to shape-only validation instead of blocking on it.
1188
- */
1189
- #names(host) {
1190
- const names = attempt(() => {
1191
- const manifest = readHostManifest(host);
1192
- const full = locateHostSource(manifest, CATALOG_AGENT_PATH, host);
1193
- if (full === void 0 || !existsSync(full)) return void 0;
1194
- const relative$1 = relative(host, full).replaceAll("\\", "/");
1195
- return catalogNames(readFileText(host, relative$1, "TARGET", "host"));
1196
- });
1197
- return names.success ? names.value : void 0;
1198
- }
1199
- /** Prompt for `@orkestrel` short-name dependencies until every token resolves or input is empty. */
1200
- async #prompt(terminal, catalog) {
1201
- for (;;) {
1202
- const tokens = splitTokens(await this.#guard(terminal.input({
1203
- message: ORKESTREL_DEPS_PROMPT,
1204
- default: ""
1205
- })));
1206
- if (tokens.length === 0) return [];
1207
- const normalized = tokens.map(normalizeOrkestrelToken);
1208
- const issue = normalized.map((token) => orkestrelTokenIssue(token, catalog)).find((message) => message !== void 0);
1209
- if (issue === void 0) return normalized;
1210
- this.#reporter.line(issue);
1211
- }
1212
- }
1213
- /** `scaffold new` — scaffold a package into `./<name>` (or `--target`). */
1214
- async #new(values, argument, json) {
1215
- const terminal = createTerminal();
1216
- let name;
1217
- if (argument !== void 0) name = argument;
1218
- else if (json) this.#usage("a package name is required with --json", json);
1219
- else if (!this.#tty) this.#usage(missingInput("a package name", "new"), json);
1220
- else name = await this.#guard(terminal.input({
1221
- message: "Package name",
1222
- validate: { pattern: NAME_PATTERN.source }
1223
- }));
1224
- if (!NAME_PATTERN.test(name)) this.#usage(invalidName(name, NAME_PATTERN.source), json);
1225
- let srcInput;
1226
- let appInput;
1227
- if (values.src !== void 0) srcInput = values.src.split(",").map((candidate) => candidate.trim());
1228
- else if (values.app !== void 0) srcInput = [];
1229
- else if (json || !this.#tty) this.#usage("at least one of --src or --app is required", json);
1230
- else srcInput = await this.#guard(terminal.checkbox({
1231
- message: "Published src environments",
1232
- choices: ENVIRONMENT_CHOICES,
1233
- min: 0
1234
- }));
1235
- if (values.app !== void 0) appInput = values.app.split(",").map((candidate) => candidate.trim());
1236
- else if (values.src !== void 0 || json || !this.#tty) appInput = [];
1237
- else appInput = await this.#guard(terminal.checkbox({
1238
- message: "Application environments",
1239
- choices: ENVIRONMENT_CHOICES,
1240
- min: 0
1241
- }));
1242
- const unrecognizedSrcEnvironment = srcInput.filter((candidate) => !ENVIRONMENTS.some((environment) => environment === candidate));
1243
- if (unrecognizedSrcEnvironment.length > 0) this.#usage(`Environment "${unrecognizedSrcEnvironment.join("\", \"")}" is not recognized`, json);
1244
- if (new Set(srcInput).size !== srcInput.length) this.#usage("Published src environments must not repeat", json);
1245
- const src = ENVIRONMENTS.filter((environment) => srcInput.includes(environment));
1246
- const unrecognizedAppEnvironment = appInput.filter((candidate) => !ENVIRONMENTS.some((environment) => environment === candidate));
1247
- if (unrecognizedAppEnvironment.length > 0) this.#usage(`Application environment "${unrecognizedAppEnvironment.join("\", \"")}" is not recognized`, json);
1248
- if (new Set(appInput).size !== appInput.length) this.#usage("Application environments must not repeat", json);
1249
- const app = ENVIRONMENTS.filter((environment) => appInput.includes(environment));
1250
- if (src.length === 0 && app.length === 0) this.#usage("at least one source or application environment is required", json);
1251
- const destination = this.#contain(values.target ?? `./${name}`, json);
1252
- if (!isVacant(destination)) this.#error(new ScaffoldError("TARGET", "new requires a vacant target", { target: destination }), json);
1253
- const explicitHost = values.from?.[0];
1254
- if (explicitHost !== void 0) {
1255
- if (!isRealDirectory(explicitHost)) this.#error(new ScaffoldError("TARGET", `Host root is not a physical directory at ${explicitHost}`, { host: explicitHost }), json);
1256
- readHostManifest(explicitHost);
1257
- }
1258
- let depNames;
1259
- if (values.deps !== void 0) {
1260
- depNames = values.deps.split(",").filter((depName) => depName.length > 0);
1261
- const badDep = depNames.find((depName) => !DEPENDENCY_NAME_PATTERN.test(depName));
1262
- if (badDep !== void 0) this.#usage(`Dependency name "${badDep}" must match ${DEPENDENCY_NAME_PATTERN.source}`, json);
1263
- } else if (json || !this.#tty) depNames = [];
1264
- else {
1265
- const catalogHost = values.from?.[0] ?? hostRoot();
1266
- const catalog = this.#names(catalogHost);
1267
- if (catalog === void 0) this.#reporter.line(CATALOG_UNRESOLVED_NOTE);
1268
- depNames = await this.#prompt(terminal, catalog);
1269
- }
1270
- const sync = createSync(this.#sync);
1271
- let versions;
1272
- try {
1273
- versions = await sync.lookup(depNames);
1274
- } finally {
1275
- sync.destroy();
1276
- }
1277
- const unresolved = versions.filter((version) => version.freshness === "missing" || version.freshness === "failed").map((version) => version.name);
1278
- if (unresolved.length > 0) this.#fail(unresolvedVersion(unresolved), json);
1279
- const deps = versions.map((version) => {
1280
- if (version.freshness !== "behind") this.#fail(unresolvedVersion([version.name]), json);
1281
- return dependency(version.name, `^${version.latest}`);
1282
- });
1283
- const [plan] = this.#compile(blueprint(name, {
1284
- src,
1285
- app,
1286
- dependencies: deps
1287
- }), json);
1288
- const summary = planToSummary(plan);
1289
- const label = describeDestination(process.cwd(), destination);
1290
- if (!json) {
1291
- this.#reporter.section("Plan");
1292
- this.#reporter.table(newPlanTable(summary));
1293
- this.#reporter.line(newPlanPreview(label));
1294
- }
1295
- if (!await this.#apply(terminal, applyConfirmMessage(summary.host + summary.template + summary.computed), values, json)) {
1296
- if (json) this.#write(summaryToNewResult(summary, false));
1297
- else this.#reporter.line(NEW_DRY_RUN_NOTE);
1298
- process.exitCode = 0;
1299
- return;
1300
- }
1301
- const spinner = this.#spinner("materializing", json);
1302
- spinner?.start();
1303
- const host = values.from?.[0];
1304
- const materializer = createMaterializer(host === void 0 ? void 0 : { host });
1305
- try {
1306
- const result = materializer.materialize(plan, destination);
1307
- const count = result.written.length + result.copied.length;
1308
- if (json) this.#write(summaryToNewResult(summary, true));
1309
- else this.#succeed(spinner, json, newApplySuccess(count, label));
1310
- } catch (error) {
1311
- this.#reject(spinner, json, error);
1312
- } finally {
1313
- materializer.destroy();
1314
- }
1315
- process.exitCode = 0;
1316
- }
1317
- /** `scaffold pull` — refresh vendored dependency mirrors and report range drift. */
1318
- async #pull(values, json) {
1319
- const target = this.#contain(values.target ?? ".", json);
1320
- const sync = createSync(values.strict === void 0 ? this.#sync : {
1321
- ...this.#sync,
1322
- strict: values.strict
1323
- });
1324
- try {
1325
- let report;
1326
- try {
1327
- const declared = manifestToDependencies(readManifest(target));
1328
- const selected = parsePullDependencies(values.deps, declared);
1329
- report = await sync.pull(target, selected);
1330
- } catch (error) {
1331
- this.#error(error, json);
1332
- }
1333
- await this.#refresh(sync, report, target, values, json, "pull", true);
1334
- } finally {
1335
- sync.destroy();
1336
- }
1337
- }
1338
- /** `scaffold mirror` — refresh every published Orkestrel package guide. */
1339
- async #mirror(values, json) {
1340
- const target = this.#contain(values.target ?? ".", json);
1341
- const sync = createSync(values.strict === void 0 ? this.#sync : {
1342
- ...this.#sync,
1343
- strict: values.strict
1344
- });
1345
- try {
1346
- let report;
1347
- try {
1348
- report = await sync.mirror(target);
1349
- } catch (error) {
1350
- this.#error(error, json);
1351
- }
1352
- await this.#refresh(sync, report, target, values, json, "mirror", report.failed === 0);
1353
- } finally {
1354
- sync.destroy();
1355
- }
1356
- }
1357
- async #refresh(sync, report, target, values, json, action, writable) {
1358
- if (!json) {
1359
- this.#reporter.table(syncTable(report));
1360
- for (const line of syncCauseNotes(report)) this.#reporter.line(line);
1361
- this.#reporter.line(syncVerdict(report, action));
1362
- }
1363
- const toWrite = [...report.guides, ...report.versions].filter((entry) => entry.freshness !== "current").length;
1364
- const terminal = createTerminal();
1365
- const proceed = writable && toWrite > 0 ? await this.#apply(terminal, applyConfirmMessage(toWrite), values, json) : false;
1366
- if (proceed) {
1367
- const spinner = this.#spinner("writing mirrors", json);
1368
- spinner?.start();
1369
- try {
1370
- const written = await sync.write(report, target);
1371
- if (json) this.#write(report);
1372
- else this.#succeed(spinner, json, syncSuccess(written.length));
1373
- } catch (error) {
1374
- this.#reject(spinner, json, error);
1375
- }
1376
- } else if (json) this.#write(report);
1377
- process.exitCode = report.failed > 0 ? 1 : report.clean || proceed ? 0 : 1;
1378
- }
1379
- /** `scaffold audit` — whole-plan conformance report; offers a repair handoff on drift. */
1380
- async #audit(values, json) {
1381
- const target = this.#contain(values.target ?? ".", json);
1382
- let spec;
1383
- try {
1384
- spec = deriveBlueprint(target);
1385
- } catch (error) {
1386
- this.#error(error, json);
1387
- }
1388
- const deps = [
1389
- ...spec.dependencies,
1390
- ...spec.peers,
1391
- ...spec.extras
1392
- ];
1393
- const groupsInput = values.groups?.split(",");
1394
- let groups;
1395
- if (groupsInput !== void 0) {
1396
- const unrecognized = groupsInput.filter((name) => !GROUPS.some((group) => group === name));
1397
- if (unrecognized.length > 0) this.#usage(`Group "${unrecognized.join("\", \"")}" is not recognized`, json);
1398
- groups = GROUPS.filter((group) => groupsInput.includes(group));
1399
- }
1400
- const [compiled, questions] = this.#compile(spec, json, groups);
1401
- const host = values.from?.[0] ?? hostRoot();
1402
- let plan;
1403
- try {
1404
- plan = hydratePlan(this.#promoteServices(compiled, target), host);
1405
- } catch (error) {
1406
- this.#error(error, json);
1407
- }
1408
- const artifactPaths = plan.artifacts.map((artifact) => artifact.path);
1409
- const rawAudit = {
1410
- ...diffPlan(plan, readTarget(target, artifactPaths)),
1411
- questions
1412
- };
1413
- const scanned = this.#scanSafe(rawAudit, target, host, spec.services);
1414
- const audit = scanned.audit;
1415
- let drifted = !audit.clean;
1416
- let live;
1417
- if (values.live) {
1418
- const sync = createSync(this.#sync);
1419
- try {
1420
- const manifestName = manifestToName(readManifest(target));
1421
- const guideDependencies = deps.filter((entry) => entry.name !== manifestName);
1422
- const guides = await sync.guides(guideDependencies);
1423
- const versions = await sync.versions(deps);
1424
- const entries = [...guides, ...versions];
1425
- drifted ||= entries.some((entry) => entry.freshness !== "current");
1426
- const current = entries.filter((entry) => entry.freshness === "current").length;
1427
- const behind = entries.filter((entry) => entry.freshness === "behind").length;
1428
- live = {
1429
- current,
1430
- behind,
1431
- failed: entries.length - current - behind
1432
- };
1433
- } finally {
1434
- sync.destroy();
1435
- }
1436
- }
1437
- if (json) {
1438
- this.#write(live === void 0 ? audit : {
1439
- ...audit,
1440
- live
1441
- });
1442
- process.exitCode = drifted ? 1 : 0;
1443
- return;
1444
- }
1445
- if (scanned.skipped) this.#reporter.line(SCAN_SKIPPED);
1446
- for (const question of audit.questions) if (!question.blocking) this.#reporter.line(`warning: ${question.text}`);
1447
- const hostArtifacts = plan.artifacts.filter((artifact) => artifact.origin === "host");
1448
- const presenceOwned = hostArtifacts.filter((artifact) => artifact.hex === void 0).length;
1449
- this.#reporter.line(comparisonLine(hostArtifacts.length - presenceOwned, presenceOwned));
1450
- this.#reporter.table(auditTable(audit, plan));
1451
- this.#reporter.line(auditVerdict(audit, plan));
1452
- if (live !== void 0) this.#reporter.line(auditLiveNote(live.current, live.behind, live.failed));
1453
- if (!audit.clean) {
1454
- const split = partitionFindings(audit.findings, plan);
1455
- const replace = values.replace === true;
1456
- const ownedCount = split.owned.missing + (replace ? split.owned.drifted : 0);
1457
- const pruneRequested = values.prune === true;
1458
- const offerHandoff = this.#tty && (ownedCount > 0 || audit.foreign > 0 && pruneRequested);
1459
- let handoffAccepted = false;
1460
- if (offerHandoff) {
1461
- const terminal = createTerminal();
1462
- const message = repairHandoff(split.owned.missing, replace ? split.owned.drifted : 0, audit.foreign, pruneRequested);
1463
- handoffAccepted = await this.#guard(terminal.confirm({
1464
- message,
1465
- default: false
1466
- }));
1467
- if (handoffAccepted) {
1468
- await this.#repair(values, false);
1469
- const rawFinal = diffPlan(plan, readTarget(target, artifactPaths));
1470
- const finalScanned = this.#scanSafe(rawFinal, target, host, spec.services);
1471
- process.exitCode = finalScanned.audit.clean ? 0 : 1;
1472
- return;
1473
- }
1474
- }
1475
- if (!handoffAccepted) {
1476
- if (split.owned.drifted > 0 && !replace) this.#reporter.line(replacementNote(split.owned.drifted));
1477
- if (audit.foreign > 0 && !pruneRequested) this.#reporter.line(FOREIGN_HINT);
1478
- for (const note of renderComputedNotes(audit.findings, plan)) this.#reporter.line(note);
1479
- }
1480
- }
1481
- process.exitCode = drifted ? 1 : 0;
1482
- }
1483
- /** `scaffold repair` — restore host-owned files and optionally generated canon for one target. */
1484
- async #repair(values, json) {
1485
- const target = this.#contain(values.target ?? ".", json);
1486
- let spec;
1487
- try {
1488
- spec = deriveBlueprint(target);
1489
- } catch (error) {
1490
- this.#error(error, json);
1491
- }
1492
- const generated = values.generated === true;
1493
- const replace = values.replace === true;
1494
- const host = values.from?.[0] ?? hostRoot();
1495
- const prepared = this.#prepareRepair(spec, target, host, generated, json);
1496
- let compiled = prepared[0];
1497
- let plan = prepared[1];
1498
- let audit = prepared[2];
1499
- let outcome = repairAuditOf(this.#scanSafe(audit, target, host, spec.services).audit, this.#outside(compiled, plan, target, host));
1500
- const verdict = {
1501
- generated,
1502
- replace,
1503
- apply: values.apply === true
1504
- };
1505
- if (!json) {
1506
- if (!outcome.clean) this.#reporter.line(scopeLine("repair", generated));
1507
- this.#reporter.section("Audit");
1508
- this.#reporter.table(auditTable(outcome, plan));
1509
- }
1510
- const prunePaths = values.prune ? this.#prunePaths(target, host, spec.services) : [];
1511
- const pruneSnapshot = readTarget(target, prunePaths);
1512
- if (outcome.clean && prunePaths.length === 0) {
1513
- if (!json) this.#reporter.line(repairVerdict(outcome, verdict));
1514
- this.#emitRepair(outcome, generated, json);
1515
- process.exitCode = hasFindings(outcome, outcome.outside) ? 1 : 0;
1516
- return;
1517
- }
1518
- if (!json) this.#reporter.line(repairVerdict(outcome, verdict));
1519
- const repairable = countWrites([audit], replace);
1520
- if (repairable === 0 && prunePaths.length === 0) {
1521
- if (!json) this.#reporter.line(repairTally({
1522
- written: 0,
1523
- unchanged: audit.findings.length - audit.drifted - audit.missing - audit.foreign,
1524
- drifted: audit.drifted,
1525
- removed: 0
1526
- }));
1527
- this.#emitRepair(outcome, generated, json);
1528
- process.exitCode = 1;
1529
- return;
1530
- }
1531
- const terminal = createTerminal();
1532
- let proceed = true;
1533
- if (!audit.clean) proceed = await this.#apply(terminal, applyConfirmMessage(repairable), values, json);
1534
- if (!proceed) {
1535
- this.#emitRepair(outcome, generated, json);
1536
- process.exitCode = 1;
1537
- return;
1538
- }
1539
- if (values.prune && !json) {
1540
- if (prunePaths.length === 0) this.#reporter.line(PRUNE_EMPTY);
1541
- else for (const line of prunePreview(prunePaths)) this.#reporter.line(line);
1542
- }
1543
- const doPrune = prunePaths.length > 0 && await this.#prune(terminal, pruneConfirmMessage(prunePaths.length), values, json);
1544
- let currentGlobal;
1545
- try {
1546
- currentGlobal = deriveBlueprint(target).global;
1547
- } catch (error) {
1548
- this.#error(error, json);
1549
- }
1550
- if (currentGlobal !== spec.global) {
1551
- spec = {
1552
- ...spec,
1553
- global: currentGlobal
1554
- };
1555
- const refreshed = this.#prepareRepair(spec, target, host, generated, json);
1556
- compiled = refreshed[0];
1557
- plan = refreshed[1];
1558
- audit = refreshed[2];
1559
- outcome = repairAuditOf(this.#scanSafe(audit, target, host, spec.services).audit, this.#outside(compiled, plan, target, host));
1560
- }
1561
- const spinner = this.#spinner("repairing", json);
1562
- spinner?.start();
1563
- const materializer = createMaterializer({ host });
1564
- try {
1565
- const result = materializer.repair(plan, audit, target, replace);
1566
- const removed = doPrune ? materializer.prune(target, pruneSnapshot).removed : [];
1567
- const finalAudit = repairAuditOf(this.#scanSafe(diffPlan(plan, readTarget(target, plan.artifacts.map((artifact) => artifact.path))), target, host, spec.services).audit, outcome.outside);
1568
- const left = replace ? 0 : audit.drifted;
1569
- if (!json) this.#succeed(spinner, json, repairTally({
1570
- written: result.written.length + result.copied.length,
1571
- unchanged: result.skipped.length - left,
1572
- drifted: left,
1573
- removed: removed.length
1574
- }));
1575
- this.#emitRepair(finalAudit, generated, json, {
1576
- ...result,
1577
- removed
1578
- });
1579
- process.exitCode = hasFindings(finalAudit, finalAudit.outside) ? 1 : 0;
1580
- } catch (error) {
1581
- this.#reject(spinner, json, error);
1582
- } finally {
1583
- materializer.destroy();
1584
- }
1585
- }
1586
- /** `scaffold fleet` — audit/repair every `@orkestrel` package beneath the current directory's immediate children. */
1587
- async #fleet(values, json) {
1588
- const root = this.#contain(".", json);
1589
- const generated = values.generated === true;
1590
- const replace = values.replace === true;
1591
- const packages = discoverPackages(root);
1592
- if (packages.length === 0) this.#fail(`no @orkestrel packages under "${root}" — fleet scans the immediate children of the current directory; stand in the folder that contains your checkouts (cd ..), or use 'repair' to true up just this repo.`, json);
1593
- const host = values.from?.[0] ?? hostRoot();
1594
- const repos = [];
1595
- const failures = [];
1596
- for (const directory of packages) {
1597
- const name = basename(directory);
1598
- try {
1599
- const compiler = createCompiler();
1600
- let compiled;
1601
- let scoped;
1602
- let questions;
1603
- try {
1604
- const spec = deriveBlueprint(directory);
1605
- const scaffolding = compiler.compile(spec);
1606
- if (!scaffolding.plan) {
1607
- const message = scaffolding.questions.map((question) => question.text).join("; ");
1608
- throw new ScaffoldError("INVALID", message);
1609
- }
1610
- compiled = scaffolding.plan;
1611
- scoped = this.#scopeRepair(compiled, directory, generated);
1612
- questions = scaffolding.questions;
1613
- } finally {
1614
- compiler.destroy();
1615
- }
1616
- const plan = hydratePlan(scoped, host);
1617
- const paths = plan.artifacts.map((artifact) => artifact.path);
1618
- const rawAudit = {
1619
- ...diffPlan(plan, readTarget(directory, paths)),
1620
- questions
1621
- };
1622
- const audit = this.#scan(rawAudit, directory, host, plan.blueprint.services);
1623
- const outside = this.#outside(compiled, plan, directory, host);
1624
- repos.push({
1625
- name,
1626
- directory,
1627
- plan,
1628
- rawAudit,
1629
- audit,
1630
- outside
1631
- });
1632
- } catch (error) {
1633
- failures.push({
1634
- name,
1635
- message: describeError(error)
1636
- });
1637
- }
1638
- }
1639
- if (!json) {
1640
- for (const repo of repos) {
1641
- this.#reporter.line(fleetRepoLine(repo.name, repo.audit.clean ? { state: "clean" } : {
1642
- state: "drifted",
1643
- drifted: repo.audit.drifted,
1644
- missing: repo.audit.missing,
1645
- foreign: repo.audit.foreign
1646
- }));
1647
- for (const question of repo.audit.questions) if (!question.blocking) this.#reporter.line(`${repo.name}: warning: ${question.text}`);
1648
- const note = scopeNote(repo.outside, generated);
1649
- if (note !== void 0) this.#reporter.line(`${repo.name}: ${note}`);
1650
- }
1651
- for (const failure of failures) this.#reporter.line(fleetRepoLine(failure.name, {
1652
- state: "failed",
1653
- message: failure.message
1654
- }));
1655
- }
1656
- const dirty = repos.filter((repo) => !repo.audit.clean);
1657
- const reportedDirty = repos.filter((repo) => hasFindings(repo.audit, repo.outside));
1658
- if (dirty.length === 0) {
1659
- if (json) this.#write([...repos.map((repo) => fleetEntryOf(repo.name, repo.audit, false, repo.outside)), ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
1660
- else this.#reporter.line(fleetTotals(reportedDirty.length, failures.length));
1661
- process.exitCode = reportedDirty.length > 0 || failures.length > 0 ? 1 : 0;
1662
- return;
1663
- }
1664
- const fileCount = countWrites(dirty.map((repo) => repo.audit), replace);
1665
- if (!json && values.apply === true) this.#reporter.line(scopeLine("fleet", generated, dirty.length));
1666
- const terminal = createTerminal();
1667
- const proceed = await this.#apply(terminal, applyConfirmMessage(fileCount, dirty.length), values, json);
1668
- const pruneSets = proceed && values.prune ? new Map(dirty.map((repo) => {
1669
- const paths = this.#prunePaths(repo.directory, host, repo.plan.blueprint.services);
1670
- return [repo.name, readTarget(repo.directory, paths)];
1671
- })) : /* @__PURE__ */ new Map();
1672
- const prunePaths = dirty.flatMap((repo) => Object.keys(pruneSets.get(repo.name) ?? {}).map((path) => `${repo.name}/${path}`));
1673
- if (proceed && values.prune && !json) {
1674
- if (prunePaths.length === 0) this.#reporter.line(PRUNE_EMPTY);
1675
- else for (const line of prunePreview(prunePaths)) this.#reporter.line(line);
1676
- }
1677
- const doPrune = proceed && prunePaths.length > 0 && await this.#prune(terminal, pruneConfirmMessage(prunePaths.length), values, json);
1678
- if (!proceed) {
1679
- if (json) this.#write([...repos.map((repo) => fleetEntryOf(repo.name, repo.audit, false, repo.outside)), ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
1680
- else this.#reporter.line(fleetTotals(reportedDirty.length, failures.length));
1681
- process.exitCode = 1;
1682
- return;
1683
- }
1684
- const materializer = createMaterializer({ host });
1685
- let drifted = repos.filter((repo) => repo.audit.clean && repo.outside > 0).length;
1686
- let failedCount = failures.length;
1687
- const entries = repos.filter((repo) => repo.audit.clean).map((repo) => fleetEntryOf(repo.name, repo.audit, false, repo.outside));
1688
- try {
1689
- for (const repo of dirty) try {
1690
- materializer.repair(repo.plan, repo.rawAudit, repo.directory, replace);
1691
- if (doPrune) materializer.prune(repo.directory, pruneSets.get(repo.name) ?? {});
1692
- const paths = repo.plan.artifacts.map((artifact) => artifact.path);
1693
- const rawFinal = diffPlan(repo.plan, readTarget(repo.directory, paths));
1694
- const finalAudit = this.#scan(rawFinal, repo.directory, host, repo.plan.blueprint.services);
1695
- if (hasFindings(finalAudit, repo.outside)) drifted += 1;
1696
- entries.push(fleetEntryOf(repo.name, finalAudit, false, repo.outside));
1697
- if (!json) this.#reporter.line(fleetRepoLine(repo.name, {
1698
- state: "repaired",
1699
- remaining: finalAudit.drifted + finalAudit.missing + finalAudit.foreign + repo.outside
1700
- }));
1701
- } catch (error) {
1702
- failedCount += 1;
1703
- entries.push(fleetEntryOf(repo.name, void 0, true));
1704
- if (!json) this.#reporter.line(fleetRepoLine(repo.name, {
1705
- state: "failed",
1706
- message: describeError(error)
1707
- }));
1708
- }
1709
- } finally {
1710
- materializer.destroy();
1711
- }
1712
- if (json) this.#write([...entries, ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
1713
- else this.#reporter.line(fleetTotals(drifted, failedCount));
1714
- process.exitCode = drifted > 0 || failedCount > 0 ? 1 : 0;
1715
- }
1716
- /** `scaffold catalog` — regenerate the fleet package catalog table embedded in `.claude/agents/orkestrel.md`. */
1717
- async #catalog(values, json) {
1718
- const target = this.#contain(values.target ?? ".", json);
1719
- const explicitRoots = values.from;
1720
- let entries;
1721
- let published = 0;
1722
- let localOnly = 0;
1723
- const notes = /* @__PURE__ */ new Map();
1724
- if (values.offline) {
1725
- const roots = explicitRoots ?? [process.cwd()];
1726
- try {
1727
- entries = catalogPackages(roots);
1728
- } catch (error) {
1729
- this.#error(error, json);
1730
- }
1731
- } else {
1732
- const sync = createSync(this.#sync);
1733
- sync.emitter.on("package", (name, note) => {
1734
- if (note !== "") notes.set(name, note);
1735
- });
1736
- let registryEntries;
1737
- try {
1738
- registryEntries = await sync.catalog();
1739
- } catch (error) {
1740
- this.#error(error, json);
1741
- } finally {
1742
- sync.destroy();
1743
- }
1744
- published = registryEntries.length;
1745
- let localEntries = [];
1746
- if (explicitRoots !== void 0) try {
1747
- localEntries = catalogPackages(explicitRoots);
1748
- } catch (error) {
1749
- this.#error(error, json);
1750
- }
1751
- const merged = /* @__PURE__ */ new Map();
1752
- for (const entry of registryEntries) merged.set(entry.name, entry);
1753
- for (const local of localEntries) {
1754
- const existing = merged.get(local.name);
1755
- if (existing === void 0) {
1756
- merged.set(local.name, local);
1757
- localOnly += 1;
1758
- } else if (local.description.length > 0) merged.set(local.name, {
1759
- ...existing,
1760
- description: local.description
1761
- });
1762
- }
1763
- entries = [...merged.values()].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
1764
- }
1765
- const block = catalogToBlock(entries);
1766
- const agentPath = this.#contain(join(target, CATALOG_AGENT_PATH), json);
1767
- let current;
1768
- let baseline;
1769
- try {
1770
- current = readFileText(target, CATALOG_AGENT_PATH, "TARGET", "target");
1771
- baseline = digestText(current);
1772
- } catch (error) {
1773
- this.#error(new ScaffoldError("TARGET", `Failed to read ${agentPath}`, {
1774
- path: agentPath,
1775
- error
1776
- }), json);
1777
- }
1778
- const startIndex = current.indexOf(CATALOG_START_MARKER);
1779
- const endIndex = current.indexOf(CATALOG_END_MARKER);
1780
- const startParts = current.split(CATALOG_START_MARKER);
1781
- const endParts = current.split(CATALOG_END_MARKER);
1782
- if (startIndex === -1 || endIndex === -1 || endIndex < startIndex || startParts.length !== 2 || endParts.length !== 2) this.#error(new ScaffoldError("TARGET", `Expected exactly one ordered "${CATALOG_START_MARKER}" / "${CATALOG_END_MARKER}" pair in ${agentPath}`, { path: agentPath }), json);
1783
- const updated = `${current.slice(0, startIndex + CATALOG_START_MARKER.length)}\n\n${block}\n${current.slice(endIndex)}`;
1784
- const oldBlock = current.slice(startIndex + CATALOG_START_MARKER.length, endIndex);
1785
- const oldRows = catalogNames(oldBlock).length;
1786
- const shrink = entries.length < oldRows ? oldRows - entries.length : void 0;
1787
- if (updated === current) {
1788
- if (json) this.#write(catalogResultOf(entries, false));
1789
- else this.#reporter.line(catalogVerdict(true));
1790
- process.exitCode = 0;
1791
- return;
1792
- }
1793
- if (!json) {
1794
- this.#reporter.table(catalogTable(entries));
1795
- const warning = catalogShrinkWarning(oldRows, entries.length);
1796
- if (warning !== void 0) this.#reporter.line(warning);
1797
- if (values.offline) {
1798
- const missingDescription = entries.filter((entry) => entry.description.length === 0).map((entry) => entry.name);
1799
- if (missingDescription.length > 0) this.#reporter.line(`${missingDescription.length} without guide description: ${missingDescription.join(", ")}`);
1800
- } else {
1801
- this.#reporter.line(catalogCounts(published, localOnly));
1802
- for (const [name, note] of notes) this.#reporter.line(` ${name}: ${note}`);
1803
- }
1804
- }
1805
- const terminal = createTerminal();
1806
- if (!await this.#apply(terminal, applyConfirmMessage(1), values, json)) {
1807
- if (json) this.#write(catalogResultOf(entries, true, shrink));
1808
- else this.#reporter.line(catalogVerdict(false));
1809
- process.exitCode = 1;
1810
- return;
1811
- }
1812
- if (Buffer.byteLength(updated, "utf8") > MAX_ARTIFACT_BYTES) this.#error(new ScaffoldError("WRITE", `Catalog exceeds the artifact limit at ${agentPath}`, {
1813
- path: agentPath,
1814
- limit: MAX_ARTIFACT_BYTES
1815
- }), json);
1816
- const transaction = WriteTransaction.create(target, [CATALOG_AGENT_PATH], [{
1817
- path: CATALOG_AGENT_PATH,
1818
- shape: "file",
1819
- digest: baseline
1820
- }]);
1821
- const staged = attempt(() => {
1822
- validateWriteDirectories(transaction);
1823
- const destination = resolvePhysicalPath(transaction.stage, CATALOG_AGENT_PATH, "WRITE", "staging");
1824
- mkdirSync(dirname(destination), { recursive: true });
1825
- validateWriteDirectories(transaction);
1826
- const contained = resolvePhysicalPath(transaction.stage, CATALOG_AGENT_PATH, "WRITE", "staging");
1827
- writeFileSync(contained, updated, {
1828
- encoding: "utf8",
1829
- flag: "wx"
1830
- });
1831
- if (digestFile(contained) !== digestText(updated)) throw new ScaffoldError("WRITE", `Staged catalog changed at ${agentPath}`, { path: agentPath });
1832
- validateWriteDirectories(transaction);
1833
- });
1834
- if (!staged.success) {
1835
- const cleanup = attempt(() => discardWriteTransaction(transaction));
1836
- this.#error(new ScaffoldError("WRITE", `Failed to stage ${agentPath}`, {
1837
- path: agentPath,
1838
- error: staged.error,
1839
- cleanup: cleanup.success ? void 0 : cleanup.error
1840
- }), json);
1841
- }
1842
- const committed = attempt(() => commitWriteTransaction(transaction, [CATALOG_AGENT_PATH]));
1843
- if (!committed.success) this.#error(committed.error, json);
1844
- if (json) this.#write(catalogResultOf(entries, true, shrink));
1845
- else this.#reporter.status("success", catalogApplySuccess(agentPath));
1846
- process.exitCode = 0;
1847
- }
1848
- /**
1849
- * The whole command dispatch — a single top-level driver (no nested function
1850
- * declarations, AGENTS §4). Every verb sets `process.exitCode` (never
1851
- * `process.exit`) and returns, or `halt()`s through a `finally` that
1852
- * tears its entities down first; the caller at the bottom of this file
1853
- * catches exactly one sentinel (`CliExit`) and stops.
1854
- */
1855
- async #dispatch(argv) {
1856
- let parsed;
1857
- try {
1858
- parsed = parseArguments(argv);
1859
- } catch (error) {
1860
- process.stderr.write(`${error instanceof Error ? error.message : INVALID_ARGUMENTS_MESSAGE}\n`);
1861
- process.exitCode = 2;
1862
- return;
1863
- }
1864
- const { values, positionals } = parsed;
1865
- const [command, argument] = positionals;
1866
- const json = values.json === true;
1867
- this.#json = json;
1868
- if (command === void 0) {
1869
- process.stdout.write(`${values.help ? fullHelp() : shortUsage()}\n`);
1870
- process.exitCode = 0;
1871
- return;
1872
- }
1873
- if (!isVerb(command)) this.#usage(didYouMean(command), json);
1874
- if (command !== "catalog" && values.from !== void 0 && values.from.length > 1) this.#usage(`--from may be provided only once for '${command}'`, json);
1875
- if (command === "mirror" && values.deps !== void 0) this.#usage("--deps is not supported for 'mirror'; use 'pull --deps'", json);
1876
- if (command === "fleet" && values.target !== void 0) this.#usage("--target is not supported for 'fleet'; change into the fleet root", json);
1877
- if (values.help) {
1878
- process.stdout.write(`${verbHelp(command)}\n`);
1879
- process.exitCode = 0;
1880
- return;
1881
- }
1882
- if (command === "new") return this.#new(values, argument, json);
1883
- if (command === "pull") return this.#pull(values, json);
1884
- if (command === "mirror") return this.#mirror(values, json);
1885
- if (command === "audit") return this.#audit(values, json);
1886
- if (command === "repair") return this.#repair(values, json);
1887
- if (command === "fleet") return this.#fleet(values, json);
1888
- return this.#catalog(values, json);
1889
- }
1890
- };
1891
- //#endregion
1892
- //#region src/bin/scaffold.ts
1893
- await new CLI().run(process.argv.slice(2));
1894
- //#endregion
1895
-
1896
- //# sourceMappingURL=scaffold.js.map