@orkestrel/scaffold 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -15
- package/dist/bin/scaffold.js +1162 -1200
- package/dist/bin/scaffold.js.map +1 -1
- package/dist/host/AGENTS.md +124 -925
- package/dist/host/CLAUDE.md +260 -495
- package/dist/host/agents/skills/orkestrel-align-packages/SKILL.md +53 -0
- package/dist/host/agents/skills/orkestrel-align-packages/agents/openai.yaml +4 -0
- package/dist/host/agents/skills/orkestrel-align-packages/references/fleet.md +50 -0
- package/dist/host/agents/skills/orkestrel-align-packages/references/integration.md +56 -0
- package/dist/host/agents/skills/orkestrel-build-application/SKILL.md +64 -0
- package/dist/host/agents/skills/orkestrel-build-application/agents/openai.yaml +4 -0
- package/dist/host/agents/skills/orkestrel-build-application/references/application.md +129 -0
- package/dist/host/agents/skills/orkestrel-harden-package/SKILL.md +64 -0
- package/dist/host/agents/skills/orkestrel-harden-package/agents/openai.yaml +4 -0
- package/dist/host/agents/skills/orkestrel-harden-package/references/centralization.md +85 -0
- package/dist/host/agents/skills/orkestrel-harden-package/references/contract.md +57 -0
- package/dist/host/agents/skills/orkestrel-harden-package/references/hardening.md +93 -0
- package/dist/host/agents/skills/orkestrel-harden-package/references/research.md +61 -0
- package/dist/host/claude/agents/application.md +32 -0
- package/dist/host/claude/agents/builder.md +12 -7
- package/dist/host/claude/agents/checker.md +15 -8
- package/dist/host/claude/agents/codex.md +40 -0
- package/dist/host/claude/agents/grok.md +20 -37
- package/dist/host/claude/agents/orkestrel.md +89 -223
- package/dist/host/claude/agents/planner.md +14 -34
- package/dist/host/claude/agents/reviewer.md +39 -24
- package/dist/host/claude/agents/verifier.md +15 -10
- package/dist/host/claude/rules/application.md +57 -0
- package/dist/host/claude/rules/architecture.md +179 -0
- package/dist/host/claude/rules/browser.md +28 -0
- package/dist/host/claude/rules/documentation.md +64 -0
- package/dist/host/claude/rules/names.md +209 -0
- package/dist/host/claude/rules/patterns.md +130 -0
- package/dist/host/claude/rules/quality.md +45 -0
- package/dist/host/claude/rules/styles.md +64 -0
- package/dist/host/claude/rules/tests.md +123 -0
- package/dist/host/claude/rules/typescript.md +78 -0
- package/dist/host/claude/rules/workspace.md +180 -0
- package/dist/host/claude/settings.json +116 -0
- package/dist/host/claude/skills/orkestrel-align-packages/SKILL.md +10 -0
- package/dist/host/claude/skills/orkestrel-build-application/SKILL.md +12 -0
- package/dist/host/claude/skills/orkestrel-harden-package/SKILL.md +10 -0
- package/dist/host/codex/agents/analyst.toml +15 -0
- package/dist/host/codex/agents/application.toml +25 -0
- package/dist/host/codex/agents/builder.toml +24 -0
- package/dist/host/codex/agents/checker.toml +15 -0
- package/dist/host/codex/agents/grok.toml +17 -0
- package/dist/host/codex/agents/implementer.toml +17 -0
- package/dist/host/codex/agents/orkestrel.toml +16 -0
- package/dist/host/codex/agents/planner.toml +20 -0
- package/dist/host/codex/agents/reviewer.toml +18 -0
- package/dist/host/codex/agents/verifier.toml +14 -0
- package/dist/host/codex/config.toml +64 -0
- package/dist/host/dotfiles/oxlintrc.json +307 -1
- package/dist/host/guides/src/scaffold.md +2059 -2007
- package/dist/host/manifest.json +355 -137
- package/dist/host/scripts/codex.sh +49 -0
- package/dist/host/scripts/cursor.sh +33 -47
- package/dist/host/scripts/deps.sh +34 -16
- package/dist/host/scripts/ollama.sh +6 -149
- package/dist/host/tests/setupPolicy.ts +354 -0
- package/dist/src/core/index.cjs +6600 -1199
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +801 -266
- package/dist/src/core/index.d.ts +801 -266
- package/dist/src/core/index.js +6518 -1191
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +3132 -593
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +643 -95
- package/dist/src/server/index.d.ts +643 -95
- package/dist/src/server/index.js +3054 -594
- package/dist/src/server/index.js.map +1 -1
- package/package.json +15 -12
- package/dist/host/claude/agents/composer.md +0 -64
- package/dist/host/claude/agents/researcher.md +0 -38
- package/dist/host/claude/agents/scout.md +0 -35
- package/dist/host/github/workflows/ci.yml +0 -64
package/dist/bin/scaffold.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync,
|
|
3
|
-
import { basename, dirname, join, relative
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
4
4
|
import * as tls from "node:tls";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { catalogPackages, createMaterializer, createSync, deriveBlueprint, discoverPackages, hostRoot, hydratePlan, locateHostSource, pruneTargets, readHostManifest, readManifest, readTarget } from "../src/server/index.js";
|
|
5
|
+
import { DEPENDENCY_NAME_PATTERN, ENVIRONMENTS, GROUPS, MAX_ARTIFACT_BYTES, NAME_PATTERN, ScaffoldError, blueprint, blueprintToPlan, catalogNames, catalogToBlock, createCompiler, dependency, diffPlan, isScaffoldError, manifestToDependencies, 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, pruneTargets, readFileText, readHostManifest, readManifest, readTarget, resolvePhysicalPath, validateWriteDirectories } from "../src/server/index.js";
|
|
8
7
|
import { createReporter, createSpinner, createStyler } from "@orkestrel/console";
|
|
9
8
|
import { createServerSink } from "@orkestrel/console/server";
|
|
9
|
+
import { attempt } from "@orkestrel/contract";
|
|
10
10
|
import { isTerminalError } from "@orkestrel/terminal";
|
|
11
11
|
import { createTerminal } from "@orkestrel/terminal/server";
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
import { parseArgs } from "node:util";
|
|
13
|
+
//#region src/bin/constants.ts
|
|
14
|
+
/** The command-line interface's closed command vocabulary. */
|
|
14
15
|
var KNOWN_VERBS = Object.freeze([
|
|
15
16
|
"new",
|
|
16
17
|
"pull",
|
|
@@ -19,51 +20,185 @@ var KNOWN_VERBS = Object.freeze([
|
|
|
19
20
|
"fleet",
|
|
20
21
|
"catalog"
|
|
21
22
|
]);
|
|
22
|
-
/**
|
|
23
|
+
/** Internal artifact origins translated into user-facing labels. */
|
|
23
24
|
var ORIGIN_LABEL = Object.freeze({
|
|
24
|
-
host: "
|
|
25
|
-
template: "
|
|
25
|
+
host: "host-owned",
|
|
26
|
+
template: "starter",
|
|
26
27
|
computed: "generated"
|
|
27
28
|
});
|
|
28
|
-
/**
|
|
29
|
+
/** Internal drift states translated into user-facing labels. */
|
|
29
30
|
var DRIFT_LABEL = Object.freeze({
|
|
30
31
|
aligned: "unchanged",
|
|
31
32
|
stale: "drifted",
|
|
32
33
|
missing: "missing",
|
|
33
34
|
foreign: "unexpected file"
|
|
34
35
|
});
|
|
35
|
-
/**
|
|
36
|
+
/** Dependency freshness states translated into user-facing labels. */
|
|
36
37
|
var FRESHNESS_LABEL = Object.freeze({
|
|
37
38
|
current: "unchanged",
|
|
38
39
|
behind: "behind",
|
|
39
40
|
missing: "missing upstream",
|
|
40
41
|
failed: "fetch failed"
|
|
41
42
|
});
|
|
42
|
-
/**
|
|
43
|
+
/** Materializer actions translated into user-facing labels. */
|
|
43
44
|
var ACTION_LABEL = Object.freeze({
|
|
44
45
|
written: "wrote",
|
|
45
46
|
copied: "wrote",
|
|
46
47
|
skipped: "unchanged",
|
|
47
48
|
removed: "removed"
|
|
48
49
|
});
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
50
|
+
/** Repair's deliberately limited ownership boundary. */
|
|
51
|
+
var REPAIR_SCOPE = "repair scope: shared host-owned artifacts only — starter and generated files are never touched";
|
|
52
|
+
/** The dry-run note for `new`. */
|
|
53
|
+
var NEW_DRY_RUN_NOTE = "dry run — pass --apply to write";
|
|
54
|
+
/** The fallback message for a malformed command line without an error message. */
|
|
55
|
+
var INVALID_ARGUMENTS_MESSAGE = "invalid arguments";
|
|
56
|
+
/** Shared prompt-cancellation message. */
|
|
57
|
+
var CANCELLED_MESSAGE = "cancelled — nothing written";
|
|
58
|
+
/** Terminal choices for a new workspace's src and app environments. */
|
|
59
|
+
var ENVIRONMENT_CHOICES = Object.freeze([
|
|
60
|
+
{
|
|
61
|
+
name: "core",
|
|
62
|
+
value: "core",
|
|
63
|
+
description: "the pure engine"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "browser",
|
|
67
|
+
value: "browser",
|
|
68
|
+
description: "DOM-facing environment"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "server",
|
|
72
|
+
value: "server",
|
|
73
|
+
description: "node-facing environment"
|
|
74
|
+
}
|
|
75
|
+
]);
|
|
76
|
+
/** The safety model included in full help. */
|
|
77
|
+
var SAFETY_BANNER = [
|
|
78
|
+
"safety: every verb is a dry run by default.",
|
|
79
|
+
"on a terminal, a write prompts for confirmation; in a script, pass --apply (and --yes to skip the confirm).",
|
|
80
|
+
"every write is confined to the current working directory — cd there first.",
|
|
81
|
+
"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."
|
|
82
|
+
].join("\n");
|
|
83
|
+
/** Stable command exit-code meanings. */
|
|
84
|
+
var EXIT_CODES = Object.freeze([
|
|
85
|
+
["0", "clean / success"],
|
|
86
|
+
["1", "drift or failure"],
|
|
87
|
+
["2", "usage error"]
|
|
88
|
+
]);
|
|
89
|
+
/** One-line command summaries. */
|
|
90
|
+
var VERB_SUMMARY = Object.freeze({
|
|
91
|
+
new: "scaffold a workspace into ./<name>",
|
|
92
|
+
pull: "refresh vendored guides/versions, report drift",
|
|
93
|
+
audit: "whole-plan conformance report",
|
|
94
|
+
repair: "restore the shared host-owned set",
|
|
95
|
+
fleet: "audit/repair every workspace under the cwd's immediate children",
|
|
96
|
+
catalog: "regenerate the fleet package-catalog table"
|
|
97
|
+
});
|
|
98
|
+
/** Compact command flag references. */
|
|
99
|
+
var VERB_FLAGS = Object.freeze({
|
|
100
|
+
new: "--src a,b --app a,b --deps x,y --apply --yes --target <path> --from <path>",
|
|
101
|
+
pull: "--target . --deps x,y --apply --yes --strict",
|
|
102
|
+
audit: "--target . --live --from <path> --groups a,b",
|
|
103
|
+
repair: "--target . --apply --yes --prune --from <path>",
|
|
104
|
+
fleet: "--apply --yes --prune --from <path>",
|
|
105
|
+
catalog: "--from <path> ... --target <repo> --offline --apply --yes"
|
|
106
|
+
});
|
|
107
|
+
/** Plain-language command flag descriptions. */
|
|
108
|
+
var VERB_FLAG_HELP = Object.freeze({
|
|
109
|
+
new: [
|
|
110
|
+
["--src a,b", "which src environments to include (core, browser, server)"],
|
|
111
|
+
["--app a,b", "which app environments to include (core, browser, server)"],
|
|
112
|
+
["--deps x,y", "@orkestrel/* dependencies to add (installed as dependencies)"],
|
|
113
|
+
["--apply", "write the files (default is a dry run)"],
|
|
114
|
+
["--yes", "skip the confirmation question"],
|
|
115
|
+
["--target <path>", "destination directory (default: ./<name>)"],
|
|
116
|
+
["--from <path>", "read the template from a local path instead of the bundled one"]
|
|
117
|
+
],
|
|
118
|
+
pull: [
|
|
119
|
+
["--target .", "directory to refresh (default: current directory)"],
|
|
120
|
+
["--deps x,y", "limit the refresh to these dependencies"],
|
|
121
|
+
["--apply", "write the refreshed files (default is a dry run)"],
|
|
122
|
+
["--yes", "skip the confirmation question"],
|
|
123
|
+
["--strict", "fail (exit 1) on any drift, even non-fatal"]
|
|
124
|
+
],
|
|
125
|
+
audit: [
|
|
126
|
+
["--target .", "directory to audit (default: current directory)"],
|
|
127
|
+
["--live", "also check upstream freshness over the network"],
|
|
128
|
+
["--from <path>", "read the template from a local path instead of the bundled one"],
|
|
129
|
+
["--groups a,b", "limit the audit to these artifact groups"]
|
|
130
|
+
],
|
|
131
|
+
repair: [
|
|
132
|
+
["--target .", "directory to repair (default: current directory)"],
|
|
133
|
+
["--apply", "write the fixes (default is a dry run)"],
|
|
134
|
+
["--yes", "skip the confirmation question"],
|
|
135
|
+
["--prune", "also DELETE unexpected files under .claude/agents, .codex/agents, and scripts"],
|
|
136
|
+
["--from <path>", "read the template from a local path instead of the bundled one"]
|
|
137
|
+
],
|
|
138
|
+
fleet: [
|
|
139
|
+
["--apply", "write fixes across every package (default is a dry run)"],
|
|
140
|
+
["--yes", "skip the confirmation question"],
|
|
141
|
+
["--prune", "also DELETE unexpected files under .claude/agents, .codex/agents, and scripts, per package"],
|
|
142
|
+
["--from <path>", "read the template from a local path instead of the bundled one"]
|
|
143
|
+
],
|
|
144
|
+
catalog: [
|
|
145
|
+
["--from <path> ...", "one or more local package paths to include"],
|
|
146
|
+
["--target <repo>", "the repository whose Orkestrel agent catalog table gets updated"],
|
|
147
|
+
["--offline", "skip network lookups (npm registry) for package descriptions"],
|
|
148
|
+
["--apply", "write the updated table (default is a dry run)"],
|
|
149
|
+
["--yes", "skip the confirmation question"]
|
|
150
|
+
]
|
|
151
|
+
});
|
|
152
|
+
/** Dry-run and confirmation notes per command. */
|
|
153
|
+
var VERB_DRY_RUN_NOTE = Object.freeze({
|
|
154
|
+
new: "dry run by default — add --apply to write the files, --yes to skip the question",
|
|
155
|
+
pull: "dry run by default — add --apply to write the refreshed files, --yes to skip the question",
|
|
156
|
+
audit: "read-only — audit never writes; pass --live to also check upstream freshness",
|
|
157
|
+
repair: "dry run by default — add --apply to write, --yes to skip the question",
|
|
158
|
+
fleet: "dry run by default — add --apply to write across every package, --yes to skip the question",
|
|
159
|
+
catalog: "dry run by default — add --apply to write, --yes to skip the question"
|
|
160
|
+
});
|
|
161
|
+
/** One concrete invocation per command. */
|
|
162
|
+
var VERB_EXAMPLE = Object.freeze({
|
|
163
|
+
new: "example: scaffold new widget --src core,server --app core,browser --apply",
|
|
164
|
+
pull: "example: scaffold pull --apply",
|
|
165
|
+
audit: "example: scaffold audit --live",
|
|
166
|
+
repair: "example: scaffold repair --apply",
|
|
167
|
+
fleet: "example: scaffold fleet --apply --yes",
|
|
168
|
+
catalog: "example: scaffold catalog --apply"
|
|
169
|
+
});
|
|
170
|
+
/** Message used when a prune scan has no candidates. */
|
|
171
|
+
var PRUNE_EMPTY = "no unexpected files to delete";
|
|
172
|
+
/** Guidance for unexpected files outside a non-pruning repair handoff. */
|
|
173
|
+
var FOREIGN_HINT = "unexpected files found — run 'scaffold repair --prune' to delete them";
|
|
174
|
+
/** Interactive dependency prompt. */
|
|
175
|
+
var ORKESTREL_DEPS_PROMPT = "@orkestrel dependencies (comma-separated short names, e.g. contract, emitter — installed as dependencies)";
|
|
176
|
+
/** Catalog-degraded validation note. */
|
|
177
|
+
var CATALOG_UNRESOLVED_NOTE = "couldn't resolve the vendored @orkestrel catalog — validating names by shape only";
|
|
178
|
+
/** Catalog block destination in a scaffolded repository. */
|
|
179
|
+
var CATALOG_AGENT_PATH = ".claude/agents/orkestrel.md";
|
|
180
|
+
/** Opening marker for the generated package catalog block. */
|
|
181
|
+
var CATALOG_START_MARKER = "<!-- catalog:start -->";
|
|
182
|
+
/** Closing marker for the generated package catalog block. */
|
|
183
|
+
var CATALOG_END_MARKER = "<!-- catalog:end -->";
|
|
184
|
+
/** Non-terminal prune safety note. */
|
|
185
|
+
var PRUNE_SKIPPED = "prune skipped — not a terminal; add --apply (or --yes) to delete non-interactively";
|
|
186
|
+
/** Degraded unexpected-file scan note. */
|
|
187
|
+
var SCAN_SKIPPED = "unexpected-file scanning skipped — couldn't establish the template source";
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/bin/errors.ts
|
|
190
|
+
/** Internal sentinel that unwinds command dispatch without bypassing cleanup. */
|
|
191
|
+
var CLIExitError = class extends Error {
|
|
192
|
+
code;
|
|
193
|
+
constructor(code) {
|
|
194
|
+
super(`cli-exit:${String(code)}`);
|
|
195
|
+
this.code = code;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/bin/shapers.ts
|
|
200
|
+
/** Partition findings by whether repair owns their artifact origin. */
|
|
201
|
+
function partitionFindings(findings, plan) {
|
|
67
202
|
const origins = new Map(plan.artifacts.map((artifact) => [artifact.path, artifact.origin]));
|
|
68
203
|
let ownedDrifted = 0;
|
|
69
204
|
let ownedMissing = 0;
|
|
@@ -73,13 +208,13 @@ function partitionOrigin(findings, plan) {
|
|
|
73
208
|
let generatedForeign = 0;
|
|
74
209
|
for (const finding of findings) {
|
|
75
210
|
const origin = origins.get(finding.path);
|
|
76
|
-
const
|
|
211
|
+
const owned = origin === "host" || origin === "template";
|
|
77
212
|
if (finding.drift === "aligned") continue;
|
|
78
|
-
|
|
213
|
+
if (finding.drift === "stale") if (owned) ownedDrifted += 1;
|
|
79
214
|
else generatedDrifted += 1;
|
|
80
|
-
else if (finding.drift === "missing") if (
|
|
215
|
+
else if (finding.drift === "missing") if (owned) ownedMissing += 1;
|
|
81
216
|
else generatedMissing += 1;
|
|
82
|
-
else if (
|
|
217
|
+
else if (owned) ownedForeign += 1;
|
|
83
218
|
else generatedForeign += 1;
|
|
84
219
|
}
|
|
85
220
|
return {
|
|
@@ -95,27 +230,88 @@ function partitionOrigin(findings, plan) {
|
|
|
95
230
|
}
|
|
96
231
|
};
|
|
97
232
|
}
|
|
98
|
-
/**
|
|
233
|
+
/** Create one deterministic fleet result entry. */
|
|
234
|
+
function fleetEntryOf(name, counts, failed) {
|
|
235
|
+
return {
|
|
236
|
+
name,
|
|
237
|
+
drifted: counts?.drifted ?? 0,
|
|
238
|
+
missing: counts?.missing ?? 0,
|
|
239
|
+
foreign: counts?.foreign ?? 0,
|
|
240
|
+
failed
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/** Create the command-line interface's machine-readable failure envelope. */
|
|
244
|
+
function errorEnvelopeOf(code, message) {
|
|
245
|
+
return { error: {
|
|
246
|
+
code,
|
|
247
|
+
message
|
|
248
|
+
} };
|
|
249
|
+
}
|
|
250
|
+
/** Project a plan summary to the stable machine-readable `new` result. */
|
|
251
|
+
function summaryToNewResult(summary, applied) {
|
|
252
|
+
return {
|
|
253
|
+
name: summary.name,
|
|
254
|
+
src: summary.src,
|
|
255
|
+
app: summary.app,
|
|
256
|
+
host: summary.host,
|
|
257
|
+
template: summary.template,
|
|
258
|
+
computed: summary.computed,
|
|
259
|
+
applied
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/** Add a materialization result to an audit. */
|
|
263
|
+
function auditToRepairResult(audit, result) {
|
|
264
|
+
return {
|
|
265
|
+
...audit,
|
|
266
|
+
result
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/** Create the stable machine-readable catalog result. */
|
|
270
|
+
function catalogResultOf(entries, drift, shrink) {
|
|
271
|
+
return shrink === void 0 ? {
|
|
272
|
+
entries,
|
|
273
|
+
drift
|
|
274
|
+
} : {
|
|
275
|
+
entries,
|
|
276
|
+
drift,
|
|
277
|
+
shrink
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region src/bin/helpers.ts
|
|
282
|
+
/** Render one pluralized count. */
|
|
283
|
+
function countPart(count, label) {
|
|
284
|
+
return `${count} ${label}${count === 1 ? "" : "s"}`;
|
|
285
|
+
}
|
|
286
|
+
/** Render nonzero audit buckets or `clean`. */
|
|
287
|
+
function bucketText(counts) {
|
|
288
|
+
const parts = [];
|
|
289
|
+
if (counts.drifted > 0) parts.push(countPart(counts.drifted, "drifted"));
|
|
290
|
+
if (counts.missing > 0) parts.push(countPart(counts.missing, "missing"));
|
|
291
|
+
if (counts.foreign > 0) parts.push(countPart(counts.foreign, "unexpected"));
|
|
292
|
+
return parts.length > 0 ? parts.join(", ") : "clean";
|
|
293
|
+
}
|
|
294
|
+
/** Render audit's origin-aware verdict. */
|
|
99
295
|
function auditVerdict(audit, plan) {
|
|
100
|
-
const
|
|
101
|
-
if (audit.clean) return `audit: ${countPart(
|
|
102
|
-
const split =
|
|
103
|
-
return split.owned.drifted === 0 && split.owned.missing === 0 && split.owned.foreign === 0 ? `audit: ${countPart(
|
|
296
|
+
const count = audit.findings.length;
|
|
297
|
+
if (audit.clean) return `audit: ${countPart(count, "artifact")} — clean`;
|
|
298
|
+
const split = partitionFindings(audit.findings, plan);
|
|
299
|
+
return split.owned.drifted === 0 && split.owned.missing === 0 && split.owned.foreign === 0 ? `audit: ${countPart(count, "artifact")} — host-owned clean; ${bucketText(split.generated)} (generated)` : `audit: ${countPart(count, "artifact")} — host-owned: ${bucketText(split.owned)}; generated: ${bucketText(split.generated)}`;
|
|
104
300
|
}
|
|
105
|
-
/**
|
|
301
|
+
/** Render non-aligned audit findings as terminal table rows. */
|
|
106
302
|
function findingRows(findings, plan) {
|
|
107
303
|
const origins = new Map(plan.artifacts.map((artifact) => [artifact.path, artifact.origin]));
|
|
108
304
|
return findings.filter((finding) => finding.drift !== "aligned").map((finding) => {
|
|
109
305
|
const origin = origins.get(finding.path);
|
|
110
|
-
const
|
|
306
|
+
const category = origin === void 0 ? "unexpected file" : ORIGIN_LABEL[origin];
|
|
111
307
|
return [
|
|
112
308
|
DRIFT_LABEL[finding.drift],
|
|
113
|
-
|
|
309
|
+
category,
|
|
114
310
|
finding.path
|
|
115
311
|
];
|
|
116
312
|
});
|
|
117
313
|
}
|
|
118
|
-
/**
|
|
314
|
+
/** Create audit's terminal table. */
|
|
119
315
|
function auditTable(audit, plan) {
|
|
120
316
|
return {
|
|
121
317
|
columns: [
|
|
@@ -126,42 +322,36 @@ function auditTable(audit, plan) {
|
|
|
126
322
|
rows: findingRows(audit.findings, plan)
|
|
127
323
|
};
|
|
128
324
|
}
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (outsideCount === 0) return void 0;
|
|
134
|
-
return `note: ${countPart(outsideCount, "finding")} outside repair's scope — run 'audit' for the list; generated files are yours to edit`;
|
|
325
|
+
/** Render drift outside repair's ownership boundary. */
|
|
326
|
+
function scopeNote(count) {
|
|
327
|
+
if (count === 0) return void 0;
|
|
328
|
+
return `note: ${countPart(count, "finding")} outside repair's scope — run 'audit' for the list; generated files are yours to edit`;
|
|
135
329
|
}
|
|
136
|
-
/**
|
|
330
|
+
/** Render repair's dry-run verdict. */
|
|
137
331
|
function repairVerdict(audit) {
|
|
138
|
-
if (audit.clean) return `repair: ${countPart(audit.findings.length, "
|
|
139
|
-
return `repair: ${bucketText(
|
|
140
|
-
drifted: audit.drifted,
|
|
141
|
-
missing: audit.missing,
|
|
142
|
-
foreign: audit.foreign
|
|
143
|
-
})} — pass --apply to write`;
|
|
332
|
+
if (audit.clean) return `repair: ${countPart(audit.findings.length, "host-owned artifact")} aligned — nothing to write`;
|
|
333
|
+
return `repair: ${bucketText(audit)} — pass --apply to write`;
|
|
144
334
|
}
|
|
145
|
-
/**
|
|
335
|
+
/** Render repair's materialization tally. */
|
|
146
336
|
function repairSuccess(result, removed) {
|
|
147
337
|
const written = result.written.length + result.copied.length;
|
|
148
338
|
return `${ACTION_LABEL.written} ${written}, ${ACTION_LABEL.skipped} ${result.skipped.length}, ${ACTION_LABEL.removed} ${removed.length}`;
|
|
149
339
|
}
|
|
150
|
-
/**
|
|
340
|
+
/** Render pull freshness as table rows. */
|
|
151
341
|
function pullRows(report) {
|
|
152
|
-
const
|
|
342
|
+
const guides = report.guides.map((guide) => [
|
|
153
343
|
guide.name,
|
|
154
344
|
"guide",
|
|
155
345
|
FRESHNESS_LABEL[guide.freshness] ?? guide.freshness
|
|
156
346
|
]);
|
|
157
|
-
const
|
|
347
|
+
const versions = report.versions.map((version) => [
|
|
158
348
|
version.name,
|
|
159
349
|
"version",
|
|
160
350
|
FRESHNESS_LABEL[version.freshness] ?? version.freshness
|
|
161
351
|
]);
|
|
162
|
-
return [...
|
|
352
|
+
return [...guides, ...versions];
|
|
163
353
|
}
|
|
164
|
-
/**
|
|
354
|
+
/** Create pull's terminal table. */
|
|
165
355
|
function pullTable(report) {
|
|
166
356
|
return {
|
|
167
357
|
columns: [
|
|
@@ -172,162 +362,93 @@ function pullTable(report) {
|
|
|
172
362
|
rows: pullRows(report)
|
|
173
363
|
};
|
|
174
364
|
}
|
|
175
|
-
/**
|
|
365
|
+
/** Render cause notes from non-current pull entries. */
|
|
176
366
|
function pullCauseNotes(report) {
|
|
177
367
|
return [...report.guides, ...report.versions].filter((entry) => entry.note !== void 0).map((entry) => ` ${entry.name}: ${FRESHNESS_LABEL[entry.freshness] ?? entry.freshness} — ${entry.note}`);
|
|
178
368
|
}
|
|
179
|
-
/**
|
|
369
|
+
/** Render pull's tally. */
|
|
180
370
|
function pullVerdict(report) {
|
|
181
371
|
return `pull: ${countPart(report.guides.length + report.versions.length, "entry")} — ${countPart(report.failed, "failed")}`;
|
|
182
372
|
}
|
|
183
|
-
/**
|
|
373
|
+
/** Render pull's success tally. */
|
|
184
374
|
function pullSuccess(count) {
|
|
185
375
|
return `wrote ${countPart(count, "guide")}`;
|
|
186
376
|
}
|
|
187
|
-
/**
|
|
377
|
+
/** Render one fleet repository outcome. */
|
|
188
378
|
function fleetRepoLine(name, outcome) {
|
|
189
|
-
if (outcome.
|
|
190
|
-
if (outcome.
|
|
191
|
-
|
|
192
|
-
missing: outcome.missing,
|
|
193
|
-
foreign: outcome.foreign
|
|
194
|
-
})}`;
|
|
195
|
-
if (outcome.kind === "repaired") return `${name}: repaired (${countPart(outcome.remaining, "finding")} remaining)`;
|
|
379
|
+
if (outcome.state === "clean") return `${name}: clean`;
|
|
380
|
+
if (outcome.state === "drifted") return `${name}: ${bucketText(outcome)}`;
|
|
381
|
+
if (outcome.state === "repaired") return `${name}: repaired (${countPart(outcome.remaining, "finding")} remaining)`;
|
|
196
382
|
return `${name}: ${outcome.message}`;
|
|
197
383
|
}
|
|
198
|
-
/**
|
|
384
|
+
/** Render fleet's repository totals. */
|
|
199
385
|
function fleetTotals(drifted, failed) {
|
|
200
386
|
return `total: ${countPart(drifted, "drifted repo")}, ${countPart(failed, "failed")}`;
|
|
201
387
|
}
|
|
202
|
-
/**
|
|
388
|
+
/** Create catalog's terminal table. */
|
|
203
389
|
function catalogTable(entries) {
|
|
204
390
|
return {
|
|
205
391
|
columns: [{ label: "Package" }, { label: "Version" }],
|
|
206
392
|
rows: entries.map((entry) => [entry.name, entry.version])
|
|
207
393
|
};
|
|
208
394
|
}
|
|
209
|
-
/**
|
|
395
|
+
/** Render catalog shrink risk when present. */
|
|
210
396
|
function catalogShrinkWarning(oldRows, newRows) {
|
|
211
397
|
if (newRows >= oldRows) return void 0;
|
|
212
398
|
return `warning: catalog shrinks from ${countPart(oldRows, "row")} to ${newRows}`;
|
|
213
399
|
}
|
|
214
|
-
/**
|
|
215
|
-
function catalogCounts(published,
|
|
216
|
-
return `catalog: ${countPart(published, "published package")}, ${countPart(
|
|
400
|
+
/** Render catalog source tallies. */
|
|
401
|
+
function catalogCounts(published, local) {
|
|
402
|
+
return `catalog: ${countPart(published, "published package")}, ${countPart(local, "local-only")}`;
|
|
217
403
|
}
|
|
218
|
-
/** `new`'s
|
|
219
|
-
function newPlanTable(
|
|
404
|
+
/** Create `new`'s plan summary table. */
|
|
405
|
+
function newPlanTable(summary) {
|
|
220
406
|
return {
|
|
221
407
|
columns: [{ label: "Origin" }, {
|
|
222
408
|
label: "Count",
|
|
223
409
|
align: "right"
|
|
224
410
|
}],
|
|
225
|
-
rows: [
|
|
411
|
+
rows: [
|
|
412
|
+
["host-owned", String(summary.host)],
|
|
413
|
+
["starter", String(summary.template)],
|
|
414
|
+
["generated", String(summary.computed)]
|
|
415
|
+
]
|
|
226
416
|
};
|
|
227
417
|
}
|
|
228
|
-
/** `new`'s dry-run destination
|
|
418
|
+
/** Render `new`'s dry-run destination. */
|
|
229
419
|
function newPlanPreview(name) {
|
|
230
420
|
return `will write into ./${name}`;
|
|
231
421
|
}
|
|
232
|
-
/** `new
|
|
422
|
+
/** Render `new`'s write result. */
|
|
233
423
|
function newApplySuccess(count, name) {
|
|
234
424
|
return `wrote ${countPart(count, "file")} into ./${name}`;
|
|
235
425
|
}
|
|
236
|
-
/**
|
|
237
|
-
var NEW_DRY_RUN_NOTE = "dry run — pass --apply to write";
|
|
238
|
-
/** `catalog --apply`'s success line. */
|
|
426
|
+
/** Render catalog's write result. */
|
|
239
427
|
function catalogApplySuccess(path) {
|
|
240
428
|
return `wrote ${path}`;
|
|
241
429
|
}
|
|
242
|
-
/**
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const scope = repoCount === void 0 ? "" : ` across ${countPart(repoCount, "repo")}`;
|
|
247
|
-
return `Apply — write ${countPart(fileCount, "file")}${scope}? `;
|
|
430
|
+
/** Render the shared write confirmation. */
|
|
431
|
+
function applyConfirmMessage(files, repos) {
|
|
432
|
+
const scope = repos === void 0 ? "" : ` across ${countPart(repos, "repo")}`;
|
|
433
|
+
return `Apply — write ${countPart(files, "file")}${scope}? `;
|
|
248
434
|
}
|
|
249
|
-
/**
|
|
435
|
+
/** Render the separate prune confirmation. */
|
|
250
436
|
function pruneConfirmMessage(count) {
|
|
251
|
-
return `Also delete ${countPart(count, "unexpected file")} under .claude/agents and scripts? `;
|
|
437
|
+
return `Also delete ${countPart(count, "unexpected file")} under .claude/agents, .codex/agents, and scripts? `;
|
|
252
438
|
}
|
|
253
|
-
/**
|
|
254
|
-
* The audit→repair handoff prompt message — names what will actually be
|
|
255
|
-
* acted on: `owned` template-owned files with drift, and (only when `prune`
|
|
256
|
-
* is active) `foreign` unexpected files that will be deleted. Never promises
|
|
257
|
-
* a deletion the handoff will not perform — `prune` gates the foreign clause.
|
|
258
|
-
*/
|
|
439
|
+
/** Render the interactive audit-to-repair handoff. */
|
|
259
440
|
function repairHandoff(owned, foreign, prune) {
|
|
260
441
|
const parts = [];
|
|
261
|
-
if (owned > 0) parts.push(`${countPart(owned, "
|
|
442
|
+
if (owned > 0) parts.push(`${countPart(owned, "host-owned file")} ${owned === 1 ? "has" : "have"} drift`);
|
|
262
443
|
if (prune && foreign > 0) parts.push(`${countPart(foreign, "unexpected file")} will be deleted`);
|
|
263
444
|
return `${parts.join(" and ")} — run repair now? `;
|
|
264
445
|
}
|
|
265
|
-
/**
|
|
266
|
-
function foreignHint() {
|
|
267
|
-
return "unexpected files found — run 'scaffold repair --prune' to delete them";
|
|
268
|
-
}
|
|
269
|
-
/** `new`'s interactive Q1 prompt (TTY only) — `@orkestrel` short-name deps, landing in `dependencies`. */
|
|
270
|
-
function orkestrelDepsPrompt() {
|
|
271
|
-
return "@orkestrel dependencies (comma-separated short names, e.g. contract, emitter — installed as dependencies)";
|
|
272
|
-
}
|
|
273
|
-
/**
|
|
274
|
-
* Re-ask wording for a Q1 token that does not resolve against the vendored
|
|
275
|
-
* `@orkestrel` catalog — names the offending token and, when one was found,
|
|
276
|
-
* the nearest catalog name (`render.ts`'s own `nearest`).
|
|
277
|
-
*/
|
|
446
|
+
/** Render one unresolved Orkestrel dependency token. */
|
|
278
447
|
function unknownOrkestrelToken(token, suggestion) {
|
|
279
|
-
const
|
|
280
|
-
return suggestion === void 0 ? `${
|
|
281
|
-
}
|
|
282
|
-
/** Printed once when the vendored `@orkestrel` catalog cannot be resolved — Q1 degrades to shape-only (`DEPENDENCY_NAME_PATTERN`) validation instead of blocking on it. */
|
|
283
|
-
function catalogUnresolvedNote() {
|
|
284
|
-
return "couldn't resolve the vendored @orkestrel catalog — validating names by shape only";
|
|
285
|
-
}
|
|
286
|
-
/** The line printed when a confirm prompt is declined. */
|
|
287
|
-
var CANCELLED_MESSAGE = "cancelled — nothing written";
|
|
288
|
-
/** `new`'s surface checkbox choices — per-choice descriptions grounded on the terminal guide's surface semantics. */
|
|
289
|
-
function surfaceChoices() {
|
|
290
|
-
return [
|
|
291
|
-
{
|
|
292
|
-
name: "core",
|
|
293
|
-
value: "core",
|
|
294
|
-
description: "the pure engine"
|
|
295
|
-
},
|
|
296
|
-
{
|
|
297
|
-
name: "browser",
|
|
298
|
-
value: "browser",
|
|
299
|
-
description: "DOM-facing surface"
|
|
300
|
-
},
|
|
301
|
-
{
|
|
302
|
-
name: "server",
|
|
303
|
-
value: "server",
|
|
304
|
-
description: "node-facing surface"
|
|
305
|
-
}
|
|
306
|
-
];
|
|
448
|
+
const message = `"${token}" is not a published @orkestrel package`;
|
|
449
|
+
return suggestion === void 0 ? `${message} — try again` : `${message} — did you mean "${suggestion}"? try again`;
|
|
307
450
|
}
|
|
308
|
-
/**
|
|
309
|
-
var SAFETY_BANNER = [
|
|
310
|
-
"safety: every verb is a dry run by default.",
|
|
311
|
-
"on a terminal, a write prompts for confirmation; in a script, pass --apply (and --yes to skip the confirm).",
|
|
312
|
-
"every write is confined to the current working directory — cd there first.",
|
|
313
|
-
"TLS trusts the system certificate store automatically (corporate proxies); NODE_EXTRA_CA_CERTS adds custom PEMs."
|
|
314
|
-
].join("\n");
|
|
315
|
-
/** The exit-code reference table every full-help tier includes. */
|
|
316
|
-
var EXIT_CODES = [
|
|
317
|
-
["0", "clean / success"],
|
|
318
|
-
["1", "drift or failure"],
|
|
319
|
-
["2", "usage error"]
|
|
320
|
-
];
|
|
321
|
-
/** One-line-per-verb summaries — the short and full help tiers share this table. */
|
|
322
|
-
var VERB_SUMMARY = Object.freeze({
|
|
323
|
-
new: "scaffold a package into ./<name>",
|
|
324
|
-
pull: "refresh vendored guides/versions, report drift",
|
|
325
|
-
audit: "whole-plan conformance report",
|
|
326
|
-
repair: "restore the shared template-owned set",
|
|
327
|
-
fleet: "audit/repair every package under the cwd's immediate children",
|
|
328
|
-
catalog: "regenerate the fleet package-catalog table"
|
|
329
|
-
});
|
|
330
|
-
/** ≤10 lines: one-liner per verb plus the escape hatch to `verbHelp`. */
|
|
451
|
+
/** Render compact command usage. */
|
|
331
452
|
function shortUsage() {
|
|
332
453
|
return [
|
|
333
454
|
"scaffold <verb> [options]",
|
|
@@ -337,383 +458,129 @@ function shortUsage() {
|
|
|
337
458
|
"run 'scaffold <verb> --help' for a verb's full reference"
|
|
338
459
|
].join("\n");
|
|
339
460
|
}
|
|
340
|
-
/**
|
|
341
|
-
var VERB_FLAGS = Object.freeze({
|
|
342
|
-
new: "--surfaces a,b --deps x,y --apply --yes --target <path> --from <path>",
|
|
343
|
-
pull: "--target . --deps x,y --apply --yes --strict",
|
|
344
|
-
audit: "--target . --live --from <path> --groups a,b",
|
|
345
|
-
repair: "--target . --apply --yes --prune --from <path>",
|
|
346
|
-
fleet: "--apply --yes --prune --from <path>",
|
|
347
|
-
catalog: "--from <path> ... --target <repo> --offline --apply --yes"
|
|
348
|
-
});
|
|
349
|
-
/** Per-verb, per-flag plain-language descriptions — `verbHelp`'s one-line-per-flag body. `--prune` is marked destructive. */
|
|
350
|
-
var VERB_FLAG_HELP = Object.freeze({
|
|
351
|
-
new: [
|
|
352
|
-
["--surfaces a,b", "which surfaces to include (core, browser, server)"],
|
|
353
|
-
["--deps x,y", "@orkestrel/* dependencies to add (installed as dependencies)"],
|
|
354
|
-
["--apply", "write the files (default is a dry run)"],
|
|
355
|
-
["--yes", "skip the confirmation question"],
|
|
356
|
-
["--target <path>", "destination directory (default: ./<name>)"],
|
|
357
|
-
["--from <path>", "read the template from a local path instead of the bundled one"]
|
|
358
|
-
],
|
|
359
|
-
pull: [
|
|
360
|
-
["--target .", "directory to refresh (default: current directory)"],
|
|
361
|
-
["--deps x,y", "limit the refresh to these dependencies"],
|
|
362
|
-
["--apply", "write the refreshed files (default is a dry run)"],
|
|
363
|
-
["--yes", "skip the confirmation question"],
|
|
364
|
-
["--strict", "fail (exit 1) on any drift, even non-fatal"]
|
|
365
|
-
],
|
|
366
|
-
audit: [
|
|
367
|
-
["--target .", "directory to audit (default: current directory)"],
|
|
368
|
-
["--live", "also check upstream freshness over the network"],
|
|
369
|
-
["--from <path>", "read the template from a local path instead of the bundled one"],
|
|
370
|
-
["--groups a,b", "limit the audit to these artifact groups"]
|
|
371
|
-
],
|
|
372
|
-
repair: [
|
|
373
|
-
["--target .", "directory to repair (default: current directory)"],
|
|
374
|
-
["--apply", "write the fixes (default is a dry run)"],
|
|
375
|
-
["--yes", "skip the confirmation question"],
|
|
376
|
-
["--prune", "also DELETE unexpected files under .claude/agents and scripts"],
|
|
377
|
-
["--from <path>", "read the template from a local path instead of the bundled one"]
|
|
378
|
-
],
|
|
379
|
-
fleet: [
|
|
380
|
-
["--apply", "write fixes across every package (default is a dry run)"],
|
|
381
|
-
["--yes", "skip the confirmation question"],
|
|
382
|
-
["--prune", "also DELETE unexpected files under .claude/agents and scripts, per package"],
|
|
383
|
-
["--from <path>", "read the template from a local path instead of the bundled one"]
|
|
384
|
-
],
|
|
385
|
-
catalog: [
|
|
386
|
-
["--from <path> ...", "one or more local package paths to include"],
|
|
387
|
-
["--target <repo>", "the repo whose README catalog table gets updated"],
|
|
388
|
-
["--offline", "skip network lookups (npm registry) for package descriptions"],
|
|
389
|
-
["--apply", "write the updated table (default is a dry run)"],
|
|
390
|
-
["--yes", "skip the confirmation question"]
|
|
391
|
-
]
|
|
392
|
-
});
|
|
393
|
-
/** The dry-run/confirm note per verb — `audit` never writes, so its note says so instead. */
|
|
394
|
-
var VERB_DRY_RUN_NOTE = Object.freeze({
|
|
395
|
-
new: "dry run by default — add --apply to write the files, --yes to skip the question",
|
|
396
|
-
pull: "dry run by default — add --apply to write the refreshed files, --yes to skip the question",
|
|
397
|
-
audit: "read-only — audit never writes; pass --live to also check upstream freshness",
|
|
398
|
-
repair: "dry run by default — add --apply to write, --yes to skip the question",
|
|
399
|
-
fleet: "dry run by default — add --apply to write across every package, --yes to skip the question",
|
|
400
|
-
catalog: "dry run by default — add --apply to write, --yes to skip the question"
|
|
401
|
-
});
|
|
402
|
-
/** One concrete example invocation per verb — `verbHelp`'s closing line. */
|
|
403
|
-
var VERB_EXAMPLE = Object.freeze({
|
|
404
|
-
new: "example: scaffold new widget --surfaces core,server --apply",
|
|
405
|
-
pull: "example: scaffold pull --apply",
|
|
406
|
-
audit: "example: scaffold audit --live",
|
|
407
|
-
repair: "example: scaffold repair --apply",
|
|
408
|
-
fleet: "example: scaffold fleet --apply --yes",
|
|
409
|
-
catalog: "example: scaffold catalog --apply"
|
|
410
|
-
});
|
|
411
|
-
/** The full reference help tier — every verb's summary + flags, the safety banner, and the exit-code table. */
|
|
461
|
+
/** Render the full command reference. */
|
|
412
462
|
function fullHelp() {
|
|
413
|
-
const
|
|
414
|
-
const
|
|
463
|
+
const verbs = KNOWN_VERBS.map((verb) => ` ${verb} ${VERB_FLAGS[verb]}\n ${VERB_SUMMARY[verb]}`);
|
|
464
|
+
const exits = EXIT_CODES.map(([code, meaning]) => ` ${code} ${meaning}`);
|
|
415
465
|
return [
|
|
416
466
|
"scaffold <verb> [options]",
|
|
417
467
|
"",
|
|
418
|
-
...
|
|
468
|
+
...verbs,
|
|
419
469
|
"",
|
|
420
470
|
SAFETY_BANNER,
|
|
421
471
|
"",
|
|
422
472
|
"exit codes:",
|
|
423
|
-
...
|
|
473
|
+
...exits
|
|
424
474
|
].join("\n");
|
|
425
475
|
}
|
|
426
|
-
/**
|
|
476
|
+
/** Render one command's help reference. */
|
|
427
477
|
function verbHelp(verb) {
|
|
428
|
-
const
|
|
478
|
+
const flags = VERB_FLAG_HELP[verb].map(([flag, meaning]) => ` ${flag.padEnd(20)}${meaning}`);
|
|
429
479
|
return [
|
|
430
480
|
`scaffold ${verb} ${VERB_FLAGS[verb]}`,
|
|
431
481
|
"",
|
|
432
482
|
VERB_SUMMARY[verb],
|
|
433
483
|
VERB_DRY_RUN_NOTE[verb],
|
|
434
484
|
"",
|
|
435
|
-
...
|
|
485
|
+
...flags,
|
|
436
486
|
"",
|
|
437
487
|
VERB_EXAMPLE[verb]
|
|
438
488
|
].join("\n");
|
|
439
489
|
}
|
|
440
|
-
/** Levenshtein edit distance
|
|
441
|
-
function editDistance(
|
|
442
|
-
const rows =
|
|
443
|
-
const
|
|
444
|
-
const table =
|
|
445
|
-
for (let
|
|
446
|
-
for (let
|
|
447
|
-
for (let
|
|
448
|
-
const cost =
|
|
449
|
-
|
|
490
|
+
/** Compute Levenshtein edit distance. */
|
|
491
|
+
function editDistance(left, right) {
|
|
492
|
+
const rows = left.length + 1;
|
|
493
|
+
const columns = right.length + 1;
|
|
494
|
+
const table = new Uint32Array(rows * columns);
|
|
495
|
+
for (let row = 0; row < rows; row += 1) table[row * columns] = row;
|
|
496
|
+
for (let column = 0; column < columns; column += 1) table[column] = column;
|
|
497
|
+
for (let row = 1; row < rows; row += 1) for (let column = 1; column < columns; column += 1) {
|
|
498
|
+
const cost = left[row - 1] === right[column - 1] ? 0 : 1;
|
|
499
|
+
const index = row * columns + column;
|
|
500
|
+
table[index] = Math.min((table[index - columns] ?? 0) + 1, (table[index - 1] ?? 0) + 1, (table[index - columns - 1] ?? 0) + cost);
|
|
450
501
|
}
|
|
451
|
-
return table[rows - 1]
|
|
502
|
+
return table[rows * columns - 1] ?? 0;
|
|
452
503
|
}
|
|
453
|
-
/**
|
|
504
|
+
/** Find the nearest string by edit distance. */
|
|
454
505
|
function nearest(input, set) {
|
|
455
506
|
let best;
|
|
456
|
-
let
|
|
507
|
+
let distance = Number.POSITIVE_INFINITY;
|
|
457
508
|
for (const candidate of set) {
|
|
458
|
-
const
|
|
459
|
-
if (
|
|
460
|
-
|
|
509
|
+
const candidateDistance = editDistance(input, candidate);
|
|
510
|
+
if (candidateDistance < distance) {
|
|
511
|
+
distance = candidateDistance;
|
|
461
512
|
best = candidate;
|
|
462
513
|
}
|
|
463
514
|
}
|
|
464
515
|
return best;
|
|
465
516
|
}
|
|
466
|
-
/**
|
|
467
|
-
var RETIRED_VERBS = Object.freeze({
|
|
468
|
-
sync: "pull",
|
|
469
|
-
mirror: "fleet"
|
|
470
|
-
});
|
|
471
|
-
/** The unknown-command message — a retired-verb redirect when recognized, otherwise the nearest `KNOWN_VERBS` guess. */
|
|
517
|
+
/** Render an unknown command with a nearest-command hint. */
|
|
472
518
|
function didYouMean(command) {
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
const guess = nearest(command, [...KNOWN_VERBS]);
|
|
476
|
-
return guess === void 0 ? `unknown command "${command}"` : `unknown command "${command}" — did you mean "${guess}"?`;
|
|
519
|
+
const suggestion = nearest(command, KNOWN_VERBS);
|
|
520
|
+
return suggestion === void 0 ? `unknown command "${command}"` : `unknown command "${command}" — did you mean "${suggestion}"?`;
|
|
477
521
|
}
|
|
478
|
-
/**
|
|
479
|
-
function
|
|
480
|
-
return
|
|
481
|
-
code,
|
|
482
|
-
message
|
|
483
|
-
} };
|
|
522
|
+
/** Render one prune preview line per exact path. */
|
|
523
|
+
function prunePreview(paths) {
|
|
524
|
+
return paths.map((path) => ` delete ${path}`);
|
|
484
525
|
}
|
|
485
|
-
/**
|
|
486
|
-
function
|
|
487
|
-
return {
|
|
488
|
-
name: summary.name,
|
|
489
|
-
surfaces: summary.surfaces,
|
|
490
|
-
host: summary.host,
|
|
491
|
-
template: summary.template,
|
|
492
|
-
computed: summary.computed,
|
|
493
|
-
applied
|
|
494
|
-
};
|
|
526
|
+
/** Render non-terminal guidance for missing input. */
|
|
527
|
+
function missingInput(input, verb) {
|
|
528
|
+
return `missing ${input} — pass it as a flag/argument, or run 'scaffold ${verb}' on a terminal to be guided`;
|
|
495
529
|
}
|
|
496
|
-
/**
|
|
497
|
-
function
|
|
498
|
-
return
|
|
530
|
+
/** Render an invalid package name. */
|
|
531
|
+
function invalidName(name, pattern) {
|
|
532
|
+
return `Package name "${name}" must match ${pattern}`;
|
|
499
533
|
}
|
|
500
|
-
/**
|
|
501
|
-
function
|
|
502
|
-
return
|
|
534
|
+
/** Render dependencies whose latest versions could not be resolved. */
|
|
535
|
+
function unresolvedVersion(names) {
|
|
536
|
+
return `could not resolve the latest version for ${names.map((name) => `"${name}"`).join(", ")} — check the name or pass name@range`;
|
|
503
537
|
}
|
|
504
|
-
/**
|
|
505
|
-
function repairJson(audit, result) {
|
|
506
|
-
return result === void 0 ? audit : {
|
|
507
|
-
...audit,
|
|
508
|
-
result
|
|
509
|
-
};
|
|
510
|
-
}
|
|
511
|
-
/** `fleet --json`'s value — a top-level ARRAY of per-repo objects, one entry per package. */
|
|
512
|
-
function fleetJson(entries) {
|
|
513
|
-
return entries;
|
|
514
|
-
}
|
|
515
|
-
/** `catalog --json`'s value — the entries, drift flag, and optional shrink count. */
|
|
516
|
-
function catalogJson(entries, drifted, shrink) {
|
|
517
|
-
return shrink === void 0 ? {
|
|
518
|
-
entries,
|
|
519
|
-
drift: drifted
|
|
520
|
-
} : {
|
|
521
|
-
entries,
|
|
522
|
-
drift: drifted,
|
|
523
|
-
shrink
|
|
524
|
-
};
|
|
525
|
-
}
|
|
526
|
-
/** `enabled` for `createStyler` — off under `NO_COLOR` or when the sink is not a TTY (both read by the caller). */
|
|
527
|
-
function chooseStyler(sinkIsTTY, noColor) {
|
|
528
|
-
return createStyler({ enabled: !noColor && sinkIsTTY });
|
|
529
|
-
}
|
|
530
|
-
/** Whether a long-running step should animate a spinner (TTY) or fall back to a one-line `status` (piped/non-TTY). */
|
|
531
|
-
function shouldSpin(sinkIsTTY) {
|
|
532
|
-
return sinkIsTTY;
|
|
533
|
-
}
|
|
534
|
-
/** One line per exact relative path that WOULD be deleted — printed before the prune confirm. */
|
|
535
|
-
function prunePreview(paths) {
|
|
536
|
-
return paths.map((path) => ` delete ${path}`);
|
|
537
|
-
}
|
|
538
|
-
/** Printed when the prune scan finds nothing to delete. */
|
|
539
|
-
var PRUNE_EMPTY = "no unexpected files to delete";
|
|
540
|
-
/** Printed on a non-TTY session when the prune question cannot be asked. */
|
|
541
|
-
function pruneSkipped() {
|
|
542
|
-
return "prune skipped — not a terminal; add --apply (or --yes) to delete non-interactively";
|
|
543
|
-
}
|
|
544
|
-
/** Non-TTY usage-error guidance for a missing required input. */
|
|
545
|
-
function missingInput(what, verb) {
|
|
546
|
-
return `missing ${what} — pass it as a flag/argument, or run 'scaffold ${verb}' on a terminal to be guided`;
|
|
547
|
-
}
|
|
548
|
-
/** Printed when `audit` cannot establish the template source for the unexpected-file scan — the audit degrades to the un-scanned findings instead of crashing. */
|
|
549
|
-
function scanSkipped() {
|
|
550
|
-
return "unexpected-file scanning skipped — couldn't establish the template source";
|
|
551
|
-
}
|
|
552
|
-
/** Usage-error message for a `new` package name that fails `PACKAGE_NAME_PATTERN` — same shape the interactive prompt enforces. */
|
|
553
|
-
function invalidName(name, pattern) {
|
|
554
|
-
return `Package name "${name}" must match ${pattern}`;
|
|
555
|
-
}
|
|
556
|
-
/** `new`'s hard failure when `sync.versions` cannot resolve a latest version for one or more `--deps` names — names every unresolved package plainly so a `^` (unresolved-latest) range can never be silently written. */
|
|
557
|
-
function unresolvedVersion(names) {
|
|
558
|
-
return `could not resolve the latest version for ${names.map((name) => `"${name}"`).join(", ")} — check the name or pass name@range`;
|
|
559
|
-
}
|
|
560
|
-
/** Printed when every finding is `generated` drift — repair does not touch generated files. */
|
|
538
|
+
/** Render drift that belongs to generated artifacts. */
|
|
561
539
|
function generatedNote(count) {
|
|
562
540
|
return `${countPart(count, "finding")} in generated files — these are regenerated, not hand-edited; repair does not touch them`;
|
|
563
541
|
}
|
|
564
|
-
/**
|
|
542
|
+
/** Render live dependency freshness tallies. */
|
|
565
543
|
function auditLiveNote(current, behind, failed) {
|
|
566
544
|
return `live: ${countPart(current, "current")}, ${countPart(behind, "behind")}, ${countPart(failed, "failed")}`;
|
|
567
545
|
}
|
|
568
|
-
/**
|
|
546
|
+
/** Render whether an audit compared content or presence. */
|
|
569
547
|
function comparisonLine(aware) {
|
|
570
|
-
return aware ? "comparing: file contents for
|
|
548
|
+
return aware ? "comparing: file contents for host-owned files" : "comparing: file names only for host-owned files (no vendored source found)";
|
|
571
549
|
}
|
|
572
|
-
/**
|
|
573
|
-
function fleetCiSkipped() {
|
|
574
|
-
return "ci.yml: left unchanged — each package customizes its own CI; run 'scaffold repair --apply' inside that package to update it";
|
|
575
|
-
}
|
|
576
|
-
/** `catalog`'s verdict line — clean or drifted. */
|
|
550
|
+
/** Render catalog's final verdict. */
|
|
577
551
|
function catalogVerdict(clean) {
|
|
578
552
|
return clean ? "catalog: clean" : "catalog: drifted — pass --apply to write";
|
|
579
553
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
var sink = createServerSink();
|
|
583
|
-
var sinkIsTTY = process.stdout.isTTY === true;
|
|
584
|
-
var styler = chooseStyler(sinkIsTTY, process.env.NO_COLOR !== void 0);
|
|
585
|
-
var reporter = createReporter({
|
|
586
|
-
sink,
|
|
587
|
-
width: sink.columns,
|
|
588
|
-
styler
|
|
589
|
-
});
|
|
590
|
-
/**
|
|
591
|
-
* Widen Node's default trusted-issuer set to include the OS certificate
|
|
592
|
-
* store, so `fetch` behind a corporate TLS-inspecting proxy behaves like npm
|
|
593
|
-
* (`cafile`) and browsers (OS trust store) instead of failing with
|
|
594
|
-
* `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` against Node's bundled CA list alone.
|
|
595
|
-
* Feature-detected (`tls.getCACertificates` / `tls.setDefaultCACertificates`
|
|
596
|
-
* ship on Node ≈22.16+/24.5+; this package's floor is `>=22`) and wrapped in
|
|
597
|
-
* try/catch — any failure is a silent no-op, never a crash. This only ADDS
|
|
598
|
-
* trusted issuers; it never touches `rejectUnauthorized` or
|
|
599
|
-
* `NODE_TLS_REJECT_UNAUTHORIZED`, so certificate verification stays on.
|
|
600
|
-
*/
|
|
601
|
-
function trustSystemCertificates() {
|
|
602
|
-
if (typeof tls.getCACertificates !== "function" || typeof tls.setDefaultCACertificates !== "function") return;
|
|
603
|
-
try {
|
|
604
|
-
const merged = /* @__PURE__ */ new Set([...tls.getCACertificates("default"), ...tls.getCACertificates("system")]);
|
|
605
|
-
tls.setDefaultCACertificates([...merged]);
|
|
606
|
-
} catch {}
|
|
607
|
-
}
|
|
608
|
-
/**
|
|
609
|
-
* The one sentinel that unwinds the whole command dispatch to a chosen exit
|
|
610
|
-
* code (H4/Windows) — thrown instead of calling `process.exit`, so every
|
|
611
|
-
* `finally` between the throw site and the top-level driver still runs
|
|
612
|
-
* (entity teardown drains naturally instead of racing process teardown).
|
|
613
|
-
* Caught exactly once, at the bottom of this file.
|
|
614
|
-
*/
|
|
615
|
-
var CliExit = class extends Error {
|
|
616
|
-
code;
|
|
617
|
-
constructor(code) {
|
|
618
|
-
super(`cli-exit:${String(code)}`);
|
|
619
|
-
this.code = code;
|
|
620
|
-
}
|
|
621
|
-
};
|
|
622
|
-
/** Halt command dispatch with `code` (H4) — never `process.exit`; unwinds through every `finally` first. */
|
|
623
|
-
function halt(code) {
|
|
624
|
-
throw new CliExit(code);
|
|
625
|
-
}
|
|
626
|
-
/** Render a caught error as a clean one-line message — a `ScaffoldError`'s code, or a bare message otherwise. */
|
|
627
|
-
function describe(error) {
|
|
554
|
+
/** Render a caught error without leaking implementation detail. */
|
|
555
|
+
function describeError(error) {
|
|
628
556
|
if (isScaffoldError(error)) return `[${error.code}] ${error.message}`;
|
|
629
557
|
return error instanceof Error ? error.message : "unknown error";
|
|
630
558
|
}
|
|
631
|
-
/**
|
|
632
|
-
function
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
* can still honor `--json` for an error that escapes every verb runner's own
|
|
639
|
-
* handling, emitting exactly one JSON error envelope instead of prose.
|
|
640
|
-
*/
|
|
641
|
-
var sessionJson = false;
|
|
642
|
-
/** A general operation failure (H1: exit 1) — a prose status line, or the one JSON error envelope under `--json`. */
|
|
643
|
-
function fail(message, json) {
|
|
644
|
-
if (json) writeJson(errorEnvelope("ERROR", message));
|
|
645
|
-
else reporter.status("error", message);
|
|
646
|
-
halt(1);
|
|
647
|
-
}
|
|
648
|
-
/**
|
|
649
|
-
* A general operation failure FROM A CAUGHT ERROR (H1: exit 1) — the real
|
|
650
|
-
* `ScaffoldError` code when available ('ERROR' last resort), prose (via
|
|
651
|
-
* `describe`, which still carries the bracketed code for a human reader) or
|
|
652
|
-
* the one JSON error envelope under `--json` (code and message kept
|
|
653
|
-
* SEPARATE — never double-encoding the code into the message text).
|
|
654
|
-
*/
|
|
655
|
-
function failError(error, json) {
|
|
656
|
-
if (json) writeJson(errorEnvelope(isScaffoldError(error) ? error.code : "ERROR", isScaffoldError(error) ? error.message : describe(error)));
|
|
657
|
-
else reporter.status("error", describe(error));
|
|
658
|
-
halt(1);
|
|
659
|
-
}
|
|
660
|
-
/** A usage error (bad flag value, unknown verb — exit 2) — stderr prose, or the one JSON error envelope under `--json`. */
|
|
661
|
-
function usageFail(message, json) {
|
|
662
|
-
if (json) writeJson(errorEnvelope("USAGE", message));
|
|
663
|
-
else process.stderr.write(`${message}\n`);
|
|
664
|
-
halt(2);
|
|
665
|
-
}
|
|
666
|
-
function resolveReal(path) {
|
|
667
|
-
if (existsSync(path)) return realpathSync(path);
|
|
668
|
-
const parent = dirname(path);
|
|
669
|
-
if (parent === path) return path;
|
|
670
|
-
return join(resolveReal(parent), relative(parent, path));
|
|
671
|
-
}
|
|
672
|
-
/**
|
|
673
|
-
* Confine a WRITE destination to the current working directory (global-CLI
|
|
674
|
-
* safety): `new`'s resolved target, `--target` on pull/audit/repair/catalog,
|
|
675
|
-
* and `fleet`'s always-cwd root all pass through here before use. A
|
|
676
|
-
* READ-ONLY source (`--from`) is exempt — sibling-repo sourcing from outside
|
|
677
|
-
* the cwd is legitimate. Equal to the cwd or nested beneath it passes;
|
|
678
|
-
* anything else is a coded `INVALID` failure (AGENTS §12), never a silent clamp.
|
|
679
|
-
*
|
|
680
|
-
* @returns The resolved (non-realpath'd) absolute path, for use as the
|
|
681
|
-
* verb's destination.
|
|
682
|
-
*/
|
|
683
|
-
function containDestination(candidate) {
|
|
684
|
-
const resolvedCwd = resolveReal(resolve(process.cwd()));
|
|
685
|
-
const resolvedCandidate = resolveReal(resolve(candidate));
|
|
686
|
-
if (resolvedCandidate !== resolvedCwd && !resolvedCandidate.startsWith(resolvedCwd + sep)) throw new ScaffoldError("INVALID", `Target "${candidate}" escapes the working directory — run scaffold from the directory you want to write beneath.`, { path: candidate });
|
|
687
|
-
return resolve(candidate);
|
|
688
|
-
}
|
|
689
|
-
/** `containDestination`, halting (H1/`fail`) on a coded escape instead of throwing — the shared entry every runner's target resolution goes through. */
|
|
690
|
-
function containOrFail(candidate, json) {
|
|
691
|
-
try {
|
|
692
|
-
return containDestination(candidate);
|
|
693
|
-
} catch (error) {
|
|
694
|
-
failError(error, json);
|
|
695
|
-
}
|
|
559
|
+
/** Resolve and confine one write destination to its invocation root. */
|
|
560
|
+
function containDestination(root, candidate) {
|
|
561
|
+
if (!isFilesystemPath(root) || !isFilesystemPath(candidate)) throw new ScaffoldError("INVALID", "Target paths must not contain control characters");
|
|
562
|
+
const contained = attempt(() => resolvePhysicalPath(root, candidate, "INVALID", "working directory"));
|
|
563
|
+
if (contained.success) return contained.value;
|
|
564
|
+
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 });
|
|
565
|
+
throw contained.error;
|
|
696
566
|
}
|
|
697
|
-
/**
|
|
698
|
-
function
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
if (!scaffolding.plan) fail(scaffolding.questions.map((question) => question.text).join("; "), json);
|
|
703
|
-
return scaffolding.plan;
|
|
704
|
-
} finally {
|
|
705
|
-
compiler.destroy();
|
|
706
|
-
}
|
|
567
|
+
/** Return one dependency-token issue, or absence when it is valid. */
|
|
568
|
+
function orkestrelTokenIssue(normalized, catalog) {
|
|
569
|
+
if (catalog === void 0) return DEPENDENCY_NAME_PATTERN.test(normalized) ? void 0 : unknownOrkestrelToken(normalized, void 0);
|
|
570
|
+
if (catalog.includes(normalized)) return void 0;
|
|
571
|
+
return unknownOrkestrelToken(normalized, nearest(normalized, catalog));
|
|
707
572
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region src/bin/parsers.ts
|
|
575
|
+
/** Parse a strict command-line argument vector. */
|
|
576
|
+
function parseArguments(argv) {
|
|
577
|
+
if (argv.some((argument) => !isTerminalText(argument))) throw new ScaffoldError("INVALID", "Command arguments must not contain control characters");
|
|
712
578
|
return parseArgs({
|
|
713
|
-
args,
|
|
579
|
+
args: argv[0] === "--" ? argv.slice(1) : [...argv],
|
|
714
580
|
allowPositionals: true,
|
|
715
581
|
options: {
|
|
716
|
-
|
|
582
|
+
src: { type: "string" },
|
|
583
|
+
app: { type: "string" },
|
|
717
584
|
deps: { type: "string" },
|
|
718
585
|
groups: { type: "string" },
|
|
719
586
|
target: { type: "string" },
|
|
@@ -757,783 +624,878 @@ function parseArguments() {
|
|
|
757
624
|
}
|
|
758
625
|
});
|
|
759
626
|
}
|
|
760
|
-
/**
|
|
761
|
-
function isVerb(value) {
|
|
762
|
-
return KNOWN_VERBS.some((verb) => verb === value);
|
|
763
|
-
}
|
|
764
|
-
/**
|
|
765
|
-
* Best-effort `hydratePlan` — used by `audit` / `repair` / `fleet` so a
|
|
766
|
-
* missing DEFAULT vendored-source root degrades to presence-only auditing
|
|
767
|
-
* instead of failing the verb. An EXPLICITLY-passed `--from` that does not
|
|
768
|
-
* resolve to a usable directory is NOT downgraded silently — that is a coded
|
|
769
|
-
* `TARGET` failure (M1), since the caller named that source on purpose.
|
|
770
|
-
*/
|
|
771
|
-
function hydrateBestEffort(plan, host, explicit) {
|
|
772
|
-
if (!existsSync(host)) {
|
|
773
|
-
if (explicit) throw new ScaffoldError("TARGET", `--from does not resolve to a directory: ${host}`, { host });
|
|
774
|
-
return {
|
|
775
|
-
plan,
|
|
776
|
-
aware: false
|
|
777
|
-
};
|
|
778
|
-
}
|
|
779
|
-
try {
|
|
780
|
-
return {
|
|
781
|
-
plan: hydratePlan(plan, host),
|
|
782
|
-
aware: true
|
|
783
|
-
};
|
|
784
|
-
} catch (error) {
|
|
785
|
-
if (isScaffoldError(error) && error.code === "TARGET") return {
|
|
786
|
-
plan,
|
|
787
|
-
aware: false
|
|
788
|
-
};
|
|
789
|
-
throw error;
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
/**
|
|
793
|
-
* Merge a `pruneTargets` scan into `audit` as `foreign` findings — pure
|
|
794
|
-
* object-spread composition in the BIN only (`src/core`'s `diffPlan` is never
|
|
795
|
-
* modified/reimplemented). Every unexpected path becomes one
|
|
796
|
-
* `'orchestration'`-group `foreign` finding (the `Group` every `PRUNE_DIRECTORIES`
|
|
797
|
-
* entry — `.claude/agents`, `scripts` — belongs to); any scan hit makes the
|
|
798
|
-
* merged audit unclean, so an "unexpected file" is honestly counted as drift
|
|
799
|
-
* (exit 1) instead of the structurally-always-zero `diffPlan.foreign`.
|
|
800
|
-
*/
|
|
801
|
-
function withForeignScan(audit, target, host) {
|
|
802
|
-
const paths = pruneTargets(target, host);
|
|
803
|
-
if (paths.length === 0) return audit;
|
|
804
|
-
const findings = paths.map((path) => ({
|
|
805
|
-
path,
|
|
806
|
-
group: "orchestration",
|
|
807
|
-
drift: "foreign"
|
|
808
|
-
}));
|
|
809
|
-
return {
|
|
810
|
-
...audit,
|
|
811
|
-
clean: false,
|
|
812
|
-
foreign: audit.foreign + paths.length,
|
|
813
|
-
findings: [...audit.findings, ...findings]
|
|
814
|
-
};
|
|
815
|
-
}
|
|
816
|
-
/**
|
|
817
|
-
* `withForeignScan`, degrading instead of crashing (F3): `pruneTargets` throws
|
|
818
|
-
* a coded `TARGET` failure when a prune directory exists under `target` but
|
|
819
|
-
* `host` cannot positively establish its allowlist (`vendoredPruneSet`'s
|
|
820
|
-
* fail-closed contract) — an audit should report the un-scanned findings and
|
|
821
|
-
* say scanning was skipped, never crash on this alone (`runFleet` already
|
|
822
|
-
* shields its own per-repo audit the same way).
|
|
823
|
-
*/
|
|
824
|
-
function withForeignScanSafe(audit, target, host) {
|
|
825
|
-
try {
|
|
826
|
-
return {
|
|
827
|
-
audit: withForeignScan(audit, target, host),
|
|
828
|
-
skipped: false
|
|
829
|
-
};
|
|
830
|
-
} catch (error) {
|
|
831
|
-
if (isScaffoldError(error) && error.code === "TARGET") return {
|
|
832
|
-
audit,
|
|
833
|
-
skipped: true
|
|
834
|
-
};
|
|
835
|
-
throw error;
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
/**
|
|
839
|
-
* `repair`'s own scope is host-only, but the caller compiles the FULL plan anyway — diff it too
|
|
840
|
-
* (cheap, local, no network) so a clean host verdict can point at drift OUTSIDE repair's reach.
|
|
841
|
-
* The count feeds render.ts's `scopeNote`.
|
|
842
|
-
*/
|
|
843
|
-
function repairOutsideCount(compiled, target) {
|
|
844
|
-
const full = diffPlan(compiled, readTarget(target, compiled.artifacts.map((artifact) => artifact.path)));
|
|
845
|
-
return full.drifted + full.missing + full.foreign;
|
|
846
|
-
}
|
|
847
|
-
/** One `fleet --json` entry — `undefined` counts (a failed repo never got an audit) fall back to zero. */
|
|
848
|
-
function fleetEntry(name, counts, failed) {
|
|
849
|
-
return {
|
|
850
|
-
name,
|
|
851
|
-
drifted: counts?.drifted ?? 0,
|
|
852
|
-
missing: counts?.missing ?? 0,
|
|
853
|
-
foreign: counts?.foreign ?? 0,
|
|
854
|
-
failed
|
|
855
|
-
};
|
|
856
|
-
}
|
|
857
|
-
/** Reject a cancelled prompt (ctrl-c) with the shared `CANCELLED_MESSAGE` — exit 1, nothing written. */
|
|
858
|
-
async function guarded(promise) {
|
|
859
|
-
try {
|
|
860
|
-
return await promise;
|
|
861
|
-
} catch (error) {
|
|
862
|
-
if (isTerminalError(error) && error.code === "CANCEL") {
|
|
863
|
-
reporter.line(CANCELLED_MESSAGE);
|
|
864
|
-
halt(1);
|
|
865
|
-
}
|
|
866
|
-
throw error;
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
/**
|
|
870
|
-
* The shared write-confirmation gate every verb calls before it touches disk.
|
|
871
|
-
* `--apply` writes without asking; `--json` (without `--apply`) is a pure
|
|
872
|
-
* dry-run and NEVER prompts; `--yes` auto-answers yes; otherwise a real
|
|
873
|
-
* confirm with `default: false` (EOF on stdin resolves to the default).
|
|
874
|
-
*/
|
|
875
|
-
async function resolveApply(terminal, message, values, json) {
|
|
876
|
-
if (values.apply) return true;
|
|
877
|
-
if (json) return false;
|
|
878
|
-
if (values.yes) return true;
|
|
879
|
-
return guarded(terminal.confirm({
|
|
880
|
-
message,
|
|
881
|
-
default: false
|
|
882
|
-
}));
|
|
883
|
-
}
|
|
884
|
-
/**
|
|
885
|
-
* The SECOND, separate confirm for `--prune`-eligible deletions — never
|
|
886
|
-
* bundled into `resolveApply`'s question. `--yes` only auto-answers this
|
|
887
|
-
* when `--prune` was also passed (it never enables pruning by itself).
|
|
888
|
-
*/
|
|
889
|
-
async function resolvePrune(terminal, message, values, json) {
|
|
890
|
-
if (!values.prune) return false;
|
|
891
|
-
if (values.apply) return true;
|
|
892
|
-
if (json) return false;
|
|
893
|
-
if (values.yes) return true;
|
|
894
|
-
if (!sinkIsTTY) {
|
|
895
|
-
reporter.line(pruneSkipped());
|
|
896
|
-
return false;
|
|
897
|
-
}
|
|
898
|
-
return guarded(terminal.confirm({
|
|
899
|
-
message,
|
|
900
|
-
default: false
|
|
901
|
-
}));
|
|
902
|
-
}
|
|
903
|
-
/** A spinner for a long-running write step — `undefined` under `--json` or off a TTY sink (render.ts's `shouldSpin`). */
|
|
904
|
-
function createSpinnerMaybe(message, json) {
|
|
905
|
-
return json || !shouldSpin(sinkIsTTY) ? void 0 : createSpinner({
|
|
906
|
-
message,
|
|
907
|
-
sink,
|
|
908
|
-
styler
|
|
909
|
-
});
|
|
910
|
-
}
|
|
911
|
-
/** Announce a successful write — the spinner's own success line, or a plain `reporter.status` without one (never under `--json`). */
|
|
912
|
-
function announceApply(spinner, json, message) {
|
|
913
|
-
if (spinner) spinner.success(message);
|
|
914
|
-
else if (!json) reporter.status("success", message);
|
|
915
|
-
}
|
|
916
|
-
/** Announce a failed write and halt(1) — the spinner's own failure line (if any), then the shared `fail`. */
|
|
917
|
-
function announceFailure(spinner, json, error) {
|
|
918
|
-
const message = describe(error);
|
|
919
|
-
if (spinner) spinner.failure(message);
|
|
920
|
-
fail(message, json);
|
|
921
|
-
}
|
|
922
|
-
/** Split a comma-separated token list, trimming and dropping empties — the parse `new`'s dependency prompt goes through. */
|
|
627
|
+
/** Split a comma-separated token list, trimming and dropping empty entries. */
|
|
923
628
|
function splitTokens(raw) {
|
|
924
629
|
return raw.split(",").map((token) => token.trim()).filter((token) => token.length > 0);
|
|
925
630
|
}
|
|
926
|
-
/** Normalize
|
|
631
|
+
/** Normalize an Orkestrel dependency token to its full package name. */
|
|
927
632
|
function normalizeOrkestrelToken(token) {
|
|
928
633
|
return token.startsWith("@orkestrel/") ? token : `@orkestrel/${token}`;
|
|
929
634
|
}
|
|
930
635
|
/**
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
*
|
|
636
|
+
* Parse and resolve a pull dependency selection against the target manifest.
|
|
637
|
+
*
|
|
638
|
+
* @param raw - The comma-separated full package names, or `undefined` for all.
|
|
639
|
+
* @param declared - The target's declared Orkestrel dependencies.
|
|
640
|
+
* @returns `undefined` for all dependencies, otherwise the exact selected records.
|
|
641
|
+
* @throws `ScaffoldError('INVALID')` for empty, malformed, repeated, or undeclared names.
|
|
936
642
|
*/
|
|
937
|
-
function
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
}
|
|
643
|
+
function parsePullDependencies(raw, declared) {
|
|
644
|
+
if (raw === void 0) return void 0;
|
|
645
|
+
const names = raw.split(",").map((name) => name.trim());
|
|
646
|
+
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");
|
|
647
|
+
const selected = declared.filter((dependency) => names.includes(dependency.name));
|
|
648
|
+
const found = new Set(selected.map((dependency) => dependency.name));
|
|
649
|
+
const missing = names.filter((name) => !found.has(name));
|
|
650
|
+
if (missing.length > 0) throw new ScaffoldError("INVALID", `Pull dependencies are not declared by the target: ${missing.join(", ")}`, { dependencies: missing });
|
|
651
|
+
return selected;
|
|
945
652
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
return
|
|
653
|
+
//#endregion
|
|
654
|
+
//#region src/bin/validators.ts
|
|
655
|
+
/** Narrow a positional command to the command-line interface's vocabulary. */
|
|
656
|
+
function isVerb(value) {
|
|
657
|
+
return KNOWN_VERBS.some((verb) => verb === value);
|
|
951
658
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
659
|
+
//#endregion
|
|
660
|
+
//#region src/bin/CLI.ts
|
|
661
|
+
/** Stateful command-line orchestration and its process boundary. */
|
|
662
|
+
var CLI = class {
|
|
663
|
+
#sink = createServerSink();
|
|
664
|
+
#tty = process.stdout.isTTY === true;
|
|
665
|
+
#styler = createStyler({ enabled: process.env.NO_COLOR === void 0 && this.#tty });
|
|
666
|
+
#reporter = createReporter({
|
|
667
|
+
sink: this.#sink,
|
|
668
|
+
width: this.#sink.columns,
|
|
669
|
+
styler: this.#styler
|
|
670
|
+
});
|
|
671
|
+
#json = false;
|
|
672
|
+
/** Execute one command-line argument vector. */
|
|
673
|
+
async run(argv) {
|
|
674
|
+
this.#trust();
|
|
675
|
+
try {
|
|
676
|
+
await this.#dispatch(argv);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
if (error instanceof CLIExitError) process.exitCode = error.code;
|
|
679
|
+
else if (this.#json) {
|
|
680
|
+
const code = isScaffoldError(error) ? error.code : "ERROR";
|
|
681
|
+
const message = isScaffoldError(error) ? error.message : describeError(error);
|
|
682
|
+
this.#write(errorEnvelopeOf(code, message));
|
|
683
|
+
process.exitCode = 1;
|
|
684
|
+
} else {
|
|
685
|
+
this.#reporter.status("error", describeError(error));
|
|
686
|
+
process.exitCode = 1;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
964
689
|
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
else surfaceInput = await guarded(terminal.checkbox({
|
|
983
|
-
message: "Surfaces",
|
|
984
|
-
choices: surfaceChoices(),
|
|
985
|
-
min: 1
|
|
986
|
-
}));
|
|
987
|
-
const unrecognizedSurface = surfaceInput.filter((candidate) => !SURFACES.some((surface) => surface === candidate));
|
|
988
|
-
if (unrecognizedSurface.length > 0) usageFail(`Surface "${unrecognizedSurface.join("\", \"")}" is not recognized`, json);
|
|
989
|
-
const surfaces = SURFACES.filter((surface) => surfaceInput.includes(surface));
|
|
990
|
-
const destination = containOrFail(values.target ?? `./${name}`, json);
|
|
991
|
-
let depNames;
|
|
992
|
-
if (values.deps !== void 0) {
|
|
993
|
-
depNames = values.deps.split(",").filter((depName) => depName.length > 0);
|
|
994
|
-
const badDep = depNames.find((depName) => !DEPENDENCY_NAME_PATTERN.test(depName));
|
|
995
|
-
if (badDep !== void 0) usageFail(`Dependency name "${badDep}" must match ${DEPENDENCY_NAME_PATTERN.source}`, json);
|
|
996
|
-
} else if (json || !sinkIsTTY) depNames = [];
|
|
997
|
-
else {
|
|
998
|
-
const catalog = resolveCatalogNames(values.from?.[0] ?? hostRoot());
|
|
999
|
-
if (catalog === void 0) reporter.line(catalogUnresolvedNote());
|
|
1000
|
-
depNames = await promptOrkestrelDeps(terminal, catalog);
|
|
690
|
+
/**
|
|
691
|
+
* Widen Node's default trusted-issuer set to include the OS certificate
|
|
692
|
+
* store, so `fetch` behind a corporate TLS-inspecting proxy behaves like npm
|
|
693
|
+
* (`cafile`) and browsers (OS trust store) instead of failing with
|
|
694
|
+
* `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` against Node's bundled CA list alone.
|
|
695
|
+
* Feature-detected (`tls.getCACertificates` / `tls.setDefaultCACertificates`
|
|
696
|
+
* ship on Node ≈22.16+/24.5+; this package's floor is `>=22.12.0`) and captured
|
|
697
|
+
* through `attempt` — any failure is a silent no-op, never a crash. This only ADDS
|
|
698
|
+
* trusted issuers; it never touches `rejectUnauthorized` or
|
|
699
|
+
* `NODE_TLS_REJECT_UNAUTHORIZED`, so certificate verification stays on.
|
|
700
|
+
*/
|
|
701
|
+
#trust() {
|
|
702
|
+
if (typeof tls.getCACertificates !== "function" || typeof tls.setDefaultCACertificates !== "function") return;
|
|
703
|
+
attempt(() => {
|
|
704
|
+
const merged = /* @__PURE__ */ new Set([...tls.getCACertificates("default"), ...tls.getCACertificates("system")]);
|
|
705
|
+
tls.setDefaultCACertificates([...merged]);
|
|
706
|
+
});
|
|
1001
707
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
versions = await sync.versions(depNames.map((depName) => dependency(depName, "*")));
|
|
1006
|
-
} finally {
|
|
1007
|
-
sync.destroy();
|
|
708
|
+
/** Write ONE machine-readable JSON value to stdout — the entire `--json` output contract. */
|
|
709
|
+
#write(value) {
|
|
710
|
+
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
1008
711
|
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
dependencies: deps
|
|
1015
|
-
}), json);
|
|
1016
|
-
const summary = planToSummary(plan);
|
|
1017
|
-
if (!json) {
|
|
1018
|
-
reporter.section("Plan");
|
|
1019
|
-
reporter.table(newPlanTable(summary));
|
|
1020
|
-
reporter.line(newPlanPreview(name));
|
|
712
|
+
/** Report a general operation failure with exit 1 as prose or one JSON error envelope. */
|
|
713
|
+
#fail(message, json) {
|
|
714
|
+
if (json) this.#write(errorEnvelopeOf("ERROR", message));
|
|
715
|
+
else this.#reporter.status("error", message);
|
|
716
|
+
throw new CLIExitError(1);
|
|
1021
717
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
718
|
+
/**
|
|
719
|
+
* A general operation failure from a caught error — the real
|
|
720
|
+
* `ScaffoldError` code when available ('ERROR' last resort), prose (via
|
|
721
|
+
* `describe`, which still carries the bracketed code for a human reader) or
|
|
722
|
+
* the one JSON error envelope under `--json` (code and message kept
|
|
723
|
+
* SEPARATE — never double-encoding the code into the message text).
|
|
724
|
+
*/
|
|
725
|
+
#error(error, json) {
|
|
726
|
+
if (json) {
|
|
727
|
+
const code = isScaffoldError(error) ? error.code : "ERROR";
|
|
728
|
+
const message = isScaffoldError(error) ? error.message : describeError(error);
|
|
729
|
+
this.#write(errorEnvelopeOf(code, message));
|
|
730
|
+
} else this.#reporter.status("error", describeError(error));
|
|
731
|
+
throw new CLIExitError(1);
|
|
1027
732
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
const count = result.written.length + result.copied.length;
|
|
1034
|
-
if (json) writeJson(newJson(summary, true));
|
|
1035
|
-
else announceApply(spinner, json, newApplySuccess(count, name));
|
|
1036
|
-
} catch (error) {
|
|
1037
|
-
announceFailure(spinner, json, error);
|
|
1038
|
-
} finally {
|
|
1039
|
-
materializer.destroy();
|
|
733
|
+
/** A usage error (bad flag value, unknown verb — exit 2) — stderr prose, or the one JSON error envelope under `--json`. */
|
|
734
|
+
#usage(message, json) {
|
|
735
|
+
if (json) this.#write(errorEnvelopeOf("USAGE", message));
|
|
736
|
+
else process.stderr.write(`${message}\n`);
|
|
737
|
+
throw new CLIExitError(2);
|
|
1040
738
|
}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
739
|
+
/** Contain a destination and report any coded escape through the shared CLI error path. */
|
|
740
|
+
#contain(candidate, json) {
|
|
741
|
+
const contained = attempt(() => containDestination(process.cwd(), candidate));
|
|
742
|
+
if (contained.success) return contained.value;
|
|
743
|
+
this.#error(contained.error, json);
|
|
744
|
+
}
|
|
745
|
+
/** Compile a spec and report unresolved blocking questions through the shared CLI error path. */
|
|
746
|
+
#compile(spec, json) {
|
|
747
|
+
const compiler = createCompiler();
|
|
1050
748
|
try {
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
const versions = await sync.versions(deps);
|
|
1056
|
-
const failed = [...guides, ...versions].filter((entry) => entry.freshness === "missing" || entry.freshness === "failed").length;
|
|
1057
|
-
report = {
|
|
1058
|
-
target,
|
|
1059
|
-
guides,
|
|
1060
|
-
versions,
|
|
1061
|
-
clean: failed === 0 && guides.every((guide) => guide.freshness === "current") && versions.every((version) => version.freshness === "current"),
|
|
1062
|
-
failed
|
|
1063
|
-
};
|
|
1064
|
-
} else report = await sync.pull(target);
|
|
1065
|
-
} catch (error) {
|
|
1066
|
-
failError(error, json);
|
|
1067
|
-
}
|
|
1068
|
-
if (!json) {
|
|
1069
|
-
reporter.table(pullTable(report));
|
|
1070
|
-
for (const line of pullCauseNotes(report)) reporter.line(line);
|
|
1071
|
-
reporter.line(pullVerdict(report));
|
|
1072
|
-
}
|
|
1073
|
-
const toWrite = [...report.guides, ...report.versions].filter((entry) => entry.freshness !== "current").length;
|
|
1074
|
-
const terminal = createTerminal();
|
|
1075
|
-
const proceed = toWrite > 0 ? await resolveApply(terminal, applyConfirmMessage(toWrite), values, json) : false;
|
|
1076
|
-
if (proceed) {
|
|
1077
|
-
const spinner = createSpinnerMaybe("writing mirrors", json);
|
|
1078
|
-
spinner?.start();
|
|
1079
|
-
try {
|
|
1080
|
-
const written = await sync.write(report, target);
|
|
1081
|
-
if (json) writeJson(pullJson(report));
|
|
1082
|
-
else announceApply(spinner, json, pullSuccess(written.length));
|
|
1083
|
-
} catch (error) {
|
|
1084
|
-
announceFailure(spinner, json, error);
|
|
749
|
+
const scaffolding = compiler.compile(spec);
|
|
750
|
+
if (!scaffolding.plan) {
|
|
751
|
+
const message = scaffolding.questions.map((question) => question.text).join("; ");
|
|
752
|
+
this.#fail(message, json);
|
|
1085
753
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
754
|
+
return scaffolding.plan;
|
|
755
|
+
} finally {
|
|
756
|
+
compiler.destroy();
|
|
757
|
+
}
|
|
1090
758
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
759
|
+
/**
|
|
760
|
+
* Merge a `pruneTargets` scan into `audit` as `foreign` findings — pure
|
|
761
|
+
* object-spread composition in the BIN only (`src/core`'s `diffPlan` is never
|
|
762
|
+
* modified/reimplemented). Every unexpected path becomes one
|
|
763
|
+
* `'orchestration'`-group `foreign` finding (the `Group` every `PRUNE_DIRECTORIES`
|
|
764
|
+
* entry—`.claude/agents`, `.codex/agents`, `scripts`—belongs to); any scan hit makes the
|
|
765
|
+
* merged audit unclean, so an "unexpected file" is honestly counted as drift
|
|
766
|
+
* (exit 1) instead of the structurally-always-zero `diffPlan.foreign`.
|
|
767
|
+
*/
|
|
768
|
+
#scan(audit, target, host) {
|
|
769
|
+
const paths = pruneTargets(target, host);
|
|
770
|
+
if (paths.length === 0) return audit;
|
|
771
|
+
const findings = paths.map((path) => ({
|
|
772
|
+
path,
|
|
773
|
+
group: "orchestration",
|
|
774
|
+
drift: "foreign"
|
|
775
|
+
}));
|
|
776
|
+
return {
|
|
777
|
+
...audit,
|
|
778
|
+
clean: false,
|
|
779
|
+
foreign: audit.foreign + paths.length,
|
|
780
|
+
findings: [...audit.findings, ...findings]
|
|
781
|
+
};
|
|
1100
782
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
783
|
+
/**
|
|
784
|
+
* Add unexpected-file findings when the vendored allowlist can be established.
|
|
785
|
+
* When fail-closed allowlist discovery raises a coded `TARGET` failure, retain
|
|
786
|
+
* the existing findings and mark the audit incomplete instead of crashing.
|
|
787
|
+
*/
|
|
788
|
+
#scanSafe(audit, target, host) {
|
|
789
|
+
const scanned = attempt(() => this.#scan(audit, target, host));
|
|
790
|
+
if (scanned.success) return {
|
|
791
|
+
audit: scanned.value,
|
|
792
|
+
skipped: false
|
|
793
|
+
};
|
|
794
|
+
if (isScaffoldError(scanned.error) && scanned.error.code === "TARGET") return {
|
|
795
|
+
audit: {
|
|
796
|
+
...audit,
|
|
797
|
+
clean: false,
|
|
798
|
+
complete: false,
|
|
799
|
+
questions: [...audit.questions, {
|
|
800
|
+
field: "host",
|
|
801
|
+
text: scanned.error.message,
|
|
802
|
+
blocking: true
|
|
803
|
+
}]
|
|
804
|
+
},
|
|
805
|
+
skipped: true
|
|
806
|
+
};
|
|
807
|
+
throw scanned.error;
|
|
1112
808
|
}
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
809
|
+
/**
|
|
810
|
+
* `repair`'s own scope is host-only, but the caller compiles the FULL plan anyway — diff it too
|
|
811
|
+
* (cheap, local, no network) so a clean host verdict can point at drift OUTSIDE repair's reach.
|
|
812
|
+
* The count feeds the shared `scopeNote` renderer.
|
|
813
|
+
*/
|
|
814
|
+
#outside(compiled, target) {
|
|
815
|
+
const full = diffPlan(compiled, readTarget(target, compiled.artifacts.map((artifact) => artifact.path)));
|
|
816
|
+
return full.drifted + full.missing + full.foreign;
|
|
1121
817
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
const rawAudit = diffPlan(plan, readTarget(target, artifactPaths));
|
|
1125
|
-
const scanned = hydrated.aware ? withForeignScanSafe(rawAudit, target, host) : {
|
|
1126
|
-
audit: rawAudit,
|
|
1127
|
-
skipped: false
|
|
1128
|
-
};
|
|
1129
|
-
const audit = scanned.audit;
|
|
1130
|
-
let drifted = !audit.clean;
|
|
1131
|
-
let live;
|
|
1132
|
-
if (values.live) {
|
|
1133
|
-
const sync = createSync();
|
|
818
|
+
/** Reject a cancelled prompt (ctrl-c) with the shared `CANCELLED_MESSAGE` — exit 1, nothing written. */
|
|
819
|
+
async #guard(promise) {
|
|
1134
820
|
try {
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
current,
|
|
1143
|
-
behind,
|
|
1144
|
-
failed: entries.length - current - behind
|
|
1145
|
-
};
|
|
1146
|
-
} finally {
|
|
1147
|
-
sync.destroy();
|
|
821
|
+
return await promise;
|
|
822
|
+
} catch (error) {
|
|
823
|
+
if (isTerminalError(error) && error.code === "CANCEL") {
|
|
824
|
+
this.#reporter.line(CANCELLED_MESSAGE);
|
|
825
|
+
throw new CLIExitError(1);
|
|
826
|
+
}
|
|
827
|
+
throw error;
|
|
1148
828
|
}
|
|
1149
829
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
830
|
+
/**
|
|
831
|
+
* The shared write-confirmation gate every verb calls before it touches disk.
|
|
832
|
+
* `--apply` writes without asking; `--json` (without `--apply`) is a pure
|
|
833
|
+
* dry-run and NEVER prompts; `--yes` auto-answers yes; otherwise a real
|
|
834
|
+
* confirm with `default: false` (EOF on stdin resolves to the default).
|
|
835
|
+
*/
|
|
836
|
+
async #apply(terminal, message, values, json) {
|
|
837
|
+
if (values.apply) return true;
|
|
838
|
+
if (json) return false;
|
|
839
|
+
if (values.yes) return true;
|
|
840
|
+
return this.#guard(terminal.confirm({
|
|
841
|
+
message,
|
|
842
|
+
default: false
|
|
843
|
+
}));
|
|
1157
844
|
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
if (offerHandoff) {
|
|
1172
|
-
const terminal = createTerminal();
|
|
1173
|
-
const message = repairHandoff(ownedCount, audit.foreign, pruneRequested);
|
|
1174
|
-
handoffAccepted = await guarded(terminal.confirm({
|
|
1175
|
-
message,
|
|
1176
|
-
default: false
|
|
1177
|
-
}));
|
|
1178
|
-
if (handoffAccepted) {
|
|
1179
|
-
await runRepair(values, false);
|
|
1180
|
-
const rawFinal = diffPlan(plan, readTarget(target, artifactPaths));
|
|
1181
|
-
const finalScanned = hydrated.aware ? withForeignScanSafe(rawFinal, target, host) : {
|
|
1182
|
-
audit: rawFinal,
|
|
1183
|
-
skipped: false
|
|
1184
|
-
};
|
|
1185
|
-
process.exitCode = finalScanned.audit.clean ? 0 : 1;
|
|
1186
|
-
return;
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
if (!handoffAccepted) {
|
|
1190
|
-
if (audit.foreign > 0 && !pruneRequested) reporter.line(foreignHint());
|
|
1191
|
-
if (computedCount > 0) reporter.line(generatedNote(computedCount));
|
|
845
|
+
/**
|
|
846
|
+
* The SECOND, separate confirm for `--prune`-eligible deletions — never
|
|
847
|
+
* bundled into `resolveApply`'s question. `--yes` only auto-answers this
|
|
848
|
+
* when `--prune` was also passed (it never enables pruning by itself).
|
|
849
|
+
*/
|
|
850
|
+
async #prune(terminal, message, values, json) {
|
|
851
|
+
if (!values.prune) return false;
|
|
852
|
+
if (values.apply) return true;
|
|
853
|
+
if (json) return false;
|
|
854
|
+
if (values.yes) return true;
|
|
855
|
+
if (!this.#tty) {
|
|
856
|
+
this.#reporter.line(PRUNE_SKIPPED);
|
|
857
|
+
return false;
|
|
1192
858
|
}
|
|
859
|
+
return this.#guard(terminal.confirm({
|
|
860
|
+
message,
|
|
861
|
+
default: false
|
|
862
|
+
}));
|
|
1193
863
|
}
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
spec = deriveBlueprint(target);
|
|
1202
|
-
} catch (error) {
|
|
1203
|
-
failError(error, json);
|
|
864
|
+
/** A spinner for a long-running write step, absent under `--json` or off a TTY sink. */
|
|
865
|
+
#spinner(message, json) {
|
|
866
|
+
return json || !this.#tty ? void 0 : createSpinner({
|
|
867
|
+
message,
|
|
868
|
+
sink: this.#sink,
|
|
869
|
+
styler: this.#styler
|
|
870
|
+
});
|
|
1204
871
|
}
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
};
|
|
1210
|
-
const from = values.from?.[0];
|
|
1211
|
-
const host = from ?? hostRoot();
|
|
1212
|
-
let plan;
|
|
1213
|
-
try {
|
|
1214
|
-
plan = hydrateBestEffort(scoped, host, from !== void 0).plan;
|
|
1215
|
-
} catch (error) {
|
|
1216
|
-
failError(error, json);
|
|
872
|
+
/** Announce a successful write — the spinner's own success line, or a plain `reporter.status` without one (never under `--json`). */
|
|
873
|
+
#succeed(spinner, json, message) {
|
|
874
|
+
if (spinner) spinner.success(message);
|
|
875
|
+
else if (!json) this.#reporter.status("success", message);
|
|
1217
876
|
}
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
877
|
+
/** Announce a failed write and halt(1) — the spinner's own failure line (if any), then the shared `fail`. */
|
|
878
|
+
#reject(spinner, json, error) {
|
|
879
|
+
const message = describeError(error);
|
|
880
|
+
if (spinner) spinner.failure(message);
|
|
881
|
+
this.#fail(message, json);
|
|
1223
882
|
}
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
883
|
+
/**
|
|
884
|
+
* Best-effort vendored `@orkestrel` catalog names, resolved via `host`
|
|
885
|
+
* (`hostRoot()`, or the active `--from` override) through the host manifest
|
|
886
|
+
* — `undefined` when the catalog cannot be established (a missing/unreadable
|
|
887
|
+
* manifest, no `.claude/agents/orkestrel.md` entry, or any other failure),
|
|
888
|
+
* degrading the dependency prompt to shape-only validation instead of blocking on it.
|
|
889
|
+
*/
|
|
890
|
+
#names(host) {
|
|
891
|
+
const names = attempt(() => {
|
|
892
|
+
const full = locateHostSource(readHostManifest(host), ".claude/agents/orkestrel.md", host);
|
|
893
|
+
if (full === void 0 || !existsSync(full)) return void 0;
|
|
894
|
+
return catalogNames(readFileText(host, relative(host, full).replaceAll("\\", "/"), "TARGET", "host"));
|
|
895
|
+
});
|
|
896
|
+
return names.success ? names.value : void 0;
|
|
897
|
+
}
|
|
898
|
+
/** Prompt for `@orkestrel` short-name dependencies until every token resolves or input is empty. */
|
|
899
|
+
async #prompt(terminal, catalog) {
|
|
900
|
+
for (;;) {
|
|
901
|
+
const tokens = splitTokens(await this.#guard(terminal.input({
|
|
902
|
+
message: ORKESTREL_DEPS_PROMPT,
|
|
903
|
+
default: ""
|
|
904
|
+
})));
|
|
905
|
+
if (tokens.length === 0) return [];
|
|
906
|
+
const normalized = tokens.map(normalizeOrkestrelToken);
|
|
907
|
+
const issue = normalized.map((token) => orkestrelTokenIssue(token, catalog)).find((message) => message !== void 0);
|
|
908
|
+
if (issue === void 0) return normalized;
|
|
909
|
+
this.#reporter.line(issue);
|
|
910
|
+
}
|
|
1228
911
|
}
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
912
|
+
/** `scaffold new` — scaffold a package into `./<name>` (or `--target`). */
|
|
913
|
+
async #new(values, argument, json) {
|
|
914
|
+
const terminal = createTerminal();
|
|
915
|
+
let name;
|
|
916
|
+
if (argument !== void 0) name = argument;
|
|
917
|
+
else if (json) this.#usage("a package name is required with --json", json);
|
|
918
|
+
else if (!this.#tty) this.#usage(missingInput("a package name", "new"), json);
|
|
919
|
+
else name = await this.#guard(terminal.input({
|
|
920
|
+
message: "Package name",
|
|
921
|
+
validate: { pattern: NAME_PATTERN.source }
|
|
922
|
+
}));
|
|
923
|
+
if (!NAME_PATTERN.test(name)) this.#usage(invalidName(name, NAME_PATTERN.source), json);
|
|
924
|
+
let srcInput;
|
|
925
|
+
let appInput;
|
|
926
|
+
if (values.src !== void 0) srcInput = values.src.split(",").map((candidate) => candidate.trim());
|
|
927
|
+
else if (values.app !== void 0) srcInput = [];
|
|
928
|
+
else if (json || !this.#tty) this.#usage("at least one of --src or --app is required", json);
|
|
929
|
+
else srcInput = await this.#guard(terminal.checkbox({
|
|
930
|
+
message: "Published src environments",
|
|
931
|
+
choices: ENVIRONMENT_CHOICES,
|
|
932
|
+
min: 0
|
|
933
|
+
}));
|
|
934
|
+
if (values.app !== void 0) appInput = values.app.split(",").map((candidate) => candidate.trim());
|
|
935
|
+
else if (values.src !== void 0 || json || !this.#tty) appInput = [];
|
|
936
|
+
else appInput = await this.#guard(terminal.checkbox({
|
|
937
|
+
message: "Application environments",
|
|
938
|
+
choices: ENVIRONMENT_CHOICES,
|
|
939
|
+
min: 0
|
|
940
|
+
}));
|
|
941
|
+
const unrecognizedSrcEnvironment = srcInput.filter((candidate) => !ENVIRONMENTS.some((environment) => environment === candidate));
|
|
942
|
+
if (unrecognizedSrcEnvironment.length > 0) this.#usage(`Environment "${unrecognizedSrcEnvironment.join("\", \"")}" is not recognized`, json);
|
|
943
|
+
if (new Set(srcInput).size !== srcInput.length) this.#usage("Published src environments must not repeat", json);
|
|
944
|
+
const src = ENVIRONMENTS.filter((environment) => srcInput.includes(environment));
|
|
945
|
+
const unrecognizedAppEnvironment = appInput.filter((candidate) => !ENVIRONMENTS.some((environment) => environment === candidate));
|
|
946
|
+
if (unrecognizedAppEnvironment.length > 0) this.#usage(`Application environment "${unrecognizedAppEnvironment.join("\", \"")}" is not recognized`, json);
|
|
947
|
+
if (new Set(appInput).size !== appInput.length) this.#usage("Application environments must not repeat", json);
|
|
948
|
+
const app = ENVIRONMENTS.filter((environment) => appInput.includes(environment));
|
|
949
|
+
if (src.length === 0 && app.length === 0) this.#usage("at least one source or application environment is required", json);
|
|
950
|
+
const destination = this.#contain(values.target ?? `./${name}`, json);
|
|
951
|
+
if (!isVacant(destination)) this.#error(new ScaffoldError("TARGET", "new requires a vacant target", { target: destination }), json);
|
|
952
|
+
const explicitHost = values.from?.[0];
|
|
953
|
+
if (explicitHost !== void 0) {
|
|
954
|
+
if (!isRealDirectory(explicitHost)) this.#error(new ScaffoldError("TARGET", `Host root is not a physical directory at ${explicitHost}`, { host: explicitHost }), json);
|
|
955
|
+
readHostManifest(explicitHost);
|
|
956
|
+
}
|
|
957
|
+
let depNames;
|
|
958
|
+
if (values.deps !== void 0) {
|
|
959
|
+
depNames = values.deps.split(",").filter((depName) => depName.length > 0);
|
|
960
|
+
const badDep = depNames.find((depName) => !DEPENDENCY_NAME_PATTERN.test(depName));
|
|
961
|
+
if (badDep !== void 0) this.#usage(`Dependency name "${badDep}" must match ${DEPENDENCY_NAME_PATTERN.source}`, json);
|
|
962
|
+
} else if (json || !this.#tty) depNames = [];
|
|
1232
963
|
else {
|
|
1233
|
-
|
|
1234
|
-
const
|
|
1235
|
-
if (
|
|
964
|
+
const catalogHost = values.from?.[0] ?? hostRoot();
|
|
965
|
+
const catalog = this.#names(catalogHost);
|
|
966
|
+
if (catalog === void 0) this.#reporter.line(CATALOG_UNRESOLVED_NOTE);
|
|
967
|
+
depNames = await this.#prompt(terminal, catalog);
|
|
968
|
+
}
|
|
969
|
+
const sync = createSync();
|
|
970
|
+
let versions;
|
|
971
|
+
try {
|
|
972
|
+
versions = await sync.versions(depNames.map((depName) => dependency(depName, "*")));
|
|
973
|
+
} finally {
|
|
974
|
+
sync.destroy();
|
|
975
|
+
}
|
|
976
|
+
const unresolved = versions.filter((version) => version.freshness !== "current" && version.freshness !== "behind" || version.latest === "").map((version) => version.name);
|
|
977
|
+
if (unresolved.length > 0) this.#fail(unresolvedVersion(unresolved), json);
|
|
978
|
+
const deps = versions.map((version) => dependency(version.name, `^${version.latest}`));
|
|
979
|
+
const plan = this.#compile(blueprint(name, {
|
|
980
|
+
src,
|
|
981
|
+
app,
|
|
982
|
+
dependencies: deps
|
|
983
|
+
}), json);
|
|
984
|
+
const summary = planToSummary(plan);
|
|
985
|
+
if (!json) {
|
|
986
|
+
this.#reporter.section("Plan");
|
|
987
|
+
this.#reporter.table(newPlanTable(summary));
|
|
988
|
+
this.#reporter.line(newPlanPreview(name));
|
|
989
|
+
}
|
|
990
|
+
if (!await this.#apply(terminal, applyConfirmMessage(summary.host + summary.template + summary.computed), values, json)) {
|
|
991
|
+
if (json) this.#write(summaryToNewResult(summary, false));
|
|
992
|
+
else this.#reporter.line(NEW_DRY_RUN_NOTE);
|
|
993
|
+
process.exitCode = 0;
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const spinner = this.#spinner("materializing", json);
|
|
997
|
+
spinner?.start();
|
|
998
|
+
const host = values.from?.[0];
|
|
999
|
+
const materializer = createMaterializer(host === void 0 ? void 0 : { host });
|
|
1000
|
+
try {
|
|
1001
|
+
const result = materializer.materialize(plan, destination);
|
|
1002
|
+
const count = result.written.length + result.copied.length;
|
|
1003
|
+
if (json) this.#write(summaryToNewResult(summary, true));
|
|
1004
|
+
else this.#succeed(spinner, json, newApplySuccess(count, name));
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
this.#reject(spinner, json, error);
|
|
1007
|
+
} finally {
|
|
1008
|
+
materializer.destroy();
|
|
1236
1009
|
}
|
|
1237
1010
|
process.exitCode = 0;
|
|
1238
|
-
return;
|
|
1239
1011
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1012
|
+
/** `scaffold pull` — refresh vendored dependency mirrors and report range drift. */
|
|
1013
|
+
async #pull(values, json) {
|
|
1014
|
+
const target = this.#contain(values.target ?? ".", json);
|
|
1015
|
+
const sync = createSync(values.strict === void 0 ? void 0 : { strict: values.strict });
|
|
1016
|
+
try {
|
|
1017
|
+
let report;
|
|
1018
|
+
try {
|
|
1019
|
+
const declared = manifestToDependencies(readManifest(target));
|
|
1020
|
+
const selected = parsePullDependencies(values.deps, declared);
|
|
1021
|
+
report = await sync.pull(target, selected);
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
this.#error(error, json);
|
|
1024
|
+
}
|
|
1025
|
+
if (!json) {
|
|
1026
|
+
this.#reporter.table(pullTable(report));
|
|
1027
|
+
for (const line of pullCauseNotes(report)) this.#reporter.line(line);
|
|
1028
|
+
this.#reporter.line(pullVerdict(report));
|
|
1029
|
+
}
|
|
1030
|
+
const toWrite = [...report.guides, ...report.versions].filter((entry) => entry.freshness !== "current").length;
|
|
1031
|
+
const terminal = createTerminal();
|
|
1032
|
+
const proceed = toWrite > 0 ? await this.#apply(terminal, applyConfirmMessage(toWrite), values, json) : false;
|
|
1033
|
+
if (proceed) {
|
|
1034
|
+
const spinner = this.#spinner("writing mirrors", json);
|
|
1035
|
+
spinner?.start();
|
|
1036
|
+
try {
|
|
1037
|
+
const written = await sync.write(report, target);
|
|
1038
|
+
if (json) this.#write(report);
|
|
1039
|
+
else this.#succeed(spinner, json, pullSuccess(written.length));
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
this.#reject(spinner, json, error);
|
|
1042
|
+
}
|
|
1043
|
+
} else if (json) this.#write(report);
|
|
1044
|
+
process.exitCode = report.clean ? 0 : proceed ? 0 : 1;
|
|
1045
|
+
} finally {
|
|
1046
|
+
sync.destroy();
|
|
1047
|
+
}
|
|
1267
1048
|
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
const
|
|
1049
|
+
/** `scaffold audit` — whole-plan conformance report; offers a repair handoff on drift. */
|
|
1050
|
+
async #audit(values, json) {
|
|
1051
|
+
const target = this.#contain(values.target ?? ".", json);
|
|
1052
|
+
let spec;
|
|
1053
|
+
try {
|
|
1054
|
+
spec = deriveBlueprint(target);
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
this.#error(error, json);
|
|
1057
|
+
}
|
|
1058
|
+
const deps = [
|
|
1059
|
+
...spec.dependencies,
|
|
1060
|
+
...spec.peers,
|
|
1061
|
+
...spec.extras
|
|
1062
|
+
];
|
|
1063
|
+
const groupsInput = values.groups?.split(",");
|
|
1064
|
+
let groups;
|
|
1065
|
+
if (groupsInput !== void 0) {
|
|
1066
|
+
const unrecognized = groupsInput.filter((name) => !GROUPS.some((group) => group === name));
|
|
1067
|
+
if (unrecognized.length > 0) this.#usage(`Group "${unrecognized.join("\", \"")}" is not recognized`, json);
|
|
1068
|
+
groups = GROUPS.filter((group) => groupsInput.includes(group));
|
|
1069
|
+
}
|
|
1070
|
+
const compiled = blueprintToPlan(spec, groups);
|
|
1071
|
+
const host = values.from?.[0] ?? hostRoot();
|
|
1072
|
+
let plan;
|
|
1283
1073
|
try {
|
|
1284
|
-
|
|
1285
|
-
|
|
1074
|
+
plan = hydratePlan(compiled, host);
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
this.#error(error, json);
|
|
1077
|
+
}
|
|
1078
|
+
const artifactPaths = plan.artifacts.map((artifact) => artifact.path);
|
|
1079
|
+
const rawAudit = diffPlan(plan, readTarget(target, artifactPaths));
|
|
1080
|
+
const scanned = this.#scanSafe(rawAudit, target, host);
|
|
1081
|
+
const audit = scanned.audit;
|
|
1082
|
+
let drifted = !audit.clean;
|
|
1083
|
+
let live;
|
|
1084
|
+
if (values.live) {
|
|
1085
|
+
const sync = createSync();
|
|
1286
1086
|
try {
|
|
1287
|
-
const
|
|
1288
|
-
const
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1087
|
+
const guides = await sync.guides(deps);
|
|
1088
|
+
const versions = await sync.versions(deps);
|
|
1089
|
+
const entries = [...guides, ...versions];
|
|
1090
|
+
drifted ||= entries.some((entry) => entry.freshness !== "current");
|
|
1091
|
+
const current = entries.filter((entry) => entry.freshness === "current").length;
|
|
1092
|
+
const behind = entries.filter((entry) => entry.freshness === "behind").length;
|
|
1093
|
+
live = {
|
|
1094
|
+
current,
|
|
1095
|
+
behind,
|
|
1096
|
+
failed: entries.length - current - behind
|
|
1293
1097
|
};
|
|
1294
|
-
if (!ciExcluded && scaffolding.plan.artifacts.some((artifact) => artifact.path === ".github/workflows/ci.yml")) {
|
|
1295
|
-
if (!json) reporter.line(fleetCiSkipped());
|
|
1296
|
-
ciExcluded = true;
|
|
1297
|
-
}
|
|
1298
1098
|
} finally {
|
|
1299
|
-
|
|
1099
|
+
sync.destroy();
|
|
1300
1100
|
}
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
name,
|
|
1307
|
-
directory,
|
|
1308
|
-
plan,
|
|
1309
|
-
audit,
|
|
1310
|
-
aware: hydrated.aware
|
|
1311
|
-
});
|
|
1312
|
-
} catch (error) {
|
|
1313
|
-
failures.push({
|
|
1314
|
-
name,
|
|
1315
|
-
message: describe(error)
|
|
1101
|
+
}
|
|
1102
|
+
if (json) {
|
|
1103
|
+
this.#write(live === void 0 ? audit : {
|
|
1104
|
+
...audit,
|
|
1105
|
+
live
|
|
1316
1106
|
});
|
|
1107
|
+
process.exitCode = drifted ? 1 : 0;
|
|
1108
|
+
return;
|
|
1317
1109
|
}
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
return;
|
|
1350
|
-
}
|
|
1351
|
-
const materializer = createMaterializer({ host });
|
|
1352
|
-
let drifted = 0;
|
|
1353
|
-
let failedCount = failures.length;
|
|
1354
|
-
const entries = repos.filter((repo) => repo.audit.clean).map((repo) => fleetEntry(repo.name, repo.audit, false));
|
|
1355
|
-
try {
|
|
1356
|
-
for (const repo of dirty) try {
|
|
1357
|
-
materializer.repair(repo.plan, repo.audit, repo.directory);
|
|
1358
|
-
if (doPrune) materializer.prune(repo.directory);
|
|
1359
|
-
const paths = repo.plan.artifacts.map((artifact) => artifact.path);
|
|
1360
|
-
const rawFinal = diffPlan(repo.plan, readTarget(repo.directory, paths));
|
|
1361
|
-
const finalAudit = repo.aware ? withForeignScan(rawFinal, repo.directory, host) : rawFinal;
|
|
1362
|
-
if (!finalAudit.clean) drifted += 1;
|
|
1363
|
-
entries.push(fleetEntry(repo.name, finalAudit, false));
|
|
1364
|
-
if (!json) reporter.line(fleetRepoLine(repo.name, {
|
|
1365
|
-
kind: "repaired",
|
|
1366
|
-
remaining: finalAudit.drifted + finalAudit.missing + finalAudit.foreign
|
|
1367
|
-
}));
|
|
1368
|
-
} catch (error) {
|
|
1369
|
-
failedCount += 1;
|
|
1370
|
-
entries.push(fleetEntry(repo.name, void 0, true));
|
|
1371
|
-
if (!json) reporter.line(fleetRepoLine(repo.name, {
|
|
1372
|
-
kind: "failed",
|
|
1373
|
-
message: describe(error)
|
|
1374
|
-
}));
|
|
1110
|
+
if (scanned.skipped) this.#reporter.line(SCAN_SKIPPED);
|
|
1111
|
+
this.#reporter.line(comparisonLine(true));
|
|
1112
|
+
this.#reporter.table(auditTable(audit, plan));
|
|
1113
|
+
this.#reporter.line(auditVerdict(audit, plan));
|
|
1114
|
+
if (live !== void 0) this.#reporter.line(auditLiveNote(live.current, live.behind, live.failed));
|
|
1115
|
+
if (!audit.clean) {
|
|
1116
|
+
const split = partitionFindings(audit.findings, plan);
|
|
1117
|
+
const ownedCount = split.owned.drifted + split.owned.missing;
|
|
1118
|
+
const computedCount = split.generated.drifted + split.generated.missing;
|
|
1119
|
+
const pruneRequested = values.prune === true;
|
|
1120
|
+
const offerHandoff = this.#tty && (ownedCount > 0 || audit.foreign > 0 && pruneRequested);
|
|
1121
|
+
let handoffAccepted = false;
|
|
1122
|
+
if (offerHandoff) {
|
|
1123
|
+
const terminal = createTerminal();
|
|
1124
|
+
const message = repairHandoff(ownedCount, audit.foreign, pruneRequested);
|
|
1125
|
+
handoffAccepted = await this.#guard(terminal.confirm({
|
|
1126
|
+
message,
|
|
1127
|
+
default: false
|
|
1128
|
+
}));
|
|
1129
|
+
if (handoffAccepted) {
|
|
1130
|
+
await this.#repair(values, false);
|
|
1131
|
+
const rawFinal = diffPlan(plan, readTarget(target, artifactPaths));
|
|
1132
|
+
const finalScanned = this.#scanSafe(rawFinal, target, host);
|
|
1133
|
+
process.exitCode = finalScanned.audit.clean ? 0 : 1;
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
if (!handoffAccepted) {
|
|
1138
|
+
if (audit.foreign > 0 && !pruneRequested) this.#reporter.line(FOREIGN_HINT);
|
|
1139
|
+
if (computedCount > 0) this.#reporter.line(generatedNote(computedCount));
|
|
1140
|
+
}
|
|
1375
1141
|
}
|
|
1376
|
-
|
|
1377
|
-
materializer.destroy();
|
|
1142
|
+
process.exitCode = drifted ? 1 : 0;
|
|
1378
1143
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
/** `scaffold catalog` — regenerate the fleet package catalog table embedded in `.claude/agents/orkestrel.md`. */
|
|
1384
|
-
async function runCatalog(values, json) {
|
|
1385
|
-
const target = containOrFail(values.target ?? ".", json);
|
|
1386
|
-
const explicitRoots = values.from;
|
|
1387
|
-
let entries;
|
|
1388
|
-
let published = 0;
|
|
1389
|
-
let localOnly = 0;
|
|
1390
|
-
const notes = /* @__PURE__ */ new Map();
|
|
1391
|
-
if (values.offline) {
|
|
1392
|
-
const roots = explicitRoots ?? [process.cwd()];
|
|
1144
|
+
/** `scaffold repair` — restore the shared host-owned set for one target. */
|
|
1145
|
+
async #repair(values, json) {
|
|
1146
|
+
const target = this.#contain(values.target ?? ".", json);
|
|
1147
|
+
let spec;
|
|
1393
1148
|
try {
|
|
1394
|
-
|
|
1149
|
+
spec = deriveBlueprint(target);
|
|
1395
1150
|
} catch (error) {
|
|
1396
|
-
|
|
1151
|
+
this.#error(error, json);
|
|
1397
1152
|
}
|
|
1398
|
-
|
|
1399
|
-
const
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1153
|
+
const compiled = this.#compile(spec, json);
|
|
1154
|
+
const scoped = {
|
|
1155
|
+
...compiled,
|
|
1156
|
+
artifacts: compiled.artifacts.filter((artifact) => artifact.origin === "host")
|
|
1157
|
+
};
|
|
1158
|
+
const host = values.from?.[0] ?? hostRoot();
|
|
1159
|
+
let plan;
|
|
1403
1160
|
try {
|
|
1404
|
-
|
|
1161
|
+
plan = hydratePlan(scoped, host);
|
|
1405
1162
|
} catch (error) {
|
|
1406
|
-
|
|
1407
|
-
} finally {
|
|
1408
|
-
sync.destroy();
|
|
1163
|
+
this.#error(error, json);
|
|
1409
1164
|
}
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
localEntries = catalogPackages(explicitRoots);
|
|
1165
|
+
let audit;
|
|
1166
|
+
try {
|
|
1167
|
+
audit = diffPlan(plan, readTarget(target, plan.artifacts.map((artifact) => artifact.path)));
|
|
1414
1168
|
} catch (error) {
|
|
1415
|
-
|
|
1169
|
+
this.#error(error, json);
|
|
1416
1170
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1171
|
+
if (!json) {
|
|
1172
|
+
this.#reporter.line(REPAIR_SCOPE);
|
|
1173
|
+
this.#reporter.section("Audit");
|
|
1174
|
+
this.#reporter.table(auditTable(audit, plan));
|
|
1175
|
+
}
|
|
1176
|
+
const prunePaths = values.prune ? pruneTargets(target, host) : [];
|
|
1177
|
+
const pruneSnapshot = readTarget(target, prunePaths);
|
|
1178
|
+
if (audit.clean && prunePaths.length === 0) {
|
|
1179
|
+
if (json) this.#write(audit);
|
|
1180
|
+
else {
|
|
1181
|
+
this.#reporter.line(repairVerdict(audit));
|
|
1182
|
+
const note = scopeNote(this.#outside(compiled, target));
|
|
1183
|
+
if (note !== void 0) this.#reporter.line(note);
|
|
1184
|
+
}
|
|
1185
|
+
process.exitCode = 0;
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if (!json) this.#reporter.line(repairVerdict(audit));
|
|
1189
|
+
const terminal = createTerminal();
|
|
1190
|
+
let proceed = true;
|
|
1191
|
+
if (!audit.clean) proceed = await this.#apply(terminal, applyConfirmMessage(audit.drifted + audit.missing + audit.foreign), values, json);
|
|
1192
|
+
if (!proceed) {
|
|
1193
|
+
if (json) this.#write(audit);
|
|
1194
|
+
process.exitCode = 1;
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (values.prune && !json) if (prunePaths.length === 0) this.#reporter.line(PRUNE_EMPTY);
|
|
1198
|
+
else for (const line of prunePreview(prunePaths)) this.#reporter.line(line);
|
|
1199
|
+
const doPrune = prunePaths.length > 0 && await this.#prune(terminal, pruneConfirmMessage(prunePaths.length), values, json);
|
|
1200
|
+
const spinner = this.#spinner("repairing", json);
|
|
1201
|
+
spinner?.start();
|
|
1202
|
+
const materializer = createMaterializer({ host });
|
|
1203
|
+
try {
|
|
1204
|
+
const result = materializer.repair(plan, audit, target);
|
|
1205
|
+
const removed = doPrune ? materializer.prune(target, pruneSnapshot).removed : [];
|
|
1206
|
+
if (json) this.#write(auditToRepairResult(audit, {
|
|
1207
|
+
...result,
|
|
1208
|
+
removed
|
|
1209
|
+
}));
|
|
1210
|
+
else this.#succeed(spinner, json, repairSuccess(result, removed));
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
this.#reject(spinner, json, error);
|
|
1213
|
+
} finally {
|
|
1214
|
+
materializer.destroy();
|
|
1428
1215
|
}
|
|
1429
|
-
entries = [...merged.values()].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
1430
|
-
}
|
|
1431
|
-
const block = catalogToBlock(entries);
|
|
1432
|
-
const agentPath = containOrFail(join(target, ".claude", "agents", "orkestrel.md"), json);
|
|
1433
|
-
let current;
|
|
1434
|
-
try {
|
|
1435
|
-
current = readFileSync(agentPath, "utf8");
|
|
1436
|
-
} catch (error) {
|
|
1437
|
-
failError(new ScaffoldError("TARGET", `Failed to read ${agentPath}`, {
|
|
1438
|
-
path: agentPath,
|
|
1439
|
-
error
|
|
1440
|
-
}), json);
|
|
1441
|
-
}
|
|
1442
|
-
const startMarker = "<!-- catalog:start -->";
|
|
1443
|
-
const endMarker = "<!-- catalog:end -->";
|
|
1444
|
-
const startIndex = current.indexOf(startMarker);
|
|
1445
|
-
const endIndex = current.indexOf(endMarker);
|
|
1446
|
-
if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) failError(new ScaffoldError("TARGET", `Markers "${startMarker}" / "${endMarker}" not found in ${agentPath}`, { path: agentPath }), json);
|
|
1447
|
-
const updated = `${current.slice(0, startIndex + 22)}\n\n${block}\n${current.slice(endIndex)}`;
|
|
1448
|
-
const oldRows = catalogNames(current.slice(startIndex + 22, endIndex)).length;
|
|
1449
|
-
const shrink = entries.length < oldRows ? oldRows - entries.length : void 0;
|
|
1450
|
-
if (updated === current) {
|
|
1451
|
-
if (json) writeJson(catalogJson(entries, false));
|
|
1452
|
-
else reporter.line(catalogVerdict(true));
|
|
1453
1216
|
process.exitCode = 0;
|
|
1454
|
-
return;
|
|
1455
1217
|
}
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
const
|
|
1459
|
-
|
|
1218
|
+
/** `scaffold fleet` — audit/repair every `@orkestrel` package beneath the current directory's immediate children. */
|
|
1219
|
+
async #fleet(values, json) {
|
|
1220
|
+
const root = this.#contain(".", json);
|
|
1221
|
+
const packages = discoverPackages(root);
|
|
1222
|
+
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);
|
|
1223
|
+
const host = values.from?.[0] ?? hostRoot();
|
|
1224
|
+
const repos = [];
|
|
1225
|
+
const failures = [];
|
|
1226
|
+
for (const directory of packages) {
|
|
1227
|
+
const name = basename(directory);
|
|
1228
|
+
try {
|
|
1229
|
+
const compiler = createCompiler();
|
|
1230
|
+
let scoped;
|
|
1231
|
+
try {
|
|
1232
|
+
const spec = deriveBlueprint(directory);
|
|
1233
|
+
const scaffolding = compiler.compile(spec);
|
|
1234
|
+
if (!scaffolding.plan) throw new ScaffoldError("INVALID", scaffolding.questions.map((question) => question.text).join("; "));
|
|
1235
|
+
scoped = {
|
|
1236
|
+
...scaffolding.plan,
|
|
1237
|
+
artifacts: scaffolding.plan.artifacts.filter((artifact) => artifact.origin === "host")
|
|
1238
|
+
};
|
|
1239
|
+
} finally {
|
|
1240
|
+
compiler.destroy();
|
|
1241
|
+
}
|
|
1242
|
+
const plan = hydratePlan(scoped, host);
|
|
1243
|
+
const rawAudit = diffPlan(plan, readTarget(directory, plan.artifacts.map((artifact) => artifact.path)));
|
|
1244
|
+
const audit = this.#scan(rawAudit, directory, host);
|
|
1245
|
+
repos.push({
|
|
1246
|
+
name,
|
|
1247
|
+
directory,
|
|
1248
|
+
plan,
|
|
1249
|
+
audit
|
|
1250
|
+
});
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
failures.push({
|
|
1253
|
+
name,
|
|
1254
|
+
message: describeError(error)
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if (!json) {
|
|
1259
|
+
for (const repo of repos) this.#reporter.line(fleetRepoLine(repo.name, repo.audit.clean ? { state: "clean" } : {
|
|
1260
|
+
state: "drifted",
|
|
1261
|
+
drifted: repo.audit.drifted,
|
|
1262
|
+
missing: repo.audit.missing,
|
|
1263
|
+
foreign: repo.audit.foreign
|
|
1264
|
+
}));
|
|
1265
|
+
for (const failure of failures) this.#reporter.line(fleetRepoLine(failure.name, {
|
|
1266
|
+
state: "failed",
|
|
1267
|
+
message: failure.message
|
|
1268
|
+
}));
|
|
1269
|
+
}
|
|
1270
|
+
const dirty = repos.filter((repo) => !repo.audit.clean);
|
|
1271
|
+
if (dirty.length === 0) {
|
|
1272
|
+
if (json) this.#write([...repos.map((repo) => fleetEntryOf(repo.name, repo.audit, false)), ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
|
|
1273
|
+
else this.#reporter.line(fleetTotals(0, failures.length));
|
|
1274
|
+
process.exitCode = failures.length > 0 ? 1 : 0;
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
const fileCount = dirty.reduce((total, repo) => total + repo.audit.drifted + repo.audit.missing + repo.audit.foreign, 0);
|
|
1278
|
+
const terminal = createTerminal();
|
|
1279
|
+
const proceed = await this.#apply(terminal, applyConfirmMessage(fileCount, dirty.length), values, json);
|
|
1280
|
+
const pruneSets = proceed && values.prune ? new Map(dirty.map((repo) => {
|
|
1281
|
+
const paths = pruneTargets(repo.directory, host);
|
|
1282
|
+
return [repo.name, readTarget(repo.directory, paths)];
|
|
1283
|
+
})) : /* @__PURE__ */ new Map();
|
|
1284
|
+
const prunePaths = dirty.flatMap((repo) => Object.keys(pruneSets.get(repo.name) ?? {}).map((path) => `${repo.name}/${path}`));
|
|
1285
|
+
if (proceed && values.prune && !json) if (prunePaths.length === 0) this.#reporter.line(PRUNE_EMPTY);
|
|
1286
|
+
else for (const line of prunePreview(prunePaths)) this.#reporter.line(line);
|
|
1287
|
+
const doPrune = proceed && prunePaths.length > 0 && await this.#prune(terminal, pruneConfirmMessage(prunePaths.length), values, json);
|
|
1288
|
+
if (!proceed) {
|
|
1289
|
+
if (json) this.#write([...repos.map((repo) => fleetEntryOf(repo.name, repo.audit, false)), ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
|
|
1290
|
+
else this.#reporter.line(fleetTotals(dirty.length, failures.length));
|
|
1291
|
+
process.exitCode = 1;
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
const materializer = createMaterializer({ host });
|
|
1295
|
+
let drifted = 0;
|
|
1296
|
+
let failedCount = failures.length;
|
|
1297
|
+
const entries = repos.filter((repo) => repo.audit.clean).map((repo) => fleetEntryOf(repo.name, repo.audit, false));
|
|
1298
|
+
try {
|
|
1299
|
+
for (const repo of dirty) try {
|
|
1300
|
+
materializer.repair(repo.plan, repo.audit, repo.directory);
|
|
1301
|
+
if (doPrune) materializer.prune(repo.directory, pruneSets.get(repo.name) ?? {});
|
|
1302
|
+
const paths = repo.plan.artifacts.map((artifact) => artifact.path);
|
|
1303
|
+
const rawFinal = diffPlan(repo.plan, readTarget(repo.directory, paths));
|
|
1304
|
+
const finalAudit = this.#scan(rawFinal, repo.directory, host);
|
|
1305
|
+
if (!finalAudit.clean) drifted += 1;
|
|
1306
|
+
entries.push(fleetEntryOf(repo.name, finalAudit, false));
|
|
1307
|
+
if (!json) this.#reporter.line(fleetRepoLine(repo.name, {
|
|
1308
|
+
state: "repaired",
|
|
1309
|
+
remaining: finalAudit.drifted + finalAudit.missing + finalAudit.foreign
|
|
1310
|
+
}));
|
|
1311
|
+
} catch (error) {
|
|
1312
|
+
failedCount += 1;
|
|
1313
|
+
entries.push(fleetEntryOf(repo.name, void 0, true));
|
|
1314
|
+
if (!json) this.#reporter.line(fleetRepoLine(repo.name, {
|
|
1315
|
+
state: "failed",
|
|
1316
|
+
message: describeError(error)
|
|
1317
|
+
}));
|
|
1318
|
+
}
|
|
1319
|
+
} finally {
|
|
1320
|
+
materializer.destroy();
|
|
1321
|
+
}
|
|
1322
|
+
if (json) this.#write([...entries, ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
|
|
1323
|
+
else this.#reporter.line(fleetTotals(drifted, failedCount));
|
|
1324
|
+
process.exitCode = drifted > 0 || failedCount > 0 ? 1 : 0;
|
|
1325
|
+
}
|
|
1326
|
+
/** `scaffold catalog` — regenerate the fleet package catalog table embedded in `.claude/agents/orkestrel.md`. */
|
|
1327
|
+
async #catalog(values, json) {
|
|
1328
|
+
const target = this.#contain(values.target ?? ".", json);
|
|
1329
|
+
const explicitRoots = values.from;
|
|
1330
|
+
let entries;
|
|
1331
|
+
let published = 0;
|
|
1332
|
+
let localOnly = 0;
|
|
1333
|
+
const notes = /* @__PURE__ */ new Map();
|
|
1460
1334
|
if (values.offline) {
|
|
1461
|
-
const
|
|
1462
|
-
|
|
1335
|
+
const roots = explicitRoots ?? [process.cwd()];
|
|
1336
|
+
try {
|
|
1337
|
+
entries = catalogPackages(roots);
|
|
1338
|
+
} catch (error) {
|
|
1339
|
+
this.#error(error, json);
|
|
1340
|
+
}
|
|
1463
1341
|
} else {
|
|
1464
|
-
|
|
1465
|
-
|
|
1342
|
+
const sync = createSync();
|
|
1343
|
+
sync.emitter.on("package", (name, note) => {
|
|
1344
|
+
if (note !== "") notes.set(name, note);
|
|
1345
|
+
});
|
|
1346
|
+
let registryEntries;
|
|
1347
|
+
try {
|
|
1348
|
+
registryEntries = await sync.catalog();
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
this.#error(error, json);
|
|
1351
|
+
} finally {
|
|
1352
|
+
sync.destroy();
|
|
1353
|
+
}
|
|
1354
|
+
published = registryEntries.length;
|
|
1355
|
+
let localEntries = [];
|
|
1356
|
+
if (explicitRoots !== void 0) try {
|
|
1357
|
+
localEntries = catalogPackages(explicitRoots);
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
this.#error(error, json);
|
|
1360
|
+
}
|
|
1361
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1362
|
+
for (const entry of registryEntries) merged.set(entry.name, entry);
|
|
1363
|
+
for (const local of localEntries) {
|
|
1364
|
+
const existing = merged.get(local.name);
|
|
1365
|
+
if (existing === void 0) {
|
|
1366
|
+
merged.set(local.name, local);
|
|
1367
|
+
localOnly += 1;
|
|
1368
|
+
} else if (local.description.length > 0) merged.set(local.name, {
|
|
1369
|
+
...existing,
|
|
1370
|
+
description: local.description
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
entries = [...merged.values()].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
1466
1374
|
}
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1375
|
+
const block = catalogToBlock(entries);
|
|
1376
|
+
const agentPath = this.#contain(join(target, CATALOG_AGENT_PATH), json);
|
|
1377
|
+
let current;
|
|
1378
|
+
let baseline;
|
|
1379
|
+
try {
|
|
1380
|
+
current = readFileText(target, CATALOG_AGENT_PATH, "TARGET", "target");
|
|
1381
|
+
baseline = digestText(current);
|
|
1382
|
+
} catch (error) {
|
|
1383
|
+
this.#error(new ScaffoldError("TARGET", `Failed to read ${agentPath}`, {
|
|
1384
|
+
path: agentPath,
|
|
1385
|
+
error
|
|
1386
|
+
}), json);
|
|
1387
|
+
}
|
|
1388
|
+
const startIndex = current.indexOf(CATALOG_START_MARKER);
|
|
1389
|
+
const endIndex = current.indexOf(CATALOG_END_MARKER);
|
|
1390
|
+
const startParts = current.split(CATALOG_START_MARKER);
|
|
1391
|
+
const endParts = current.split(CATALOG_END_MARKER);
|
|
1392
|
+
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);
|
|
1393
|
+
const updated = `${current.slice(0, startIndex + CATALOG_START_MARKER.length)}\n\n${block}\n${current.slice(endIndex)}`;
|
|
1394
|
+
const oldRows = catalogNames(current.slice(startIndex + CATALOG_START_MARKER.length, endIndex)).length;
|
|
1395
|
+
const shrink = entries.length < oldRows ? oldRows - entries.length : void 0;
|
|
1396
|
+
if (updated === current) {
|
|
1397
|
+
if (json) this.#write(catalogResultOf(entries, false));
|
|
1398
|
+
else this.#reporter.line(catalogVerdict(true));
|
|
1399
|
+
process.exitCode = 0;
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
if (!json) {
|
|
1403
|
+
this.#reporter.table(catalogTable(entries));
|
|
1404
|
+
const warning = catalogShrinkWarning(oldRows, entries.length);
|
|
1405
|
+
if (warning !== void 0) this.#reporter.line(warning);
|
|
1406
|
+
if (values.offline) {
|
|
1407
|
+
const missingDescription = entries.filter((entry) => entry.description.length === 0).map((entry) => entry.name);
|
|
1408
|
+
if (missingDescription.length > 0) this.#reporter.line(`${missingDescription.length} without guide description: ${missingDescription.join(", ")}`);
|
|
1409
|
+
} else {
|
|
1410
|
+
this.#reporter.line(catalogCounts(published, localOnly));
|
|
1411
|
+
for (const [name, note] of notes) this.#reporter.line(` ${name}: ${note}`);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
const terminal = createTerminal();
|
|
1415
|
+
if (!await this.#apply(terminal, applyConfirmMessage(1), values, json)) {
|
|
1416
|
+
if (json) this.#write(catalogResultOf(entries, true, shrink));
|
|
1417
|
+
else this.#reporter.line(catalogVerdict(false));
|
|
1418
|
+
process.exitCode = 1;
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
if (Buffer.byteLength(updated, "utf8") > MAX_ARTIFACT_BYTES) this.#error(new ScaffoldError("WRITE", `Catalog exceeds the artifact limit at ${agentPath}`, {
|
|
1478
1422
|
path: agentPath,
|
|
1479
|
-
|
|
1423
|
+
limit: MAX_ARTIFACT_BYTES
|
|
1480
1424
|
}), json);
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
}
|
|
1511
|
-
if (!isVerb(command)) usageFail(didYouMean(command), json);
|
|
1512
|
-
if (values.help) {
|
|
1513
|
-
process.stdout.write(`${verbHelp(command)}\n`);
|
|
1425
|
+
const transaction = WriteTransaction.create(target, [CATALOG_AGENT_PATH], [{
|
|
1426
|
+
path: CATALOG_AGENT_PATH,
|
|
1427
|
+
shape: "file",
|
|
1428
|
+
digest: baseline
|
|
1429
|
+
}]);
|
|
1430
|
+
const staged = attempt(() => {
|
|
1431
|
+
validateWriteDirectories(transaction);
|
|
1432
|
+
mkdirSync(dirname(resolvePhysicalPath(transaction.stage, CATALOG_AGENT_PATH, "WRITE", "staging")), { recursive: true });
|
|
1433
|
+
validateWriteDirectories(transaction);
|
|
1434
|
+
const contained = resolvePhysicalPath(transaction.stage, CATALOG_AGENT_PATH, "WRITE", "staging");
|
|
1435
|
+
writeFileSync(contained, updated, {
|
|
1436
|
+
encoding: "utf8",
|
|
1437
|
+
flag: "wx"
|
|
1438
|
+
});
|
|
1439
|
+
if (digestFile(contained) !== digestText(updated)) throw new ScaffoldError("WRITE", `Staged catalog changed at ${agentPath}`, { path: agentPath });
|
|
1440
|
+
validateWriteDirectories(transaction);
|
|
1441
|
+
});
|
|
1442
|
+
if (!staged.success) {
|
|
1443
|
+
const cleanup = attempt(() => discardWriteTransaction(transaction));
|
|
1444
|
+
this.#error(new ScaffoldError("WRITE", `Failed to stage ${agentPath}`, {
|
|
1445
|
+
path: agentPath,
|
|
1446
|
+
error: staged.error,
|
|
1447
|
+
cleanup: cleanup.success ? void 0 : cleanup.error
|
|
1448
|
+
}), json);
|
|
1449
|
+
}
|
|
1450
|
+
const committed = attempt(() => commitWriteTransaction(transaction, [CATALOG_AGENT_PATH]));
|
|
1451
|
+
if (!committed.success) this.#error(committed.error, json);
|
|
1452
|
+
if (json) this.#write(catalogResultOf(entries, true, shrink));
|
|
1453
|
+
else this.#reporter.status("success", catalogApplySuccess(agentPath));
|
|
1514
1454
|
process.exitCode = 0;
|
|
1515
|
-
return;
|
|
1516
1455
|
}
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1456
|
+
/**
|
|
1457
|
+
* The whole command dispatch — a single top-level driver (no nested function
|
|
1458
|
+
* declarations, AGENTS §4). Every verb sets `process.exitCode` (never
|
|
1459
|
+
* `process.exit`) and returns, or `halt()`s through a `finally` that
|
|
1460
|
+
* tears its entities down first; the caller at the bottom of this file
|
|
1461
|
+
* catches exactly one sentinel (`CliExit`) and stops.
|
|
1462
|
+
*/
|
|
1463
|
+
async #dispatch(argv) {
|
|
1464
|
+
let parsed;
|
|
1465
|
+
try {
|
|
1466
|
+
parsed = parseArguments(argv);
|
|
1467
|
+
} catch (error) {
|
|
1468
|
+
process.stderr.write(`${error instanceof Error ? error.message : INVALID_ARGUMENTS_MESSAGE}\n`);
|
|
1469
|
+
process.exitCode = 2;
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
const { values, positionals } = parsed;
|
|
1473
|
+
const [command, argument] = positionals;
|
|
1474
|
+
const json = values.json === true;
|
|
1475
|
+
this.#json = json;
|
|
1476
|
+
if (command === void 0) {
|
|
1477
|
+
process.stdout.write(`${values.help ? fullHelp() : shortUsage()}\n`);
|
|
1478
|
+
process.exitCode = 0;
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
if (!isVerb(command)) this.#usage(didYouMean(command), json);
|
|
1482
|
+
if (command !== "catalog" && values.from !== void 0 && values.from.length > 1) this.#usage(`--from may be provided only once for '${command}'`, json);
|
|
1483
|
+
if (values.help) {
|
|
1484
|
+
process.stdout.write(`${verbHelp(command)}\n`);
|
|
1485
|
+
process.exitCode = 0;
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
if (command === "new") return this.#new(values, argument, json);
|
|
1489
|
+
if (command === "pull") return this.#pull(values, json);
|
|
1490
|
+
if (command === "audit") return this.#audit(values, json);
|
|
1491
|
+
if (command === "repair") return this.#repair(values, json);
|
|
1492
|
+
if (command === "fleet") return this.#fleet(values, json);
|
|
1493
|
+
return this.#catalog(values, json);
|
|
1535
1494
|
}
|
|
1536
|
-
}
|
|
1495
|
+
};
|
|
1496
|
+
//#endregion
|
|
1497
|
+
//#region src/bin/scaffold.ts
|
|
1498
|
+
await new CLI().run(process.argv.slice(2));
|
|
1537
1499
|
//#endregion
|
|
1538
1500
|
|
|
1539
1501
|
//# sourceMappingURL=scaffold.js.map
|