@orkestrel/scaffold 0.0.22 → 0.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -99
- package/dist/bin/main.js +1094 -0
- package/dist/bin/main.js.map +1 -0
- package/dist/host/CLAUDE.md +3 -1
- package/dist/host/agents/orchestration.md +61 -4
- package/dist/host/agents/skills/orkestrel-align-packages/SKILL.md +1 -1
- package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +7 -5
- package/dist/host/agents/skills/orkestrel-harden-package/SKILL.md +1 -1
- package/dist/host/agents/skills/orkestrel-harden-package/references/contract.md +1 -1
- package/dist/host/claude/agents/orkestrel.md +9 -9
- package/dist/host/claude/rules/architecture.md +45 -3
- package/dist/host/claude/rules/quality.md +4 -0
- package/dist/host/claude/rules/tests.md +57 -1
- package/dist/host/claude/rules/workspace.md +50 -17
- package/dist/host/codex/agents/orkestrel.toml +1 -1
- package/dist/host/configs/helpers.ts +762 -0
- package/dist/host/dotfiles/oxlintrc.json +2 -1
- package/dist/host/guides/scaffold.md +862 -0
- package/dist/host/manifest.json +40 -33
- package/dist/host/tests/config.test.ts +544 -0
- package/dist/host/tests/policy.test.ts +46 -0
- package/dist/host/tests/setupPolicy.ts +557 -602
- package/dist/src/core/index.cjs +3569 -10510
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +2361 -2789
- package/dist/src/core/index.d.ts +2361 -2789
- package/dist/src/core/index.js +3513 -10374
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +2855 -3765
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +1920 -1335
- package/dist/src/server/index.d.ts +1920 -1335
- package/dist/src/server/index.js +2812 -3680
- package/dist/src/server/index.js.map +1 -1
- package/package.json +16 -23
- package/dist/bin/scaffold.js +0 -1896
- package/dist/bin/scaffold.js.map +0 -1
- package/dist/host/guides/src/scaffold.md +0 -2886
- /package/dist/host/guides/{src/guide.md → guide.md} +0 -0
package/dist/bin/main.js
ADDED
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { align, renderTable, strip, stripControls, width } from "@orkestrel/console";
|
|
4
|
+
import { attempt, isRecord, isString, parseJSON } from "@orkestrel/contract";
|
|
5
|
+
import { createMarkdown, flattenText, isTableNode } from "@orkestrel/markdown";
|
|
6
|
+
import { BIN_ENTRY_PATH, CATALOG_AGENT_PATH, DEPENDENCY_NAME_PATTERN, ENVIRONMENTS, GLOBAL_SETUP_PATH, GROUPS, GUIDES_TEST_PATH, INTEGRATION_TEST_PATH, MAX_MANIFEST_BYTES, SHOWCASE_CONFIG_PATH, ScaffoldError, blueprintToRootVite, createBlueprint, createCompiler, isScaffoldError, manifestToDependencies, manifestToName, nameToGuide } from "../src/core/index.js";
|
|
7
|
+
import { createMaterializer, createUpstream, isExactCaseFile, isPhysicalDirectory, isRepository, readFileText, readSnapshot, resolveContainedPath } from "../src/server/index.js";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
//#region src/bin/constants.ts
|
|
10
|
+
/**
|
|
11
|
+
* The name the executable installs as.
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* `package.json`'s `bin` field is the authority for what the command is called;
|
|
15
|
+
* this is the same word, so every usage line the executable prints is a command
|
|
16
|
+
* a reader can paste back.
|
|
17
|
+
*/
|
|
18
|
+
var EXECUTABLE_NAME = "scaffold";
|
|
19
|
+
/**
|
|
20
|
+
* The five {@link Verb} values in usage order, frozen.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* The order the type declares them in, which is also the order usage lists them:
|
|
24
|
+
* the verb that creates a workspace, then the one that only reads it, then the
|
|
25
|
+
* three that write to one that already exists, widest last.
|
|
26
|
+
*/
|
|
27
|
+
var VERBS = Object.freeze([
|
|
28
|
+
"new",
|
|
29
|
+
"audit",
|
|
30
|
+
"repair",
|
|
31
|
+
"catalog",
|
|
32
|
+
"overwrite"
|
|
33
|
+
]);
|
|
34
|
+
/**
|
|
35
|
+
* What each exit code means, frozen.
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* Keyed by the three code constants rather than by literals, so the usage block
|
|
39
|
+
* cannot document a code the executable does not return.
|
|
40
|
+
*/
|
|
41
|
+
var EXIT_SUMMARY = Object.freeze({
|
|
42
|
+
[0]: "clean",
|
|
43
|
+
[1]: "drift or failure",
|
|
44
|
+
[2]: "usage error"
|
|
45
|
+
});
|
|
46
|
+
/**
|
|
47
|
+
* The machine-readable code a malformed command line reports.
|
|
48
|
+
*
|
|
49
|
+
* @remarks
|
|
50
|
+
* The executable contributes its own codes to the failure envelope, which is why
|
|
51
|
+
* that envelope's `code` is a plain string rather than a `ScaffoldErrorCode`: a
|
|
52
|
+
* command line that never became a command failed before any coded package
|
|
53
|
+
* operation could.
|
|
54
|
+
*/
|
|
55
|
+
var USAGE_CODE = "USAGE";
|
|
56
|
+
/** The machine-readable code a failure carrying no code of its own reports. */
|
|
57
|
+
var FAILED_CODE = "FAILED";
|
|
58
|
+
/** What the failure envelope says when the raised value carried no message. */
|
|
59
|
+
var FAILED_MESSAGE = "The command failed for an unrecognized reason";
|
|
60
|
+
/**
|
|
61
|
+
* The positional argument `new` alone takes, as usage writes it.
|
|
62
|
+
*
|
|
63
|
+
* @remarks
|
|
64
|
+
* The workspace name is the only positional argument any verb takes, so it is
|
|
65
|
+
* one value rather than a per-verb table with four holes in it.
|
|
66
|
+
*/
|
|
67
|
+
var NAME_ARGUMENT = "<name>";
|
|
68
|
+
/**
|
|
69
|
+
* Every option the executable accepts, as `node:util` parses them, frozen.
|
|
70
|
+
*
|
|
71
|
+
* @remarks
|
|
72
|
+
* One table for every verb rather than one per verb, because the verb an option
|
|
73
|
+
* belongs to is a domain fact the command union already fixes: parsing decides
|
|
74
|
+
* only whether the word is an option at all, and {@link VERB_OPTIONS} decides
|
|
75
|
+
* whether this verb takes it. No option declares a default, so the parsed keys
|
|
76
|
+
* are exactly the options the caller supplied, which is what makes an option
|
|
77
|
+
* offered to the wrong verb visible rather than silently absorbed. `from`
|
|
78
|
+
* collects repeats because `catalog` may draw on more than one local source; a
|
|
79
|
+
* verb that takes it once refuses the second.
|
|
80
|
+
*/
|
|
81
|
+
var COMMAND_OPTIONS = Object.freeze({
|
|
82
|
+
src: Object.freeze({ type: "string" }),
|
|
83
|
+
app: Object.freeze({ type: "string" }),
|
|
84
|
+
bin: Object.freeze({ type: "boolean" }),
|
|
85
|
+
deps: Object.freeze({ type: "string" }),
|
|
86
|
+
groups: Object.freeze({ type: "string" }),
|
|
87
|
+
all: Object.freeze({ type: "boolean" }),
|
|
88
|
+
dirty: Object.freeze({ type: "boolean" }),
|
|
89
|
+
from: Object.freeze({
|
|
90
|
+
type: "string",
|
|
91
|
+
multiple: true
|
|
92
|
+
}),
|
|
93
|
+
target: Object.freeze({ type: "string" }),
|
|
94
|
+
json: Object.freeze({ type: "boolean" })
|
|
95
|
+
});
|
|
96
|
+
/**
|
|
97
|
+
* What each option does, keyed by the token usage prints, frozen.
|
|
98
|
+
*
|
|
99
|
+
* @remarks
|
|
100
|
+
* The key order is the glossary order. A key is the whole displayed token,
|
|
101
|
+
* value placeholder included, because that token is what a reader copies and
|
|
102
|
+
* what {@link VERB_OPTIONS} lists.
|
|
103
|
+
*/
|
|
104
|
+
var OPTION_SUMMARY = Object.freeze({
|
|
105
|
+
"--src <list>": "the published library environments to build: core, browser, server",
|
|
106
|
+
"--app <list>": "the private application environments to build: core, browser, server",
|
|
107
|
+
"--bin": "scaffold a command-line executable at src/bin/main.ts",
|
|
108
|
+
"--deps <list>": "the @orkestrel/* packages the workspace depends on",
|
|
109
|
+
"--groups <list>": "the artifact groups to cover; every group when absent",
|
|
110
|
+
"--all": "fetch a guide for every package the organization publishes, not just the declared ones",
|
|
111
|
+
"--dirty": "delete from a tree carrying uncommitted changes",
|
|
112
|
+
"--from <path>": "read the data root from a local path instead of the bundled one; catalog alone accepts it more than once",
|
|
113
|
+
"--target <path>": "the directory the verb operates on; the working directory when absent",
|
|
114
|
+
"--json": "emit one machine-readable value instead of a report"
|
|
115
|
+
});
|
|
116
|
+
/**
|
|
117
|
+
* The options each verb takes, in usage order, frozen.
|
|
118
|
+
*
|
|
119
|
+
* @remarks
|
|
120
|
+
* The executable's half of the frozen command union: every option a branch
|
|
121
|
+
* declares is listed against its verb, and every option a branch excludes is
|
|
122
|
+
* absent from it. An option a verb does not list is refused by name rather than
|
|
123
|
+
* parsed and ignored.
|
|
124
|
+
*/
|
|
125
|
+
var VERB_OPTIONS = Object.freeze({
|
|
126
|
+
new: Object.freeze([
|
|
127
|
+
"--src <list>",
|
|
128
|
+
"--app <list>",
|
|
129
|
+
"--bin",
|
|
130
|
+
"--deps <list>",
|
|
131
|
+
"--from <path>",
|
|
132
|
+
"--target <path>",
|
|
133
|
+
"--json"
|
|
134
|
+
]),
|
|
135
|
+
audit: Object.freeze([
|
|
136
|
+
"--groups <list>",
|
|
137
|
+
"--from <path>",
|
|
138
|
+
"--target <path>",
|
|
139
|
+
"--json"
|
|
140
|
+
]),
|
|
141
|
+
repair: Object.freeze([
|
|
142
|
+
"--groups <list>",
|
|
143
|
+
"--from <path>",
|
|
144
|
+
"--target <path>",
|
|
145
|
+
"--json"
|
|
146
|
+
]),
|
|
147
|
+
catalog: Object.freeze([
|
|
148
|
+
"--all",
|
|
149
|
+
"--from <path>",
|
|
150
|
+
"--target <path>",
|
|
151
|
+
"--json"
|
|
152
|
+
]),
|
|
153
|
+
overwrite: Object.freeze([
|
|
154
|
+
"--groups <list>",
|
|
155
|
+
"--dirty",
|
|
156
|
+
"--from <path>",
|
|
157
|
+
"--target <path>",
|
|
158
|
+
"--json"
|
|
159
|
+
])
|
|
160
|
+
});
|
|
161
|
+
/**
|
|
162
|
+
* What each verb does, in one line, frozen.
|
|
163
|
+
*
|
|
164
|
+
* @remarks
|
|
165
|
+
* Each line names what the verb writes, because authority is the verb's: a
|
|
166
|
+
* reader deciding which one to run is deciding what they are authorizing.
|
|
167
|
+
*/
|
|
168
|
+
var VERB_SUMMARY = Object.freeze({
|
|
169
|
+
new: "scaffold a workspace",
|
|
170
|
+
audit: "report how the target compares to its plan, writing nothing",
|
|
171
|
+
repair: "write each planned path the target is missing or has let drift",
|
|
172
|
+
catalog: "regenerate the package table and refresh the guide mirrors",
|
|
173
|
+
overwrite: "do everything repair and catalog do, then delete what the plan does not own and re-declare the dependency ranges"
|
|
174
|
+
});
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/bin/errors.ts
|
|
177
|
+
/**
|
|
178
|
+
* The error raised when a command line is not a command.
|
|
179
|
+
*
|
|
180
|
+
* @remarks
|
|
181
|
+
* Distinct from `ScaffoldError` because the two answer different questions and
|
|
182
|
+
* exit differently: a `ScaffoldError` says the package could not serve a
|
|
183
|
+
* well-formed request and exits `1`, while this says there was no request to
|
|
184
|
+
* serve and exits `2`. Folding a usage error into `INVALID` would report a
|
|
185
|
+
* mistyped flag as a failed run.
|
|
186
|
+
*
|
|
187
|
+
* It carries no `context`. Everything a caller can act on is in the message,
|
|
188
|
+
* because the reader of a usage error is a person at a terminal rather than a
|
|
189
|
+
* program branching on a cause.
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* ```ts
|
|
193
|
+
* import { isUsageError, UsageError } from './errors.js'
|
|
194
|
+
*
|
|
195
|
+
* try {
|
|
196
|
+
* throw new UsageError("Unknown command 'pull'.")
|
|
197
|
+
* } catch (error) {
|
|
198
|
+
* if (isUsageError(error)) error.code // 'USAGE'
|
|
199
|
+
* }
|
|
200
|
+
* ```
|
|
201
|
+
*/
|
|
202
|
+
var UsageError = class extends Error {
|
|
203
|
+
code;
|
|
204
|
+
/**
|
|
205
|
+
* Construct a usage error.
|
|
206
|
+
*
|
|
207
|
+
* @param message - What was wrong with the command line, in one sentence.
|
|
208
|
+
*/
|
|
209
|
+
constructor(message) {
|
|
210
|
+
super(message);
|
|
211
|
+
this.name = "UsageError";
|
|
212
|
+
this.code = USAGE_CODE;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
/**
|
|
216
|
+
* Narrow a caught value to a {@link UsageError}.
|
|
217
|
+
*
|
|
218
|
+
* @param value - The caught value to narrow.
|
|
219
|
+
* @returns `true` when `value` is a {@link UsageError}.
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```ts
|
|
223
|
+
* import { isUsageError } from './errors.js'
|
|
224
|
+
*
|
|
225
|
+
* isUsageError(new Error('plain')) // false
|
|
226
|
+
* isUsageError(undefined) // false
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
function isUsageError(value) {
|
|
230
|
+
return value instanceof UsageError;
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region src/bin/helpers.ts
|
|
234
|
+
/**
|
|
235
|
+
* Read the option name out of the token usage displays it as.
|
|
236
|
+
*
|
|
237
|
+
* @param option - The displayed token, such as `--from <path>`.
|
|
238
|
+
* @returns The bare name `node:util` parses the option under.
|
|
239
|
+
*
|
|
240
|
+
* @remarks
|
|
241
|
+
* One token serves both readers: a person reads the value placeholder and the
|
|
242
|
+
* parser reads the name in front of it. Deriving the name means a documented
|
|
243
|
+
* option and an accepted option cannot be two different lists.
|
|
244
|
+
*
|
|
245
|
+
* @example
|
|
246
|
+
* ```ts
|
|
247
|
+
* import { optionToName } from './helpers.js'
|
|
248
|
+
*
|
|
249
|
+
* optionToName('--from <path>') // 'from'
|
|
250
|
+
* optionToName('--json') // 'json'
|
|
251
|
+
* ```
|
|
252
|
+
*/
|
|
253
|
+
function optionToName(option) {
|
|
254
|
+
const [token = option] = option.split(" ");
|
|
255
|
+
return token.startsWith("--") ? token.slice(2) : token;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Render one verb's synopsis.
|
|
259
|
+
*
|
|
260
|
+
* @param verb - The verb to describe.
|
|
261
|
+
* @returns The command line this verb accepts, with every option bracketed.
|
|
262
|
+
*
|
|
263
|
+
* @remarks
|
|
264
|
+
* The synopsis is derived from the verb's own option list rather than stored
|
|
265
|
+
* beside it, so a line that documents an option the verb does not take cannot be
|
|
266
|
+
* written. `new` alone carries the positional argument.
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* ```ts
|
|
270
|
+
* import { verbToSyntax } from './helpers.js'
|
|
271
|
+
*
|
|
272
|
+
* verbToSyntax('audit') // 'scaffold audit [--groups <list>] …'
|
|
273
|
+
* ```
|
|
274
|
+
*/
|
|
275
|
+
function verbToSyntax(verb) {
|
|
276
|
+
return `${EXECUTABLE_NAME} ${verb}${verb === "new" ? ` ${NAME_ARGUMENT}` : ""} ${VERB_OPTIONS[verb].map((option) => `[${option}]`).join(" ")}`;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Render the whole command reference.
|
|
280
|
+
*
|
|
281
|
+
* @returns One line per output call: the synopsis, every verb, the option glossary, and the exit codes.
|
|
282
|
+
*
|
|
283
|
+
* @remarks
|
|
284
|
+
* Returned as lines because the executable writes through a handler that takes
|
|
285
|
+
* one line, so the caller never has to split a block back apart. The glossary is
|
|
286
|
+
* printed once for every verb rather than repeated per verb, since seven of the
|
|
287
|
+
* nine options are shared and a reader comparing two verbs wants the difference,
|
|
288
|
+
* not the repetition.
|
|
289
|
+
*/
|
|
290
|
+
function renderUsage() {
|
|
291
|
+
const summaries = Object.entries(OPTION_SUMMARY);
|
|
292
|
+
const column = Math.max(...summaries.map(([option]) => width(option)));
|
|
293
|
+
return [
|
|
294
|
+
`${EXECUTABLE_NAME} <verb> [options]`,
|
|
295
|
+
"",
|
|
296
|
+
...VERBS.flatMap((verb) => [` ${verbToSyntax(verb)}`, ` ${VERB_SUMMARY[verb]}`]),
|
|
297
|
+
"",
|
|
298
|
+
"options",
|
|
299
|
+
...summaries.map(([option, summary]) => ` ${align(option, column)} ${summary}`),
|
|
300
|
+
"",
|
|
301
|
+
"exit codes",
|
|
302
|
+
...Object.entries(EXIT_SUMMARY).map(([code, meaning]) => ` ${code} ${meaning}`)
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Read one command out of the arguments following the executable's own name.
|
|
307
|
+
*
|
|
308
|
+
* @param argv - The arguments the executable was given.
|
|
309
|
+
* @returns The command the arguments denote.
|
|
310
|
+
* @throws {@link UsageError} when they denote no command.
|
|
311
|
+
*
|
|
312
|
+
* @remarks
|
|
313
|
+
* The one place untrusted argument text becomes a domain value, and it stays one
|
|
314
|
+
* function because the command union admits no partial command to hand on: every
|
|
315
|
+
* refusal has to happen before the value exists. It refuses in four ways, each
|
|
316
|
+
* naming what was wrong — a word that is not a verb, a word that is not an
|
|
317
|
+
* option, an option this verb does not take, and an argument this verb does not
|
|
318
|
+
* take. `node:util` decides the second and this decides the rest, so an unknown
|
|
319
|
+
* option is reported by the parser that found it rather than re-derived here.
|
|
320
|
+
*
|
|
321
|
+
* A request for usage is not a command and never reaches this: the caller
|
|
322
|
+
* answers it first.
|
|
323
|
+
*
|
|
324
|
+
* @example
|
|
325
|
+
* ```ts
|
|
326
|
+
* import { argvToCommand } from './helpers.js'
|
|
327
|
+
*
|
|
328
|
+
* argvToCommand(['audit', '--json']) // { verb: 'audit', json: true }
|
|
329
|
+
* ```
|
|
330
|
+
*/
|
|
331
|
+
function argvToCommand(argv) {
|
|
332
|
+
const [head, ...rest] = argv;
|
|
333
|
+
const verb = VERBS.find((candidate) => candidate === head);
|
|
334
|
+
if (verb === void 0) throw new UsageError(`${head === void 0 ? "No command given." : `Unknown command '${head}'.`} Run '${EXECUTABLE_NAME} --help' for the command list.`);
|
|
335
|
+
const parsed = attempt(() => parseArgs({
|
|
336
|
+
args: rest,
|
|
337
|
+
options: COMMAND_OPTIONS,
|
|
338
|
+
allowPositionals: true,
|
|
339
|
+
strict: true
|
|
340
|
+
}));
|
|
341
|
+
if (!parsed.success) {
|
|
342
|
+
const cause = parsed.error;
|
|
343
|
+
throw new UsageError(cause instanceof Error ? cause.message : `Could not read the arguments to '${verb}'.`);
|
|
344
|
+
}
|
|
345
|
+
const { positionals, values } = parsed.value;
|
|
346
|
+
const accepted = VERB_OPTIONS[verb].map((option) => optionToName(option));
|
|
347
|
+
const refused = Object.keys(values).filter((name) => !accepted.includes(name));
|
|
348
|
+
if (refused.length > 0) throw new UsageError(`'${verb}' does not take ${refused.map((name) => `--${name}`).join(", ")}.`);
|
|
349
|
+
const [name] = positionals;
|
|
350
|
+
if (positionals.length > 1) throw new UsageError(`'${verb}' takes at most one argument, and was given ${String(positionals.length)}.`);
|
|
351
|
+
if (verb !== "new" && name !== void 0) throw new UsageError(`'${verb}' takes no argument, and was given '${name}'.`);
|
|
352
|
+
const paths = Array.isArray(values.from) ? values.from.filter((value) => typeof value === "string") : [];
|
|
353
|
+
if (verb !== "catalog" && paths.length > 1) throw new UsageError(`'${verb}' takes --from once, and was given it ${String(paths.length)} times.`);
|
|
354
|
+
const src = typeof values.src === "string" ? values.src : void 0;
|
|
355
|
+
const app = typeof values.app === "string" ? values.app : void 0;
|
|
356
|
+
const bin = values.bin === true;
|
|
357
|
+
const dependencies = typeof values.deps === "string" ? values.deps : void 0;
|
|
358
|
+
const target = typeof values.target === "string" ? values.target : void 0;
|
|
359
|
+
const json = values.json === true;
|
|
360
|
+
const [from] = paths;
|
|
361
|
+
const location = target === void 0 ? {} : { target };
|
|
362
|
+
const source = from === void 0 ? {} : { from };
|
|
363
|
+
const selection = typeof values.groups === "string" ? { groups: values.groups } : {};
|
|
364
|
+
switch (verb) {
|
|
365
|
+
case "new":
|
|
366
|
+
if (name === void 0) throw new UsageError(`'new' needs the workspace ${NAME_ARGUMENT} it is scaffolding.`);
|
|
367
|
+
return {
|
|
368
|
+
verb,
|
|
369
|
+
name,
|
|
370
|
+
json,
|
|
371
|
+
...location,
|
|
372
|
+
...source,
|
|
373
|
+
...src === void 0 ? {} : { src },
|
|
374
|
+
...app === void 0 ? {} : { app },
|
|
375
|
+
...bin ? { bin } : {},
|
|
376
|
+
...dependencies === void 0 ? {} : { dependencies }
|
|
377
|
+
};
|
|
378
|
+
case "audit": return {
|
|
379
|
+
verb,
|
|
380
|
+
json,
|
|
381
|
+
...location,
|
|
382
|
+
...source,
|
|
383
|
+
...selection
|
|
384
|
+
};
|
|
385
|
+
case "repair": return {
|
|
386
|
+
verb,
|
|
387
|
+
json,
|
|
388
|
+
...location,
|
|
389
|
+
...source,
|
|
390
|
+
...selection
|
|
391
|
+
};
|
|
392
|
+
case "catalog": return {
|
|
393
|
+
verb,
|
|
394
|
+
json,
|
|
395
|
+
all: values.all === true,
|
|
396
|
+
...location,
|
|
397
|
+
...paths.length === 0 ? {} : { from: paths }
|
|
398
|
+
};
|
|
399
|
+
case "overwrite": return {
|
|
400
|
+
verb,
|
|
401
|
+
json,
|
|
402
|
+
dirty: values.dirty === true,
|
|
403
|
+
...location,
|
|
404
|
+
...source,
|
|
405
|
+
...selection
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Read the exit code an audit reports.
|
|
411
|
+
*
|
|
412
|
+
* @param audit - The comparison of a plan against a target.
|
|
413
|
+
* @returns `EXIT_CLEAN` when the target matched the plan, `EXIT_DRIFT` otherwise.
|
|
414
|
+
*
|
|
415
|
+
* @remarks
|
|
416
|
+
* One rule for every verb that carries an audit, so `audit`, `repair`, and
|
|
417
|
+
* `overwrite` cannot disagree about what a clean run is. A blocking question
|
|
418
|
+
* means the gate refused the blueprint, so the audit says nothing about the
|
|
419
|
+
* target and the run is a failure. A foreign finding counts: the target holds a
|
|
420
|
+
* file the plan does not own, which is a difference from the plan whether or not
|
|
421
|
+
* this verb was allowed to remove it. A non-blocking question rides a complete
|
|
422
|
+
* result and does not.
|
|
423
|
+
*
|
|
424
|
+
* @example
|
|
425
|
+
* ```ts
|
|
426
|
+
* import { auditToExit } from './helpers.js'
|
|
427
|
+
*
|
|
428
|
+
* auditToExit({ findings: [], questions: [] }) // 0
|
|
429
|
+
* ```
|
|
430
|
+
*/
|
|
431
|
+
function auditToExit(audit) {
|
|
432
|
+
const blocked = audit.questions.some((question) => question.blocking);
|
|
433
|
+
const drifted = audit.findings.some((finding) => finding.drift !== "aligned");
|
|
434
|
+
return blocked || drifted ? 1 : 0;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Project a raised value into the machine-readable failure envelope.
|
|
438
|
+
*
|
|
439
|
+
* @param error - The value a command raised.
|
|
440
|
+
* @returns The envelope naming the coded reason and what went wrong.
|
|
441
|
+
*
|
|
442
|
+
* @remarks
|
|
443
|
+
* A `ScaffoldError` and a {@link UsageError} both publish a code, so both are
|
|
444
|
+
* reported under their own. Anything else failed without saying why, and is
|
|
445
|
+
* reported under one code rather than under an invented reading of it; a raised
|
|
446
|
+
* value that is not an `Error` carries no message worth quoting, so the envelope
|
|
447
|
+
* says so instead of stringifying whatever it was.
|
|
448
|
+
*
|
|
449
|
+
* @example
|
|
450
|
+
* ```ts
|
|
451
|
+
* import { errorToEnvelope } from './helpers.js'
|
|
452
|
+
*
|
|
453
|
+
* errorToEnvelope(new Error('boom')) // { error: { code: 'FAILED', message: 'boom' } }
|
|
454
|
+
* ```
|
|
455
|
+
*/
|
|
456
|
+
function errorToEnvelope(error) {
|
|
457
|
+
if (isUsageError(error) || isScaffoldError(error)) return { error: {
|
|
458
|
+
code: error.code,
|
|
459
|
+
message: error.message
|
|
460
|
+
} };
|
|
461
|
+
return { error: {
|
|
462
|
+
code: FAILED_CODE,
|
|
463
|
+
message: error instanceof Error ? error.message : FAILED_MESSAGE
|
|
464
|
+
} };
|
|
465
|
+
}
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region src/bin/CLI.ts
|
|
468
|
+
/**
|
|
469
|
+
* The executable: one command line in, one exit code out.
|
|
470
|
+
*
|
|
471
|
+
* @remarks
|
|
472
|
+
* Every destination this class writes to is a handler it was given, and the run
|
|
473
|
+
* ends by returning its code rather than by setting one, so the whole executable
|
|
474
|
+
* is drivable from inside another process. That is what makes proving what a
|
|
475
|
+
* command prints cost a function call: `src/bin/main.ts` is the only module
|
|
476
|
+
* that reads `process.argv` or assigns `process.exitCode`.
|
|
477
|
+
*
|
|
478
|
+
* Every line leaving here is stripped of ANSI escapes and control characters
|
|
479
|
+
* once, on the way out, because a refusal quotes the argument that caused it and
|
|
480
|
+
* that argument came from an untrusted command line. The machine-readable path
|
|
481
|
+
* needs no second pass: `JSON.stringify` escapes a control character into text
|
|
482
|
+
* before it reaches the handler.
|
|
483
|
+
*
|
|
484
|
+
* The collaborators are constructed per run rather than received, because
|
|
485
|
+
* `--from` decides the vendored root the materializer reads and an instance
|
|
486
|
+
* handed in before the command line was parsed could not honour it. The
|
|
487
|
+
* upstream reader is built the same way but from the options, because no
|
|
488
|
+
* command line names an endpoint: a terminal caller means the published
|
|
489
|
+
* registry and the published guide host, and only the process driving the
|
|
490
|
+
* executable can mean anything else. That is the seam that makes the three
|
|
491
|
+
* verbs which read the network provable without one.
|
|
492
|
+
*
|
|
493
|
+
* @example
|
|
494
|
+
* ```ts
|
|
495
|
+
* import { CLI } from './CLI.js'
|
|
496
|
+
*
|
|
497
|
+
* const lines: string[] = []
|
|
498
|
+
* const code = await new CLI({ output: (line) => lines.push(line) }).execute(['--help'])
|
|
499
|
+
* code // 0
|
|
500
|
+
* ```
|
|
501
|
+
*/
|
|
502
|
+
var CLI = class CLI {
|
|
503
|
+
static #stdout = (line) => void process.stdout.write(`${line}\n`);
|
|
504
|
+
static #stderr = (line) => void process.stderr.write(`${line}\n`);
|
|
505
|
+
#output;
|
|
506
|
+
#diagnostic;
|
|
507
|
+
#upstream;
|
|
508
|
+
/**
|
|
509
|
+
* Construct the executable over the two destinations it writes to.
|
|
510
|
+
*
|
|
511
|
+
* @param options - The report and diagnostic handlers and the upstream
|
|
512
|
+
* endpoints; the process streams and the published endpoints when absent.
|
|
513
|
+
*/
|
|
514
|
+
constructor(options) {
|
|
515
|
+
this.#output = options?.output ?? CLI.#stdout;
|
|
516
|
+
this.#diagnostic = options?.diagnostic ?? CLI.#stderr;
|
|
517
|
+
this.#upstream = options?.upstream;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Run one command line to completion and report through the configured output.
|
|
521
|
+
*
|
|
522
|
+
* @param argv - The arguments following the executable's own name.
|
|
523
|
+
* @returns The exit code: `0` clean, `1` drift or failure, `2` a usage error.
|
|
524
|
+
*
|
|
525
|
+
* @remarks
|
|
526
|
+
* A request for usage is answered before anything is parsed, because it
|
|
527
|
+
* replaces the run rather than modifying it, and because `--help` is not an
|
|
528
|
+
* option any verb takes. Everything after that is one command: read it,
|
|
529
|
+
* dispatch it, render what it produced, and answer with the code it earned.
|
|
530
|
+
*
|
|
531
|
+
* @example
|
|
532
|
+
* ```ts
|
|
533
|
+
* import { CLI } from './CLI.js'
|
|
534
|
+
*
|
|
535
|
+
* await new CLI().execute(['audit', '--json']) // 0 when the target matches its plan
|
|
536
|
+
* ```
|
|
537
|
+
*/
|
|
538
|
+
async execute(argv) {
|
|
539
|
+
if (argv.includes("--help")) {
|
|
540
|
+
for (const line of renderUsage()) this.#say(line);
|
|
541
|
+
return 0;
|
|
542
|
+
}
|
|
543
|
+
const read = attempt(() => argvToCommand(argv));
|
|
544
|
+
if (!read.success) return this.#refuse(read.error, false);
|
|
545
|
+
const command = read.value;
|
|
546
|
+
try {
|
|
547
|
+
return await this.#dispatch(command);
|
|
548
|
+
} catch (error) {
|
|
549
|
+
return this.#refuse(error, command.json === true);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
async #dispatch(command) {
|
|
553
|
+
switch (command.verb) {
|
|
554
|
+
case "new": return this.#create(command);
|
|
555
|
+
case "audit": return this.#inspect(command);
|
|
556
|
+
case "repair": return this.#restore(command);
|
|
557
|
+
case "catalog": return this.#refresh(command);
|
|
558
|
+
case "overwrite": return this.#replace(command);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async #create(command) {
|
|
562
|
+
const target = command.target ?? command.name;
|
|
563
|
+
const blueprint = createBlueprint(command.name, {
|
|
564
|
+
src: this.#environments(command.src, "src"),
|
|
565
|
+
app: this.#environments(command.app, "app"),
|
|
566
|
+
bin: command.bin === true,
|
|
567
|
+
dependencies: await this.#resolve(this.#packages(command.dependencies))
|
|
568
|
+
});
|
|
569
|
+
const plan = this.#compile(blueprint);
|
|
570
|
+
const materializer = createMaterializer(command.from === void 0 ? void 0 : { host: command.from });
|
|
571
|
+
try {
|
|
572
|
+
const result = materializer.materialize(plan, target);
|
|
573
|
+
if (command.json === true) this.#report(result);
|
|
574
|
+
else {
|
|
575
|
+
this.#say(`Scaffolded ${blueprint.name} into ${result.target}.`);
|
|
576
|
+
this.#say(this.#tally(result));
|
|
577
|
+
}
|
|
578
|
+
return 0;
|
|
579
|
+
} finally {
|
|
580
|
+
materializer.destroy();
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
#inspect(command) {
|
|
584
|
+
const target = command.target ?? ".";
|
|
585
|
+
const blueprint = this.#derive(target);
|
|
586
|
+
const question = this.#projectQuestion(target, blueprint);
|
|
587
|
+
const materializer = createMaterializer(command.from === void 0 ? void 0 : { host: command.from });
|
|
588
|
+
try {
|
|
589
|
+
const [measured] = this.#survey(materializer, blueprint, target, this.#groups(command.groups));
|
|
590
|
+
const audit = question === void 0 ? measured : {
|
|
591
|
+
...measured,
|
|
592
|
+
questions: [...measured.questions, question]
|
|
593
|
+
};
|
|
594
|
+
if (command.json === true) this.#report(audit);
|
|
595
|
+
else this.#present(audit);
|
|
596
|
+
return auditToExit(audit);
|
|
597
|
+
} finally {
|
|
598
|
+
materializer.destroy();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
#restore(command) {
|
|
602
|
+
const target = command.target ?? ".";
|
|
603
|
+
const groups = this.#groups(command.groups);
|
|
604
|
+
const blueprint = this.#derive(target);
|
|
605
|
+
this.#assertProjects(target, blueprint);
|
|
606
|
+
const materializer = createMaterializer(command.from === void 0 ? void 0 : { host: command.from });
|
|
607
|
+
try {
|
|
608
|
+
const [audit, plan] = this.#survey(materializer, blueprint, target, groups);
|
|
609
|
+
if (plan === void 0) {
|
|
610
|
+
if (command.json === true) this.#report(audit);
|
|
611
|
+
else this.#present(audit);
|
|
612
|
+
return 1;
|
|
613
|
+
}
|
|
614
|
+
const result = materializer.repair(plan, audit, target);
|
|
615
|
+
const [terminal] = this.#survey(materializer, blueprint, target, groups);
|
|
616
|
+
const outcome = {
|
|
617
|
+
...result,
|
|
618
|
+
audit: terminal
|
|
619
|
+
};
|
|
620
|
+
if (command.json === true) this.#report(outcome);
|
|
621
|
+
else {
|
|
622
|
+
this.#present(terminal);
|
|
623
|
+
this.#say(this.#tally(result));
|
|
624
|
+
}
|
|
625
|
+
return auditToExit(terminal);
|
|
626
|
+
} finally {
|
|
627
|
+
materializer.destroy();
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
async #refresh(command) {
|
|
631
|
+
const target = command.target ?? ".";
|
|
632
|
+
const [host, ...extra] = command.from ?? [];
|
|
633
|
+
if (extra.length > 0) this.#warn(`Read the data root from ${String(host)}. The other ${String(extra.length)} local root${extra.length === 1 ? "" : "s"} named by --from reach nothing this run does.`);
|
|
634
|
+
const previous = this.#previous(target);
|
|
635
|
+
const fetched = await this.#fetch(target, command.all === true);
|
|
636
|
+
const materializer = createMaterializer(host === void 0 ? void 0 : { host });
|
|
637
|
+
let result;
|
|
638
|
+
try {
|
|
639
|
+
result = this.#publish(materializer, target, fetched.entries, fetched.mirrors);
|
|
640
|
+
} finally {
|
|
641
|
+
materializer.destroy();
|
|
642
|
+
}
|
|
643
|
+
const outcome = {
|
|
644
|
+
...result,
|
|
645
|
+
entries: fetched.entries,
|
|
646
|
+
mirrors: fetched.mirrors,
|
|
647
|
+
dropped: previous.filter((name) => !fetched.entries.some((entry) => entry.name === name))
|
|
648
|
+
};
|
|
649
|
+
if (command.json === true) this.#report(outcome);
|
|
650
|
+
else this.#recount(outcome);
|
|
651
|
+
return fetched.mirrors.some((mirror) => mirror.lookup === "failed") ? 1 : 0;
|
|
652
|
+
}
|
|
653
|
+
async #replace(command) {
|
|
654
|
+
const target = command.target ?? ".";
|
|
655
|
+
const groups = this.#groups(command.groups);
|
|
656
|
+
const blueprint = this.#derive(target);
|
|
657
|
+
this.#assertProjects(target, blueprint);
|
|
658
|
+
const repository = this.#repository(target);
|
|
659
|
+
if (repository.dirty.length > 0 && command.dirty !== true) throw new ScaffoldError("TARGET", `The target at ${target} carries ${String(repository.dirty.length)} uncommitted change${repository.dirty.length === 1 ? "" : "s"}. Commit them, or pass --dirty to waive the refusal.`, {
|
|
660
|
+
target,
|
|
661
|
+
dirty: repository.dirty.length
|
|
662
|
+
});
|
|
663
|
+
const materializer = createMaterializer(command.from === void 0 ? void 0 : { host: command.from });
|
|
664
|
+
try {
|
|
665
|
+
const [audit, plan] = this.#survey(materializer, blueprint, target, groups);
|
|
666
|
+
if (plan === void 0) {
|
|
667
|
+
if (command.json === true) this.#report(audit);
|
|
668
|
+
else this.#present(audit);
|
|
669
|
+
return 1;
|
|
670
|
+
}
|
|
671
|
+
const repaired = materializer.repair(plan, audit, target);
|
|
672
|
+
const removed = materializer.remove(audit, command.dirty === true ? {
|
|
673
|
+
tracked: repository.tracked,
|
|
674
|
+
dirty: []
|
|
675
|
+
} : repository, target);
|
|
676
|
+
const offline = this.#merge(repaired, removed);
|
|
677
|
+
const online = await this.#reconcile(materializer, target, blueprint.dependencies);
|
|
678
|
+
const [terminal] = this.#survey(materializer, blueprint, target, groups);
|
|
679
|
+
const outcome = {
|
|
680
|
+
...online,
|
|
681
|
+
...this.#merge(offline, online),
|
|
682
|
+
audit: terminal
|
|
683
|
+
};
|
|
684
|
+
if (command.json === true) this.#report(outcome);
|
|
685
|
+
else {
|
|
686
|
+
this.#present(terminal);
|
|
687
|
+
this.#recount(outcome);
|
|
688
|
+
if (online.note !== void 0) this.#warn(online.note);
|
|
689
|
+
}
|
|
690
|
+
if (online.note !== void 0) return 1;
|
|
691
|
+
return auditToExit(terminal);
|
|
692
|
+
} finally {
|
|
693
|
+
materializer.destroy();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
async #reconcile(materializer, target, declared) {
|
|
697
|
+
const previous = this.#previous(target);
|
|
698
|
+
try {
|
|
699
|
+
const releases = await this.#lookup(declared);
|
|
700
|
+
const fetched = await this.#fetch(target, false);
|
|
701
|
+
return {
|
|
702
|
+
...this.#merge(this.#publish(materializer, target, fetched.entries, fetched.mirrors), materializer.declare(this.#pin(releases), target)),
|
|
703
|
+
entries: fetched.entries,
|
|
704
|
+
mirrors: fetched.mirrors,
|
|
705
|
+
dropped: previous.filter((name) => !fetched.entries.some((entry) => entry.name === name)),
|
|
706
|
+
releases
|
|
707
|
+
};
|
|
708
|
+
} catch (error) {
|
|
709
|
+
return {
|
|
710
|
+
target,
|
|
711
|
+
written: [],
|
|
712
|
+
skipped: [],
|
|
713
|
+
removed: [],
|
|
714
|
+
entries: [],
|
|
715
|
+
mirrors: [],
|
|
716
|
+
dropped: [],
|
|
717
|
+
releases: [],
|
|
718
|
+
note: `The catalog step did not complete: ${errorToEnvelope(error).error.message}`
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
async #lookup(declared) {
|
|
723
|
+
const upstream = createUpstream(this.#upstream);
|
|
724
|
+
try {
|
|
725
|
+
return await upstream.lookup(declared);
|
|
726
|
+
} finally {
|
|
727
|
+
upstream.destroy();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
async #fetch(target, all) {
|
|
731
|
+
const manifest = this.#manifest(target);
|
|
732
|
+
const own = manifestToName(manifest);
|
|
733
|
+
const declared = manifestToDependencies(manifest).map((dependency) => dependency.name);
|
|
734
|
+
const upstream = createUpstream(this.#upstream);
|
|
735
|
+
try {
|
|
736
|
+
const entries = await upstream.catalog();
|
|
737
|
+
const names = (all ? entries.map((entry) => entry.name) : declared).filter((name) => name !== own);
|
|
738
|
+
return {
|
|
739
|
+
entries,
|
|
740
|
+
mirrors: await upstream.fetch(names, readSnapshot(target, names.map(nameToGuide)))
|
|
741
|
+
};
|
|
742
|
+
} finally {
|
|
743
|
+
upstream.destroy();
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
#publish(materializer, target, entries, mirrors) {
|
|
747
|
+
return this.#merge(materializer.mirror(mirrors, target), materializer.catalog(entries, target));
|
|
748
|
+
}
|
|
749
|
+
#pin(releases) {
|
|
750
|
+
const pinned = [];
|
|
751
|
+
for (const release of releases) if (release.lookup === "found") pinned.push({
|
|
752
|
+
name: release.name,
|
|
753
|
+
range: `^${release.latest}`
|
|
754
|
+
});
|
|
755
|
+
return pinned;
|
|
756
|
+
}
|
|
757
|
+
#compile(blueprint, groups) {
|
|
758
|
+
const compiler = createCompiler();
|
|
759
|
+
try {
|
|
760
|
+
const scaffolding = compiler.compile(blueprint, groups);
|
|
761
|
+
if (scaffolding.plan !== void 0) return scaffolding.plan;
|
|
762
|
+
throw new ScaffoldError("BLOCKED", scaffolding.questions.filter((question) => question.blocking).map((question) => `${question.field}: ${question.message}`).join(" "), { questions: scaffolding.questions.length });
|
|
763
|
+
} finally {
|
|
764
|
+
compiler.destroy();
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
#survey(materializer, blueprint, target, groups) {
|
|
768
|
+
const compiler = createCompiler();
|
|
769
|
+
try {
|
|
770
|
+
const scaffolding = compiler.compile(blueprint, groups);
|
|
771
|
+
if (scaffolding.plan === void 0) return [compiler.audit(blueprint, {}, groups), void 0];
|
|
772
|
+
return [materializer.audit(scaffolding.plan, target), scaffolding.plan];
|
|
773
|
+
} finally {
|
|
774
|
+
compiler.destroy();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
#derive(target) {
|
|
778
|
+
const manifest = this.#manifest(target);
|
|
779
|
+
const declared = manifestToName(manifest);
|
|
780
|
+
if (declared === void 0) throw new ScaffoldError("TARGET", `The manifest at ${target} declares no package name.`, { target });
|
|
781
|
+
const bin = resolveContainedPath(target, BIN_ENTRY_PATH);
|
|
782
|
+
const integration = resolveContainedPath(target, INTEGRATION_TEST_PATH);
|
|
783
|
+
const global = resolveContainedPath(target, GLOBAL_SETUP_PATH);
|
|
784
|
+
const showcase = resolveContainedPath(target, SHOWCASE_CONFIG_PATH);
|
|
785
|
+
return createBlueprint(declared.slice(declared.lastIndexOf("/") + 1), {
|
|
786
|
+
src: this.#probe(target, "src"),
|
|
787
|
+
app: this.#probe(target, "app"),
|
|
788
|
+
dependencies: manifestToDependencies(manifest),
|
|
789
|
+
bin: bin !== void 0 && isExactCaseFile(bin),
|
|
790
|
+
integration: integration !== void 0 && isExactCaseFile(integration),
|
|
791
|
+
global: global !== void 0 && isExactCaseFile(global),
|
|
792
|
+
showcase: showcase !== void 0 && isExactCaseFile(showcase)
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
#scriptProjects(script) {
|
|
796
|
+
const tokens = [];
|
|
797
|
+
let value = "";
|
|
798
|
+
let resolved = true;
|
|
799
|
+
let started = false;
|
|
800
|
+
let quote;
|
|
801
|
+
for (let index = 0; index < script.length; index += 1) {
|
|
802
|
+
const character = script[index];
|
|
803
|
+
if (character === void 0) return void 0;
|
|
804
|
+
if (quote === void 0) {
|
|
805
|
+
if (/\s/.test(character)) {
|
|
806
|
+
if (started) tokens.push({
|
|
807
|
+
value,
|
|
808
|
+
resolved
|
|
809
|
+
});
|
|
810
|
+
value = "";
|
|
811
|
+
resolved = true;
|
|
812
|
+
started = false;
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
if (";&|()".includes(character)) {
|
|
816
|
+
if (started) tokens.push({
|
|
817
|
+
value,
|
|
818
|
+
resolved
|
|
819
|
+
});
|
|
820
|
+
const paired = script[index + 1] === character && (character === "&" || character === "|");
|
|
821
|
+
tokens.push({
|
|
822
|
+
value: paired ? `${character}${character}` : character,
|
|
823
|
+
resolved: true
|
|
824
|
+
});
|
|
825
|
+
value = "";
|
|
826
|
+
resolved = true;
|
|
827
|
+
started = false;
|
|
828
|
+
if (paired) index += 1;
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
if (character === "\"" || character === "'") {
|
|
832
|
+
quote = character;
|
|
833
|
+
started = true;
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
if (character === "\\") {
|
|
837
|
+
const escaped = script[index + 1];
|
|
838
|
+
if (escaped === void 0) return void 0;
|
|
839
|
+
value += escaped;
|
|
840
|
+
started = true;
|
|
841
|
+
index += 1;
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
if (character === "$" || character === "`" || character === "%") resolved = false;
|
|
845
|
+
value += character;
|
|
846
|
+
started = true;
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
if (character === quote) {
|
|
850
|
+
quote = void 0;
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (character === "\\" && quote === "\"") {
|
|
854
|
+
const escaped = script[index + 1];
|
|
855
|
+
if (escaped === void 0) return void 0;
|
|
856
|
+
value += escaped;
|
|
857
|
+
index += 1;
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
if (quote === "\"" && (character === "$" || character === "`" || character === "%")) resolved = false;
|
|
861
|
+
value += character;
|
|
862
|
+
started = true;
|
|
863
|
+
}
|
|
864
|
+
if (quote !== void 0) return void 0;
|
|
865
|
+
if (started) tokens.push({
|
|
866
|
+
value,
|
|
867
|
+
resolved
|
|
868
|
+
});
|
|
869
|
+
const projects = [];
|
|
870
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
871
|
+
const token = tokens[index];
|
|
872
|
+
if (token === void 0) return void 0;
|
|
873
|
+
if (token.value === "--project") {
|
|
874
|
+
const project = tokens[index + 1];
|
|
875
|
+
if (project === void 0 || !project.resolved || project.value.length === 0 || [
|
|
876
|
+
"&&",
|
|
877
|
+
"||",
|
|
878
|
+
";",
|
|
879
|
+
"|",
|
|
880
|
+
"&",
|
|
881
|
+
"(",
|
|
882
|
+
")"
|
|
883
|
+
].includes(project.value)) return void 0;
|
|
884
|
+
projects.push(project.value);
|
|
885
|
+
index += 1;
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
if (token.value.startsWith("--project=")) {
|
|
889
|
+
const project = token.value.slice(10);
|
|
890
|
+
if (!token.resolved || project.length === 0) return void 0;
|
|
891
|
+
projects.push(project);
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (!token.resolved && token.value.includes("--project")) return void 0;
|
|
895
|
+
}
|
|
896
|
+
return projects;
|
|
897
|
+
}
|
|
898
|
+
#projectQuestion(target, blueprint, writing = false) {
|
|
899
|
+
const manifest = this.#manifest(target);
|
|
900
|
+
const guides = resolveContainedPath(target, GUIDES_TEST_PATH);
|
|
901
|
+
const planned = blueprintToRootVite(blueprint);
|
|
902
|
+
const parsed = parseJSON(manifest);
|
|
903
|
+
const scripts = isRecord(parsed) && isRecord(parsed.scripts) ? parsed.scripts : void 0;
|
|
904
|
+
const absent = /* @__PURE__ */ new Set();
|
|
905
|
+
let unresolved = false;
|
|
906
|
+
if (scripts !== void 0) for (const script of Object.values(scripts)) {
|
|
907
|
+
if (!isString(script) || !script.includes("vitest")) continue;
|
|
908
|
+
const projects = this.#scriptProjects(script);
|
|
909
|
+
if (projects === void 0) {
|
|
910
|
+
unresolved = true;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
for (const project of projects) {
|
|
914
|
+
const guide = project === "guides" && guides !== void 0 && isExactCaseFile(guides);
|
|
915
|
+
if (!planned.includes(`name: { label: '${project}',`) || project === "guides" && !guide) absent.add(project);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (absent.size === 0 && !unresolved) return void 0;
|
|
919
|
+
const projects = [...absent].sort();
|
|
920
|
+
if (unresolved) return {
|
|
921
|
+
field: "projects",
|
|
922
|
+
message: `The manifest at ${target} contains a Vitest project expression that cannot be resolved statically.${projects.length === 0 ? "" : ` It also names projects the planned configuration does not register: ${projects.join(", ")}.`} ${writing ? "Replace it with a literal --project value or remove the script before using a scaffold writing verb." : "Replace it with a literal --project value before relying on the planned configuration."}`,
|
|
923
|
+
blocking: false
|
|
924
|
+
};
|
|
925
|
+
return {
|
|
926
|
+
field: "projects",
|
|
927
|
+
message: writing ? `The manifest at ${target} names ${projects.length === 1 ? "a Vitest project" : "Vitest projects"} the planned configuration does not register: ${projects.join(", ")}. To continue, remove the ${projects.length === 1 ? "script that names it" : "scripts that name them"} or do not use scaffold writing verbs on a workspace that needs ${projects.length === 1 ? "a custom Vitest project" : "custom Vitest projects"}.` : `The manifest at ${target} names ${projects.length === 1 ? "a Vitest project" : "Vitest projects"} the planned configuration does not register: ${projects.join(", ")}. Add ${projects.length === 1 ? "the project" : "each project"} to vite.config.ts or remove the ${projects.length === 1 ? "script that names it" : "scripts that name them"}.`,
|
|
928
|
+
blocking: false
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
#assertProjects(target, blueprint) {
|
|
932
|
+
const question = this.#projectQuestion(target, blueprint, true);
|
|
933
|
+
if (question === void 0) return;
|
|
934
|
+
throw new ScaffoldError("TARGET", question.message, { target });
|
|
935
|
+
}
|
|
936
|
+
#manifest(target) {
|
|
937
|
+
const manifest = readFileText(target, "package.json", MAX_MANIFEST_BYTES);
|
|
938
|
+
if (manifest === void 0) throw new ScaffoldError("TARGET", `The target at ${target} carries no readable manifest.`, { target });
|
|
939
|
+
return manifest;
|
|
940
|
+
}
|
|
941
|
+
#probe(target, axis) {
|
|
942
|
+
return ENVIRONMENTS.filter((environment) => {
|
|
943
|
+
const full = resolveContainedPath(target, `${axis}/${environment}`);
|
|
944
|
+
return full !== void 0 && isPhysicalDirectory(full);
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
#previous(target) {
|
|
948
|
+
const text = readFileText(target, CATALOG_AGENT_PATH);
|
|
949
|
+
if (text === void 0) return [];
|
|
950
|
+
const names = [];
|
|
951
|
+
for (const table of createMarkdown(text).filter(isTableNode)) for (const row of table.rows) {
|
|
952
|
+
const [cell] = row;
|
|
953
|
+
if (cell === void 0) continue;
|
|
954
|
+
const name = cell.map(flattenText).join("").trim();
|
|
955
|
+
if (DEPENDENCY_NAME_PATTERN.test(name)) names.push(name);
|
|
956
|
+
}
|
|
957
|
+
return names;
|
|
958
|
+
}
|
|
959
|
+
#repository(target) {
|
|
960
|
+
const tracked = this.#inventory(target, ["ls-files", "-z"]);
|
|
961
|
+
const dirty = this.#inventory(target, [
|
|
962
|
+
"status",
|
|
963
|
+
"--porcelain=v1",
|
|
964
|
+
"--untracked-files=all",
|
|
965
|
+
"-z"
|
|
966
|
+
]).map((record) => record.length > 3 && record[2] === " " ? record.slice(3) : record);
|
|
967
|
+
const state = {
|
|
968
|
+
tracked,
|
|
969
|
+
dirty
|
|
970
|
+
};
|
|
971
|
+
if (!isRepository(state)) throw new ScaffoldError("TARGET", `The git state at ${target} is not a readable inventory.`, {
|
|
972
|
+
target,
|
|
973
|
+
tracked: tracked.length,
|
|
974
|
+
dirty: dirty.length
|
|
975
|
+
});
|
|
976
|
+
return state;
|
|
977
|
+
}
|
|
978
|
+
#inventory(target, args) {
|
|
979
|
+
const read = attempt(() => execFileSync("git", [...args], {
|
|
980
|
+
cwd: target,
|
|
981
|
+
encoding: "utf8",
|
|
982
|
+
windowsHide: true,
|
|
983
|
+
maxBuffer: MAX_MANIFEST_BYTES,
|
|
984
|
+
stdio: [
|
|
985
|
+
"ignore",
|
|
986
|
+
"pipe",
|
|
987
|
+
"ignore"
|
|
988
|
+
]
|
|
989
|
+
}));
|
|
990
|
+
if (!read.success) throw new ScaffoldError("TARGET", `The target at ${target} is not a git repository.`, { target });
|
|
991
|
+
return read.value.split("\0").filter((record) => record.length > 0);
|
|
992
|
+
}
|
|
993
|
+
#environments(selection, axis) {
|
|
994
|
+
if (selection === void 0) return [];
|
|
995
|
+
const requested = selection.split(",");
|
|
996
|
+
const refused = requested.filter((name) => !ENVIRONMENTS.some((environment) => environment === name));
|
|
997
|
+
if (refused.length > 0) throw new UsageError(`'--${axis}' does not take ${refused.join(", ")}. It takes ${ENVIRONMENTS.join(", ")}.`);
|
|
998
|
+
return ENVIRONMENTS.filter((environment) => requested.includes(environment));
|
|
999
|
+
}
|
|
1000
|
+
#groups(selection) {
|
|
1001
|
+
if (selection === void 0) return void 0;
|
|
1002
|
+
const requested = selection.split(",");
|
|
1003
|
+
const refused = requested.filter((name) => !GROUPS.some((group) => group === name));
|
|
1004
|
+
if (refused.length > 0) throw new UsageError(`'--groups' does not take ${refused.join(", ")}. It takes ${GROUPS.join(", ")}.`);
|
|
1005
|
+
return GROUPS.filter((group) => requested.includes(group));
|
|
1006
|
+
}
|
|
1007
|
+
#packages(selection) {
|
|
1008
|
+
if (selection === void 0) return [];
|
|
1009
|
+
const requested = selection.split(",");
|
|
1010
|
+
const refused = requested.filter((name) => !DEPENDENCY_NAME_PATTERN.test(name));
|
|
1011
|
+
if (refused.length > 0) throw new UsageError(`'--deps' does not take ${refused.join(", ")}. Every name is a published @orkestrel package.`);
|
|
1012
|
+
return requested;
|
|
1013
|
+
}
|
|
1014
|
+
async #resolve(names) {
|
|
1015
|
+
if (names.length === 0) return [];
|
|
1016
|
+
const upstream = createUpstream(this.#upstream);
|
|
1017
|
+
let releases;
|
|
1018
|
+
try {
|
|
1019
|
+
releases = await upstream.lookup(names.map((name) => ({
|
|
1020
|
+
name,
|
|
1021
|
+
range: "*"
|
|
1022
|
+
})));
|
|
1023
|
+
} finally {
|
|
1024
|
+
upstream.destroy();
|
|
1025
|
+
}
|
|
1026
|
+
const refused = releases.filter((release) => release.lookup !== "found");
|
|
1027
|
+
if (refused.length > 0) throw new ScaffoldError("FETCH", `The registry named no release for ${refused.map((release) => release.name).join(", ")}.`, { names: refused.length });
|
|
1028
|
+
return this.#pin(releases);
|
|
1029
|
+
}
|
|
1030
|
+
#merge(first, second) {
|
|
1031
|
+
return {
|
|
1032
|
+
target: first.target,
|
|
1033
|
+
written: [...first.written, ...second.written],
|
|
1034
|
+
skipped: [...first.skipped, ...second.skipped],
|
|
1035
|
+
removed: [...first.removed, ...second.removed]
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
#present(audit) {
|
|
1039
|
+
for (const question of audit.questions) this.#warn(`${question.field}: ${question.message}`);
|
|
1040
|
+
const rows = audit.findings.filter((finding) => finding.drift !== "aligned").map((finding) => [
|
|
1041
|
+
finding.path,
|
|
1042
|
+
finding.group,
|
|
1043
|
+
finding.drift
|
|
1044
|
+
]);
|
|
1045
|
+
if (rows.length > 0) {
|
|
1046
|
+
const table = renderTable({
|
|
1047
|
+
columns: [
|
|
1048
|
+
{ label: "path" },
|
|
1049
|
+
{ label: "group" },
|
|
1050
|
+
{ label: "drift" }
|
|
1051
|
+
],
|
|
1052
|
+
rows
|
|
1053
|
+
});
|
|
1054
|
+
for (const line of table.split("\n")) this.#say(line);
|
|
1055
|
+
}
|
|
1056
|
+
this.#say(`${String(rows.length)} of ${String(audit.findings.length)} planned path${audit.findings.length === 1 ? "" : "s"} differ from the plan.`);
|
|
1057
|
+
}
|
|
1058
|
+
#recount(result) {
|
|
1059
|
+
this.#say(this.#tally(result));
|
|
1060
|
+
this.#say(`${String(result.entries.length)} published, ${String(result.mirrors.filter((mirror) => mirror.lookup === "found").length)} guide${result.mirrors.length === 1 ? "" : "s"} fetched, ${String(result.dropped.length)} no longer listed.`);
|
|
1061
|
+
for (const mirror of result.mirrors) if (mirror.lookup !== "found") this.#warn(`${mirror.name}: ${mirror.note}`);
|
|
1062
|
+
}
|
|
1063
|
+
#tally(result) {
|
|
1064
|
+
return `${String(result.written.length)} written, ${String(result.skipped.length)} unchanged, ${String(result.removed.length)} removed in ${result.target}.`;
|
|
1065
|
+
}
|
|
1066
|
+
#report(value) {
|
|
1067
|
+
this.#say(JSON.stringify(value));
|
|
1068
|
+
}
|
|
1069
|
+
#refuse(error, json) {
|
|
1070
|
+
const envelope = errorToEnvelope(error);
|
|
1071
|
+
if (json) this.#report(envelope);
|
|
1072
|
+
else this.#warn(`${envelope.error.code}: ${envelope.error.message}`);
|
|
1073
|
+
return isUsageError(error) ? 2 : 1;
|
|
1074
|
+
}
|
|
1075
|
+
#say(line) {
|
|
1076
|
+
this.#output(this.#sanitize(line));
|
|
1077
|
+
}
|
|
1078
|
+
#warn(line) {
|
|
1079
|
+
this.#diagnostic(this.#sanitize(line));
|
|
1080
|
+
}
|
|
1081
|
+
#sanitize(line) {
|
|
1082
|
+
return stripControls(strip(line)).split(/\r?\n/).join(" ");
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
//#endregion
|
|
1086
|
+
//#region src/bin/main.ts
|
|
1087
|
+
process.stdout.on("error", (error) => {
|
|
1088
|
+
if ("code" in error && error.code === "EPIPE") return;
|
|
1089
|
+
throw error;
|
|
1090
|
+
});
|
|
1091
|
+
process.exitCode = await new CLI().execute(process.argv.slice(2));
|
|
1092
|
+
//#endregion
|
|
1093
|
+
|
|
1094
|
+
//# sourceMappingURL=main.js.map
|