heximon 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -4
- package/dist/bin.mjs +2576 -823
- package/dist/index.d.mts +1097 -4
- package/dist/index.mjs +2 -2
- package/dist/skills-command-Czsv_c74.mjs +1799 -0
- package/package.json +16 -16
- package/dist/skills-command-W72zUEYD.mjs +0 -940
|
@@ -0,0 +1,1799 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { cancel, confirm, intro, log, outro, password, select, text } from "@clack/prompts";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { stripVTControlCharacters } from "node:util";
|
|
5
|
+
import { defineCommand, parseArgs, renderUsage, runCommand, runMain } from "citty";
|
|
6
|
+
import { readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { DiagnosticSeverity, SourceLocation } from "@heximon/compiler";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { dirname, join, relative, resolve } from "pathe";
|
|
10
|
+
import { cp, lstat, mkdir, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
11
|
+
//#region src/prompter.ts
|
|
12
|
+
/** Thrown when the operator cancels an interactive prompt (Ctrl-C). */
|
|
13
|
+
var PromptCancelledError = class extends Error {
|
|
14
|
+
constructor() {
|
|
15
|
+
super("Prompt cancelled.");
|
|
16
|
+
this.name = "PromptCancelledError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/clack-prompter.ts
|
|
21
|
+
/**
|
|
22
|
+
* The {@link Prompter} port backed by `@clack/prompts` — the one interactive surface the CLI exposes. A cancelled
|
|
23
|
+
* prompt (clack's sentinel symbol) is normalized to a thrown {@link PromptCancelledError} so the credential
|
|
24
|
+
* resolver handles one cancel failure mode.
|
|
25
|
+
*/
|
|
26
|
+
var ClackPrompter = class {
|
|
27
|
+
/** @throws PromptCancelledError when cancelled. */
|
|
28
|
+
async secret(message) {
|
|
29
|
+
const value = await password({ message });
|
|
30
|
+
if (typeof value === "symbol") throw new PromptCancelledError();
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
/** @throws PromptCancelledError when cancelled. */
|
|
34
|
+
async text(message) {
|
|
35
|
+
const value = await text({ message });
|
|
36
|
+
if (typeof value === "symbol") throw new PromptCancelledError();
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/cli-diagnostics.ts
|
|
42
|
+
/** Builds the {@link CliDiagnostic}s of a `--json` document. */
|
|
43
|
+
var CliDiagnostics = class CliDiagnostics {
|
|
44
|
+
/**
|
|
45
|
+
* Convert compiler diagnostics. Every compiler field stays as it is; `file`, `line` and `column` are added
|
|
46
|
+
* from the primary span. A source file that cannot be read gives `file` only.
|
|
47
|
+
*
|
|
48
|
+
* @param diagnostics - The diagnostics of a compile.
|
|
49
|
+
* @returns One CLI diagnostic for each compiler diagnostic, in the same order.
|
|
50
|
+
*/
|
|
51
|
+
static fromCompiler(diagnostics) {
|
|
52
|
+
const sources = /* @__PURE__ */ new Map();
|
|
53
|
+
return diagnostics.map((diagnostic) => {
|
|
54
|
+
const span = CliDiagnostics.primarySpan(diagnostic);
|
|
55
|
+
if (span === void 0) return { ...diagnostic };
|
|
56
|
+
if (!sources.has(span.sourceFile)) sources.set(span.sourceFile, CliDiagnostics.readSource(span.sourceFile));
|
|
57
|
+
const position = sources.get(span.sourceFile)?.locationAt(span.start);
|
|
58
|
+
return {
|
|
59
|
+
...diagnostic,
|
|
60
|
+
file: span.sourceFile,
|
|
61
|
+
...position === void 0 ? {} : {
|
|
62
|
+
line: position.line,
|
|
63
|
+
column: position.column
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Make one error diagnostic that the CLI raises.
|
|
70
|
+
*
|
|
71
|
+
* @param code - The stable code.
|
|
72
|
+
* @param message - The sentence that names the problem.
|
|
73
|
+
* @param details - The fix hint, the option the diagnostic is about, and the file it points at.
|
|
74
|
+
* @returns The diagnostic.
|
|
75
|
+
*/
|
|
76
|
+
static error(code, message, details = {}) {
|
|
77
|
+
return CliDiagnostics.of(DiagnosticSeverity.Error, code, message, details);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Make one diagnostic that the CLI raises, at a chosen severity.
|
|
81
|
+
*
|
|
82
|
+
* @param severity - `error` fails the command; `warning` is advisory.
|
|
83
|
+
* @param code - The stable code.
|
|
84
|
+
* @param message - The sentence that names the problem.
|
|
85
|
+
* @param details - The fix hint, the option the diagnostic is about, and the file it points at.
|
|
86
|
+
* @returns The diagnostic.
|
|
87
|
+
*/
|
|
88
|
+
static of(severity, code, message, details = {}) {
|
|
89
|
+
return {
|
|
90
|
+
severity,
|
|
91
|
+
code,
|
|
92
|
+
message,
|
|
93
|
+
...details.hint === void 0 ? {} : { hint: details.hint },
|
|
94
|
+
...details.file === void 0 ? {} : { file: details.file },
|
|
95
|
+
...details.option === void 0 ? {} : { option: details.option }
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** The span a renderer anchors on: the diagnostic's own span, else its primary label. */
|
|
99
|
+
static primarySpan(diagnostic) {
|
|
100
|
+
return diagnostic.span ?? diagnostic.labels?.find((label) => label.isPrimary === true)?.span ?? diagnostic.labels?.[0]?.span;
|
|
101
|
+
}
|
|
102
|
+
/** Read one source file for its line table, or `undefined` when it cannot be read. */
|
|
103
|
+
static readSource(sourceFile) {
|
|
104
|
+
try {
|
|
105
|
+
return new SourceLocation(readFileSync(sourceFile, "utf8"));
|
|
106
|
+
} catch {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/cli-json-document.ts
|
|
113
|
+
/**
|
|
114
|
+
* The stable codes of the failures the CLI itself raises in a `--json` document. A compiler diagnostic keeps
|
|
115
|
+
* its own compiler code; these cover what happens around the compile.
|
|
116
|
+
*/
|
|
117
|
+
const CliDiagnosticCode = {
|
|
118
|
+
/** The command threw an error it does not expect. The message is the error message; stderr has the stack. */
|
|
119
|
+
UnexpectedError: "cli.unexpected-error",
|
|
120
|
+
/** The command needs an answer that it would ask for in a prompt, and `--json` never prompts. */
|
|
121
|
+
OptionRequired: "cli.option-required",
|
|
122
|
+
/** The options of the run contradict each other, or an option has a value the command cannot use. */
|
|
123
|
+
InvalidOptions: "cli.invalid-options",
|
|
124
|
+
/** The `heximon.config.ts` sidecar threw when it was loaded. `file` names the sidecar. */
|
|
125
|
+
ConfigUnreadable: "cli.config-unreadable",
|
|
126
|
+
/** The command could not start the child process that runs the app's TypeScript source. */
|
|
127
|
+
ReexecFailed: "cli.reexec-failed",
|
|
128
|
+
/** The arguments do not fit the command: a missing positional, an unknown subcommand. stderr has the usage. */
|
|
129
|
+
UsageError: "cli.usage-error"
|
|
130
|
+
};
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/stdout-redirect.ts
|
|
133
|
+
/**
|
|
134
|
+
* Sends everything written to stdout to stderr for a while, so the log lines of an app that runs in this
|
|
135
|
+
* process cannot mix with the JSON the command prints. {@link write} reaches the true stdout for that JSON,
|
|
136
|
+
* also when this redirect starts inside another one (the executable's, which stays on until the process exits).
|
|
137
|
+
*/
|
|
138
|
+
var StdoutRedirect = class StdoutRedirect {
|
|
139
|
+
/** Every started redirect that has not stopped, so a redirect that starts inside one of them finds it. */
|
|
140
|
+
static started = /* @__PURE__ */ new Set();
|
|
141
|
+
/** The writer this redirect replaced, bound to stdout, for {@link write}. */
|
|
142
|
+
original;
|
|
143
|
+
/** The same writer as it was installed, unbound, which {@link stop} puts back so an outer redirect sees itself. */
|
|
144
|
+
replaced;
|
|
145
|
+
/** The writer this redirect installed, so {@link stop} restores only its own replacement. */
|
|
146
|
+
redirected;
|
|
147
|
+
/** The redirect this one started inside; its {@link write} reaches the true stdout. */
|
|
148
|
+
outer;
|
|
149
|
+
/** Whether {@link write} wrote a document, here or in a redirect started inside this one. */
|
|
150
|
+
documentWritten = false;
|
|
151
|
+
/** Start the redirect. A second call does nothing. */
|
|
152
|
+
start() {
|
|
153
|
+
if (this.original !== void 0) return;
|
|
154
|
+
this.outer = [...StdoutRedirect.started].find((redirect) => redirect.isInstalled());
|
|
155
|
+
this.replaced = Reflect.get(process.stdout, "write");
|
|
156
|
+
this.original = process.stdout.write.bind(process.stdout);
|
|
157
|
+
this.redirected = ((...parameters) => process.stderr.write(...parameters));
|
|
158
|
+
process.stdout.write = this.redirected;
|
|
159
|
+
StdoutRedirect.started.add(this);
|
|
160
|
+
}
|
|
161
|
+
/** Write a document to the true stdout, redirected or not: through the outer redirect, when there is one. */
|
|
162
|
+
write(text) {
|
|
163
|
+
this.documentWritten = true;
|
|
164
|
+
if (this.outer !== void 0) this.outer.write(text);
|
|
165
|
+
else (this.original ?? process.stdout.write.bind(process.stdout))(text);
|
|
166
|
+
}
|
|
167
|
+
/** Whether a document reached the true stdout through this redirect. */
|
|
168
|
+
isDocumentWritten() {
|
|
169
|
+
return this.documentWritten;
|
|
170
|
+
}
|
|
171
|
+
/** Whether the writer of this redirect is the current `process.stdout.write`. */
|
|
172
|
+
isInstalled() {
|
|
173
|
+
return this.redirected !== void 0 && process.stdout.write === this.redirected;
|
|
174
|
+
}
|
|
175
|
+
/** Stop the redirect. A writer that someone installed after this redirect stays in place. */
|
|
176
|
+
stop() {
|
|
177
|
+
if (this.original === void 0) return;
|
|
178
|
+
if (this.isInstalled() && this.replaced !== void 0) process.stdout.write = this.replaced;
|
|
179
|
+
StdoutRedirect.started.delete(this);
|
|
180
|
+
this.original = void 0;
|
|
181
|
+
this.replaced = void 0;
|
|
182
|
+
this.redirected = void 0;
|
|
183
|
+
this.outer = void 0;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/cli-entry.ts
|
|
188
|
+
/**
|
|
189
|
+
* Starts the `heximon` command tree. Without a JSON flag it is citty's own `runMain`. With one, an argument
|
|
190
|
+
* error that citty raises before a command runs (a missing positional, an unknown subcommand) is printed as one
|
|
191
|
+
* {@link CliFailureDocument} on stdout, and the usage text goes to stderr. Any other error there is a
|
|
192
|
+
* `cli.unexpected-error` document, with the stack on stderr. Under a JSON request, stdout is on stderr from the
|
|
193
|
+
* start of the command until the process exits, and the documents reach the true stdout.
|
|
194
|
+
*/
|
|
195
|
+
var CliEntry = class CliEntry {
|
|
196
|
+
/** The flags that print usage; they keep citty's own output, JSON flag or not. */
|
|
197
|
+
static helpFlags = /* @__PURE__ */ new Set(["--help", "-h"]);
|
|
198
|
+
/** The name citty gives its argument and command errors. */
|
|
199
|
+
static usageErrorName = "CLIError";
|
|
200
|
+
/** The output flags as every command declares them, so the entry reads them as the command's parser does. */
|
|
201
|
+
static outputFlags = {
|
|
202
|
+
json: {
|
|
203
|
+
type: "boolean",
|
|
204
|
+
default: false
|
|
205
|
+
},
|
|
206
|
+
format: { type: "string" }
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* Run the command tree.
|
|
210
|
+
*
|
|
211
|
+
* @param main - The root command.
|
|
212
|
+
* @param rawArgs - The arguments after the binary name.
|
|
213
|
+
*/
|
|
214
|
+
static async run(main, rawArgs = process.argv.slice(2)) {
|
|
215
|
+
if (!CliEntry.isJsonRequest(rawArgs) || rawArgs.some((arg) => CliEntry.helpFlags.has(arg))) {
|
|
216
|
+
await runMain(main, { rawArgs: [...rawArgs] });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const stdout = new StdoutRedirect();
|
|
220
|
+
stdout.start();
|
|
221
|
+
try {
|
|
222
|
+
await runCommand(main, { rawArgs: [...rawArgs] });
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (stdout.isDocumentWritten()) {
|
|
225
|
+
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
await CliEntry.reportEntryError(main, rawArgs, error, stdout);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Print an error raised before a command ran as a failure document. */
|
|
233
|
+
static async reportEntryError(main, rawArgs, error, stdout) {
|
|
234
|
+
if (!CliEntry.isUsageError(error)) {
|
|
235
|
+
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
236
|
+
CliEntry.fail(stdout, CliDiagnostics.error(CliDiagnosticCode.UnexpectedError, error instanceof Error ? error.message : String(error), { hint: "The command failed in a way it does not expect. The stack trace is on stderr." }));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const [command, parent] = await CliEntry.commandOf(main, rawArgs);
|
|
240
|
+
process.stderr.write(`${await renderUsage(command, parent)}\n`);
|
|
241
|
+
CliEntry.fail(stdout, CliDiagnostics.error(CliDiagnosticCode.UsageError, stripVTControlCharacters(error.message), { hint: "Run the command with --help to see its arguments and options." }));
|
|
242
|
+
}
|
|
243
|
+
/** Print a {@link CliFailureDocument} with one diagnostic on the true stdout, and set exit status 1. */
|
|
244
|
+
static fail(stdout, diagnostic) {
|
|
245
|
+
const document = {
|
|
246
|
+
ok: false,
|
|
247
|
+
diagnostics: [diagnostic]
|
|
248
|
+
};
|
|
249
|
+
stdout.write(`${JSON.stringify(document)}\n`);
|
|
250
|
+
process.exitCode = 1;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Whether the arguments ask for JSON output: `--json`, or `--format json`. citty's own parser reads them,
|
|
254
|
+
* so `--no-json`, `--json=false`, a repeated `--format` and a `--` separator count as the command sees them.
|
|
255
|
+
*
|
|
256
|
+
* @param rawArgs - The arguments after the binary name.
|
|
257
|
+
* @returns Whether the run prints a JSON document.
|
|
258
|
+
*/
|
|
259
|
+
static isJsonRequest(rawArgs) {
|
|
260
|
+
const parsed = parseArgs([...rawArgs], CliEntry.outputFlags);
|
|
261
|
+
return parsed.json === true || parsed.format === "json";
|
|
262
|
+
}
|
|
263
|
+
/** Whether a thrown value is a citty argument or command error. */
|
|
264
|
+
static isUsageError(error) {
|
|
265
|
+
return error instanceof Error && error.name === CliEntry.usageErrorName;
|
|
266
|
+
}
|
|
267
|
+
/** The deepest subcommand the arguments name, with its parent, for the usage text. */
|
|
268
|
+
static async commandOf(main, rawArgs) {
|
|
269
|
+
let command = main;
|
|
270
|
+
let parent;
|
|
271
|
+
for (const argument of rawArgs) {
|
|
272
|
+
if (argument.startsWith("-")) continue;
|
|
273
|
+
const next = (await CliEntry.resolve(command.subCommands))?.[argument];
|
|
274
|
+
if (next === void 0) break;
|
|
275
|
+
parent = command;
|
|
276
|
+
command = await CliEntry.resolve(next);
|
|
277
|
+
}
|
|
278
|
+
return [command, parent];
|
|
279
|
+
}
|
|
280
|
+
/** A citty `Resolvable`: the value, or what its function returns. */
|
|
281
|
+
static async resolve(value) {
|
|
282
|
+
return typeof value === "function" ? await value() : value;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/cli-environment.ts
|
|
287
|
+
/** Detects how the CLI is being run, so the deploy credential ladder knows whether interactive prompting is safe. */
|
|
288
|
+
var CliEnvironment = class {
|
|
289
|
+
/**
|
|
290
|
+
* Whether this is an interactive session a prompt may block on: a real TTY on stdin AND not a CI runner. In CI
|
|
291
|
+
* (or a piped/non-TTY invocation) the deploy credential ladder must fail fast on a missing value rather than
|
|
292
|
+
* hang on a prompt no one can answer.
|
|
293
|
+
*/
|
|
294
|
+
static isInteractive() {
|
|
295
|
+
return process.stdin.isTTY === true && process.env["CI"] === void 0;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
//#endregion
|
|
299
|
+
//#region src/command-failure.ts
|
|
300
|
+
/**
|
|
301
|
+
* An expected failure that stops a command. {@link CommandOutput.run} prints it as a `{ ok: false }` document
|
|
302
|
+
* under `--json`, and as one `heximon <command>: <message>` line on stderr without it. Both exit 1.
|
|
303
|
+
*/
|
|
304
|
+
var CommandFailure = class CommandFailure extends Error {
|
|
305
|
+
diagnostics;
|
|
306
|
+
/**
|
|
307
|
+
* @param diagnostics - The diagnostics of the failure; the first one gives the error message.
|
|
308
|
+
*/
|
|
309
|
+
constructor(diagnostics) {
|
|
310
|
+
super(diagnostics[0]?.message ?? "The command failed.");
|
|
311
|
+
this.diagnostics = diagnostics;
|
|
312
|
+
this.name = "CommandFailure";
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* A failure with one error diagnostic.
|
|
316
|
+
*
|
|
317
|
+
* @param code - The stable code.
|
|
318
|
+
* @param message - The sentence that names the problem.
|
|
319
|
+
* @param hint - How to fix it.
|
|
320
|
+
* @returns The failure, ready to throw.
|
|
321
|
+
*/
|
|
322
|
+
static of(code, message, hint) {
|
|
323
|
+
return new CommandFailure([CliDiagnostics.error(code, message, hint === void 0 ? {} : { hint })]);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
/**
|
|
327
|
+
* Thrown when a command would prompt under `--json`. The diagnostic has the code
|
|
328
|
+
* {@link CliDiagnosticCode.OptionRequired} and names the option that supplies the answer.
|
|
329
|
+
*/
|
|
330
|
+
var PromptRefusedError = class extends CommandFailure {
|
|
331
|
+
option;
|
|
332
|
+
question;
|
|
333
|
+
/**
|
|
334
|
+
* @param option - The flag or environment variable that answers the prompt, for example `--yes`.
|
|
335
|
+
* @param question - The prompt the command did not show.
|
|
336
|
+
*/
|
|
337
|
+
constructor(option, question) {
|
|
338
|
+
super([CliDiagnostics.error(CliDiagnosticCode.OptionRequired, `The command needs an answer to "${question}", and it never prompts under --json.`, {
|
|
339
|
+
hint: `Pass ${option}.`,
|
|
340
|
+
option
|
|
341
|
+
})]);
|
|
342
|
+
this.option = option;
|
|
343
|
+
this.question = question;
|
|
344
|
+
this.name = "PromptRefusedError";
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/refusing-prompter.ts
|
|
349
|
+
/**
|
|
350
|
+
* The {@link Prompter} of a `--json` run: every prompt fails with a {@link PromptRefusedError} that names the
|
|
351
|
+
* option or environment variable that supplies the answer.
|
|
352
|
+
*/
|
|
353
|
+
var RefusingPrompter = class {
|
|
354
|
+
optionFor;
|
|
355
|
+
/**
|
|
356
|
+
* @param optionFor - Maps a prompt message to the option that answers it, for example `CLOUDFLARE_API_TOKEN`.
|
|
357
|
+
*/
|
|
358
|
+
constructor(optionFor) {
|
|
359
|
+
this.optionFor = optionFor;
|
|
360
|
+
}
|
|
361
|
+
/** @throws PromptRefusedError always. */
|
|
362
|
+
secret(message) {
|
|
363
|
+
return Promise.reject(new PromptRefusedError(this.optionFor(message), message));
|
|
364
|
+
}
|
|
365
|
+
/** @throws PromptRefusedError always. */
|
|
366
|
+
text(message) {
|
|
367
|
+
return Promise.reject(new PromptRefusedError(this.optionFor(message), message));
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/command-output.ts
|
|
372
|
+
/**
|
|
373
|
+
* Owns the output mode of one command run. Under `--json`, stdout holds exactly one JSON document, written
|
|
374
|
+
* when the command ends: everything else written to stdout during the run goes to stderr, a prompt fails, and a
|
|
375
|
+
* thrown error becomes a `{ ok: false, diagnostics }` document.
|
|
376
|
+
*/
|
|
377
|
+
var CommandOutput = class CommandOutput {
|
|
378
|
+
commandName;
|
|
379
|
+
json;
|
|
380
|
+
/** The `--json` argument every command declares, so the flag is the same everywhere. */
|
|
381
|
+
static jsonFlag = {
|
|
382
|
+
type: "boolean",
|
|
383
|
+
description: "Print one JSON document on stdout when the command ends. Logs go to stderr, and the command never prompts.",
|
|
384
|
+
default: false
|
|
385
|
+
};
|
|
386
|
+
/** The document {@link emit} recorded, written when {@link run} ends. */
|
|
387
|
+
document;
|
|
388
|
+
stdout = new StdoutRedirect();
|
|
389
|
+
/**
|
|
390
|
+
* @param commandName - The verb, for the text failure line (`check`, `workflows`).
|
|
391
|
+
* @param json - Whether this run prints a JSON document.
|
|
392
|
+
*/
|
|
393
|
+
constructor(commandName, json) {
|
|
394
|
+
this.commandName = commandName;
|
|
395
|
+
this.json = json;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Decide the output mode of one run.
|
|
399
|
+
*
|
|
400
|
+
* @param commandName - The verb, for the text failure line.
|
|
401
|
+
* @param args - The parsed arguments: `--json`, or `--format json` where the command has `--format`.
|
|
402
|
+
* @returns The output owner of the run.
|
|
403
|
+
*/
|
|
404
|
+
static resolve(commandName, args) {
|
|
405
|
+
return new CommandOutput(commandName, args.json === true || args.format === "json");
|
|
406
|
+
}
|
|
407
|
+
/** Whether this run prints a JSON document instead of text. */
|
|
408
|
+
isJson() {
|
|
409
|
+
return this.json;
|
|
410
|
+
}
|
|
411
|
+
/** Whether a prompt or a spinner may run: an interactive session, and not `--json`. */
|
|
412
|
+
isInteractive() {
|
|
413
|
+
return !this.json && CliEnvironment.isInteractive();
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Run the body of the command. Under `--json` it writes the one document when the body ends: the one
|
|
417
|
+
* {@link emit} recorded, or a failure document when the body threw. A {@link CommandFailure} exits 1 in both
|
|
418
|
+
* modes; without `--json`, any other error is thrown again.
|
|
419
|
+
*
|
|
420
|
+
* @param body - The work of the command. It calls {@link emit} once under `--json`.
|
|
421
|
+
*/
|
|
422
|
+
async run(body) {
|
|
423
|
+
if (!this.json) {
|
|
424
|
+
try {
|
|
425
|
+
await body();
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (!(error instanceof CommandFailure)) throw error;
|
|
428
|
+
this.reportText(error.diagnostics);
|
|
429
|
+
process.exitCode = 1;
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
this.stdout.start();
|
|
434
|
+
try {
|
|
435
|
+
let document;
|
|
436
|
+
try {
|
|
437
|
+
await body();
|
|
438
|
+
document = this.document ?? CommandOutput.failure(CommandOutput.missingDocument());
|
|
439
|
+
} catch (error) {
|
|
440
|
+
if (!(error instanceof CommandFailure)) process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
441
|
+
document = CommandOutput.failure(CommandOutput.diagnosticsOf(error));
|
|
442
|
+
}
|
|
443
|
+
if (document.ok && !CommandOutput.isZero(process.exitCode)) document = {
|
|
444
|
+
...document,
|
|
445
|
+
ok: false,
|
|
446
|
+
diagnostics: [...document.diagnostics, CliDiagnostics.error(CliDiagnosticCode.UnexpectedError, `The command exited with status ${String(process.exitCode)}, but its document said it succeeded.`)]
|
|
447
|
+
};
|
|
448
|
+
let text;
|
|
449
|
+
try {
|
|
450
|
+
text = JSON.stringify(document);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
document = CommandOutput.failure(CommandOutput.diagnosticsOf(error));
|
|
453
|
+
text = JSON.stringify(document);
|
|
454
|
+
}
|
|
455
|
+
this.stdout.write(`${text}\n`);
|
|
456
|
+
if (!document.ok && CommandOutput.isZero(process.exitCode)) process.exitCode = 1;
|
|
457
|
+
} finally {
|
|
458
|
+
this.stdout.stop();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Record the document of the run. It is written when {@link run} ends, so a later throw replaces it with a
|
|
463
|
+
* failure document. Does nothing without `--json`.
|
|
464
|
+
*
|
|
465
|
+
* @param document - The document; its `ok` must agree with the exit status the command sets.
|
|
466
|
+
* @throws Error when the run already recorded a document.
|
|
467
|
+
*/
|
|
468
|
+
emit(document) {
|
|
469
|
+
if (!this.json) return;
|
|
470
|
+
if (this.document !== void 0) throw new Error(`heximon ${this.commandName} recorded a second JSON document.`);
|
|
471
|
+
this.document = document;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Stop before a direct `@clack/prompts` call under `--json`. Does nothing without `--json`.
|
|
475
|
+
*
|
|
476
|
+
* @param option - The option that supplies the answer, for example `--yes` or `--name`.
|
|
477
|
+
* @param question - The prompt the command would show.
|
|
478
|
+
* @throws PromptRefusedError under `--json`.
|
|
479
|
+
*/
|
|
480
|
+
assertCanPrompt(option, question) {
|
|
481
|
+
if (this.json) throw new PromptRefusedError(option, question);
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* The {@link Prompter} of the run: the clack prompts, or under `--json` one that refuses every prompt.
|
|
485
|
+
*
|
|
486
|
+
* @param optionFor - Maps a prompt message to the option or environment variable that answers it.
|
|
487
|
+
* @returns The prompter.
|
|
488
|
+
*/
|
|
489
|
+
prompter(optionFor) {
|
|
490
|
+
return this.json ? new RefusingPrompter(optionFor) : new ClackPrompter();
|
|
491
|
+
}
|
|
492
|
+
/** Print the errors of a {@link CommandFailure} as text lines on stderr. */
|
|
493
|
+
reportText(diagnostics) {
|
|
494
|
+
for (const diagnostic of diagnostics) {
|
|
495
|
+
const hint = diagnostic.hint === void 0 ? "" : ` ${diagnostic.hint}`;
|
|
496
|
+
process.stderr.write(`heximon ${this.commandName}: ${diagnostic.message}${hint}\n`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/** The diagnostics of a thrown value: those of a {@link CommandFailure}, else one unexpected error. */
|
|
500
|
+
static diagnosticsOf(error) {
|
|
501
|
+
if (error instanceof CommandFailure) return error.diagnostics;
|
|
502
|
+
return [CliDiagnostics.error(CliDiagnosticCode.UnexpectedError, error instanceof Error ? error.message : String(error), { hint: "The command failed in a way it does not expect. The stack trace is on stderr." })];
|
|
503
|
+
}
|
|
504
|
+
/** The diagnostic of a body that ended without {@link emit}: a defect of the command. */
|
|
505
|
+
static missingDocument() {
|
|
506
|
+
return [CliDiagnostics.error(CliDiagnosticCode.UnexpectedError, "The command ended without a JSON document.")];
|
|
507
|
+
}
|
|
508
|
+
/** A failure document. */
|
|
509
|
+
static failure(diagnostics) {
|
|
510
|
+
return {
|
|
511
|
+
ok: false,
|
|
512
|
+
diagnostics
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
/** Whether an exit status is unset or zero. */
|
|
516
|
+
static isZero(exitCode) {
|
|
517
|
+
return exitCode === void 0 || exitCode === null || Number(exitCode) === 0;
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
//#endregion
|
|
521
|
+
//#region src/create-json-document.ts
|
|
522
|
+
/** The codes of the failures `heximon create` raises in a `--json` document. */
|
|
523
|
+
const CreateDiagnosticCode = {
|
|
524
|
+
/** The target directory already holds files. `create` never overwrites. */
|
|
525
|
+
TargetNotEmpty: "create.target-not-empty",
|
|
526
|
+
/** The starter template could not be downloaded, from the version branch or from `main`. */
|
|
527
|
+
TemplateDownloadFailed: "create.template-download-failed",
|
|
528
|
+
/** The scaffold failed for another reason, for example a template without a `package.json`. */
|
|
529
|
+
ScaffoldFailed: "create.scaffold-failed",
|
|
530
|
+
/** The registry check before the install failed, so the command did not install. The project stays. */
|
|
531
|
+
RegistryAccessFailed: "create.registry-access-failed",
|
|
532
|
+
/** The dependency install exited with a non-zero status. The project stays. */
|
|
533
|
+
InstallFailed: "create.install-failed",
|
|
534
|
+
/** A warning: the agent skill could not be fetched. The project is complete without it. */
|
|
535
|
+
SkillsUnavailable: "create.skills-unavailable"
|
|
536
|
+
};
|
|
537
|
+
//#endregion
|
|
538
|
+
//#region src/registry-access.ts
|
|
539
|
+
/** The verdict {@link RegistryAccess.probe} classifies an `npm view` attempt into. */
|
|
540
|
+
const RegistryAccessStatus = {
|
|
541
|
+
/** The probe package resolved — the registry is reachable and authorized. */
|
|
542
|
+
Ok: "ok",
|
|
543
|
+
/** The registry rejected the request for lacking (or holding invalid) credentials. */
|
|
544
|
+
Unauthorized: "unauthorized",
|
|
545
|
+
/** The registry could not be reached at all (DNS, network, timeout, an unrecognized failure). */
|
|
546
|
+
Unreachable: "unreachable",
|
|
547
|
+
/** The registry was reached and authorized the request, but the probe package is not there. */
|
|
548
|
+
NotFound: "not-found"
|
|
549
|
+
};
|
|
550
|
+
/**
|
|
551
|
+
* Probes whether `@heximon/*` packages are readable from GitHub Packages, the private registry the
|
|
552
|
+
* framework publishes to (the `heximon` CLI itself stays on public npm). A scaffolded project needs a
|
|
553
|
+
* `.npmrc` scope line plus a token in the user's `~/.npmrc`; this probe reuses npm's own config
|
|
554
|
+
* resolution — the same files npm itself reads — so the verdict matches what an install would do.
|
|
555
|
+
*/
|
|
556
|
+
var RegistryAccess = class RegistryAccess {
|
|
557
|
+
/** The private registry `@heximon/*` packages publish to. */
|
|
558
|
+
static gitHubPackagesRegistry = "https://npm.pkg.github.com";
|
|
559
|
+
/** The package probed to decide reachability — small, always published, always current. */
|
|
560
|
+
static probePackage = "@heximon/runtime";
|
|
561
|
+
/** The scope line a scaffolded project's `.npmrc` carries, routing `@heximon/*` to GitHub Packages. */
|
|
562
|
+
static npmrcScopeLine = `@heximon:registry=${RegistryAccess.gitHubPackagesRegistry}`;
|
|
563
|
+
run;
|
|
564
|
+
/**
|
|
565
|
+
* @param run - The process runner to probe with. Defaults to spawning `npm` for real; a test injects a
|
|
566
|
+
* stub so the probe never touches the network.
|
|
567
|
+
*/
|
|
568
|
+
constructor(run = (command, commandArguments) => RegistryAccess.spawnProcess(command, commandArguments)) {
|
|
569
|
+
this.run = run;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Runs `npm view @heximon/runtime version` against GitHub Packages and classifies the result.
|
|
573
|
+
*
|
|
574
|
+
* @returns The classified verdict, with the raw output kept as `detail` for diagnostics.
|
|
575
|
+
*/
|
|
576
|
+
async probe() {
|
|
577
|
+
const result = await this.run("npm", [
|
|
578
|
+
"view",
|
|
579
|
+
RegistryAccess.probePackage,
|
|
580
|
+
"version",
|
|
581
|
+
`--registry=${RegistryAccess.gitHubPackagesRegistry}`,
|
|
582
|
+
"--json"
|
|
583
|
+
]);
|
|
584
|
+
const combined = `${result.stdout}\n${result.stderr}`;
|
|
585
|
+
const detail = combined.trim();
|
|
586
|
+
if (result.exitCode === 0) return {
|
|
587
|
+
status: RegistryAccessStatus.Ok,
|
|
588
|
+
detail: detail.length === 0 ? void 0 : detail
|
|
589
|
+
};
|
|
590
|
+
if (/E401|E403|ENEEDAUTH/.test(combined)) return {
|
|
591
|
+
status: RegistryAccessStatus.Unauthorized,
|
|
592
|
+
detail
|
|
593
|
+
};
|
|
594
|
+
if (/E404/.test(combined)) return {
|
|
595
|
+
status: RegistryAccessStatus.NotFound,
|
|
596
|
+
detail
|
|
597
|
+
};
|
|
598
|
+
return {
|
|
599
|
+
status: RegistryAccessStatus.Unreachable,
|
|
600
|
+
detail
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* The fix instructions printed when {@link probe} does not report {@link RegistryAccessStatus.Ok}.
|
|
605
|
+
*
|
|
606
|
+
* @param status - The non-Ok verdict, when known — shapes the wording between an auth fix and a network hint.
|
|
607
|
+
*/
|
|
608
|
+
static fixInstructions(status = RegistryAccessStatus.Unauthorized) {
|
|
609
|
+
if (status === RegistryAccessStatus.Unreachable) return [
|
|
610
|
+
`Cannot reach ${RegistryAccess.gitHubPackagesRegistry}.`,
|
|
611
|
+
"Check your network connection.",
|
|
612
|
+
"Then run the install again."
|
|
613
|
+
].join("\n");
|
|
614
|
+
return [
|
|
615
|
+
"Cannot read @heximon/* from GitHub Packages.",
|
|
616
|
+
"Add these lines to ~/.npmrc:",
|
|
617
|
+
` ${RegistryAccess.npmrcScopeLine}`,
|
|
618
|
+
" //npm.pkg.github.com/:_authToken=<a GitHub token with the read:packages scope>",
|
|
619
|
+
"Then run the install again."
|
|
620
|
+
].join("\n");
|
|
621
|
+
}
|
|
622
|
+
/** The default {@link ProcessRunner}: spawns `npm` for real, capturing stdout/stderr. */
|
|
623
|
+
static spawnProcess(command, commandArguments) {
|
|
624
|
+
return new Promise((resolveRun) => {
|
|
625
|
+
const child = spawn(command, commandArguments, { shell: false });
|
|
626
|
+
let stdout = "";
|
|
627
|
+
let stderr = "";
|
|
628
|
+
child.stdout.on("data", (chunk) => {
|
|
629
|
+
stdout += chunk.toString();
|
|
630
|
+
});
|
|
631
|
+
child.stderr.on("data", (chunk) => {
|
|
632
|
+
stderr += chunk.toString();
|
|
633
|
+
});
|
|
634
|
+
child.on("error", (error) => {
|
|
635
|
+
resolveRun({
|
|
636
|
+
exitCode: 1,
|
|
637
|
+
stdout: "",
|
|
638
|
+
stderr: error.message
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
child.on("close", (exitCode) => {
|
|
642
|
+
resolveRun({
|
|
643
|
+
exitCode: exitCode ?? 1,
|
|
644
|
+
stdout,
|
|
645
|
+
stderr
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
//#endregion
|
|
652
|
+
//#region src/project-scaffolder.ts
|
|
653
|
+
/**
|
|
654
|
+
* A template fetch that failed — the giget ref could not be downloaded. Distinct from every local failure so a
|
|
655
|
+
* caller may retry another ref without also retrying (or cleaning up after) a validation or manifest error.
|
|
656
|
+
*/
|
|
657
|
+
var TemplateDownloadError = class extends Error {
|
|
658
|
+
/** The giget source string that could not be fetched. */
|
|
659
|
+
gigetSource;
|
|
660
|
+
constructor(gigetSource, cause) {
|
|
661
|
+
super(`Could not download the template ${gigetSource}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
662
|
+
this.name = "TemplateDownloadError";
|
|
663
|
+
this.gigetSource = gigetSource;
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
/** A target directory that already holds content. `create` never overwrites, so it refuses the target. */
|
|
667
|
+
var ProjectDirectoryOccupiedError = class extends Error {
|
|
668
|
+
projectDirectory;
|
|
669
|
+
/**
|
|
670
|
+
* @param projectDirectory - The absolute directory that is not empty.
|
|
671
|
+
*/
|
|
672
|
+
constructor(projectDirectory) {
|
|
673
|
+
super(`The directory ${projectDirectory} already exists and is not empty — choose another name, or remove the directory yourself.`);
|
|
674
|
+
this.projectDirectory = projectDirectory;
|
|
675
|
+
this.name = "ProjectDirectoryOccupiedError";
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
/**
|
|
679
|
+
* Materializes a new Heximon project from a starters template: resolves the template directory name from
|
|
680
|
+
* the {@link TemplateSelection}, fetches (giget) or copies (local) it, then stamps the project name and
|
|
681
|
+
* the `@heximon/*` dependency ranges onto the fetched `package.json`. Pure filesystem work — prompting,
|
|
682
|
+
* git, and dependency installation live with the CLI command.
|
|
683
|
+
*/
|
|
684
|
+
var ProjectScaffolder = class ProjectScaffolder {
|
|
685
|
+
/** A project/directory name: lowercase npm-safe segments (no scopes — the directory is the name). */
|
|
686
|
+
static projectNamePattern = /^[a-z0-9][a-z0-9._-]*$/;
|
|
687
|
+
/**
|
|
688
|
+
* Resolve the concrete template directory name for a selection — the validator variant is a `-zod` /
|
|
689
|
+
* `-valibot` suffix; `"none"` selects the base directory.
|
|
690
|
+
*
|
|
691
|
+
* @param selection - The template + validator axes.
|
|
692
|
+
* @returns The starters directory name (e.g. `"database-valibot"`).
|
|
693
|
+
*/
|
|
694
|
+
static templateNameOf(selection) {
|
|
695
|
+
return selection.validator === "none" ? selection.template : `${selection.template}-${selection.validator}`;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Validate a project name for use as both the directory and the `package.json` name.
|
|
699
|
+
*
|
|
700
|
+
* @param name - The candidate name.
|
|
701
|
+
* @returns `undefined` when valid, else a human-readable problem statement.
|
|
702
|
+
*/
|
|
703
|
+
static validateProjectName(name) {
|
|
704
|
+
if (name.length === 0) return "The project name is empty.";
|
|
705
|
+
if (!ProjectScaffolder.projectNamePattern.test(name)) return `"${name}" is not a valid project name — use lowercase letters, digits, and . _ - (it becomes the directory and package name).`;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Materialize the project: fetch/copy the template into `<parentDirectory>/<projectName>` and stamp the
|
|
709
|
+
* manifest. A target directory that already holds content is refused, never overwritten.
|
|
710
|
+
*
|
|
711
|
+
* @param request - What to scaffold, from where, and with which stamped versions.
|
|
712
|
+
* @returns The created project directory + the resolved template name.
|
|
713
|
+
*/
|
|
714
|
+
async scaffold(request) {
|
|
715
|
+
const templateName = ProjectScaffolder.templateNameOf(request);
|
|
716
|
+
const { projectDirectory } = await ProjectScaffolder.prepareTarget(request.parentDirectory, request.projectName);
|
|
717
|
+
if (request.source.localDirectory !== void 0) await cp(join(request.source.localDirectory, templateName), projectDirectory, { recursive: true });
|
|
718
|
+
else if (request.source.gigetSource !== void 0) await ProjectScaffolder.fetchTemplate(request.source.gigetSource, projectDirectory);
|
|
719
|
+
else throw new Error("No template source: provide a giget source or a local starters directory.");
|
|
720
|
+
await this.stampManifest(projectDirectory, request);
|
|
721
|
+
await ProjectScaffolder.writeNpmrc(projectDirectory, request.heximonVersion);
|
|
722
|
+
return {
|
|
723
|
+
projectDirectory,
|
|
724
|
+
templateName,
|
|
725
|
+
source: request.source
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Accept a target directory: the name must be valid, the resolved path must stay a direct child of the
|
|
730
|
+
* parent, and the directory must be absent or empty. A directory holding content is refused outright —
|
|
731
|
+
* `create` never overwrites.
|
|
732
|
+
*
|
|
733
|
+
* @param parentDirectory - The directory the project directory is created under.
|
|
734
|
+
* @param projectName - The candidate project (directory + package) name.
|
|
735
|
+
* @returns The accepted target, and whether this call created the directory.
|
|
736
|
+
*/
|
|
737
|
+
static async prepareTarget(parentDirectory, projectName) {
|
|
738
|
+
const problem = ProjectScaffolder.validateProjectName(projectName);
|
|
739
|
+
if (problem !== void 0) throw new Error(problem);
|
|
740
|
+
const parent = resolve(parentDirectory);
|
|
741
|
+
const projectDirectory = resolve(parent, projectName);
|
|
742
|
+
if (projectDirectory === parent || dirname(projectDirectory) !== parent) throw new Error(`"${projectName}" resolves to ${projectDirectory}, which is outside ${parent} — the project directory must be a direct child of the parent directory.`);
|
|
743
|
+
const entries = await ProjectScaffolder.readEntries(projectDirectory);
|
|
744
|
+
if (entries === void 0) {
|
|
745
|
+
await mkdir(projectDirectory, { recursive: true });
|
|
746
|
+
return {
|
|
747
|
+
projectDirectory,
|
|
748
|
+
createdDirectory: true
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
if (entries.length > 0) throw new ProjectDirectoryOccupiedError(projectDirectory);
|
|
752
|
+
return {
|
|
753
|
+
projectDirectory,
|
|
754
|
+
createdDirectory: false
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Discard what a failed scaffold left in a prepared target: the directory itself when this run created it,
|
|
759
|
+
* otherwise only the entries — a pre-existing directory was empty when accepted, so nothing else is ours.
|
|
760
|
+
*/
|
|
761
|
+
static async discardPreparedTarget(target) {
|
|
762
|
+
if (target.createdDirectory) {
|
|
763
|
+
await rm(target.projectDirectory, {
|
|
764
|
+
recursive: true,
|
|
765
|
+
force: true
|
|
766
|
+
});
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
for (const entry of await ProjectScaffolder.readEntries(target.projectDirectory) ?? []) await rm(join(target.projectDirectory, entry), {
|
|
770
|
+
recursive: true,
|
|
771
|
+
force: true
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
/** Fetch a giget template, tagging a fetch failure so a caller can tell it from a local error. */
|
|
775
|
+
static async fetchTemplate(gigetSource, projectDirectory) {
|
|
776
|
+
const { downloadTemplate } = await import("giget");
|
|
777
|
+
try {
|
|
778
|
+
await downloadTemplate(gigetSource, { dir: projectDirectory });
|
|
779
|
+
} catch (error) {
|
|
780
|
+
throw new TemplateDownloadError(gigetSource, error);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Rewrite the scaffolded `package.json`: the project name, and every `@heximon/*` range to the CLI's
|
|
785
|
+
* lockstep version (`^<version>`) or `workspace:*`.
|
|
786
|
+
*/
|
|
787
|
+
async stampManifest(projectDirectory, request) {
|
|
788
|
+
const manifestPath = join(projectDirectory, "package.json");
|
|
789
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
790
|
+
manifest["name"] = request.projectName;
|
|
791
|
+
const range = request.heximonVersion === "workspace" ? "workspace:*" : `^${request.heximonVersion}`;
|
|
792
|
+
for (const section of ["dependencies", "devDependencies"]) {
|
|
793
|
+
const record = manifest[section];
|
|
794
|
+
if (record === void 0) continue;
|
|
795
|
+
for (const dependencyName of Object.keys(record)) if (dependencyName.startsWith("@heximon/") || dependencyName === "heximon") record[dependencyName] = range;
|
|
796
|
+
}
|
|
797
|
+
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Write the `.npmrc` scope line that routes `@heximon/*` installs to GitHub Packages — skipped when the
|
|
801
|
+
* template already ships its own `.npmrc` (never overwritten), and in workspace mode (`heximonVersion ===
|
|
802
|
+
* "workspace"`), where the `@heximon/*` ranges are workspace links and need no registry at all.
|
|
803
|
+
*/
|
|
804
|
+
static async writeNpmrc(projectDirectory, heximonVersion) {
|
|
805
|
+
if (heximonVersion === "workspace") return;
|
|
806
|
+
const npmrcPath = join(projectDirectory, ".npmrc");
|
|
807
|
+
if (await ProjectScaffolder.pathExists(npmrcPath)) return;
|
|
808
|
+
await writeFile(npmrcPath, `${RegistryAccess.npmrcScopeLine}\n`);
|
|
809
|
+
}
|
|
810
|
+
/** Whether a filesystem path exists, of any kind. */
|
|
811
|
+
static async pathExists(path) {
|
|
812
|
+
try {
|
|
813
|
+
await stat(path);
|
|
814
|
+
return true;
|
|
815
|
+
} catch (error) {
|
|
816
|
+
if (error.code === "ENOENT") return false;
|
|
817
|
+
throw error;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/** A directory's entries, or `undefined` when the directory does not exist. */
|
|
821
|
+
static async readEntries(directory) {
|
|
822
|
+
try {
|
|
823
|
+
return await readdir(directory);
|
|
824
|
+
} catch (error) {
|
|
825
|
+
if (error.code === "ENOENT") return;
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
//#endregion
|
|
831
|
+
//#region src/skill-installer.ts
|
|
832
|
+
/**
|
|
833
|
+
* Installs the published Heximon agent skill into a scaffolded project from the docs site's RFC 8615
|
|
834
|
+
* well-known endpoint — `/.well-known/skills/index.json` plus `/.well-known/skills/<name>/<file>`, the
|
|
835
|
+
* same layout `npx skills add https://heximon.dev` consumes (Docus serves the docs repo's `skills/`
|
|
836
|
+
* directory there natively). Best-effort by contract: the caller treats a throw as a skippable step with
|
|
837
|
+
* a printed hint, never a scaffold failure.
|
|
838
|
+
*/
|
|
839
|
+
var SkillInstaller = class SkillInstaller {
|
|
840
|
+
sourceUrl;
|
|
841
|
+
/** The published docs site serving the skill. */
|
|
842
|
+
static defaultSourceUrl = "https://heximon.dev";
|
|
843
|
+
/** Where skills land inside the project — the universal agent layout; `.claude/skills` symlinks here. */
|
|
844
|
+
static targetDirectory = ".agents/skills";
|
|
845
|
+
/** Per-agent skills directories, each created as a relative symlink onto {@link targetDirectory}. */
|
|
846
|
+
static agentSymlinks = [".claude/skills"];
|
|
847
|
+
/** Per-request timeout — scaffolding must never hang on an unreachable docs deploy. */
|
|
848
|
+
static fetchTimeoutMs = 15e3;
|
|
849
|
+
constructor(sourceUrl = SkillInstaller.defaultSourceUrl) {
|
|
850
|
+
this.sourceUrl = sourceUrl;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Install every skill the source publishes into `projectDirectory`.
|
|
854
|
+
*
|
|
855
|
+
* @returns The installed skills, in index order.
|
|
856
|
+
* @throws {Error} When the index is unreachable/malformed or any listed file fails to download.
|
|
857
|
+
*/
|
|
858
|
+
async install(projectDirectory) {
|
|
859
|
+
const base = this.sourceUrl.replace(/\/+$/, "");
|
|
860
|
+
const index = await SkillInstaller.fetchIndex(`${base}/.well-known/skills/index.json`);
|
|
861
|
+
const installed = [];
|
|
862
|
+
for (const entry of index) {
|
|
863
|
+
const files = entry.files.filter((file) => SkillInstaller.isSafeRelativePath(file));
|
|
864
|
+
const skillDirectory = join(projectDirectory, SkillInstaller.targetDirectory, entry.name);
|
|
865
|
+
for (const file of files) {
|
|
866
|
+
const content = await SkillInstaller.fetchText(`${base}/.well-known/skills/${entry.name}/${file}`);
|
|
867
|
+
const targetPath = join(skillDirectory, file);
|
|
868
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
869
|
+
await writeFile(targetPath, content);
|
|
870
|
+
}
|
|
871
|
+
installed.push({
|
|
872
|
+
name: entry.name,
|
|
873
|
+
directory: skillDirectory,
|
|
874
|
+
fileCount: files.length
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
if (installed.length === 0) throw new Error(`No skills published at ${base}/.well-known/skills/index.json`);
|
|
878
|
+
await SkillInstaller.linkAgentDirectories(projectDirectory);
|
|
879
|
+
return installed;
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Point each per-agent skills directory (`.claude/skills`) at the universal `.agents/skills` copy via a
|
|
883
|
+
* relative symlink, so every agent convention reads the one install. Best-effort: an existing entry is
|
|
884
|
+
* left untouched, and a filesystem that cannot symlink (e.g. Windows without developer mode) is not an
|
|
885
|
+
* install failure — the universal copy stands on its own.
|
|
886
|
+
*/
|
|
887
|
+
static async linkAgentDirectories(projectDirectory) {
|
|
888
|
+
for (const agentDirectory of SkillInstaller.agentSymlinks) {
|
|
889
|
+
const linkPath = join(projectDirectory, agentDirectory);
|
|
890
|
+
const parentSegments = agentDirectory.split("/").slice(0, -1);
|
|
891
|
+
const relativeTarget = join(...parentSegments.map(() => ".."), SkillInstaller.targetDirectory);
|
|
892
|
+
try {
|
|
893
|
+
await lstat(linkPath);
|
|
894
|
+
continue;
|
|
895
|
+
} catch {}
|
|
896
|
+
try {
|
|
897
|
+
await mkdir(dirname(linkPath), { recursive: true });
|
|
898
|
+
await symlink(relativeTarget, linkPath, "dir");
|
|
899
|
+
} catch {}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
/** Fetch and validate the discovery index, keeping only well-shaped entries with safe names. */
|
|
903
|
+
static async fetchIndex(indexUrl) {
|
|
904
|
+
const body = await SkillInstaller.fetchJson(indexUrl);
|
|
905
|
+
if (!Array.isArray(body.skills)) throw new Error(`Malformed skills index at ${indexUrl}`);
|
|
906
|
+
return body.skills.flatMap((entry) => typeof entry.name === "string" && /^[a-z0-9][a-z0-9-]*$/.test(entry.name) && Array.isArray(entry.files) ? [{
|
|
907
|
+
name: entry.name,
|
|
908
|
+
files: entry.files.filter((file) => typeof file === "string")
|
|
909
|
+
}] : []);
|
|
910
|
+
}
|
|
911
|
+
/** A relative file path with no traversal, absolute prefix, or backslash trickery. */
|
|
912
|
+
static isSafeRelativePath(file) {
|
|
913
|
+
return file.length > 0 && !file.startsWith("/") && !file.includes("\\") && !file.split("/").includes("..");
|
|
914
|
+
}
|
|
915
|
+
static async fetchJson(url) {
|
|
916
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(SkillInstaller.fetchTimeoutMs) });
|
|
917
|
+
if (!response.ok) throw new Error(`GET ${url} failed with ${response.status.toString()}`);
|
|
918
|
+
return response.json();
|
|
919
|
+
}
|
|
920
|
+
static async fetchText(url) {
|
|
921
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(SkillInstaller.fetchTimeoutMs) });
|
|
922
|
+
if (!response.ok) throw new Error(`GET ${url} failed with ${response.status.toString()}`);
|
|
923
|
+
return response.text();
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
//#endregion
|
|
927
|
+
//#region src/skill-sources.ts
|
|
928
|
+
/**
|
|
929
|
+
* The project's recorded agent-skill sources, persisted as `heximon.skills` in its `package.json`.
|
|
930
|
+
*
|
|
931
|
+
* The manifest rather than the config sidecar, for one reason: `heximon create` records the default source
|
|
932
|
+
* before an app exists to compile, and a tool reads the list without evaluating any TypeScript. Every entry
|
|
933
|
+
* is a base URL {@link SkillInstaller} can install from — the same value `npx skills add <url>` takes.
|
|
934
|
+
*/
|
|
935
|
+
var SkillSources = class SkillSources {
|
|
936
|
+
/** The `package.json` member holding the framework's own manifest block. */
|
|
937
|
+
static manifestKey = "heximon";
|
|
938
|
+
/** The member of that block holding the source list. */
|
|
939
|
+
static listKey = "skills";
|
|
940
|
+
/**
|
|
941
|
+
* The sources recorded for a project, in the order they were added.
|
|
942
|
+
*
|
|
943
|
+
* @param projectDirectory - The project root holding `package.json`.
|
|
944
|
+
* @returns The recorded base URLs; empty when the manifest records none (or has no readable list).
|
|
945
|
+
* @throws {Error} When `package.json` is missing or is not readable JSON — a project whose manifest cannot
|
|
946
|
+
* be read is not one to silently report as having no skills.
|
|
947
|
+
*/
|
|
948
|
+
static async read(projectDirectory) {
|
|
949
|
+
const block = (await SkillSources.readManifest(projectDirectory))[SkillSources.manifestKey];
|
|
950
|
+
if (typeof block !== "object" || block === null || Array.isArray(block)) return [];
|
|
951
|
+
const list = block[SkillSources.listKey];
|
|
952
|
+
return Array.isArray(list) ? list.filter((entry) => typeof entry === "string") : [];
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Record one source, leaving every other manifest member untouched.
|
|
956
|
+
*
|
|
957
|
+
* @param projectDirectory - The project root holding `package.json`.
|
|
958
|
+
* @param sourceUrl - The base URL to record, normalized by {@link normalize} first.
|
|
959
|
+
* @returns `true` when the source was added, `false` when it was already recorded — so recording twice is
|
|
960
|
+
* a no-op the caller can report as such rather than a duplicated entry.
|
|
961
|
+
*/
|
|
962
|
+
static async record(projectDirectory, sourceUrl) {
|
|
963
|
+
const normalized = SkillSources.normalize(sourceUrl);
|
|
964
|
+
const manifest = await SkillSources.readManifest(projectDirectory);
|
|
965
|
+
const existingBlock = manifest[SkillSources.manifestKey];
|
|
966
|
+
const block = typeof existingBlock === "object" && existingBlock !== null && !Array.isArray(existingBlock) ? { ...existingBlock } : {};
|
|
967
|
+
const recorded = Array.isArray(block[SkillSources.listKey]) ? block[SkillSources.listKey].filter((entry) => typeof entry === "string") : [];
|
|
968
|
+
if (recorded.includes(normalized)) return false;
|
|
969
|
+
block[SkillSources.listKey] = [...recorded, normalized];
|
|
970
|
+
manifest[SkillSources.manifestKey] = block;
|
|
971
|
+
await writeFile(join(projectDirectory, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
972
|
+
return true;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* The canonical form of a source URL: trailing slashes removed, so `https://heximon.dev/` and
|
|
976
|
+
* `https://heximon.dev` are one recorded entry rather than two installs of the same site.
|
|
977
|
+
*/
|
|
978
|
+
static normalize(sourceUrl) {
|
|
979
|
+
return sourceUrl.replace(/\/+$/, "");
|
|
980
|
+
}
|
|
981
|
+
/** Whether a string is an absolute `http`/`https` URL — the only form the installer can fetch from. */
|
|
982
|
+
static isInstallable(sourceUrl) {
|
|
983
|
+
try {
|
|
984
|
+
const url = new URL(sourceUrl);
|
|
985
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
986
|
+
} catch {
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
/** The project manifest as a mutable record. */
|
|
991
|
+
static async readManifest(projectDirectory) {
|
|
992
|
+
const manifestPath = join(projectDirectory, "package.json");
|
|
993
|
+
const contents = await readFile(manifestPath, "utf8");
|
|
994
|
+
const manifest = JSON.parse(contents);
|
|
995
|
+
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) throw new Error(`${manifestPath} is not a JSON object.`);
|
|
996
|
+
return manifest;
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
//#endregion
|
|
1000
|
+
//#region src/create-command.ts
|
|
1001
|
+
/** Where a child process of `create` writes its output. */
|
|
1002
|
+
const ChildOutput = {
|
|
1003
|
+
/** Discard every stream (a probe such as `git rev-parse`). */
|
|
1004
|
+
Ignore: "ignore",
|
|
1005
|
+
/** Share this terminal, so the user sees the real output. */
|
|
1006
|
+
Inherit: "inherit",
|
|
1007
|
+
/** No stdin, and stdout copied to this process's stderr: the `--json` mode. */
|
|
1008
|
+
Stderr: "stderr"
|
|
1009
|
+
};
|
|
1010
|
+
/** The `heximon create` verb: scaffold a new Heximon app from the starters templates. */
|
|
1011
|
+
const createCommand = defineCommand({
|
|
1012
|
+
meta: {
|
|
1013
|
+
name: "create",
|
|
1014
|
+
description: "Scaffold a new Heximon app from a starter template (minimal or database, with an optional zod/valibot validator)."
|
|
1015
|
+
},
|
|
1016
|
+
args: {
|
|
1017
|
+
name: {
|
|
1018
|
+
type: "positional",
|
|
1019
|
+
description: "The project directory (and package) name.",
|
|
1020
|
+
required: false
|
|
1021
|
+
},
|
|
1022
|
+
cwd: {
|
|
1023
|
+
type: "string",
|
|
1024
|
+
description: "Parent directory to create the project in.",
|
|
1025
|
+
default: "."
|
|
1026
|
+
},
|
|
1027
|
+
template: {
|
|
1028
|
+
type: "string",
|
|
1029
|
+
description: "Starter template: minimal | database."
|
|
1030
|
+
},
|
|
1031
|
+
validator: {
|
|
1032
|
+
type: "string",
|
|
1033
|
+
description: "Request-body validator baked into the starter: zod | valibot | none."
|
|
1034
|
+
},
|
|
1035
|
+
pm: {
|
|
1036
|
+
type: "string",
|
|
1037
|
+
description: "Package manager for the install step + printed commands (npm/pnpm/yarn/bun). Default: detected from the invoking manager."
|
|
1038
|
+
},
|
|
1039
|
+
install: {
|
|
1040
|
+
type: "boolean",
|
|
1041
|
+
description: "Install dependencies after scaffolding (--no-install to skip)."
|
|
1042
|
+
},
|
|
1043
|
+
git: {
|
|
1044
|
+
type: "boolean",
|
|
1045
|
+
description: "Initialize a git repository in the new project (skipped automatically inside an existing repository).",
|
|
1046
|
+
default: true
|
|
1047
|
+
},
|
|
1048
|
+
yes: {
|
|
1049
|
+
type: "boolean",
|
|
1050
|
+
description: "Accept every default without prompting (non-interactive/CI).",
|
|
1051
|
+
default: false
|
|
1052
|
+
},
|
|
1053
|
+
skills: {
|
|
1054
|
+
type: "boolean",
|
|
1055
|
+
description: "Install the Heximon agent skill from heximon.dev into .agents/skills, with .claude/skills symlinked onto it (--no-skills to skip).",
|
|
1056
|
+
default: true
|
|
1057
|
+
},
|
|
1058
|
+
local: {
|
|
1059
|
+
type: "string",
|
|
1060
|
+
description: "Maintainer path: scaffold from a local starters directory instead of fetching from GitHub."
|
|
1061
|
+
},
|
|
1062
|
+
workspace: {
|
|
1063
|
+
type: "boolean",
|
|
1064
|
+
description: "Maintainer path: stamp workspace:* onto the @heximon/* ranges (in-monorepo dogfooding).",
|
|
1065
|
+
default: false
|
|
1066
|
+
},
|
|
1067
|
+
"skip-registry-check": {
|
|
1068
|
+
type: "boolean",
|
|
1069
|
+
description: "Skip the GitHub Packages read check before installing (@heximon/* publishes there; heximon itself is public npm).",
|
|
1070
|
+
default: false
|
|
1071
|
+
},
|
|
1072
|
+
json: CommandOutput.jsonFlag
|
|
1073
|
+
},
|
|
1074
|
+
async run({ args }) {
|
|
1075
|
+
const commandOutput = CommandOutput.resolve("create", args);
|
|
1076
|
+
await commandOutput.run(async () => {
|
|
1077
|
+
if (commandOutput.isJson() && !args.yes) CreateReport.assertAnswered(args);
|
|
1078
|
+
const interactive = commandOutput.isInteractive() && !args.yes;
|
|
1079
|
+
const packageManager = CreateDefaults.resolvePackageManager(args.pm);
|
|
1080
|
+
const report = new CreateReport(packageManager);
|
|
1081
|
+
intro("heximon create");
|
|
1082
|
+
try {
|
|
1083
|
+
let projectName = args.name;
|
|
1084
|
+
if (projectName === void 0 || projectName.length === 0) {
|
|
1085
|
+
if (interactive) {
|
|
1086
|
+
commandOutput.assertCanPrompt(CreateReport.nameOption, "Project name");
|
|
1087
|
+
const answer = await text({
|
|
1088
|
+
message: "Project name",
|
|
1089
|
+
placeholder: CreateDefaults.projectName,
|
|
1090
|
+
defaultValue: CreateDefaults.projectName,
|
|
1091
|
+
validate: (value) => ProjectScaffolder.validateProjectName(value === void 0 || value === "" ? CreateDefaults.projectName : value)
|
|
1092
|
+
});
|
|
1093
|
+
if (typeof answer === "symbol") {
|
|
1094
|
+
cancel("Cancelled — nothing created.");
|
|
1095
|
+
process.exitCode = 1;
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
projectName = answer;
|
|
1099
|
+
} else projectName = CreateDefaults.projectName;
|
|
1100
|
+
}
|
|
1101
|
+
const nameProblem = ProjectScaffolder.validateProjectName(projectName);
|
|
1102
|
+
if (nameProblem !== void 0) throw new CommandFailure([CliDiagnostics.error(CliDiagnosticCode.InvalidOptions, nameProblem, { option: CreateReport.nameOption })]);
|
|
1103
|
+
const selection = await CreateDefaults.resolveSelection(args.template, args.validator, { interactive });
|
|
1104
|
+
if (selection === void 0) {
|
|
1105
|
+
cancel("Cancelled — nothing created.");
|
|
1106
|
+
process.exitCode = 1;
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
const scaffolder = new ProjectScaffolder();
|
|
1110
|
+
const heximonVersion = args.workspace ? "workspace" : CreateDefaults.cliVersion();
|
|
1111
|
+
const parentDirectory = resolve(process.cwd(), args.cwd);
|
|
1112
|
+
const templateName = ProjectScaffolder.templateNameOf(selection);
|
|
1113
|
+
let result;
|
|
1114
|
+
if (args.local !== void 0) result = await scaffolder.scaffold({
|
|
1115
|
+
...selection,
|
|
1116
|
+
projectName,
|
|
1117
|
+
parentDirectory,
|
|
1118
|
+
heximonVersion,
|
|
1119
|
+
source: { localDirectory: resolve(process.cwd(), args.local) }
|
|
1120
|
+
});
|
|
1121
|
+
else result = await CreateDefaults.scaffoldFromStarters(scaffolder, {
|
|
1122
|
+
selection,
|
|
1123
|
+
projectName,
|
|
1124
|
+
parentDirectory,
|
|
1125
|
+
heximonVersion,
|
|
1126
|
+
templateName
|
|
1127
|
+
});
|
|
1128
|
+
report.scaffolded({
|
|
1129
|
+
projectName,
|
|
1130
|
+
selection,
|
|
1131
|
+
heximonVersion,
|
|
1132
|
+
result
|
|
1133
|
+
});
|
|
1134
|
+
log.success(`Scaffolded ${result.templateName} into ${result.projectDirectory}.`);
|
|
1135
|
+
if (args.git && !args.workspace) {
|
|
1136
|
+
const initialized = await CreateDefaults.initializeGit(result.projectDirectory);
|
|
1137
|
+
report.gitInitialized = initialized;
|
|
1138
|
+
log.info(initialized ? "Initialized a git repository." : "Skipped git init (already inside a repository, or git is unavailable).");
|
|
1139
|
+
}
|
|
1140
|
+
let installNow = args.install;
|
|
1141
|
+
if (installNow === void 0 && interactive) {
|
|
1142
|
+
commandOutput.assertCanPrompt("--install", `Install dependencies with ${packageManager}?`);
|
|
1143
|
+
const answer = await confirm({ message: `Install dependencies with ${packageManager}?` });
|
|
1144
|
+
if (typeof answer === "symbol") {
|
|
1145
|
+
cancel("Cancelled after scaffolding — the project directory was kept.");
|
|
1146
|
+
process.exitCode = 1;
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
installNow = answer;
|
|
1150
|
+
}
|
|
1151
|
+
if (installNow === true || installNow === void 0 && args.yes) {
|
|
1152
|
+
const registryReport = args.workspace || args["skip-registry-check"] ? void 0 : await new RegistryAccess().probe();
|
|
1153
|
+
report.registryAccess = registryReport;
|
|
1154
|
+
if (registryReport === void 0 || registryReport.status === RegistryAccessStatus.Ok) {
|
|
1155
|
+
log.info(`Installing dependencies with ${packageManager}…`);
|
|
1156
|
+
await CreateReport.install(packageManager, result.projectDirectory, { stdoutToStderr: commandOutput.isJson() });
|
|
1157
|
+
log.success("Dependencies installed.");
|
|
1158
|
+
report.dependenciesInstalled = true;
|
|
1159
|
+
} else {
|
|
1160
|
+
const instructions = RegistryAccess.fixInstructions(registryReport.status);
|
|
1161
|
+
log.error(instructions);
|
|
1162
|
+
report.addRegistryFailure(instructions);
|
|
1163
|
+
process.exitCode = 1;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
if (args.skills) try {
|
|
1167
|
+
const installed = await new SkillInstaller().install(result.projectDirectory);
|
|
1168
|
+
await SkillSources.record(result.projectDirectory, SkillInstaller.defaultSourceUrl);
|
|
1169
|
+
report.addSkills(installed);
|
|
1170
|
+
log.success(`Installed the ${installed.map((skill) => skill.name).join(", ")} agent skill into .agents/skills (.claude/skills symlinked), recorded in package.json.`);
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
report.addSkillsUnavailable(error);
|
|
1173
|
+
log.info(`Could not fetch the agent skill — install it later with: npx skills add ${SkillInstaller.defaultSourceUrl}`);
|
|
1174
|
+
}
|
|
1175
|
+
const changeDirectory = relative(process.cwd(), result.projectDirectory);
|
|
1176
|
+
const steps = [
|
|
1177
|
+
`cd ${changeDirectory === "" ? "." : changeDirectory}`,
|
|
1178
|
+
...report.dependenciesInstalled ? [] : [`${packageManager} install`],
|
|
1179
|
+
`${packageManager} run dev`
|
|
1180
|
+
];
|
|
1181
|
+
report.nextSteps = steps;
|
|
1182
|
+
outro(`Done. Next steps:\n\n ${steps.join("\n ")}\n\nThen: curl http://localhost:3000`);
|
|
1183
|
+
commandOutput.emit(report.toDocument());
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
cancel(error instanceof Error ? error.message : String(error));
|
|
1186
|
+
process.exitCode = 1;
|
|
1187
|
+
if (!commandOutput.isJson()) return;
|
|
1188
|
+
if (!report.isScaffolded()) throw new CommandFailure(CreateReport.diagnosticsOf(error, false));
|
|
1189
|
+
report.addDiagnostics(CreateReport.diagnosticsOf(error, true));
|
|
1190
|
+
commandOutput.emit(report.toDocument());
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
/**
|
|
1196
|
+
* The `create` command's defaults + environment glue: package-manager detection, the CLI's own lockstep
|
|
1197
|
+
* version, the starters giget refs (version branch → main fallback), git init, and the install spawn.
|
|
1198
|
+
*/
|
|
1199
|
+
var CreateDefaults = class CreateDefaults {
|
|
1200
|
+
/** The default project (directory + package) name when none is given. */
|
|
1201
|
+
static projectName = "my-heximon-app";
|
|
1202
|
+
/** The GitHub repository the published templates are fetched from. */
|
|
1203
|
+
static startersRepository = "heximon/starters";
|
|
1204
|
+
/**
|
|
1205
|
+
* The CLI's own version — the framework version, since `heximon` releases in the lockstep group. It is
|
|
1206
|
+
* what `@heximon/*` ranges are stamped to.
|
|
1207
|
+
*
|
|
1208
|
+
* @returns The version from this package's own manifest.
|
|
1209
|
+
*/
|
|
1210
|
+
static cliVersion() {
|
|
1211
|
+
return createRequire(import.meta.url)("../package.json").version;
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Detect the invoking package manager from `npm_config_user_agent` (the create-vite trick).
|
|
1215
|
+
*
|
|
1216
|
+
* @param override - An explicit `--pm` value, which wins when given.
|
|
1217
|
+
* @returns One of npm/pnpm/yarn/bun.
|
|
1218
|
+
*/
|
|
1219
|
+
static resolvePackageManager(override) {
|
|
1220
|
+
if (override !== void 0 && [
|
|
1221
|
+
"npm",
|
|
1222
|
+
"pnpm",
|
|
1223
|
+
"yarn",
|
|
1224
|
+
"bun"
|
|
1225
|
+
].includes(override)) return override;
|
|
1226
|
+
const userAgent = process.env["npm_config_user_agent"] ?? "";
|
|
1227
|
+
for (const manager of [
|
|
1228
|
+
"pnpm",
|
|
1229
|
+
"yarn",
|
|
1230
|
+
"bun"
|
|
1231
|
+
]) if (userAgent.startsWith(`${manager}/`)) return manager;
|
|
1232
|
+
return "npm";
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Resolve the template + validator axes from flags, prompts, or defaults.
|
|
1236
|
+
*
|
|
1237
|
+
* @param template - The `--template` flag value, if any.
|
|
1238
|
+
* @param validator - The `--validator` flag value, if any.
|
|
1239
|
+
* @param options - Whether prompting is allowed.
|
|
1240
|
+
* @returns The resolved selection, or `undefined` when the user cancelled a prompt.
|
|
1241
|
+
* @throws CommandFailure when a flag names an unknown template or validator.
|
|
1242
|
+
*/
|
|
1243
|
+
static async resolveSelection(template, validator, options) {
|
|
1244
|
+
let resolvedTemplate = CreateDefaults.narrow(template, ["minimal", "database"]);
|
|
1245
|
+
let resolvedValidator = CreateDefaults.narrow(validator, [
|
|
1246
|
+
"zod",
|
|
1247
|
+
"valibot",
|
|
1248
|
+
"none"
|
|
1249
|
+
]);
|
|
1250
|
+
if (template !== void 0 && resolvedTemplate === void 0) throw new CommandFailure([CliDiagnostics.error(CliDiagnosticCode.InvalidOptions, `Unknown template "${template}" — expected minimal or database.`, { option: "--template" })]);
|
|
1251
|
+
if (validator !== void 0 && resolvedValidator === void 0) throw new CommandFailure([CliDiagnostics.error(CliDiagnosticCode.InvalidOptions, `Unknown validator "${validator}" — expected zod, valibot, or none.`, { option: "--validator" })]);
|
|
1252
|
+
if (resolvedTemplate === void 0) {
|
|
1253
|
+
if (options.interactive) {
|
|
1254
|
+
const answer = await select({
|
|
1255
|
+
message: "Template",
|
|
1256
|
+
options: [{
|
|
1257
|
+
value: "minimal",
|
|
1258
|
+
label: "minimal — the smallest real app"
|
|
1259
|
+
}, {
|
|
1260
|
+
value: "database",
|
|
1261
|
+
label: "database — CRUD over Drizzle + libsql/SQLite"
|
|
1262
|
+
}]
|
|
1263
|
+
});
|
|
1264
|
+
if (typeof answer === "symbol") return;
|
|
1265
|
+
resolvedTemplate = answer;
|
|
1266
|
+
} else resolvedTemplate = "minimal";
|
|
1267
|
+
}
|
|
1268
|
+
if (resolvedValidator === void 0) {
|
|
1269
|
+
if (options.interactive) {
|
|
1270
|
+
const answer = await select({
|
|
1271
|
+
message: "Request-body validator",
|
|
1272
|
+
options: [
|
|
1273
|
+
{
|
|
1274
|
+
value: "zod",
|
|
1275
|
+
label: "zod (the docs default)"
|
|
1276
|
+
},
|
|
1277
|
+
{
|
|
1278
|
+
value: "valibot",
|
|
1279
|
+
label: "valibot"
|
|
1280
|
+
},
|
|
1281
|
+
{
|
|
1282
|
+
value: "none",
|
|
1283
|
+
label: "none — hand-checked bodies"
|
|
1284
|
+
}
|
|
1285
|
+
]
|
|
1286
|
+
});
|
|
1287
|
+
if (typeof answer === "symbol") return;
|
|
1288
|
+
resolvedValidator = answer;
|
|
1289
|
+
} else resolvedValidator = "zod";
|
|
1290
|
+
}
|
|
1291
|
+
return {
|
|
1292
|
+
template: resolvedTemplate,
|
|
1293
|
+
validator: resolvedValidator
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Scaffold from the published starters repository: try the CLI's `v<major.minor>` branch first (the
|
|
1298
|
+
* templates matching this CLI's framework minor), falling back to `main`.
|
|
1299
|
+
*
|
|
1300
|
+
* The target is accepted before the first attempt, so a refusal (bad name, escaping path, existing
|
|
1301
|
+
* content) never reaches the retry — the cleanup below discards only what this call itself created.
|
|
1302
|
+
*/
|
|
1303
|
+
static async scaffoldFromStarters(scaffolder, request) {
|
|
1304
|
+
const [major = "0", minor = "0"] = CreateDefaults.cliVersion().split(".");
|
|
1305
|
+
const base = `gh:${CreateDefaults.startersRepository}/${request.templateName}`;
|
|
1306
|
+
const target = await ProjectScaffolder.prepareTarget(request.parentDirectory, request.projectName);
|
|
1307
|
+
try {
|
|
1308
|
+
return await CreateDefaults.attemptScaffold(scaffolder, request, `${base}#v${major}.${minor}`);
|
|
1309
|
+
} catch (error) {
|
|
1310
|
+
await ProjectScaffolder.discardPreparedTarget(target);
|
|
1311
|
+
if (!(error instanceof TemplateDownloadError)) throw error;
|
|
1312
|
+
}
|
|
1313
|
+
try {
|
|
1314
|
+
return await CreateDefaults.attemptScaffold(scaffolder, request, `${base}#main`);
|
|
1315
|
+
} catch (error) {
|
|
1316
|
+
await ProjectScaffolder.discardPreparedTarget(target);
|
|
1317
|
+
throw error;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
/**
|
|
1321
|
+
* `git init` the project directory, unless it already sits inside a work tree (or git is unavailable).
|
|
1322
|
+
*
|
|
1323
|
+
* @returns Whether a repository was initialized.
|
|
1324
|
+
*/
|
|
1325
|
+
static async initializeGit(projectDirectory) {
|
|
1326
|
+
if (await CreateDefaults.run("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
1327
|
+
cwd: projectDirectory,
|
|
1328
|
+
output: ChildOutput.Ignore
|
|
1329
|
+
}) === 0) return false;
|
|
1330
|
+
return await CreateDefaults.run("git", ["init"], {
|
|
1331
|
+
cwd: projectDirectory,
|
|
1332
|
+
output: ChildOutput.Ignore
|
|
1333
|
+
}) === 0;
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Run `<pm> install` in the project directory with inherited stdio (the user sees the real output).
|
|
1337
|
+
*
|
|
1338
|
+
* @param packageManager - The package manager binary.
|
|
1339
|
+
* @param projectDirectory - The directory to install in.
|
|
1340
|
+
* @param options - `stdoutToStderr` sends the child's stdout to stderr and gives it no stdin, for `--json`.
|
|
1341
|
+
*/
|
|
1342
|
+
static async runInstall(packageManager, projectDirectory, options = {}) {
|
|
1343
|
+
const exitCode = await CreateDefaults.run(packageManager, ["install"], {
|
|
1344
|
+
cwd: projectDirectory,
|
|
1345
|
+
output: options.stdoutToStderr === true ? ChildOutput.Stderr : ChildOutput.Inherit
|
|
1346
|
+
});
|
|
1347
|
+
if (exitCode !== 0) throw new Error(`${packageManager} install failed (exit ${exitCode}) — run it manually inside the project.`);
|
|
1348
|
+
}
|
|
1349
|
+
/** Run one starters attempt against a single giget ref. */
|
|
1350
|
+
static attemptScaffold(scaffolder, request, gigetSource) {
|
|
1351
|
+
return scaffolder.scaffold({
|
|
1352
|
+
...request.selection,
|
|
1353
|
+
projectName: request.projectName,
|
|
1354
|
+
parentDirectory: request.parentDirectory,
|
|
1355
|
+
heximonVersion: request.heximonVersion,
|
|
1356
|
+
source: { gigetSource }
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
/** Narrow a raw flag value to one of the allowed literals, or `undefined`. */
|
|
1360
|
+
static narrow(value, allowed) {
|
|
1361
|
+
return allowed.includes(value) ? value : void 0;
|
|
1362
|
+
}
|
|
1363
|
+
/** Spawn a child process and resolve its exit code (non-zero-safe; spawn errors resolve to 1). */
|
|
1364
|
+
static run(command, commandArguments, options) {
|
|
1365
|
+
return new Promise((resolveExit) => {
|
|
1366
|
+
const child = spawn(command, commandArguments, {
|
|
1367
|
+
cwd: options.cwd,
|
|
1368
|
+
stdio: options.output === ChildOutput.Stderr ? [
|
|
1369
|
+
"ignore",
|
|
1370
|
+
"pipe",
|
|
1371
|
+
"inherit"
|
|
1372
|
+
] : options.output === ChildOutput.Ignore ? "ignore" : "inherit",
|
|
1373
|
+
shell: false
|
|
1374
|
+
});
|
|
1375
|
+
child.stdout?.on("data", (chunk) => {
|
|
1376
|
+
process.stderr.write(chunk);
|
|
1377
|
+
});
|
|
1378
|
+
child.on("error", () => resolveExit(1));
|
|
1379
|
+
child.on("close", (code) => resolveExit(code ?? 1));
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
/**
|
|
1384
|
+
* Collects what one `heximon create` run did, for its `--json` document: the scaffold facts, the git, install
|
|
1385
|
+
* and skill steps, and the diagnostics.
|
|
1386
|
+
*/
|
|
1387
|
+
var CreateReport = class CreateReport {
|
|
1388
|
+
packageManager;
|
|
1389
|
+
/** The name of the positional argument that answers the "Project name" prompt. */
|
|
1390
|
+
static nameOption = "<name>";
|
|
1391
|
+
/** Whether the run created a git repository. */
|
|
1392
|
+
gitInitialized = false;
|
|
1393
|
+
/** Whether the run installed the dependencies. */
|
|
1394
|
+
dependenciesInstalled = false;
|
|
1395
|
+
/** The registry check before the install, when it ran. */
|
|
1396
|
+
registryAccess;
|
|
1397
|
+
/** The shell commands that start the app. */
|
|
1398
|
+
nextSteps = [];
|
|
1399
|
+
facts;
|
|
1400
|
+
skills = [];
|
|
1401
|
+
diagnostics = [];
|
|
1402
|
+
/**
|
|
1403
|
+
* @param packageManager - The package manager of the install step.
|
|
1404
|
+
*/
|
|
1405
|
+
constructor(packageManager) {
|
|
1406
|
+
this.packageManager = packageManager;
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Fail when a `--json` run without `--yes` lacks an answer that a prompt would ask for. Every missing answer
|
|
1410
|
+
* gets its own diagnostic, so one run names them all.
|
|
1411
|
+
*
|
|
1412
|
+
* @param answers - The parsed arguments of the run.
|
|
1413
|
+
* @throws CommandFailure with one {@link CliDiagnosticCode.OptionRequired} diagnostic per missing answer.
|
|
1414
|
+
*/
|
|
1415
|
+
static assertAnswered(answers) {
|
|
1416
|
+
const missing = [];
|
|
1417
|
+
if (answers.name === void 0 || answers.name.length === 0) missing.push(CreateReport.optionRequired(CreateReport.nameOption, "Project name", `Pass the project name as the first argument, or pass --yes to use ${CreateDefaults.projectName}.`));
|
|
1418
|
+
if (answers.template === void 0) missing.push(CreateReport.optionRequired("--template", "Template", "Pass --template minimal or --template database, or pass --yes to use minimal."));
|
|
1419
|
+
if (answers.validator === void 0) missing.push(CreateReport.optionRequired("--validator", "Request-body validator", "Pass --validator zod, valibot or none, or pass --yes to use zod."));
|
|
1420
|
+
if (answers.install === void 0) missing.push(CreateReport.optionRequired("--install", "Install dependencies?", "Pass --install or --no-install, or pass --yes to install."));
|
|
1421
|
+
if (missing.length > 0) throw new CommandFailure(missing);
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Run the dependency install, and turn its failure into a {@link CommandFailure} with the same message.
|
|
1425
|
+
*
|
|
1426
|
+
* @param packageManager - The package manager binary.
|
|
1427
|
+
* @param projectDirectory - The directory to install in.
|
|
1428
|
+
* @param options - Passed on to {@link CreateDefaults.runInstall}.
|
|
1429
|
+
*/
|
|
1430
|
+
static async install(packageManager, projectDirectory, options) {
|
|
1431
|
+
try {
|
|
1432
|
+
await CreateDefaults.runInstall(packageManager, projectDirectory, options);
|
|
1433
|
+
} catch (error) {
|
|
1434
|
+
throw CommandFailure.of(CreateDiagnosticCode.InstallFailed, error instanceof Error ? error.message : String(error), `Run ${packageManager} install in ${projectDirectory}.`);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
/**
|
|
1438
|
+
* The diagnostics of an error that stopped the run.
|
|
1439
|
+
*
|
|
1440
|
+
* @param error - The thrown value.
|
|
1441
|
+
* @param scaffolded - Whether the project directory was complete when the error came.
|
|
1442
|
+
* @returns The diagnostics of a {@link CommandFailure}, else one diagnostic for the error.
|
|
1443
|
+
*/
|
|
1444
|
+
static diagnosticsOf(error, scaffolded) {
|
|
1445
|
+
if (error instanceof CommandFailure) return error.diagnostics;
|
|
1446
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1447
|
+
if (error instanceof ProjectDirectoryOccupiedError) return [CliDiagnostics.error(CreateDiagnosticCode.TargetNotEmpty, message, {
|
|
1448
|
+
hint: "Pass another project name.",
|
|
1449
|
+
option: CreateReport.nameOption
|
|
1450
|
+
})];
|
|
1451
|
+
if (error instanceof TemplateDownloadError) return [CliDiagnostics.error(CreateDiagnosticCode.TemplateDownloadFailed, message, { hint: "Check the connection to GitHub, or pass --local <starters directory>." })];
|
|
1452
|
+
return [CliDiagnostics.error(scaffolded ? CliDiagnosticCode.UnexpectedError : CreateDiagnosticCode.ScaffoldFailed, message)];
|
|
1453
|
+
}
|
|
1454
|
+
/** Whether the project directory is complete, so the document can say what is on disk. */
|
|
1455
|
+
isScaffolded() {
|
|
1456
|
+
return this.facts !== void 0;
|
|
1457
|
+
}
|
|
1458
|
+
/** Record the finished scaffold. */
|
|
1459
|
+
scaffolded(facts) {
|
|
1460
|
+
this.facts = facts;
|
|
1461
|
+
}
|
|
1462
|
+
/** Record the installed agent skills of the default source. */
|
|
1463
|
+
addSkills(installed) {
|
|
1464
|
+
for (const skill of installed) this.skills.push({
|
|
1465
|
+
name: skill.name,
|
|
1466
|
+
source: SkillInstaller.defaultSourceUrl,
|
|
1467
|
+
directory: skill.directory,
|
|
1468
|
+
fileCount: skill.fileCount
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
/** Record a skill fetch that failed: a warning, because the project is complete without the skill. */
|
|
1472
|
+
addSkillsUnavailable(error) {
|
|
1473
|
+
this.diagnostics.push({
|
|
1474
|
+
severity: DiagnosticSeverity.Warning,
|
|
1475
|
+
code: CreateDiagnosticCode.SkillsUnavailable,
|
|
1476
|
+
message: `Could not fetch the agent skill: ${error instanceof Error ? error.message : String(error)}`,
|
|
1477
|
+
hint: `Install it later with: npx skills add ${SkillInstaller.defaultSourceUrl}`
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
/** Record a registry check that failed, from the fix instructions of {@link RegistryAccess}. */
|
|
1481
|
+
addRegistryFailure(instructions) {
|
|
1482
|
+
const [message = instructions, ...hintLines] = instructions.split("\n");
|
|
1483
|
+
this.diagnostics.push(CliDiagnostics.error(CreateDiagnosticCode.RegistryAccessFailed, message, hintLines.length === 0 ? {} : { hint: hintLines.join("\n") }));
|
|
1484
|
+
}
|
|
1485
|
+
/** Record the diagnostics of an error that stopped the run after the scaffold. */
|
|
1486
|
+
addDiagnostics(diagnostics) {
|
|
1487
|
+
this.diagnostics.push(...diagnostics);
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* The document of the run. `ok` is `false` when any diagnostic is an error.
|
|
1491
|
+
*
|
|
1492
|
+
* @throws Error before {@link scaffolded}: a run without a project has no full document.
|
|
1493
|
+
*/
|
|
1494
|
+
toDocument() {
|
|
1495
|
+
if (this.facts === void 0) throw new Error("heximon create has no project to report.");
|
|
1496
|
+
const { projectName, selection, heximonVersion, result } = this.facts;
|
|
1497
|
+
return {
|
|
1498
|
+
ok: !this.diagnostics.some((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error),
|
|
1499
|
+
diagnostics: [...this.diagnostics],
|
|
1500
|
+
projectName,
|
|
1501
|
+
rootDir: result.projectDirectory,
|
|
1502
|
+
template: selection.template,
|
|
1503
|
+
validator: selection.validator,
|
|
1504
|
+
templateName: result.templateName,
|
|
1505
|
+
templateSource: result.source,
|
|
1506
|
+
heximonVersion,
|
|
1507
|
+
packageManager: this.packageManager,
|
|
1508
|
+
gitInitialized: this.gitInitialized,
|
|
1509
|
+
dependenciesInstalled: this.dependenciesInstalled,
|
|
1510
|
+
...this.registryAccess === void 0 ? {} : { registryAccess: this.registryAccess },
|
|
1511
|
+
skills: [...this.skills],
|
|
1512
|
+
nextSteps: this.nextSteps
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
/** One {@link CliDiagnosticCode.OptionRequired} diagnostic, worded as {@link PromptRefusedError} words it. */
|
|
1516
|
+
static optionRequired(option, question, hint) {
|
|
1517
|
+
return CliDiagnostics.error(CliDiagnosticCode.OptionRequired, `The command needs an answer to "${question}", and it never prompts under --json.`, {
|
|
1518
|
+
hint,
|
|
1519
|
+
option
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
};
|
|
1523
|
+
//#endregion
|
|
1524
|
+
//#region src/project-root.ts
|
|
1525
|
+
/**
|
|
1526
|
+
* The project root of a command: its `--cwd` option, resolved and checked. A command reads and writes the
|
|
1527
|
+
* project only after {@link ProjectRoot.resolve} accepts the root, so no step creates a mistyped directory.
|
|
1528
|
+
*/
|
|
1529
|
+
var ProjectRoot = class ProjectRoot {
|
|
1530
|
+
/** The option the diagnostics name. */
|
|
1531
|
+
static option = "--cwd";
|
|
1532
|
+
/** The fix every refusal suggests. */
|
|
1533
|
+
static hint = "Pass --cwd the path of an existing project directory, or leave it out to use the current directory.";
|
|
1534
|
+
/**
|
|
1535
|
+
* Resolve `--cwd` against the current directory, and refuse a path that is not an existing directory.
|
|
1536
|
+
*
|
|
1537
|
+
* @param cwdOption - The raw `--cwd` argument; a missing value means the current directory.
|
|
1538
|
+
* @returns The absolute project root.
|
|
1539
|
+
* @throws CommandFailure with one {@link CliDiagnosticCode.InvalidOptions} diagnostic for `--cwd` when the
|
|
1540
|
+
* path does not exist, is not a directory, or cannot be read.
|
|
1541
|
+
*/
|
|
1542
|
+
static resolve(cwdOption) {
|
|
1543
|
+
const rootDir = resolve(process.cwd(), typeof cwdOption === "string" ? cwdOption : ".");
|
|
1544
|
+
let stats;
|
|
1545
|
+
try {
|
|
1546
|
+
stats = statSync(rootDir, { throwIfNoEntry: false });
|
|
1547
|
+
} catch (error) {
|
|
1548
|
+
if (ProjectRoot.errorCodeOf(error) !== "ENOTDIR") throw ProjectRoot.refusal(`The --cwd project root ${rootDir} cannot be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
1549
|
+
}
|
|
1550
|
+
if (stats === void 0) throw ProjectRoot.refusal(`The --cwd project root ${rootDir} does not exist.`);
|
|
1551
|
+
if (!stats.isDirectory()) throw ProjectRoot.refusal(`The --cwd project root ${rootDir} is not a directory.`);
|
|
1552
|
+
return rootDir;
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* Check the root in a process that hands the run to a child process. A refused root is reported here, through
|
|
1556
|
+
* the output owner and no `--out` file, so no child starts and no startup failure can write under the root.
|
|
1557
|
+
*
|
|
1558
|
+
* @param cwdOption - The raw `--cwd` argument.
|
|
1559
|
+
* @param commandOutput - The output owner of the run.
|
|
1560
|
+
* @returns Whether the root was accepted; when not, the failure is printed and the exit status is 1.
|
|
1561
|
+
*/
|
|
1562
|
+
static async checkBeforeRelaunch(cwdOption, commandOutput) {
|
|
1563
|
+
try {
|
|
1564
|
+
ProjectRoot.resolve(cwdOption);
|
|
1565
|
+
return true;
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
if (!(error instanceof CommandFailure)) throw error;
|
|
1568
|
+
await commandOutput.run(() => Promise.reject(error));
|
|
1569
|
+
return false;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
/** The failure of one refused root. */
|
|
1573
|
+
static refusal(message) {
|
|
1574
|
+
return new CommandFailure([CliDiagnostics.error(CliDiagnosticCode.InvalidOptions, message, {
|
|
1575
|
+
hint: ProjectRoot.hint,
|
|
1576
|
+
option: ProjectRoot.option
|
|
1577
|
+
})]);
|
|
1578
|
+
}
|
|
1579
|
+
/** The `code` of a Node system error, when it has one. */
|
|
1580
|
+
static errorCodeOf(error) {
|
|
1581
|
+
return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
//#endregion
|
|
1585
|
+
//#region src/skills-json-document.ts
|
|
1586
|
+
/** The codes of the failures `heximon skills` raises in a `--json` document. */
|
|
1587
|
+
const SkillsDiagnosticCode = {
|
|
1588
|
+
/** A source could not be installed: its index or one of its files did not download. */
|
|
1589
|
+
InstallFailed: "skills.install-failed",
|
|
1590
|
+
/** The project's `package.json` is missing, or is not a JSON object. */
|
|
1591
|
+
ManifestUnreadable: "skills.manifest-unreadable"
|
|
1592
|
+
};
|
|
1593
|
+
/** What `heximon skills add` did with the `heximon.skills` list in `package.json`. */
|
|
1594
|
+
const SkillSourceRecording = {
|
|
1595
|
+
/** The source was not in the list, and the run added it. */
|
|
1596
|
+
Added: "added",
|
|
1597
|
+
/** The source was in the list already; the run installed it again. */
|
|
1598
|
+
AlreadyRecorded: "already-recorded",
|
|
1599
|
+
/** The install failed, so the run did not record the source. */
|
|
1600
|
+
NotRecorded: "not-recorded"
|
|
1601
|
+
};
|
|
1602
|
+
//#endregion
|
|
1603
|
+
//#region src/skills-command.ts
|
|
1604
|
+
/** The shared behavior behind `heximon skills add` and `heximon skills sync`. */
|
|
1605
|
+
var SkillsRunner = class SkillsRunner {
|
|
1606
|
+
/** The arguments every `skills` subcommand takes. */
|
|
1607
|
+
static baseArgs = {
|
|
1608
|
+
cwd: {
|
|
1609
|
+
type: "string",
|
|
1610
|
+
description: "Project root directory.",
|
|
1611
|
+
default: "."
|
|
1612
|
+
},
|
|
1613
|
+
json: CommandOutput.jsonFlag
|
|
1614
|
+
};
|
|
1615
|
+
/**
|
|
1616
|
+
* Install one source into `projectDirectory`, logging what landed.
|
|
1617
|
+
*
|
|
1618
|
+
* @returns The outcome; a failure is logged and returned, never thrown, so `sync` over several sources
|
|
1619
|
+
* still attempts the rest.
|
|
1620
|
+
*/
|
|
1621
|
+
static async install(projectDirectory, sourceUrl) {
|
|
1622
|
+
try {
|
|
1623
|
+
const installed = await new SkillInstaller(sourceUrl).install(projectDirectory);
|
|
1624
|
+
log.success(`${sourceUrl} → ${SkillsRunner.describe(installed)} in .agents/skills`);
|
|
1625
|
+
return {
|
|
1626
|
+
source: sourceUrl,
|
|
1627
|
+
skills: installed
|
|
1628
|
+
};
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1631
|
+
log.error(`${sourceUrl} — ${message}`);
|
|
1632
|
+
return {
|
|
1633
|
+
source: sourceUrl,
|
|
1634
|
+
skills: [],
|
|
1635
|
+
error: message
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
/** The `--json` entries of one install's skills. */
|
|
1640
|
+
static entriesOf(install) {
|
|
1641
|
+
return install.skills.map((skill) => ({
|
|
1642
|
+
name: skill.name,
|
|
1643
|
+
source: install.source,
|
|
1644
|
+
directory: skill.directory,
|
|
1645
|
+
fileCount: skill.fileCount
|
|
1646
|
+
}));
|
|
1647
|
+
}
|
|
1648
|
+
/** The `skills add` document of a run that recorded nothing. */
|
|
1649
|
+
static notRecorded(projectDirectory, sourceUrl, diagnostic) {
|
|
1650
|
+
return {
|
|
1651
|
+
ok: false,
|
|
1652
|
+
diagnostics: [diagnostic],
|
|
1653
|
+
rootDir: projectDirectory,
|
|
1654
|
+
source: sourceUrl,
|
|
1655
|
+
recording: SkillSourceRecording.NotRecorded,
|
|
1656
|
+
skills: []
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
/** The `skills sync` document of the installs of every recorded source, in list order. */
|
|
1660
|
+
static syncDocument(projectDirectory, installs) {
|
|
1661
|
+
const diagnostics = installs.flatMap((install) => SkillsRunner.diagnosticOf(install) ?? []);
|
|
1662
|
+
return {
|
|
1663
|
+
ok: diagnostics.length === 0,
|
|
1664
|
+
diagnostics,
|
|
1665
|
+
rootDir: projectDirectory,
|
|
1666
|
+
sources: installs.map((install) => ({
|
|
1667
|
+
source: install.source,
|
|
1668
|
+
installed: install.error === void 0
|
|
1669
|
+
})),
|
|
1670
|
+
skills: installs.flatMap((install) => SkillsRunner.entriesOf(install))
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
/** The diagnostic of a failed install, or `undefined` when it succeeded. */
|
|
1674
|
+
static diagnosticOf(install) {
|
|
1675
|
+
if (install.error === void 0) return;
|
|
1676
|
+
return CliDiagnostics.error(SkillsDiagnosticCode.InstallFailed, `${install.source} — ${install.error}`, { hint: "Check that the site serves /.well-known/skills/index.json, then run the command again." });
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Run one `package.json` read or write, and turn a failure into a {@link CommandFailure}.
|
|
1680
|
+
*
|
|
1681
|
+
* @param projectDirectory - The project root, named in the hint.
|
|
1682
|
+
* @param work - The read or write of {@link SkillSources}.
|
|
1683
|
+
* @returns What `work` returned.
|
|
1684
|
+
*/
|
|
1685
|
+
static async manifest(projectDirectory, work) {
|
|
1686
|
+
try {
|
|
1687
|
+
return await work();
|
|
1688
|
+
} catch (error) {
|
|
1689
|
+
throw CommandFailure.of(SkillsDiagnosticCode.ManifestUnreadable, error instanceof Error ? error.message : String(error), `Run the command in a project root with a valid package.json (now ${projectDirectory}), or pass --cwd.`);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
/** One install's skills, as `name (n files)` entries. */
|
|
1693
|
+
static describe(installed) {
|
|
1694
|
+
return installed.map((skill) => `${skill.name} (${skill.fileCount.toString()} files)`).join(", ");
|
|
1695
|
+
}
|
|
1696
|
+
};
|
|
1697
|
+
const addCommand = defineCommand({
|
|
1698
|
+
meta: {
|
|
1699
|
+
name: "add",
|
|
1700
|
+
description: "Record an agent-skill source in package.json and install it into .agents/skills."
|
|
1701
|
+
},
|
|
1702
|
+
args: {
|
|
1703
|
+
...SkillsRunner.baseArgs,
|
|
1704
|
+
url: {
|
|
1705
|
+
type: "positional",
|
|
1706
|
+
description: `The site publishing the skills (default ${SkillInstaller.defaultSourceUrl}).`,
|
|
1707
|
+
required: false
|
|
1708
|
+
}
|
|
1709
|
+
},
|
|
1710
|
+
async run({ args }) {
|
|
1711
|
+
const commandOutput = CommandOutput.resolve("skills add", args);
|
|
1712
|
+
await commandOutput.run(async () => {
|
|
1713
|
+
const projectDirectory = ProjectRoot.resolve(args.cwd);
|
|
1714
|
+
const requested = args.url ?? SkillInstaller.defaultSourceUrl;
|
|
1715
|
+
const sourceUrl = SkillSources.normalize(requested);
|
|
1716
|
+
intro("heximon skills add");
|
|
1717
|
+
if (!SkillSources.isInstallable(sourceUrl)) {
|
|
1718
|
+
const message = `'${requested}' is not an absolute http(s) URL — pass the site publishing the skills.`;
|
|
1719
|
+
process.exitCode = 1;
|
|
1720
|
+
outro(message);
|
|
1721
|
+
commandOutput.emit(SkillsRunner.notRecorded(projectDirectory, sourceUrl, CliDiagnostics.error(CliDiagnosticCode.InvalidOptions, message, {
|
|
1722
|
+
hint: "Pass an absolute http(s) URL, for example https://heximon.dev.",
|
|
1723
|
+
option: "<url>"
|
|
1724
|
+
})));
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const install = await SkillsRunner.install(projectDirectory, sourceUrl);
|
|
1728
|
+
const failure = SkillsRunner.diagnosticOf(install);
|
|
1729
|
+
if (failure !== void 0) {
|
|
1730
|
+
process.exitCode = 1;
|
|
1731
|
+
outro("Nothing was installed, so nothing was recorded.");
|
|
1732
|
+
commandOutput.emit(SkillsRunner.notRecorded(projectDirectory, sourceUrl, failure));
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
const added = await SkillsRunner.manifest(projectDirectory, () => SkillSources.record(projectDirectory, sourceUrl));
|
|
1736
|
+
const document = {
|
|
1737
|
+
ok: true,
|
|
1738
|
+
diagnostics: [],
|
|
1739
|
+
rootDir: projectDirectory,
|
|
1740
|
+
source: sourceUrl,
|
|
1741
|
+
recording: added ? SkillSourceRecording.Added : SkillSourceRecording.AlreadyRecorded,
|
|
1742
|
+
skills: SkillsRunner.entriesOf(install)
|
|
1743
|
+
};
|
|
1744
|
+
outro(added ? `Recorded ${sourceUrl} in package.json (heximon.skills). Re-install it any time with \`heximon skills sync\`.` : `${sourceUrl} was already recorded — the install refreshed it.`);
|
|
1745
|
+
commandOutput.emit(document);
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
});
|
|
1749
|
+
const syncCommand = defineCommand({
|
|
1750
|
+
meta: {
|
|
1751
|
+
name: "sync",
|
|
1752
|
+
description: "Re-install every agent-skill source package.json records."
|
|
1753
|
+
},
|
|
1754
|
+
args: SkillsRunner.baseArgs,
|
|
1755
|
+
async run({ args }) {
|
|
1756
|
+
const commandOutput = CommandOutput.resolve("skills sync", args);
|
|
1757
|
+
await commandOutput.run(async () => {
|
|
1758
|
+
const projectDirectory = ProjectRoot.resolve(args.cwd);
|
|
1759
|
+
intro("heximon skills sync");
|
|
1760
|
+
const sources = await SkillsRunner.manifest(projectDirectory, () => SkillSources.read(projectDirectory));
|
|
1761
|
+
if (sources.length === 0) {
|
|
1762
|
+
outro("No skill sources recorded in package.json (heximon.skills) — add one with `heximon skills add <url>`.");
|
|
1763
|
+
commandOutput.emit(SkillsRunner.syncDocument(projectDirectory, []));
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
const installs = [];
|
|
1767
|
+
for (const sourceUrl of sources) installs.push(await SkillsRunner.install(projectDirectory, sourceUrl));
|
|
1768
|
+
const document = SkillsRunner.syncDocument(projectDirectory, installs);
|
|
1769
|
+
if (!document.ok) {
|
|
1770
|
+
process.exitCode = 1;
|
|
1771
|
+
outro(`${document.diagnostics.length.toString()} of ${sources.length.toString()} source(s) failed — see above.`);
|
|
1772
|
+
commandOutput.emit(document);
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
outro(`${sources.length.toString()} source(s) re-installed into .agents/skills.`);
|
|
1776
|
+
commandOutput.emit(document);
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
});
|
|
1780
|
+
/**
|
|
1781
|
+
* The `heximon skills` command — install the published agent skills into a project and keep the list of
|
|
1782
|
+
* sources in `package.json` (`heximon.skills`), so a second machine or a fresh clone reproduces the same
|
|
1783
|
+
* install with one verb instead of a remembered URL.
|
|
1784
|
+
*
|
|
1785
|
+
* `heximon create` already installs the default source and records it, so an existing project's first use of
|
|
1786
|
+
* this command is usually `sync` after a clone, or `add` for a second, private skill site.
|
|
1787
|
+
*/
|
|
1788
|
+
const skillsCommand = defineCommand({
|
|
1789
|
+
meta: {
|
|
1790
|
+
name: "skills",
|
|
1791
|
+
description: "Install and re-install the agent skills a project records in its package.json."
|
|
1792
|
+
},
|
|
1793
|
+
subCommands: {
|
|
1794
|
+
add: addCommand,
|
|
1795
|
+
sync: syncCommand
|
|
1796
|
+
}
|
|
1797
|
+
});
|
|
1798
|
+
//#endregion
|
|
1799
|
+
export { StdoutRedirect as _, SkillSources as a, ClackPrompter as b, ProjectScaffolder as c, RegistryAccessStatus as d, CommandOutput as f, CliEntry as g, CliEnvironment as h, createCommand as i, TemplateDownloadError as l, PromptRefusedError as m, ProjectRoot as n, SkillInstaller as o, CommandFailure as p, CreateDefaults as r, ProjectDirectoryOccupiedError as s, skillsCommand as t, RegistryAccess as u, CliDiagnosticCode as v, PromptCancelledError as x, CliDiagnostics as y };
|