heximon 0.0.1 → 0.1.1

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/dist/bin.mjs ADDED
@@ -0,0 +1,2912 @@
1
+ #!/usr/bin/env node
2
+ import { c as RegistryAccess, d as ClackPrompter, f as PromptCancelledError, l as RegistryAccessStatus, n as CreateDefaults, r as createCommand, t as skillsCommand, u as CliEnvironment } from "./skills-command-W72zUEYD.mjs";
3
+ import { createRequire, register } from "node:module";
4
+ import { cancel, confirm, intro, isCancel, log, outro, select, spinner } from "@clack/prompts";
5
+ import process from "node:process";
6
+ import { spawnSync } from "node:child_process";
7
+ import { defineCommand, runMain } from "citty";
8
+ import { basename, dirname, join, relative, resolve } from "pathe";
9
+ import tab from "@bomb.sh/tab/citty";
10
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
11
+ import { CompileMode, DiagnosticCode, DiagnosticRenderMode, DiagnosticRenderer, DiagnosticSeverity, RuntimeSingletons, TerminalStyle, UpgradeBaselineFile } from "@heximon/compiler";
12
+ import { ConfigLoader, LibraryEmitter, ProjectCompiler } from "@heximon/compiler/project";
13
+ import { NodeCompileSeam } from "@heximon/node/compiler";
14
+ import { TransactionCensus, TransactionCensusPolicy } from "@heximon/cqrs/compiler";
15
+ import { BoundaryProvenanceAuditor, BoundaryProvenanceStatus, CredentialSelection, DatabaseConfigDetector, DeployDiagnosticSeverity, DeployLedger, DeployManifestStore, DeployRunner, DeployStrategyUnavailableError, DeployTargetResolver, DeployableServiceReader, DotenvCascade, DrizzleKitRunner, EnvFileReader, EnvFileUnreadableError, HostManagedDeployUnsupportedError, ImportScanner, MigrationInspector, MigrationTargetComposer, PackageCopyAuditor, PeerClosureAuditor, PlatformTracingAuditor, PlatformTracingStatus, WorkspaceConsumerLocator } from "@heximon/deploy";
16
+ import { RouteCensus, RouteScopeGap } from "@heximon/http/compiler";
17
+ import { pathToFileURL } from "node:url";
18
+ //#region src/compile-diagnostics.ts
19
+ /**
20
+ * Renders compile diagnostics to stderr with the same codeframe layout the dev server shows — a box-drawing
21
+ * frame on a TTY, a compact one-liner for CI/editor problem-matchers. Shared by the compile-driven commands.
22
+ */
23
+ var CompileDiagnostics = class {
24
+ /** Render `diagnostics` to stderr, displaying each source path relative to `rootDir`. */
25
+ static renderToStderr(rootDir, diagnostics) {
26
+ const renderer = new DiagnosticRenderer({
27
+ mode: process.stderr.isTTY ? DiagnosticRenderMode.Rich : DiagnosticRenderMode.Compact,
28
+ style: TerminalStyle.forStream(process.stderr),
29
+ sourceTextFor: (sourceFile) => {
30
+ try {
31
+ return readFileSync(sourceFile, "utf8");
32
+ } catch {
33
+ return;
34
+ }
35
+ },
36
+ displayPath: (sourceFile) => relative(rootDir, sourceFile) || sourceFile
37
+ });
38
+ process.stderr.write(`${renderer.renderAll(diagnostics)}\n`);
39
+ }
40
+ };
41
+ //#endregion
42
+ //#region src/compile-mode.ts
43
+ /**
44
+ * Resolves whether an inspection command (`check`, `routes`, `graph`) reads a project as an APP or as a
45
+ * published module package, and refuses the two argument combinations no mode can honour.
46
+ *
47
+ * Detection is by package shape ({@link LibraryEmitter.isLibraryPackage}), overridable with `--library` /
48
+ * `--app`. Every command states the outcome in its intro line rather than resolving it silently: a library
49
+ * read as an app reports failures its own `vp pack` never raises, and an app read as a library skips the
50
+ * verdict mirror the whole `pnpm check` chain depends on — neither is discoverable from the output alone.
51
+ */
52
+ var CompileModeResolver = class {
53
+ /** The two flags every inspection command declares, so the three verbs never drift apart. */
54
+ static flags = {
55
+ library: {
56
+ type: "boolean",
57
+ description: "Force the library compile (a published module package), overriding the detected project shape."
58
+ },
59
+ app: {
60
+ type: "boolean",
61
+ description: "Force the app compile, overriding the detected project shape."
62
+ }
63
+ };
64
+ /**
65
+ * Resolve one command's compile mode.
66
+ *
67
+ * @param commandName - The verb, for the refusal messages (`routes`, `graph`, `check`).
68
+ * @param rootDir - The absolute project root the shape is detected from.
69
+ * @param args - The parsed `--library` / `--app` flags.
70
+ * @param sourceDirectories - The resolved `--src` directories, checked against the library single-root rule.
71
+ * @returns The resolved mode, or `undefined` when the command must stop — the reason is already on stderr
72
+ * and `process.exitCode` is set.
73
+ */
74
+ static resolve(commandName, rootDir, args, sourceDirectories) {
75
+ if (args.library === true && args.app === true) {
76
+ process.stderr.write(`heximon ${commandName}: --library and --app are mutually exclusive — pass at most one.\n`);
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+ const forcedMode = args.library === true ? CompileMode.Library : args.app === true ? CompileMode.App : void 0;
81
+ const mode = forcedMode ?? (LibraryEmitter.isLibraryPackage(rootDir) ? CompileMode.Library : CompileMode.App);
82
+ if (mode === CompileMode.Library && (sourceDirectories?.length ?? 0) > 1) {
83
+ process.stderr.write(`heximon ${commandName}: a library compile reads a single source directory, so it cannot honour a multi-directory --src. Pass one directory, or --app to read this project as an application.
84
+ `);
85
+ process.exitCode = 1;
86
+ return;
87
+ }
88
+ return {
89
+ mode,
90
+ forced: forcedMode !== void 0
91
+ };
92
+ }
93
+ /** The mode stamp every command's intro line carries — `library mode`, or `app mode (forced)`. */
94
+ static stamp(resolved) {
95
+ return `${resolved.mode} mode${resolved.forced ? " (forced)" : ""}`;
96
+ }
97
+ };
98
+ //#endregion
99
+ //#region src/project-compile-session.ts
100
+ /**
101
+ * The from-disk compile prologue every inspection command shares (`check`, `graph`, `routes`, `workflows`):
102
+ * resolve the source directories, load the `heximon.config.ts` sidecar for the requested `$env`, derive the
103
+ * platform compile strategy, and merge the plugin set the compile runs with.
104
+ *
105
+ * The `.env` cascade is deliberately NOT loaded here. Only `heximon routes` loads it (so a booted app and its
106
+ * config sidecar read the environment `vp dev` would give them); the compile-only verbs must not pick up a
107
+ * developer's `.env` just by inspecting a project. A caller that wants it loads it before calling
108
+ * {@link open}, so the sidecar read still sees it.
109
+ */
110
+ var ProjectCompileSession = class ProjectCompileSession {
111
+ /** The gitignored directory the compile anchors its generated paths at. */
112
+ static outputDirectoryName = ".heximon";
113
+ /** The absolute project root the compile runs against. */
114
+ rootDir;
115
+ /** The warm compiler — reused by a caller that also prunes or writes type verdicts. */
116
+ compiler;
117
+ /** The resolved sidecar, or `undefined` when the project ships none. */
118
+ config;
119
+ /** The absolute source directories to scan, or `undefined` for the compiler's own `<rootDir>/src`. */
120
+ sourceDirectories;
121
+ /** The absolute `.heximon/` directory the compile anchors its generated paths at. */
122
+ outputDirectory;
123
+ /** The compiler plugins the sidecar declares, merged with the platform strategy's own. */
124
+ plugins;
125
+ strategy;
126
+ constructor(options) {
127
+ this.rootDir = options.rootDir;
128
+ this.compiler = options.compiler;
129
+ this.config = options.config;
130
+ this.sourceDirectories = options.sourceDirectories;
131
+ this.plugins = options.plugins;
132
+ this.strategy = options.strategy;
133
+ this.outputDirectory = join(options.rootDir, ProjectCompileSession.outputDirectoryName);
134
+ }
135
+ /**
136
+ * Resolve one project's compile inputs.
137
+ *
138
+ * @param options - The project root, the `$env` name, and the resolved source directories.
139
+ * @returns The session the caller compiles through.
140
+ */
141
+ static async open(options) {
142
+ const config = await ConfigLoader.load(options.rootDir, options.environment);
143
+ const strategy = NodeCompileSeam.resolveStrategy(config?.platform);
144
+ return new ProjectCompileSession({
145
+ rootDir: options.rootDir,
146
+ compiler: new ProjectCompiler(),
147
+ config,
148
+ sourceDirectories: options.sourceDirectories,
149
+ plugins: NodeCompileSeam.mergePlugins(config?.plugins ?? [], strategy),
150
+ strategy
151
+ });
152
+ }
153
+ /**
154
+ * Run the app-mode compile.
155
+ *
156
+ * @param typeVerdicts - Whether to emit the editor-verdict typegen file (only `heximon check` writes it).
157
+ * @param options - `verifyBaseline` makes every durable concept also report the records the committed
158
+ * `heximon.upgrade.json` never acknowledged. Nothing is written either way.
159
+ * @returns The compile output, diagnostics included.
160
+ */
161
+ compile(typeVerdicts = false, options = {}) {
162
+ return this.compiler.compile({
163
+ rootDir: this.rootDir,
164
+ outputDirectory: this.outputDirectory,
165
+ plugins: this.plugins,
166
+ strategy: this.strategy,
167
+ ...this.sourceDirectories === void 0 ? {} : { sourceDirectories: this.sourceDirectories },
168
+ ...this.config === void 0 ? {} : { config: this.config },
169
+ ...this.config?.root === void 0 ? {} : { apps: { main: this.config.root } },
170
+ ...typeVerdicts ? { typeVerdicts: true } : {},
171
+ ...options.verifyBaseline === true ? { verifyBaseline: true } : {}
172
+ });
173
+ }
174
+ /**
175
+ * Run the library-mode compile — a published module package goes through the same {@link LibraryEmitter}
176
+ * its own `vp pack` build uses, rather than being compiled as an app. Not a byte-for-byte replay of that
177
+ * build: the pack host supplies its own plugin set, wiring mode and `pack.entry`-derived publication set,
178
+ * while this reads plugins from the config sidecar and falls back to the committed `exports` map.
179
+ *
180
+ * Two things make the app-mode compile wrong here, not merely noisy. It enforces every `requires` hop-by-hop
181
+ * against a host binding, while a library leaves that seam open on purpose (the published factory reads a
182
+ * required token as a constructor input); and it looks for one root module, while a multi-root package
183
+ * publishes several. Both surface as errors a `vp pack` of the same source never raises.
184
+ *
185
+ * @param options - `pruneVerdicts` also removes a verdict mirror an EARLIER app-mode run wrote. Library
186
+ * mode emits none to replace it, so nothing would ever overwrite those app-mode-only errors — only
187
+ * `heximon check`, which owns the mirror, asks for it. Every other verb stays write-free.
188
+ * @returns The compile's diagnostics, files and graph projections; a throwing emit becomes one diagnostic.
189
+ */
190
+ compileLibrary(options = {}) {
191
+ const sourceDirectory = this.sourceDirectories?.[0] ?? (this.config?.sourceDirectory === void 0 ? void 0 : resolve(this.rootDir, this.config.sourceDirectory));
192
+ const prunedVerdicts = options.pruneVerdicts === true ? this.compiler.pruneTypeVerdicts({
193
+ rootDir: this.rootDir,
194
+ ...sourceDirectory === void 0 ? {} : { sourceDirectories: [sourceDirectory] }
195
+ }) : void 0;
196
+ try {
197
+ const emitted = new LibraryEmitter(this.compiler).compile({
198
+ rootDir: this.rootDir,
199
+ plugins: this.plugins,
200
+ ...sourceDirectory === void 0 ? {} : { sourceDirectory },
201
+ ...this.config === void 0 ? {} : { config: this.config }
202
+ });
203
+ return {
204
+ diagnostics: emitted.diagnostics,
205
+ files: emitted.files,
206
+ moduleGraph: emitted.moduleGraph,
207
+ boundaryNodes: emitted.boundaryNodes,
208
+ ...prunedVerdicts === void 0 ? {} : { prunedVerdicts }
209
+ };
210
+ } catch (error) {
211
+ return {
212
+ diagnostics: [{
213
+ severity: DiagnosticSeverity.Error,
214
+ message: `The library compile of this package failed: ${error instanceof Error ? error.message : String(error)}`,
215
+ location: this.rootDir
216
+ }],
217
+ files: [],
218
+ moduleGraph: void 0,
219
+ boundaryNodes: [],
220
+ ...prunedVerdicts === void 0 ? {} : { prunedVerdicts }
221
+ };
222
+ }
223
+ }
224
+ /** The `rootDir` + `sourceDirectories` pair the type-verdict write path keys on. */
225
+ typeVerdictTarget() {
226
+ return {
227
+ rootDir: this.rootDir,
228
+ ...this.sourceDirectories === void 0 ? {} : { sourceDirectories: this.sourceDirectories }
229
+ };
230
+ }
231
+ };
232
+ //#endregion
233
+ //#region src/source-directories.ts
234
+ /**
235
+ * Resolves which directories the host-agnostic compile commands (`check`, `graph`, `routes`) scan for Heximon
236
+ * source. The compiler's own default is `<rootDir>/src`; a Nitro/Nuxt host instead keeps its modules under
237
+ * `server/` and passes that directory to the compiler from its own build. The standalone CLI is just another
238
+ * host, so it detects that layout and passes `server/` too — otherwise its compile finds no modules.
239
+ */
240
+ var SourceDirectories = class SourceDirectories {
241
+ /** Host-framework config basenames whose presence marks a project that roots its modules under `server/`. */
242
+ static serverHostConfigNames = [
243
+ "nuxt.config.ts",
244
+ "nuxt.config.mts",
245
+ "nuxt.config.js",
246
+ "nuxt.config.mjs",
247
+ "nitro.config.ts",
248
+ "nitro.config.mts",
249
+ "nitro.config.js",
250
+ "nitro.config.mjs"
251
+ ];
252
+ /**
253
+ * The absolute source directories to scan, or `undefined` to accept the compiler's own `<rootDir>/src`
254
+ * default. An explicit `--src` (comma-separated) always wins; otherwise, when the project has no `src/` but
255
+ * is a Nitro/Nuxt host carrying a `server/` directory, that directory is scanned.
256
+ *
257
+ * @param rootDir - The absolute project root.
258
+ * @param sourceOption - The raw `--src` argument, when the caller passed one.
259
+ * @returns Absolute directories, or `undefined` for the compiler default.
260
+ */
261
+ static resolve(rootDir, sourceOption) {
262
+ if (sourceOption !== void 0 && sourceOption.trim() !== "") {
263
+ const directories = sourceOption.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0).map((entry) => resolve(rootDir, entry));
264
+ if (directories.length > 0) return directories;
265
+ }
266
+ if (SourceDirectories.isDirectory(join(rootDir, "src"))) return;
267
+ if (SourceDirectories.isServerHost(rootDir) && SourceDirectories.isDirectory(join(rootDir, "server"))) return [join(rootDir, "server")];
268
+ }
269
+ /** Whether the root carries a Nitro/Nuxt host config, which roots Heximon modules under `server/`. */
270
+ static isServerHost(rootDir) {
271
+ return SourceDirectories.serverHostConfigNames.some((name) => existsSync(join(rootDir, name)));
272
+ }
273
+ /** Whether `path` exists and is a directory. */
274
+ static isDirectory(path) {
275
+ try {
276
+ return statSync(path).isDirectory();
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+ };
282
+ //#endregion
283
+ //#region src/transaction-census-report.ts
284
+ /**
285
+ * Renders the compile-time transaction census — which commands are wrapped in a transaction, on which
286
+ * database, and the `app.cqrs.autoTransaction` policy that decided it.
287
+ *
288
+ * The wrap is stated rather than inferred, but a stated policy still does not show what it RESOLVED to: a
289
+ * module that binds nothing for the named token ends up unwrapped anyway. This is that answer, without
290
+ * reading generated wiring or booting the app.
291
+ */
292
+ var TransactionCensusReport = class TransactionCensusReport {
293
+ /**
294
+ * Read the census a compile emitted, or `undefined` when the app declares no command handlers.
295
+ *
296
+ * The document's `version` is the producer's own compatibility signal, so it is CHECKED rather than cast
297
+ * past: a future version could reorder or re-mean a field, and rendering it anyway would print a confident
298
+ * transaction verdict from data this build does not understand. A shape that does not match reads as no
299
+ * census — the same silence an app with no commands gets — which is the honest answer here.
300
+ */
301
+ static read(files) {
302
+ const file = files.find((candidate) => candidate.path === TransactionCensus.fileName);
303
+ if (file === void 0) return;
304
+ let parsed;
305
+ try {
306
+ parsed = JSON.parse(file.content);
307
+ } catch {
308
+ return;
309
+ }
310
+ if (typeof parsed !== "object" || parsed === null) return;
311
+ const document = parsed;
312
+ if (document.version !== TransactionCensus.version || typeof document.policy !== "string" || !Array.isArray(document.rows)) return;
313
+ return document;
314
+ }
315
+ /**
316
+ * One summary line, or `undefined` when there is nothing worth saying (no commands at all).
317
+ *
318
+ * Reports the unwrapped count too: with the policy unset that number is the size of the migration, and with
319
+ * a token named it is the set of modules that resolve nothing for it. A composed boundary's commands are
320
+ * counted alongside the app's own — they dispatch through the same registry and the same policy gates them.
321
+ */
322
+ static summarize(document) {
323
+ if (document.rows.length === 0) return;
324
+ const wrapped = document.rows.filter((row) => row.wrapped);
325
+ const composed = document.rows.filter((row) => row.boundary !== void 0);
326
+ const policy = TransactionCensusReport.describePolicy(document.policy);
327
+ const fromPackages = composed.length === 0 ? "" : ` ${String(composed.length)} of them composed from a package.`;
328
+ return `${String(wrapped.length)}/${String(document.rows.length)} command(s) run in a transaction (${policy}).${fromPackages}`;
329
+ }
330
+ /**
331
+ * The unwrapped commands, one `Command — Module` line each, for the detail under {@link summarize}. A
332
+ * composed boundary's row names its package instead of a module — the app declares no module for it.
333
+ */
334
+ static unwrapped(document) {
335
+ return document.rows.filter((row) => !row.wrapped).map((row) => TransactionCensusReport.describeRow(row));
336
+ }
337
+ /**
338
+ * The WRAPPED boundary commands, one line each naming the package and the database its wrap opens on.
339
+ *
340
+ * This is the disclosure the summary line cannot make: across a package seam the app's policy GATES the
341
+ * wrap but does not SELECT its target — the database rides the boundary's own thunk — so an app that named
342
+ * a concrete `Database` subclass still has these commands wrapped on a connection the package chose, and
343
+ * an `internal` origin is one nothing in the app can reach.
344
+ */
345
+ static composedWraps(document) {
346
+ return document.rows.filter((row) => row.wrapped && row.boundary !== void 0).map((row) => `${row.command} — ${String(row.boundary)} → ${row.database ?? "unknown"}${row.origin === void 0 ? "" : ` (${row.origin})`}`);
347
+ }
348
+ /** The census header rendered for the summary line — the reserved values named, any other read as a token. */
349
+ static describePolicy(policy) {
350
+ if (policy === TransactionCensusPolicy.Unset) return "no app.cqrs.autoTransaction policy";
351
+ if (policy === TransactionCensusPolicy.Invalid) return "app.cqrs.autoTransaction rejected by the build";
352
+ if (policy === TransactionCensusPolicy.Off) return "app.cqrs.autoTransaction: false";
353
+ return `app.cqrs.autoTransaction: ${policy}`;
354
+ }
355
+ /** One row as a detail line: the command, attributed to its owning module or its producing package. */
356
+ static describeRow(row) {
357
+ const attribution = row.boundary ?? row.module;
358
+ return `${row.command}${attribution === void 0 ? "" : ` — ${attribution}`}`;
359
+ }
360
+ };
361
+ //#endregion
362
+ //#region src/upgrade-baseline.ts
363
+ /**
364
+ * The upgrade-safety baseline's command-side helpers. Every one of them is CONCEPT-NEUTRAL: the committed file
365
+ * carries one section per durable concept, so a command that special-cased workflows would leave every other
366
+ * concept's gate half-wired — unacknowledgeable by `--update`, or unarmed with no nudge.
367
+ */
368
+ var UpgradeBaseline = class UpgradeBaseline {
369
+ /**
370
+ * The breaking-verdict codes `--update` acknowledges. Each concept contributes one; a code missing here would
371
+ * survive its own acknowledgement and fail the command that just rewrote the baseline.
372
+ */
373
+ static breakingCodes = /* @__PURE__ */ new Set([
374
+ DiagnosticCode.WorkflowUpgradeBreaking,
375
+ DiagnosticCode.QueueUpgradeBreaking,
376
+ DiagnosticCode.IntegrationUpgradeBreaking,
377
+ DiagnosticCode.SagaUpgradeBreaking
378
+ ]);
379
+ /** Whether a diagnostic is a breaking upgrade verdict that `--update` acknowledges. */
380
+ static isBreaking(code) {
381
+ return code !== void 0 && UpgradeBaseline.breakingCodes.has(code);
382
+ }
383
+ /** Whether this compile emitted ANY concept's baseline section — i.e. whether the project has a durable surface. */
384
+ static hasDurableSurface(files) {
385
+ return files.some((file) => UpgradeBaselineFile.sectionOfGeneratedPath(file.path) !== void 0);
386
+ }
387
+ /**
388
+ * `--update`: recompose the committed baseline from every concept section this compile emitted
389
+ * (write-if-changed) — the explicit acknowledgement that clears the breaking-change gate. Returns the target +
390
+ * whether it changed, or `undefined` when the project has no durable surface to baseline.
391
+ *
392
+ * Composing rather than copying is what lets a further concept join the file without touching this command:
393
+ * each plugin emits its own section, and the envelope is assembled here from whatever was emitted.
394
+ */
395
+ static update(rootDir, files) {
396
+ const sections = /* @__PURE__ */ new Map();
397
+ for (const file of files) {
398
+ const section = UpgradeBaselineFile.sectionOfGeneratedPath(file.path);
399
+ if (section === void 0) continue;
400
+ try {
401
+ sections.set(section, JSON.parse(file.content));
402
+ } catch {
403
+ continue;
404
+ }
405
+ }
406
+ if (sections.size === 0) return;
407
+ const content = UpgradeBaselineFile.compose(sections);
408
+ const target = join(rootDir, UpgradeBaselineFile.fileName);
409
+ let changed = true;
410
+ try {
411
+ changed = readFileSync(target, "utf8") !== content;
412
+ } catch {
413
+ changed = true;
414
+ }
415
+ if (changed) writeFileSync(target, content);
416
+ return {
417
+ target,
418
+ changed
419
+ };
420
+ }
421
+ };
422
+ //#endregion
423
+ //#region src/check-command.ts
424
+ /**
425
+ * `heximon check` — the headless, compile-only gate. In an APP it runs the from-disk Heximon compile with
426
+ * `typeVerdicts: true`, refreshes the editor-verdict mirror (`writeTypeVerdicts` + `healTsconfigInclude` — the
427
+ * same write path the dev server runs), reports the compile diagnostics, and exits non-zero on any error
428
+ * (`--strict` also fails on warnings). Starters wire `"check": "heximon check && vp check"` so `pnpm check`
429
+ * refreshes the DI verdicts *before* tsgo reads them — `vp check`/`tsgo` never run the Heximon compiler, so
430
+ * without this step a stale verdict survives the type-check.
431
+ *
432
+ * Two opt-in passes ride the same compile. `--emit` (and the sidecar's own `debug: true`) writes the generated
433
+ * wiring to `.heximon/` and reformats it in place, so what the compiler produced can be read; a bare run writes
434
+ * only the verdict mirror. `--verify-baseline` makes every durable concept ALSO report the records the
435
+ * committed `heximon.upgrade.json` never acknowledged — the direction the diff does not walk, since adding a
436
+ * record is safe to deploy. It writes nothing, and is refused alongside `--update`, which acknowledges exactly
437
+ * what it exists to show.
438
+ *
439
+ * In a PUBLISHED MODULE PACKAGE it compiles the library the way `vp pack` does instead
440
+ * ({@link ProjectCompileSession.compileLibrary}),
441
+ * because the app-mode compile of a library reports failures its own pack never raises. The mode is detected
442
+ * from the package shape ({@link LibraryEmitter.isLibraryPackage}), overridable with `--library` / `--app`, and
443
+ * always stated in the intro line — a wrongly suppressed verdict mirror in a real app would be worse than the
444
+ * miscompile it fixes, so the resolution is never silent.
445
+ */
446
+ const checkCommand = defineCommand({
447
+ meta: {
448
+ name: "check",
449
+ description: "Compile the project headlessly, refresh the DI type-verdict mirror, and report compile diagnostics."
450
+ },
451
+ args: {
452
+ cwd: {
453
+ type: "string",
454
+ description: "Project root directory.",
455
+ default: "."
456
+ },
457
+ format: {
458
+ type: "string",
459
+ description: "Output format: 'pretty' (default) or 'json'.",
460
+ default: "pretty"
461
+ },
462
+ strict: {
463
+ type: "boolean",
464
+ description: "Fail (exit 1) on warnings as well as errors.",
465
+ default: false
466
+ },
467
+ env: {
468
+ type: "string",
469
+ description: "The heximon.config.ts `$env` name to resolve (matches `vp dev`).",
470
+ default: "development"
471
+ },
472
+ src: {
473
+ type: "string",
474
+ description: "Source directories to scan, comma-separated (a library compile takes a single directory). Defaults to `src/`, or `server/` for a Nitro/Nuxt host."
475
+ },
476
+ update: {
477
+ type: "boolean",
478
+ description: "Regenerate + write the committed `heximon.upgrade.json` upgrade-safety baseline — the acknowledgement that clears a breaking durable-surface change (drain the in-flight work first).",
479
+ default: false
480
+ },
481
+ library: CompileModeResolver.flags.library,
482
+ app: CompileModeResolver.flags.app,
483
+ "verify-baseline": {
484
+ type: "boolean",
485
+ description: "Also report every durable record the committed `heximon.upgrade.json` never acknowledged (workflows, saga timeouts, queue channels, integration subscriptions). Writes nothing; pair with --strict to fail on them.",
486
+ default: false
487
+ },
488
+ emit: {
489
+ type: "boolean",
490
+ description: "Write the generated wiring to `.heximon/` and prettify it, for inspection. The sidecar's `debug: true` turns this on for every run.",
491
+ default: false
492
+ }
493
+ },
494
+ async run({ args }) {
495
+ const rootDir = resolve(process.cwd(), args.cwd);
496
+ const asJson = args.format === "json";
497
+ const sourceDirectories = SourceDirectories.resolve(rootDir, args.src);
498
+ const resolved = CompileModeResolver.resolve("check", rootDir, args, sourceDirectories);
499
+ if (resolved === void 0) return;
500
+ const { mode } = resolved;
501
+ const verifyBaseline = args["verify-baseline"] === true;
502
+ if (verifyBaseline && args.update === true) {
503
+ process.stderr.write("heximon check: --verify-baseline reports what the committed baseline has not acknowledged, and --update acknowledges everything — pass one or the other.\n");
504
+ process.exitCode = 1;
505
+ return;
506
+ }
507
+ if (!asJson) intro(`heximon check — ${basename(rootDir)} · ${CompileModeResolver.stamp(resolved)}`);
508
+ const session = await ProjectCompileSession.open({
509
+ rootDir,
510
+ environment: args.env,
511
+ ...sourceDirectories === void 0 ? {} : { sourceDirectories }
512
+ });
513
+ let checked;
514
+ if (mode === CompileMode.Library) {
515
+ const emitted = session.compileLibrary({ pruneVerdicts: true });
516
+ checked = {
517
+ diagnostics: emitted.diagnostics,
518
+ files: emitted.files,
519
+ ...emitted.prunedVerdicts === void 0 ? {} : { prunedVerdicts: emitted.prunedVerdicts }
520
+ };
521
+ } else {
522
+ const output = session.compile(true, { verifyBaseline });
523
+ session.compiler.writeTypeVerdicts(output);
524
+ session.compiler.healTsconfigInclude(session.typeVerdictTarget());
525
+ const emitted = args.emit || session.config?.debug === true;
526
+ if (emitted) {
527
+ session.compiler.write(session.outputDirectory, output.files);
528
+ await session.compiler.formatOnDisk(rootDir, session.outputDirectory, output.files);
529
+ }
530
+ checked = {
531
+ diagnostics: output.diagnostics,
532
+ files: output.files,
533
+ ...emitted ? { emittedTo: session.outputDirectory } : {}
534
+ };
535
+ }
536
+ const blockingErrors = checked.diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error && diagnostic.escalatedFrom === void 0 && !UpgradeBaseline.isBreaking(diagnostic.code)).length;
537
+ const rebaselined = mode === CompileMode.App && args.update && blockingErrors === 0 ? UpgradeBaseline.update(rootDir, checked.files) : void 0;
538
+ if (rebaselined?.changed === true && checked.diagnostics.some((diagnostic) => UpgradeBaseline.isBreaking(diagnostic.code))) session.compiler.writeTypeVerdicts(session.compile(true, { verifyBaseline }));
539
+ const diagnostics = rebaselined !== void 0 ? checked.diagnostics.filter((diagnostic) => !UpgradeBaseline.isBreaking(diagnostic.code)) : checked.diagnostics;
540
+ const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error).length;
541
+ const warningCount = diagnostics.length - errorCount;
542
+ const failed = errorCount > 0 || args.strict && warningCount > 0;
543
+ if (asJson) {
544
+ const census = TransactionCensusReport.read(checked.files);
545
+ process.stdout.write(`${JSON.stringify({
546
+ ok: !failed,
547
+ mode,
548
+ errors: errorCount,
549
+ warnings: warningCount,
550
+ diagnostics,
551
+ ...census === void 0 ? {} : { transactionCensus: census },
552
+ ...checked.prunedVerdicts === void 0 ? {} : { prunedVerdicts: checked.prunedVerdicts },
553
+ ...checked.emittedTo === void 0 ? {} : { emittedTo: checked.emittedTo },
554
+ ...args.update === true ? { upgradeBaseline: rebaselined === void 0 ? {
555
+ written: false,
556
+ blockingErrors
557
+ } : {
558
+ written: true,
559
+ changed: rebaselined.changed,
560
+ path: rebaselined.target
561
+ } } : {}
562
+ })}\n`);
563
+ } else {
564
+ if (checked.emittedTo !== void 0) log.info(`Wrote the generated wiring to ${checked.emittedTo}`);
565
+ if (checked.prunedVerdicts !== void 0) log.info(`Removed the stale DI verdict mirror ${checked.prunedVerdicts} — it was written by an app-mode compile of this package, and a library compile emits none to replace it.`);
566
+ if (mode === CompileMode.Library && verifyBaseline) log.warn(`${UpgradeBaselineFile.fileName} is an app-level artifact — a library's durable surface is verified by each app that composes it, so there is nothing here to verify.`);
567
+ if (mode === CompileMode.Library && args.update === true) log.warn(`${UpgradeBaselineFile.fileName} is an app-level artifact — a library's durable surface is baselined by each app that composes it, so there is nothing here to update.`);
568
+ else if (rebaselined !== void 0) log.success(rebaselined.changed ? `Updated ${UpgradeBaselineFile.fileName} — commit it to acknowledge the durable-surface changes.` : `${UpgradeBaselineFile.fileName} already up to date.`);
569
+ else if (args.update === true && blockingErrors > 0) log.warn(`${UpgradeBaselineFile.fileName} NOT updated — this compile has ${blockingErrors} unrelated error${blockingErrors === 1 ? "" : "s"}, so its durable surface is only partly analyzed and baselining it would acknowledge less than the app really has. Fix those first, then re-run.`);
570
+ else if (args.update === true) log.info("Nothing durable to baseline — no workflows, queue channels or other durable surface found.");
571
+ const census = TransactionCensusReport.read(checked.files);
572
+ const censusSummary = census === void 0 ? void 0 : TransactionCensusReport.summarize(census);
573
+ if (census !== void 0 && censusSummary !== void 0) {
574
+ const sections = [censusSummary];
575
+ const composed = TransactionCensusReport.composedWraps(census);
576
+ const unwrapped = TransactionCensusReport.unwrapped(census);
577
+ if (composed.length > 0) sections.push(`Composed, wrapped on the database the package handed over:\n${composed.map((line) => ` ${line}`).join("\n")}`);
578
+ if (unwrapped.length > 0) sections.push(`Not wrapped:\n${unwrapped.map((line) => ` ${line}`).join("\n")}`);
579
+ log.info(sections.join("\n"));
580
+ }
581
+ if (mode === CompileMode.App && UpgradeBaseline.hasDurableSurface(checked.files) && !existsSync(join(rootDir, UpgradeBaselineFile.fileName))) log.info(`Durable surface found but no committed ${UpgradeBaselineFile.fileName} — run \`heximon check --update\` once and commit it to arm the upgrade-safety gate.`);
582
+ if (diagnostics.length > 0) CompileDiagnostics.renderToStderr(rootDir, diagnostics);
583
+ if (failed) {
584
+ const parts = [`${errorCount} error${errorCount === 1 ? "" : "s"}`];
585
+ if (warningCount > 0) parts.push(`${warningCount} warning${warningCount === 1 ? "" : "s"}`);
586
+ outro(`Check failed — ${parts.join(", ")}.`);
587
+ } else {
588
+ const closing = mode === CompileMode.Library ? "Library compiled." : "Verdicts refreshed.";
589
+ if (warningCount > 0) {
590
+ log.warn(`${warningCount} warning${warningCount === 1 ? "" : "s"} — not failing (pass --strict to fail on warnings too).`);
591
+ outro(closing);
592
+ } else outro(`No issues — ${closing.toLowerCase()}`);
593
+ }
594
+ }
595
+ if (failed) process.exitCode = 1;
596
+ }
597
+ });
598
+ //#endregion
599
+ //#region src/cli-spinner.ts
600
+ /** The animated phase indicator the slow verbs (deploy, provision, migrations) show while they work. */
601
+ var CliSpinner = class {
602
+ /**
603
+ * Start a clack spinner with a blank row reserved beneath it, so the animated line never sits flush
604
+ * against the terminal's bottom edge. clack redraws the spinner line in place — each frame does
605
+ * `erase.down()` from the spinner line, which clears only downward and leaves blank rows intact — so
606
+ * writing two newlines (which scroll the viewport up when the terminal is full) then moving the cursor
607
+ * back up two rows leaves a clear row below the spinner that persists for the run.
608
+ *
609
+ * Returns `undefined` when there is no in-place spinner to draw — a non-TTY / CI run, or a run whose
610
+ * STDOUT is redirected while stdin is still a terminal. The cursor escapes are written to stdout, so
611
+ * gating on stdin alone would bake them into a captured file; the caller logs each phase as its own line
612
+ * instead.
613
+ *
614
+ * @param interactive - Whether this is an interactive session ({@link CliEnvironment.isInteractive}).
615
+ * @param label - The first phase message.
616
+ */
617
+ static begin(interactive, label) {
618
+ if (!interactive || process.stdout.isTTY !== true) return;
619
+ const active = spinner();
620
+ process.stdout.write("\n\n\x1B[2A");
621
+ active.start(label);
622
+ return active;
623
+ }
624
+ };
625
+ //#endregion
626
+ //#region src/credential-resolver.ts
627
+ /**
628
+ * The CLI's credential-resolution strategy: **environment variable → interactive prompt → skip**. A value already
629
+ * in the environment is used; otherwise a **required** spec is prompted for in an interactive session (masked for a
630
+ * secret); an optional spec (or any spec in a non-interactive run) is left out of the map. The resolver does
631
+ * **not** decide what is "missing" — it produces the map of what it could resolve, and `@heximon/deploy`'s
632
+ * `DeployRunner` reports any required spec the map fails to cover (so a non-interactive run halts with a clear
633
+ * message rather than hanging on a prompt no one can answer). Optional creds (e.g. a Cloudflare token when
634
+ * `wrangler login` already authenticates) are never prompted, so a logged-in deploy is not interrupted.
635
+ *
636
+ * This whole ladder is CLI-specific. A non-CLI caller (a dashboard) resolves the same `CredentialSpec`s its own way
637
+ * — from a vault / secret store — and hands `DeployRunner` an equivalent map; it never constructs this resolver.
638
+ */
639
+ var CredentialResolver = class {
640
+ /**
641
+ * Resolve the given credential specs into a value map.
642
+ *
643
+ * @param specs - The credentials the platform requires.
644
+ * @param options - Interactivity, the prompter, and the environment source.
645
+ * @returns The resolved `envVar → value` map (holds secrets — never log it).
646
+ * @throws PromptCancelledError when the operator cancels an interactive prompt.
647
+ */
648
+ static async resolve(specs, options) {
649
+ const env = options.env ?? process.env;
650
+ const values = /* @__PURE__ */ new Map();
651
+ for (const spec of specs) {
652
+ const fromEnv = env[spec.envVar];
653
+ if (fromEnv !== void 0 && fromEnv.length > 0) {
654
+ values.set(spec.envVar, fromEnv);
655
+ continue;
656
+ }
657
+ if (spec.required && options.interactive && options.prompter !== void 0) {
658
+ const label = `${spec.name} (${spec.envVar})`;
659
+ const prompted = spec.secret ? await options.prompter.secret(label) : await options.prompter.text(label);
660
+ if (prompted.length > 0) values.set(spec.envVar, prompted);
661
+ }
662
+ }
663
+ return values;
664
+ }
665
+ };
666
+ //#endregion
667
+ //#region src/deploy-command-failure.ts
668
+ /**
669
+ * The failure tail `heximon deploy` and `heximon provision` share: four expected errors that are an
670
+ * actionable halt rather than a bug — a cancelled prompt, an unreadable `--env-file`, a platform whose
671
+ * deploy strategy is not installed, and a host-managed (Nitro/Nuxt) build whose preset has no vendor
672
+ * ship. Anything else is rethrown, because a stack trace is the honest report for a defect.
673
+ */
674
+ var DeployCommandFailure = class {
675
+ /**
676
+ * Report one caught error when it is an expected halt.
677
+ *
678
+ * @param error - The error the command's `try` caught.
679
+ * @returns Whether it was reported (and the exit code set); `false` means the caller must rethrow.
680
+ */
681
+ static report(error) {
682
+ if (error instanceof PromptCancelledError || error instanceof DeployStrategyUnavailableError || error instanceof EnvFileUnreadableError || error instanceof HostManagedDeployUnsupportedError) {
683
+ cancel(error instanceof PromptCancelledError ? "Cancelled — nothing changed." : error.message);
684
+ process.exitCode = 1;
685
+ return true;
686
+ }
687
+ return false;
688
+ }
689
+ };
690
+ //#endregion
691
+ //#region src/deploy-command.ts
692
+ /**
693
+ * The `heximon deploy` flag cross-checks — a flag whose effect depends on another flag being set.
694
+ *
695
+ * Every one of them runs BEFORE any resolution or side effect, `--destroy` included: a teardown that
696
+ * silently swallowed `--env-file` / `--team` would tell the operator nothing about the flag it ignored.
697
+ */
698
+ var DeployArgumentGuards = class DeployArgumentGuards {
699
+ /**
700
+ * The first flag-coherence failure, or `undefined` when the combination is usable.
701
+ *
702
+ * @param args - The parsed deploy flags.
703
+ * @returns The operator-facing message, ready to `cancel`.
704
+ */
705
+ static incoherence(args) {
706
+ if (args["env-file"] !== void 0 && !args.provision && !args.migrate) return "--env-file needs --provision (to push secrets) or --migrate (to supply the migration connection env).";
707
+ if (args.team !== void 0 && args.create === void 0) return "--team needs --create <name> (it scopes the target bootstrap).";
708
+ if (args.destroy) {
709
+ const shipOnly = DeployArgumentGuards.shipOnlyFlags(args);
710
+ if (shipOnly.length > 0) return `--destroy removes the deployed app, so ${shipOnly.join(", ")} would do nothing — drop them, or drop --destroy.`;
711
+ }
712
+ }
713
+ /**
714
+ * The `--profile` / platform mismatch message, or `undefined`. `--profile` names a wrangler auth profile,
715
+ * so any other platform would ship with an ambient login the operator did not select.
716
+ */
717
+ static profileMismatch(profile, platformName) {
718
+ if (profile === void 0 || platformName === "cloudflare") return;
719
+ return `--profile selects a wrangler auth profile, which only the cloudflare platform supports — the resolved platform is ${platformName ?? "node (default)"}.`;
720
+ }
721
+ /** The ship-shaping flags this run passed, by name — every one of them inert under `--destroy`. */
722
+ static shipOnlyFlags(args) {
723
+ const named = [];
724
+ if (args["env-file"] !== void 0) named.push("--env-file");
725
+ if (args.create !== void 0) named.push("--create");
726
+ if (args.migrate) named.push("--migrate");
727
+ if (args.migrations !== void 0) named.push("--migrations");
728
+ if (args.provision) named.push("--provision");
729
+ if (args.team !== void 0) named.push("--team");
730
+ if (args.verify) named.push("--verify");
731
+ return named;
732
+ }
733
+ };
734
+ /**
735
+ * The `heximon deploy` outcome tail: everything the run reports AFTER `DeployRunner` returns — the
736
+ * diagnostics, the migration applies, the target bootstrap, the provision phase, the missing credentials,
737
+ * the ship result, and the post-ship liveness probe.
738
+ *
739
+ * Exported for the command's own tests: a partial-completion outcome is reached only through a real vendor
740
+ * failure, which a local test cannot stage.
741
+ */
742
+ var DeployReport = class DeployReport {
743
+ /**
744
+ * Print the whole outcome and decide the command's exit.
745
+ *
746
+ * @param outcome - What the runner reported.
747
+ * @param interactive - Whether a prompt could have run (shapes the missing-credential hint).
748
+ * @returns Whether the run succeeded; `false` means the caller sets a non-zero exit code.
749
+ */
750
+ static render(outcome, interactive) {
751
+ DeployReport.logDiagnostics(outcome.diagnostics);
752
+ DeployReport.logMigrations(outcome);
753
+ DeployReport.logTargetEnsure(outcome);
754
+ DeployReport.logProvision(outcome);
755
+ DeployReport.logMissingCredentials(outcome, interactive);
756
+ if (outcome.result?.failed === true) {
757
+ for (const message of outcome.result.messages) log.message(message);
758
+ cancel(`The ship to ${outcome.result.platform} failed — see above.`);
759
+ return false;
760
+ }
761
+ if (outcome.halted || outcome.result === void 0) {
762
+ const completed = DeployReport.completedBeforeHalt(outcome);
763
+ cancel(completed.length === 0 ? "Halted before ship — nothing changed." : `Halted before ship — nothing was uploaded, but earlier work already completed and was NOT rolled back: ${completed.join("; ")}.`);
764
+ return false;
765
+ }
766
+ for (const message of outcome.result.messages) log.message(message);
767
+ if (!DeployReport.logVerification(outcome)) return false;
768
+ if (DeployReport.hasFailedMigration(outcome)) {
769
+ cancel(`Deployed to ${outcome.result.platform}, but a migration failed to apply — see above.`);
770
+ return false;
771
+ }
772
+ outro(outcome.result.shipped ? `Deployed to ${outcome.result.platform}.` : `No vendor upload step for ${outcome.result.platform} — see above.`);
773
+ return true;
774
+ }
775
+ /**
776
+ * The work a halted run already finished: the created target, the provisioned resources, and the
777
+ * migrations that applied. Empty when the run stopped before any of it.
778
+ */
779
+ static completedBeforeHalt(outcome) {
780
+ const completed = [];
781
+ if (outcome.targetEnsure !== void 0 && outcome.targetEnsure.created.length > 0) completed.push(`target bootstrap (${outcome.targetEnsure.created.join(", ")})`);
782
+ if (outcome.provision !== void 0 && outcome.provision.created.length > 0) completed.push(`provisioned resources (${outcome.provision.created.join(", ")})`);
783
+ const applied = (outcome.migrations ?? []).filter((migration) => migration.applied.length > 0);
784
+ if (applied.length > 0) completed.push(`applied migrations (${applied.map((migration) => `${migration.target}: ${migration.applied.join(", ")}`).join(" · ")})`);
785
+ return completed;
786
+ }
787
+ /** Whether any migration outcome carries an apply-time error. */
788
+ static hasFailedMigration(outcome) {
789
+ return (outcome.migrations ?? []).some((migrated) => migrated.diagnostics.some((diagnostic) => diagnostic.severity === DeployDiagnosticSeverity.Error));
790
+ }
791
+ /** The runner's own findings, at the severity each carries. */
792
+ static logDiagnostics(diagnostics) {
793
+ for (const diagnostic of diagnostics ?? []) if (diagnostic.severity === DeployDiagnosticSeverity.Error) log.error(diagnostic.message);
794
+ else log.warn(diagnostic.message);
795
+ }
796
+ /**
797
+ * Which databases the tool migrated, and in which phase. drizzle-kit reports no per-migration ids, so a
798
+ * per-database success line is the honest signal; a runner that DOES report ids (a probed
799
+ * `MigrationRunner`) gets them appended.
800
+ */
801
+ static logMigrations(outcome) {
802
+ for (const migrated of outcome.migrations ?? []) if (!migrated.diagnostics.some((diagnostic) => diagnostic.severity === DeployDiagnosticSeverity.Error)) {
803
+ const ids = migrated.applied.length > 0 ? `: ${migrated.applied.join(", ")}` : "";
804
+ log.success(`Database migrations applied [${migrated.phase}] — ${migrated.target}${ids}.`);
805
+ }
806
+ }
807
+ /** The target bootstrap (`--create`): the project/site + local link the tool ensured before the build. */
808
+ static logTargetEnsure(outcome) {
809
+ if (outcome.targetEnsure === void 0) return;
810
+ for (const message of outcome.targetEnsure.messages) log.message(message);
811
+ if (outcome.targetEnsure.created.length > 0) log.success(`Target bootstrap: ${outcome.targetEnsure.created.join(", ")}.`);
812
+ if (outcome.targetEnsure.existing.length > 0) log.info(`Target already present: ${outcome.targetEnsure.existing.join(", ")}.`);
813
+ if (outcome.targetEnsure.failed.length > 0) log.error(`Target bootstrap failed: ${outcome.targetEnsure.failed.join(", ")}.`);
814
+ }
815
+ /** The provision phase (`--provision`): what the tool created / found / failed before the ship. */
816
+ static logProvision(outcome) {
817
+ if (outcome.provision === void 0) return;
818
+ for (const message of outcome.provision.messages) log.message(message);
819
+ if (outcome.provision.created.length > 0) log.success(`Provisioned: ${outcome.provision.created.join(", ")}.`);
820
+ if (outcome.provision.existing.length > 0) log.info(`Already present: ${outcome.provision.existing.join(", ")}.`);
821
+ if (outcome.provision.failed.length > 0) log.error(`Failed to provision: ${outcome.provision.failed.join(", ")}.`);
822
+ }
823
+ /** A real ship halts on a missing credential (error); a dry run only previews it (would-need). */
824
+ static logMissingCredentials(outcome, interactive) {
825
+ for (const credential of outcome.missingCredentials) {
826
+ const hint = `set ${credential.envVar}${interactive ? "" : " (no TTY to prompt)"}`;
827
+ if (outcome.halted) log.error(`Missing ${credential.name} — ${hint}.`);
828
+ else log.warn(`Would need ${credential.name} — ${hint}.`);
829
+ }
830
+ }
831
+ /**
832
+ * The post-ship liveness probe (`--verify`): the artifact uploaded, but did the app come up?
833
+ *
834
+ * @returns Whether the app answered (or no probe ran); `false` is shipped-but-dead.
835
+ */
836
+ static logVerification(outcome) {
837
+ if (outcome.verification === void 0) return true;
838
+ for (const diagnostic of outcome.verification.diagnostics) if (diagnostic.severity === DeployDiagnosticSeverity.Error) log.error(diagnostic.message);
839
+ else log.warn(diagnostic.message);
840
+ if (outcome.verification.live) {
841
+ log.success(`Liveness OK — ${outcome.verification.url} returned 2xx.`);
842
+ return true;
843
+ }
844
+ cancel(`Deployed to ${outcome.result?.platform ?? "the platform"}, but the app is not responding — see above.`);
845
+ return false;
846
+ }
847
+ };
848
+ /** The `--destroy` teardown and the pre-ship discovery/migration composition the ship path runs. */
849
+ var DeployPhases = class {
850
+ /**
851
+ * Confirm and run the teardown.
852
+ *
853
+ * @returns Whether the command should exit non-zero.
854
+ */
855
+ static async destroy(args, target, rootDir, credentials, interactive) {
856
+ const platformName = target.platformName ?? "node";
857
+ if (interactive) {
858
+ const answer = await confirm({
859
+ message: `Remove the deployed app on ${platformName} (env: ${args.env})? This cannot be undone.`,
860
+ initialValue: false
861
+ });
862
+ if (isCancel(answer)) {
863
+ cancel("Cancelled — nothing removed.");
864
+ return true;
865
+ }
866
+ if (!answer) {
867
+ cancel("Aborted — nothing removed.");
868
+ return false;
869
+ }
870
+ } else if (!args.yes) {
871
+ cancel("Refusing to --destroy non-interactively without --yes.");
872
+ return true;
873
+ }
874
+ const removing = CliSpinner.begin(interactive, `Removing the deployed app on ${platformName} (${args.env})…`);
875
+ let teardown;
876
+ try {
877
+ teardown = await DeployRunner.destroy({
878
+ target,
879
+ rootDir,
880
+ environment: args.env,
881
+ credentials,
882
+ dryRun: args["dry-run"],
883
+ ...args.profile === void 0 ? {} : { profile: args.profile }
884
+ });
885
+ } catch (teardownError) {
886
+ removing?.stop("Teardown failed — see the error below.");
887
+ throw teardownError;
888
+ }
889
+ removing?.stop(args["dry-run"] ? "Dry run — nothing removed." : "Teardown step complete.");
890
+ if (teardown.unsupported) {
891
+ cancel(`Platform ${teardown.platformName ?? "node (default)"} has nothing to remove (no destroy step).`);
892
+ return true;
893
+ }
894
+ for (const message of teardown.result?.messages ?? []) log.message(message);
895
+ outro(teardown.result?.destroyed === true ? `Removed from ${teardown.result.platform}.` : `Dry run — nothing removed (${teardown.result?.platform ?? target.platformName ?? "node"}).`);
896
+ return false;
897
+ }
898
+ /**
899
+ * Harvest the deploy metadata each vendor class co-locates on its `DeployableService` marker — what to
900
+ * configure/provision, with no central registry to maintain. The primary path reads the ACTUAL provider
901
+ * graph (a plugin-less compile surfaces every provider's cross-package ancestry), so it catches a marker
902
+ * the app never names in its own source — e.g. one bound inside a composed library boundary. It falls back
903
+ * to the whole-source import scan when the compile cannot run, and writes a `deploy.manifest.json` build
904
+ * artifact. Purely informational.
905
+ */
906
+ static async harvest(rootDir) {
907
+ try {
908
+ const harvested = await DeployableServiceReader.harvestProject(rootDir);
909
+ DeployManifestStore.write(resolve(rootDir, ".heximon"), harvested);
910
+ return harvested;
911
+ } catch (harvestError) {
912
+ log.warn(`Reading the provider graph for deploy metadata failed, falling back to the import scan (a marker bound only inside a composed boundary may go unreported): ${harvestError instanceof Error ? harvestError.message : String(harvestError)}`);
913
+ const scanned = await ImportScanner.scan(rootDir);
914
+ return DeployableServiceReader.fromProjectRoot(rootDir).harvest(scanned);
915
+ }
916
+ }
917
+ /** Report the harvested external services and, under `--migrate`, each harvested migration recipe. */
918
+ static logHarvest(harvested, migrate) {
919
+ const services = harvested.filter((entry) => (entry.meta.requiredInputs?.length ?? 0) > 0);
920
+ if (services.length > 0) {
921
+ log.info(`Detected ${services.length} external service(s) from your imports:`);
922
+ for (const service of services) {
923
+ const needs = (service.meta.requiredInputs ?? []).map((input) => `${input.name}${process.env[input.name] === void 0 ? " (not set here)" : ""}`).join(", ");
924
+ const label = service.meta.resource ? `${service.meta.resource.vendor} (${service.meta.resource.kind})` : service.forSatisfier;
925
+ log.message(`· ${label} — needs ${needs}`);
926
+ }
927
+ log.message("Ensure each is configured in your platform's secret store before deploying.");
928
+ }
929
+ if (!migrate) return;
930
+ for (const entry of harvested) if (entry.meta.migration !== void 0) {
931
+ const recipe = entry.meta.migration.guidance ?? entry.meta.migration.mode;
932
+ log.info(`Migration recipe (${entry.forSatisfier.split("#")[0]}): ${recipe}`);
933
+ }
934
+ }
935
+ /**
936
+ * Compose the run's migration targets: `--migrate` applies every auto-detected database via its local
937
+ * drizzle-kit, sequenced around the ship; a bare `--migrations <dir>` stays the read-only report over that
938
+ * directory. The deploy deliberately does NOT load the app's dev `.env` cascade — a dev `DATABASE_URL`
939
+ * would silently migrate the dev database during a production deploy. The drizzle-kit child inherits this
940
+ * process's env overlaid by the `--env-file` entries (the operator's curated env for THIS deploy beats a
941
+ * lingering dev shell export).
942
+ *
943
+ * @returns The composed migrations, or `null` when `--migrate` found no config to apply (a halt).
944
+ */
945
+ static async composeMigrations(args, rootDir, secrets) {
946
+ if (!args.migrate) {
947
+ if (args.migrations === void 0) return;
948
+ const migrationsDir = resolve(rootDir, args.migrations);
949
+ return { targets: [{
950
+ name: basename(migrationsDir),
951
+ migrationsDirectory: migrationsDir
952
+ }] };
953
+ }
954
+ const detection = await DatabaseConfigDetector.detect(rootDir);
955
+ const migrateEnv = secrets === void 0 ? void 0 : Object.fromEntries(secrets);
956
+ const composed = MigrationTargetComposer.fromDetectedConfigs(detection.configs, {
957
+ rootDir,
958
+ ...migrateEnv === void 0 ? {} : { env: migrateEnv }
959
+ });
960
+ for (const note of composed.notes) log.info(note);
961
+ let migrations;
962
+ if (composed.targets.length > 0) {
963
+ log.info(`Applying migrations for ${composed.targets.length} database(s): ${composed.targets.map((migrationTarget) => migrationTarget.name).join(", ")}.`);
964
+ migrations = {
965
+ targets: composed.targets,
966
+ apply: true,
967
+ allowDestructive: args["allow-destructive"]
968
+ };
969
+ } else if (composed.notes.length > 0) log.warn("--migrate will apply 0 databases this run — every detected database config is applied by its own mechanism (see the notes above). Nothing is migrated by this deploy.");
970
+ else {
971
+ cancel("--migrate found no database config to apply — expected a `*.config.ts` that `export default`s a Drizzle config (see `heximon migrations status`), or apply migrations manually before deploying.");
972
+ return null;
973
+ }
974
+ if (args.migrations !== void 0) log.info("--migrations <dir> is superseded by the auto-detected database config(s) under --migrate.");
975
+ return migrations;
976
+ }
977
+ };
978
+ /**
979
+ * `heximon deploy` — resolve the deploy target from `heximon.config.ts` (or the host-managed Nitro/Nuxt
980
+ * build), resolve the platform's credentials, then hand the run to `@heximon/deploy`'s `DeployRunner`, which
981
+ * validates, builds and ships. The command owns the operator conversation — the flag guards, the prompts,
982
+ * the progress surface and the exit code — and never reimplements a vendor's upload.
983
+ */
984
+ const deployCommand = defineCommand({
985
+ meta: {
986
+ name: "deploy",
987
+ description: "Build-and-ship a Heximon app to its configured platform (resolve, validate, ship the artifact)."
988
+ },
989
+ args: {
990
+ cwd: {
991
+ type: "string",
992
+ description: "Project root directory.",
993
+ default: "."
994
+ },
995
+ env: {
996
+ type: "string",
997
+ description: "Deploy environment (production / staging / preview:<id>).",
998
+ default: "production"
999
+ },
1000
+ "dry-run": {
1001
+ type: "boolean",
1002
+ description: "Build + report the ship command, but do not ship.",
1003
+ default: false
1004
+ },
1005
+ "skip-build": {
1006
+ type: "boolean",
1007
+ description: "Ship the existing build output without running `vp build` first.",
1008
+ default: false
1009
+ },
1010
+ provision: {
1011
+ type: "boolean",
1012
+ description: "Before the ship, create the resources the vendor CLI does not auto-create (e.g. Cloudflare Queues) and push --env-file secrets to the platform's secret store.",
1013
+ default: false
1014
+ },
1015
+ "env-file": {
1016
+ type: "string",
1017
+ description: "A dotenv file of the app's runtime secrets: pushed to the platform's native secret store during --provision, and/or supplied to the drizzle-kit child during --migrate (values ride stdin / a private temp file / the child env, never argv)."
1018
+ },
1019
+ create: {
1020
+ type: "string",
1021
+ description: "Create the vendor-side project/site under this NAME (Vercel/Netlify) and link this directory to it, before shipping — the one-time fresh-account bootstrap. Idempotent: an existing target is linked, never re-created."
1022
+ },
1023
+ team: {
1024
+ type: "string",
1025
+ description: "The team scope (Vercel team slug / Netlify account slug) to create the --create target under, for multi-team accounts."
1026
+ },
1027
+ verify: {
1028
+ type: "boolean",
1029
+ description: "After a real ship, GET the deployed URL and assert a 2xx (post-deploy liveness probe).",
1030
+ default: false
1031
+ },
1032
+ migrations: {
1033
+ type: "string",
1034
+ description: "The app's migrations directory — reports on pending migrations (--migrate applies via the auto-detected database config(s) instead)."
1035
+ },
1036
+ migrate: {
1037
+ type: "boolean",
1038
+ description: "Apply pending migrations via the app's local drizzle-kit against every auto-detected database config — before the ship (pass --allow-destructive to apply after it instead). Supply the connection env via the shell or --env-file.",
1039
+ default: false
1040
+ },
1041
+ "allow-destructive": {
1042
+ type: "boolean",
1043
+ description: "Acknowledge that --migrate's pending batch may be destructive (drop/narrow) — a database whose migrations the scan flags as destructive then applies after the ship. Forward-only; no DB rollback.",
1044
+ default: false
1045
+ },
1046
+ platform: {
1047
+ type: "string",
1048
+ description: "Override the deploy platform (e.g. cloudflare) when heximon.config.ts does not declare it."
1049
+ },
1050
+ profile: {
1051
+ type: "string",
1052
+ description: "Deploy as a named wrangler auth profile (Cloudflare only; wrangler >= 4.106 — see `wrangler auth`). Appended to every wrangler call this run; a directory-bound profile needs no flag, and CLOUDFLARE_API_TOKEN still takes precedence."
1053
+ },
1054
+ destroy: {
1055
+ type: "boolean",
1056
+ description: "Remove the deployed app from its platform instead of shipping (destructive).",
1057
+ default: false
1058
+ },
1059
+ yes: {
1060
+ type: "boolean",
1061
+ description: "Skip the --destroy confirmation prompt (required to --destroy non-interactively).",
1062
+ default: false
1063
+ }
1064
+ },
1065
+ async run({ args }) {
1066
+ const deployArgs = args;
1067
+ const rootDir = resolve(process.cwd(), deployArgs.cwd);
1068
+ const environment = deployArgs.env;
1069
+ const interactive = CliEnvironment.isInteractive();
1070
+ intro(`heximon deploy · ${environment}`);
1071
+ const incoherence = DeployArgumentGuards.incoherence(deployArgs);
1072
+ if (incoherence !== void 0) {
1073
+ cancel(incoherence);
1074
+ process.exitCode = 1;
1075
+ return;
1076
+ }
1077
+ try {
1078
+ const target = await DeployTargetResolver.resolve(rootDir, environment, deployArgs.platform);
1079
+ log.info(`Platform: ${target.platformName ?? "node (default)"} · target class: ${target.targetClass}`);
1080
+ if (target.hostManaged) log.info(`Host-managed build detected (${target.artifactDir}) — shipping it, skipping \`vp build\`.`);
1081
+ const profileMismatch = DeployArgumentGuards.profileMismatch(deployArgs.profile, target.platformName);
1082
+ if (profileMismatch !== void 0) {
1083
+ cancel(profileMismatch);
1084
+ process.exitCode = 1;
1085
+ return;
1086
+ }
1087
+ const specs = CredentialSelection.inPlay(target.strategy.requiredCredentials(), { provisioning: deployArgs.provision });
1088
+ const credentials = await CredentialResolver.resolve(specs, {
1089
+ interactive,
1090
+ prompter: interactive ? new ClackPrompter() : void 0
1091
+ });
1092
+ if (credentials.size > 0) log.success(`Resolved ${credentials.size} credential(s).`);
1093
+ if (deployArgs.destroy) {
1094
+ if (await DeployPhases.destroy(deployArgs, target, rootDir, credentials, interactive)) process.exitCode = 1;
1095
+ return;
1096
+ }
1097
+ let secrets;
1098
+ if (deployArgs["env-file"] !== void 0) {
1099
+ secrets = await EnvFileReader.read(resolve(rootDir, deployArgs["env-file"]));
1100
+ log.info(`Loaded ${secrets.size} secret(s) from ${deployArgs["env-file"]}.`);
1101
+ }
1102
+ if (!(deployArgs["skip-build"] || target.hostManaged === true)) {
1103
+ const injectable = DeployLedger.injectableEntries(await DeployLedger.read(rootDir, environment));
1104
+ if (injectable.size > 0) log.info(`Injecting ${injectable.size} deploy-ledger value(s) into the build: ${[...injectable.keys()].join(", ")}.`);
1105
+ }
1106
+ DeployPhases.logHarvest(await DeployPhases.harvest(rootDir), deployArgs.migrate);
1107
+ const migrations = await DeployPhases.composeMigrations(deployArgs, rootDir, secrets);
1108
+ if (migrations === null) {
1109
+ process.exitCode = 1;
1110
+ return;
1111
+ }
1112
+ const progress = CliSpinner.begin(interactive, "Preparing the deploy…");
1113
+ const onProgress = (event) => {
1114
+ if (progress === void 0) log.info(event.message);
1115
+ else progress.message(event.message);
1116
+ };
1117
+ let outcome;
1118
+ try {
1119
+ outcome = await DeployRunner.run({
1120
+ target,
1121
+ rootDir,
1122
+ environment,
1123
+ credentials,
1124
+ dryRun: deployArgs["dry-run"],
1125
+ skipBuild: deployArgs["skip-build"] || target.hostManaged === true,
1126
+ ...target.artifactDir === void 0 ? {} : { artifactDir: target.artifactDir },
1127
+ provision: deployArgs.provision,
1128
+ ...secrets === void 0 ? {} : { secrets },
1129
+ ...deployArgs.create === void 0 ? {} : { createTarget: {
1130
+ name: deployArgs.create,
1131
+ ...deployArgs.team === void 0 ? {} : { team: deployArgs.team }
1132
+ } },
1133
+ verify: deployArgs.verify,
1134
+ ...deployArgs.profile === void 0 ? {} : { profile: deployArgs.profile },
1135
+ onProgress,
1136
+ ...migrations === void 0 ? {} : { migrations }
1137
+ });
1138
+ } catch (runError) {
1139
+ progress?.stop("Deploy failed — see the error below.");
1140
+ throw runError;
1141
+ }
1142
+ progress?.stop(outcome.halted ? "Stopped — details below." : "Deploy steps complete.");
1143
+ if (!DeployReport.render(outcome, interactive)) process.exitCode = 1;
1144
+ } catch (error) {
1145
+ if (DeployCommandFailure.report(error)) return;
1146
+ throw error;
1147
+ }
1148
+ }
1149
+ });
1150
+ //#endregion
1151
+ //#region src/doctor-command.ts
1152
+ /** The rendering layer behind {@link doctorCommand}: every audit's output, and the verdicts the exit reads. */
1153
+ var DoctorReport = class DoctorReport {
1154
+ /** Renders one audited boundary as a status line. `self` reframes the wording for the current package. */
1155
+ static boundaryLine(report, self = false) {
1156
+ const name = self ? `${report.specifier} (this package)` : report.specifier;
1157
+ const version = report.packageVersion === void 0 ? "" : ` v${report.packageVersion}`;
1158
+ switch (report.status) {
1159
+ case BoundaryProvenanceStatus.Fresh: return self ? `✔ ${name}${version} — dist is current with src/ (${report.manifestSourceHash})` : `✔ ${name}${version} — up to date (${report.manifestSourceHash})`;
1160
+ case BoundaryProvenanceStatus.Stale: return self ? `✖ ${name}${version} — STALE: your src/ (${report.recomputedSourceHash}) no longer matches your own dist manifest (${report.manifestSourceHash}). Repack (\`vp pack\`) before publishing or consuming it.` : `✖ ${name}${version} — STALE: its src/ (${report.recomputedSourceHash}) no longer matches the packed manifest (${report.manifestSourceHash}). Rebuild it (\`vp pack\` in ${report.packageDirectory}) before trusting this app's build.`;
1161
+ case BoundaryProvenanceStatus.NoProvenance: return `● ${name}${version} — manifest predates the provenance stamp; repack to enable the check.`;
1162
+ case BoundaryProvenanceStatus.NoSource: return `● ${name}${version} — published package (no local src/); provenance is display-only.`;
1163
+ }
1164
+ }
1165
+ /** Logs one report at the severity its status warrants (error for stale, success for fresh, info otherwise). */
1166
+ static logBoundary(report, self = false) {
1167
+ const line = DoctorReport.boundaryLine(report, self);
1168
+ if (report.status === BoundaryProvenanceStatus.Stale) log.error(line);
1169
+ else if (report.status === BoundaryProvenanceStatus.Fresh) log.success(line);
1170
+ else log.info(line);
1171
+ }
1172
+ /**
1173
+ * The `--self` outro sentence for a NON-stale own-dist verdict.
1174
+ *
1175
+ * Only {@link BoundaryProvenanceStatus.Fresh} actually compared anything. The other two verdicts mean the
1176
+ * comparison did not run at all, and summarising them as "dist is current with its src/" would report a
1177
+ * check that never happened as a pass — the one thing a pre-publish gate must never do.
1178
+ */
1179
+ static selfProvenanceSummary(report) {
1180
+ switch (report.status) {
1181
+ case BoundaryProvenanceStatus.Fresh: return "This package's dist is current with its src/.";
1182
+ case BoundaryProvenanceStatus.NoProvenance: return "Its packed manifest predates the provenance stamp, so nothing was compared — repack (`vp pack`) to enable the staleness check.";
1183
+ case BoundaryProvenanceStatus.NoSource: return "It ships no local src/, so its packed manifest was not compared with anything.";
1184
+ case BoundaryProvenanceStatus.Stale: return "Your dist is STALE — repack (`vp pack`) before publishing or consuming this package.";
1185
+ }
1186
+ }
1187
+ /**
1188
+ * Logs the physical-copy census: a line per forked package naming every copy and who resolves to it, or one
1189
+ * summary line when nothing forked. The summary always states how many origins were probed — an audit that
1190
+ * could not look at anything must not read as a clean bill of health. `gatingPackages`, when given, scopes
1191
+ * which forks count as GATING ({@link DoctorReport.runtimeReachablePackageNames}) — every fork is still
1192
+ * printed above either way, and a fork outside the set gets one extra line noting it is reported, not gated.
1193
+ *
1194
+ * @returns Whether any GATING fork was found (any fork at all, when `gatingPackages` is omitted).
1195
+ */
1196
+ static logCopyAudit(audit, gatingPackages) {
1197
+ const forked = audit.reports.filter((report) => report.copies.length > 1);
1198
+ if (audit.originsProbed === 0) {
1199
+ log.warn("Package copies — nothing probed (no resolvable node_modules from this directory), so no fork check ran.");
1200
+ return false;
1201
+ }
1202
+ if (forked.length === 0) {
1203
+ log.success(`Package copies — ${String(audit.packagesProbed)} package(s) probed from ${String(audit.originsProbed)} origin(s); each resolves to a single copy.`);
1204
+ return false;
1205
+ }
1206
+ for (const report of forked) {
1207
+ const copies = report.copies.map((copy) => {
1208
+ return ` ${copy.version === void 0 ? "" : `v${copy.version} `}${copy.directory}\n ← ${copy.origins.join(", ")}`;
1209
+ }).join("\n");
1210
+ log.error(`${report.packageName} — ${String(report.copies.length)} physical copies:\n${copies}\n Class identity is the DI token, so these bind nominally yet split at runtime. Deduplicate to one copy (align versions, or hoist it).`);
1211
+ }
1212
+ const gatingForks = gatingPackages === void 0 ? forked : forked.filter((report) => gatingPackages.has(report.packageName));
1213
+ if (gatingPackages !== void 0 && gatingForks.length === 0) {
1214
+ log.info(`${String(forked.length)} forked package(s) across ${String(audit.originsProbed)} origin(s) sit outside this package's runtime closure (build-time tooling) — reported, not gated.`);
1215
+ return false;
1216
+ }
1217
+ if (gatingPackages === void 0) {
1218
+ log.info(`${String(forked.length)} forked package(s) across ${String(audit.originsProbed)} origin(s). Reported, not gated — this does not fail the command.`);
1219
+ return true;
1220
+ }
1221
+ log.info(`${String(forked.length)} forked package(s) across ${String(audit.originsProbed)} origin(s), ${String(gatingForks.length)} within this package's runtime closure — reported, and gating this command's exit code.`);
1222
+ return true;
1223
+ }
1224
+ /**
1225
+ * Logs the `@heximon/*` peer-pivot roster ({@link PeerClosureAuditor}): an info line for pivots acknowledged
1226
+ * as expected-open, a warn for stale acknowledgements, a warn block naming every remaining undeclared pivot,
1227
+ * a success line when nothing is left open, or nothing when the roster is empty or the manifest could not be
1228
+ * read. Advisory only — never sets the exit code, since covering a pivot unilaterally can flip a fork's
1229
+ * direction rather than remove it.
1230
+ */
1231
+ static logPeerClosureRoster(report) {
1232
+ if (report === null || report.pivots.length === 0) return;
1233
+ if (report.expectedOpen.length > 0) {
1234
+ const names = report.expectedOpen.map((pivot) => pivot.packageName).join(", ");
1235
+ log.info(`Peer closure — ${String(report.expectedOpen.length)} pivot(s) expected to stay open (acknowledged in expectedOpenPeers): ${names}.`);
1236
+ }
1237
+ if (report.staleExpectedOpen.length > 0) log.warn(`Peer closure — ${String(report.staleExpectedOpen.length)} expectedOpenPeers name(s) no longer match an undeclared pivot: ${report.staleExpectedOpen.join(", ")}. Remove them so the acknowledgement list only silences pivots that actually exist.`);
1238
+ if (report.uncovered.length === 0) {
1239
+ log.success(report.expectedOpen.length === 0 ? `Peer closure — ${String(report.pivots.length)} pivot(s), all declared here.` : `Peer closure — no unacknowledged open pivots (${String(report.pivots.length)} total).`);
1240
+ return;
1241
+ }
1242
+ const pivotLines = report.uncovered.map((pivot) => ` ${pivot.packageName} ← peer of ${pivot.declaredBy.join(", ")}`).join("\n");
1243
+ log.warn(`Peer closure — ${String(report.uncovered.length)} @heximon/* peer(s) of this package's dependency closure are declared nowhere in its package.json:\n${pivotLines}\nA consumer whose installation satisfies these resolves DIFFERENT physical instances of the packages that peer on them — and class identity is the DI token. The fix is peer-context EQUALITY with your consumers, not coverage: in a pnpm workspace a peer declaration is never inert (pnpm installs a workspace importer's peerDependencies into its own context, \`optional\` or not), so adding these unilaterally can flip which side forks. Equalize the consumers' installed peer sets; if consumers sit on different platforms the roster cannot close — acknowledge these names in \`expectedOpenPeers\` (heximon.config.ts).`);
1244
+ }
1245
+ /**
1246
+ * Logs the platform-tracing verdict ({@link PlatformTracingAuditor}): a warn block naming what the deployed
1247
+ * wrangler config must carry by hand, a success line when it already does, or nothing when the audit had no
1248
+ * evidence to judge. Advisory only — the build is correct either way, so this never sets the exit code.
1249
+ */
1250
+ static logPlatformTracing(report) {
1251
+ if (report.status === PlatformTracingStatus.NotApplicable) return;
1252
+ const target = report.wranglerConfigPath === void 0 ? "this project commits no wrangler config" : `wrangler reads ${report.wranglerConfigPath} directly`;
1253
+ if (report.status === PlatformTracingStatus.Carried) {
1254
+ log.success(`Platform tracing — ${target}, and it carries the observability directives the platform exporter needs.`);
1255
+ return;
1256
+ }
1257
+ const lines = [`Platform tracing — \`otel.exporter: "platform"\` turns tracing on by writing \`observability.traces.enabled: true\` into a GENERATED wrangler config, but this host-managed Cloudflare build produced none, so ${target}.`];
1258
+ if (report.tracingNotEnabled) lines.push(" It does not enable traces, so the deploy runs with the tracing pipeline OFF. Add `\"observability\": { \"traces\": { \"enabled\": true } }` to it by hand.");
1259
+ if (report.droppedSamplerRatio !== void 0) lines.push(` \`otel.samplerRatio\` (${String(report.droppedSamplerRatio)}) maps onto the generated config's \`observability.traces.head_sampling_rate\` only, so it does nothing here — carry it in the same section by hand.`);
1260
+ log.warn(lines.join("\n"));
1261
+ }
1262
+ /**
1263
+ * The package names a fork of which is RUNTIME-reachable from the library at `rootDir`: the library itself,
1264
+ * every package its installed `@heximon/*` dependency closure actually resolved
1265
+ * ({@link PeerClosureReport.closurePackages}), every named peer pivot of that closure
1266
+ * ({@link PeerClosureReport.pivots}), the curated {@link RuntimeSingletons}, and every installed boundary
1267
+ * package ({@link PackageCopyAuditor.installedBoundaryPackages}) — a fork of a non-`@heximon` boundary
1268
+ * package (e.g. a consumed `@acme/*` library) is exactly as much a DI-identity split as a fork of a
1269
+ * framework package, since the rule is not scope-specific. A fork of a name outside this set is confined to
1270
+ * build-time tooling (e.g. a devDependency-only bundler/pack plugin) and must be reported, never gated.
1271
+ */
1272
+ static runtimeReachablePackageNames(rootDir, closureReport) {
1273
+ const names = new Set(RuntimeSingletons.all());
1274
+ const libraryName = DoctorReport.readPackageName(rootDir);
1275
+ if (libraryName !== null) names.add(libraryName);
1276
+ for (const packageName of PackageCopyAuditor.installedBoundaryPackages(rootDir)) names.add(packageName);
1277
+ if (closureReport !== null) {
1278
+ for (const packageName of closureReport.closurePackages) names.add(packageName);
1279
+ for (const pivot of closureReport.pivots) names.add(pivot.packageName);
1280
+ }
1281
+ return names;
1282
+ }
1283
+ /** The `name` field of a directory's `package.json`, or `null` when absent/unreadable/malformed. */
1284
+ static readPackageName(directory) {
1285
+ try {
1286
+ const parsed = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
1287
+ if (typeof parsed === "object" && parsed !== null && "name" in parsed) {
1288
+ const { name } = parsed;
1289
+ return typeof name === "string" ? name : null;
1290
+ }
1291
+ } catch {
1292
+ return null;
1293
+ }
1294
+ return null;
1295
+ }
1296
+ };
1297
+ /**
1298
+ * `heximon doctor` — three read-only audits.
1299
+ *
1300
+ * Registry access ({@link RegistryAccess}) is probed first, in both modes: it runs `npm view` against
1301
+ * GitHub Packages, the private registry `@heximon/*` publishes to, and reports whether the `.npmrc` scope
1302
+ * line plus a user-level auth token are in place. Exits non-zero on a non-Ok verdict, same as the audits
1303
+ * below.
1304
+ *
1305
+ * The physical-copy census ({@link PackageCopyAuditor}) runs next and always: it resolves every
1306
+ * identity-bearing package from every origin that reaches it and reports any name with more than one physical
1307
+ * copy. Compile-free, so it still answers on a project that does not build, and it is the only check an app
1308
+ * with no consumed boundaries gets. Reported, never gated — the remedy is the dependency tree, not this run.
1309
+ *
1310
+ * The platform-tracing audit ({@link PlatformTracingAuditor}) then reports when an app compiled with the
1311
+ * OpenTelemetry platform exporter would deploy untraced, because the host writes no generated wrangler config
1312
+ * for the exporter's directives to land in. Advisory only — the build is correct; the deployment context is
1313
+ * what drops them.
1314
+ *
1315
+ * The staleness audit then compares every consumed boundary package's local `src/` fingerprint against its
1316
+ * installed manifest's pack-time provenance ({@link BoundaryProvenanceAuditor}). Exits non-zero when any
1317
+ * boundary is stale, so CI can gate on it.
1318
+ *
1319
+ * `--self` flips onto the CURRENT package's own perspective — the library author's pre-publish check — and
1320
+ * runs three legs in order:
1321
+ * 1. the `@heximon/*` peer-pivot roster ({@link PeerClosureAuditor}), predicted from this package's own
1322
+ * installed dependency closure. Advisory only, never gates — a one-sided fix can flip a fork rather than
1323
+ * remove it. Pivots acknowledged in the sidecar's `expectedOpenPeers` are reported as expected-open
1324
+ * rather than warned.
1325
+ * 2. the library-side physical-copy census ({@link PackageCopyAuditor.auditLibrary}), run from this package
1326
+ * plus every in-workspace consumer ({@link WorkspaceConsumerLocator}). Every fork is reported, but only a
1327
+ * fork of a package RUNTIME-reachable from this one — its own name, its dependency closure
1328
+ * ({@link PeerClosureReport.closurePackages}), a named peer pivot ({@link PeerClosureReport.pivots}), a
1329
+ * curated {@link RuntimeSingletons}, or an installed boundary package
1330
+ * ({@link PackageCopyAuditor.installedBoundaryPackages}) — exits non-zero; a fork confined to build-time
1331
+ * tooling (e.g. a devDependency-only bundler) is reported and does not gate.
1332
+ * 3. the staleness check ({@link BoundaryProvenanceAuditor.auditSelf}) — this package's own
1333
+ * `dist/heximon.manifest.json` against its own `src/`. Stale exits non-zero.
1334
+ *
1335
+ * Compile-free and filesystem-local (the `heximon.config.ts` sidecar is loaded only to read
1336
+ * `expectedOpenPeers`); exits non-zero when this package forks a runtime-reachable package against a
1337
+ * workspace consumer OR its own dist is stale.
1338
+ */
1339
+ const doctorCommand = defineCommand({
1340
+ meta: {
1341
+ name: "doctor",
1342
+ description: "Registry access (can @heximon/* be read from GitHub Packages), plus three read-only audits: the physical-copy census (forked identity-bearing packages), the platform-tracing check, and the staleness audit of consumed Heximon libraries (local src vs packed manifest provenance). `--self` runs the library author's pre-publish check instead."
1343
+ },
1344
+ args: {
1345
+ cwd: {
1346
+ type: "string",
1347
+ description: "Project root directory.",
1348
+ default: "."
1349
+ },
1350
+ self: {
1351
+ type: "boolean",
1352
+ description: "Library pre-publish check: this package's own peer-closure roster (advisory), its copy census against workspace consumers, and its packed manifest vs its own src/. Exits non-zero on a fork of a runtime-reachable package (its dependency closure, a named pivot, a curated runtime singleton, an installed boundary package, or itself) or a stale dist — a fork confined to build-time tooling is reported but does not gate.",
1353
+ default: false
1354
+ }
1355
+ },
1356
+ async run({ args }) {
1357
+ const rootDir = resolve(process.cwd(), args.cwd);
1358
+ intro(`heximon doctor — ${basename(rootDir)}`);
1359
+ const registryReport = await new RegistryAccess().probe();
1360
+ const registryFailed = registryReport.status !== RegistryAccessStatus.Ok;
1361
+ if (registryFailed) log.error(RegistryAccess.fixInstructions(registryReport.status));
1362
+ else log.success("✔ GitHub Packages — @heximon/* readable");
1363
+ if (args.self) {
1364
+ const expectedOpenPeers = await ConfigLoader.load(rootDir).then((sidecar) => sidecar?.expectedOpenPeers ?? []).catch((error) => {
1365
+ log.warn(`heximon.config.ts could not be loaded, so its \`expectedOpenPeers\` acknowledgements are not applied — every open pivot below is reported: ${error instanceof Error ? error.message : String(error)}`);
1366
+ return [];
1367
+ });
1368
+ const closureReport = PeerClosureAuditor.audit(rootDir, expectedOpenPeers);
1369
+ DoctorReport.logPeerClosureRoster(closureReport);
1370
+ const workspaceConsumers = WorkspaceConsumerLocator.locate(rootDir);
1371
+ let forked = false;
1372
+ if (workspaceConsumers === null) log.info("Package copies — no pnpm workspace found from this directory; census not run.");
1373
+ else forked = DoctorReport.logCopyAudit(PackageCopyAuditor.auditLibrary(rootDir, workspaceConsumers.consumers), DoctorReport.runtimeReachablePackageNames(rootDir, closureReport));
1374
+ const selfReport = BoundaryProvenanceAuditor.auditSelf(rootDir);
1375
+ const outroParts = [];
1376
+ let exitFailure = forked || registryFailed;
1377
+ if (forked) {
1378
+ const hasConsumers = (workspaceConsumers?.consumers.length ?? 0) > 0;
1379
+ outroParts.push(hasConsumers ? "Forked against a workspace consumer — equalize the two packages' installed peer contexts (a one-sided cover can flip the fork, not remove it) and reinstall." : "Forked within this package's own resolution (no workspace consumers found to compare against) — align the versions its own dependency tree resolves and reinstall.");
1380
+ }
1381
+ if (selfReport === null) outroParts.push("This directory is not a packed Heximon library (no dist/heximon.manifest.json) — the peer-closure and copy census above still ran; run `vp pack` to enable the staleness check too.");
1382
+ else {
1383
+ DoctorReport.logBoundary(selfReport, true);
1384
+ if (selfReport.status === BoundaryProvenanceStatus.Stale) {
1385
+ exitFailure = true;
1386
+ outroParts.push(DoctorReport.selfProvenanceSummary(selfReport));
1387
+ } else if (!forked) outroParts.push(DoctorReport.selfProvenanceSummary(selfReport));
1388
+ }
1389
+ if (exitFailure) process.exitCode = 1;
1390
+ outro(outroParts.join(" "));
1391
+ return;
1392
+ }
1393
+ DoctorReport.logCopyAudit(PackageCopyAuditor.audit(rootDir));
1394
+ DoctorReport.logPlatformTracing(await PlatformTracingAuditor.audit(rootDir));
1395
+ const reports = await BoundaryProvenanceAuditor.audit(rootDir);
1396
+ if (reports.length === 0) {
1397
+ if (registryFailed) process.exitCode = 1;
1398
+ outro("No consumed Heximon library packages found — no staleness audit to run.");
1399
+ return;
1400
+ }
1401
+ for (const report of reports) DoctorReport.logBoundary(report);
1402
+ const staleCount = reports.filter((report) => report.status === BoundaryProvenanceStatus.Stale).length;
1403
+ if (staleCount > 0 || registryFailed) process.exitCode = 1;
1404
+ if (staleCount > 0) outro(`${staleCount} stale package(s) — rebuild them before trusting this app's build.`);
1405
+ else outro(`${reports.length} package(s) audited — no staleness detected.`);
1406
+ }
1407
+ });
1408
+ //#endregion
1409
+ //#region src/json-output-target.ts
1410
+ /**
1411
+ * Where a JSON-emitting inspection command (`routes`, `graph`) sends its payload: an `--out` file, stdout under
1412
+ * `--json`, or nowhere when neither flag was passed. One owner for that whole decision — both commands hand it
1413
+ * a payload and never branch on the flags themselves.
1414
+ */
1415
+ var JsonOutputTarget = class JsonOutputTarget {
1416
+ filePath;
1417
+ toStdout;
1418
+ /**
1419
+ * @param filePath - The absolute `--out` path, or `undefined` when no file was requested.
1420
+ * @param toStdout - Whether the payload is printed on stdout instead.
1421
+ */
1422
+ constructor(filePath, toStdout) {
1423
+ this.filePath = filePath;
1424
+ this.toStdout = toStdout;
1425
+ }
1426
+ /**
1427
+ * Resolve the destination. `--out` wins and redirects the payload off stdout into a file; a relative path
1428
+ * resolves against the current directory (where the command was typed), not the `--cwd` project root.
1429
+ *
1430
+ * @param outOption - The raw `--out` argument, when the caller passed one.
1431
+ * @param asJson - Whether `--json` was passed, i.e. whether stdout should carry the payload.
1432
+ */
1433
+ static resolve(outOption, asJson) {
1434
+ const requested = outOption?.trim();
1435
+ const filePath = requested === void 0 || requested === "" ? void 0 : resolve(process.cwd(), requested);
1436
+ return new JsonOutputTarget(filePath, filePath === void 0 && asJson);
1437
+ }
1438
+ /** Whether the payload goes to a file — the caller's cue to report where it landed. */
1439
+ isFile() {
1440
+ return this.filePath !== void 0;
1441
+ }
1442
+ /**
1443
+ * Emit the payload. A file gets indented JSON with its parent directory created, so a run can target a
1444
+ * not-yet-existing CI artifact directory and consecutive runs diff line by line; stdout gets the compact
1445
+ * single line `jq` and pipes expect.
1446
+ *
1447
+ * @returns `false` only when a requested file write failed (the reason is reported on stderr) — the caller's
1448
+ * cue to exit non-zero. A target that emits nowhere reports success.
1449
+ */
1450
+ emit(payload) {
1451
+ if (this.filePath === void 0) {
1452
+ if (this.toStdout) process.stdout.write(`${JSON.stringify(payload)}\n`);
1453
+ return true;
1454
+ }
1455
+ try {
1456
+ mkdirSync(dirname(this.filePath), { recursive: true });
1457
+ writeFileSync(this.filePath, `${JSON.stringify(payload, null, 2)}\n`);
1458
+ return true;
1459
+ } catch (error) {
1460
+ const reason = error instanceof Error ? error.message : String(error);
1461
+ process.stderr.write(`Could not write ${this.filePath} — ${reason}\n`);
1462
+ return false;
1463
+ }
1464
+ }
1465
+ };
1466
+ //#endregion
1467
+ //#region src/module-graph-report.ts
1468
+ /**
1469
+ * Renders a {@link ModuleGraphView} (and, optionally, the cross-package {@link BoundarySummary} nodes) as a
1470
+ * module-nested tree for the terminal, and as a serializable object for `--json`. Every module is a top-level
1471
+ * node over its imports/exports and its providers, and each provider over its resolved dependency tokens.
1472
+ */
1473
+ var ModuleGraphReport = class ModuleGraphReport {
1474
+ static indent = " ";
1475
+ /** Render the module tree, appending a `boundaries` section when boundary nodes are supplied. */
1476
+ static renderTree(view, boundaries = []) {
1477
+ const lines = [];
1478
+ if (view.modules.length === 0) lines.push("(no modules)");
1479
+ const seenByIdentity = /* @__PURE__ */ new Map();
1480
+ for (const module of view.modules) {
1481
+ const identity = `${module.reference.sourceFile}#${module.reference.className}`;
1482
+ const ordinal = (seenByIdentity.get(identity) ?? 0) + 1;
1483
+ seenByIdentity.set(identity, ordinal);
1484
+ ModuleGraphReport.appendModule(lines, module, ordinal);
1485
+ }
1486
+ if (boundaries.length > 0) {
1487
+ lines.push("", "boundaries:");
1488
+ for (const boundary of boundaries) {
1489
+ lines.push(`${ModuleGraphReport.indent}${boundary.specifier} → ${boundary.factory}`);
1490
+ if (boundary.requires.length > 0) lines.push(`${ModuleGraphReport.indent.repeat(2)}requires: ${boundary.requires.join(", ")}`);
1491
+ if (boundary.exports.length > 0) lines.push(`${ModuleGraphReport.indent.repeat(2)}exports: ${boundary.exports.join(", ")}`);
1492
+ }
1493
+ }
1494
+ return lines.join("\n");
1495
+ }
1496
+ /**
1497
+ * The serializable `--json` shape: the raw module view plus (when present) the boundary summaries.
1498
+ *
1499
+ * @param view - The compile's module-graph projection.
1500
+ * @param boundaries - The cross-package boundary summaries, when `--boundaries` asked for them.
1501
+ * @param ok - Whether the compile that produced `view` was clean. A graph built from a failed compile has
1502
+ * unresolved edges, so a consumer reading only `ok` must not read it as a complete answer.
1503
+ */
1504
+ static toJson(view, boundaries = [], ok = true) {
1505
+ return {
1506
+ ok,
1507
+ modules: view.modules,
1508
+ ...boundaries.length > 0 ? { boundaries } : {}
1509
+ };
1510
+ }
1511
+ /**
1512
+ * Append one module's lines: its name (annotated with its fork ordinal when it composes to more than one
1513
+ * instance in the app), its imports/exports, then each provider over its dependencies.
1514
+ */
1515
+ static appendModule(lines, module, forkOrdinal) {
1516
+ const indent = ModuleGraphReport.indent;
1517
+ const instanceCount = module.instanceCount ?? 1;
1518
+ const forkSuffix = instanceCount > 1 ? ` (fork ${forkOrdinal} of ${instanceCount})` : "";
1519
+ lines.push(`${module.reference.className}${forkSuffix}`);
1520
+ if (module.imports.length > 0) lines.push(`${indent}imports: ${module.imports.map((token) => token.className).join(", ")}`);
1521
+ if (module.exports.length > 0) lines.push(`${indent}exports: ${module.exports.map((token) => token.className).join(", ")}`);
1522
+ if (module.providers.length > 0) {
1523
+ lines.push(`${indent}providers:`);
1524
+ for (const provider of module.providers) {
1525
+ lines.push(`${indent.repeat(2)}${ModuleGraphReport.tokenLabel(provider.token)} [${provider.kind}]`);
1526
+ for (const dependency of provider.dependencies) {
1527
+ const origin = dependency.isLazy ? `${dependency.origin}, lazy` : dependency.origin;
1528
+ const name = dependency.reference?.displayName ?? dependency.tokenName;
1529
+ lines.push(`${indent.repeat(3)}→ ${name} (${origin})`);
1530
+ }
1531
+ }
1532
+ }
1533
+ }
1534
+ /**
1535
+ * A token's display label: its class name, or — for a class shipped under a compiler-synthesized
1536
+ * machine-channel alias — its declared name with the alias kept visible, so the line still
1537
+ * cross-references the generated wiring's import.
1538
+ */
1539
+ static tokenLabel(token) {
1540
+ return token.displayName === void 0 ? token.className : `${token.displayName} (shipped as ${token.className})`;
1541
+ }
1542
+ };
1543
+ //#endregion
1544
+ //#region src/graph-command.ts
1545
+ /**
1546
+ * `heximon graph` — print the resolved module dependency graph (modules → providers → each provider's
1547
+ * resolved dependencies). This is a COMPILE-TIME artifact — like `heximon routes`, it needs no boot: it runs
1548
+ * the same from-disk `ProjectCompiler` compile `heximon check` runs and renders `CompileOutput`'s
1549
+ * module-graph projection. `--boundaries` also lists the cross-package boundary nodes the app composes.
1550
+ *
1551
+ * In a PUBLISHED MODULE PACKAGE the compile runs in library mode
1552
+ * ({@link ProjectCompileSession.compileLibrary}). Its nodes are the package's PUBLISHED ROOTS, each already
1553
+ * folded over the local sub-modules it owns — a package's individual source modules are not separately
1554
+ * composable, so they are not separately rendered.
1555
+ */
1556
+ const graphCommand = defineCommand({
1557
+ meta: {
1558
+ name: "graph",
1559
+ description: "Compile the project and print the resolved module dependency graph (modules → providers → dependencies)."
1560
+ },
1561
+ args: {
1562
+ cwd: {
1563
+ type: "string",
1564
+ description: "Project root directory.",
1565
+ default: "."
1566
+ },
1567
+ env: {
1568
+ type: "string",
1569
+ description: "The heximon.config.ts `$env` name to resolve (matches `vp dev`). Default development.",
1570
+ default: "development"
1571
+ },
1572
+ json: {
1573
+ type: "boolean",
1574
+ description: "Emit the graph as JSON on stdout instead of a tree.",
1575
+ default: false
1576
+ },
1577
+ boundaries: {
1578
+ type: "boolean",
1579
+ description: "Also list the cross-package boundary nodes the app composes.",
1580
+ default: false
1581
+ },
1582
+ out: {
1583
+ type: "string",
1584
+ description: "Write the JSON payload to this file instead of stdout, creating missing parent directories — the CI-artifact target."
1585
+ },
1586
+ src: {
1587
+ type: "string",
1588
+ description: "Source directories to scan, comma-separated. Defaults to `src/`, or `server/` for a Nitro/Nuxt host."
1589
+ },
1590
+ library: CompileModeResolver.flags.library,
1591
+ app: CompileModeResolver.flags.app
1592
+ },
1593
+ async run({ args }) {
1594
+ const rootDir = resolve(process.cwd(), args.cwd);
1595
+ const asJson = args.json;
1596
+ const withBoundaries = args.boundaries;
1597
+ const jsonOutput = JsonOutputTarget.resolve(args.out, asJson);
1598
+ const sourceDirectories = SourceDirectories.resolve(rootDir, args.src);
1599
+ const resolved = CompileModeResolver.resolve("graph", rootDir, args, sourceDirectories);
1600
+ if (resolved === void 0) return;
1601
+ if (!asJson) intro(`heximon graph — ${basename(rootDir)} · ${CompileModeResolver.stamp(resolved)}`);
1602
+ let output;
1603
+ try {
1604
+ const session = await ProjectCompileSession.open({
1605
+ rootDir,
1606
+ environment: args.env,
1607
+ ...sourceDirectories === void 0 ? {} : { sourceDirectories }
1608
+ });
1609
+ output = resolved.mode === CompileMode.Library ? session.compileLibrary() : session.compile();
1610
+ } catch (error) {
1611
+ GraphCommand.reportFailure(error, asJson, jsonOutput);
1612
+ process.exitCode = 1;
1613
+ return;
1614
+ }
1615
+ if (!GraphCommand.report(output, {
1616
+ asJson,
1617
+ withBoundaries,
1618
+ jsonOutput,
1619
+ libraryMode: resolved.mode === CompileMode.Library
1620
+ })) process.exitCode = 1;
1621
+ }
1622
+ });
1623
+ /** Output helpers for {@link graphCommand}: the module tree / JSON body and the boundary projection. */
1624
+ var GraphCommand = class GraphCommand {
1625
+ /**
1626
+ * Print the resolved graph — the JSON payload to its target, and a module-nested tree unless `--json`.
1627
+ *
1628
+ * @returns Whether the run succeeded; `false` for a failed compile or a failed `--out` write.
1629
+ */
1630
+ static report(output, options) {
1631
+ const view = output.moduleGraph ?? { modules: [] };
1632
+ const boundaries = options.withBoundaries ? GraphCommand.boundarySummaries(output) : [];
1633
+ const errors = output.diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error).length;
1634
+ const written = options.jsonOutput.emit({
1635
+ ...ModuleGraphReport.toJson(view, boundaries, errors === 0),
1636
+ errors
1637
+ });
1638
+ if (options.asJson) return written && errors === 0;
1639
+ if (written && options.jsonOutput.isFile()) log.success(`Graph JSON written to ${options.jsonOutput.filePath}.`);
1640
+ log.message(ModuleGraphReport.renderTree(view, boundaries));
1641
+ if (options.libraryMode === true) log.info("These nodes are this package's PUBLISHED ROOTS, each folded over the local sub-modules it owns. A package's own sub-modules are not separately composable, so they carry no node of their own.");
1642
+ const moduleCount = view.modules.length;
1643
+ const summary = `${moduleCount} module${moduleCount === 1 ? "" : "s"}`;
1644
+ if (errors > 0) outro(`${summary} — ${errors} compile error${errors === 1 ? "" : "s"}; the graph may be incomplete.`);
1645
+ else outro(`${summary}.`);
1646
+ return written && errors === 0;
1647
+ }
1648
+ /**
1649
+ * Report a config-load / compile failure: `{ ok: false, error }` to the JSON target, and a clack error +
1650
+ * outro unless `--json`. The failure payload goes to `--out` too, so re-running replaces the previous
1651
+ * artifact rather than leaving a stale success.
1652
+ */
1653
+ static reportFailure(error, asJson, jsonOutput) {
1654
+ const message = error instanceof Error ? error.message : String(error);
1655
+ jsonOutput.emit({
1656
+ ok: false,
1657
+ error: message
1658
+ });
1659
+ if (asJson) return;
1660
+ log.error(`Could not read the app's module graph:\n${message}`);
1661
+ outro("No graph to show.");
1662
+ }
1663
+ /** Project the compile's cross-package boundary nodes into display summaries. */
1664
+ static boundarySummaries(output) {
1665
+ return output.boundaryNodes.map((boundary) => ({
1666
+ specifier: boundary.specifier,
1667
+ factory: boundary.factoryExport,
1668
+ requires: boundary.requires.map((require) => require.token.className),
1669
+ exports: boundary.exports.map((exported) => exported.token.className)
1670
+ }));
1671
+ }
1672
+ };
1673
+ //#endregion
1674
+ //#region src/migrations-command.ts
1675
+ /**
1676
+ * The plumbing every `heximon migrations` subcommand shares: the `.env` + detection prologue, the config
1677
+ * selection, one captured `drizzle-kit` invocation, and the throw-to-cancel guard.
1678
+ */
1679
+ var MigrationsRunner = class MigrationsRunner {
1680
+ /** The flags every `heximon migrations` subcommand accepts. */
1681
+ static baseArgs = {
1682
+ cwd: {
1683
+ type: "string",
1684
+ description: "Project root directory.",
1685
+ default: "."
1686
+ },
1687
+ env: {
1688
+ type: "string",
1689
+ description: "Env mode selecting the .env overlay (.env.<mode>); default development.",
1690
+ default: "development"
1691
+ },
1692
+ "env-file": {
1693
+ type: "string",
1694
+ description: "Load an explicit dotenv file over the cascade (e.g. .env.production for a prod migrate)."
1695
+ },
1696
+ config: {
1697
+ type: "string",
1698
+ description: "Target one drizzle-kit config file, bypassing auto-detection."
1699
+ }
1700
+ };
1701
+ /** The `select` sentinel for "run every detected database". */
1702
+ static allDatabasesValue = "__all__";
1703
+ /**
1704
+ * Resolve the project root, load the `.env` cascade into `process.env` (so the spawned `drizzle-kit`
1705
+ * inherits `DATABASE_URL`), and detect the app's drizzle-kit config file(s). An explicit `--env-file`
1706
+ * overlays the cascade.
1707
+ */
1708
+ static async prepare(args) {
1709
+ const rootDir = resolve(process.cwd(), args.cwd);
1710
+ const overlay = args["env-file"] === void 0 ? void 0 : await EnvFileReader.read(resolve(rootDir, args["env-file"]));
1711
+ DotenvCascade.loadInto(rootDir, args.env, overlay);
1712
+ return {
1713
+ rootDir,
1714
+ detection: await DatabaseConfigDetector.detect(rootDir)
1715
+ };
1716
+ }
1717
+ /**
1718
+ * Choose which detected config(s) to act on: an explicit `--config` wins; a single detected config is used
1719
+ * silently; multiple prompt an interactive `select`, or — in a non-TTY run — fan out to ALL of them, which
1720
+ * is named out loud because that is several databases the operator never chose. Reports the failure itself
1721
+ * (no config / cancelled) and returns `undefined` so the caller just returns.
1722
+ */
1723
+ static async selectConfigs(rootDir, detection, args, interactive) {
1724
+ if (args.config !== void 0) {
1725
+ const path = resolve(rootDir, args.config);
1726
+ return [await DatabaseConfigDetector.classifyFile(rootDir, path) ?? MigrationsRunner.syntheticConfig(path)];
1727
+ }
1728
+ const { configs } = detection;
1729
+ if (configs.length === 0) {
1730
+ MigrationsRunner.reportNoConfig(detection);
1731
+ return;
1732
+ }
1733
+ if (configs.length === 1) return configs;
1734
+ if (!interactive) {
1735
+ log.warn(`${configs.length} databases detected and no TTY to choose between them — acting on ALL of them: ${configs.map((config) => config.name).join(", ")}. Pass --config <path> to target one.`);
1736
+ return configs;
1737
+ }
1738
+ const choice = await select({
1739
+ message: "Which database?",
1740
+ options: [{
1741
+ value: MigrationsRunner.allDatabasesValue,
1742
+ label: "All databases"
1743
+ }, ...configs.map((config) => ({
1744
+ value: config.path,
1745
+ label: config.name
1746
+ }))]
1747
+ });
1748
+ if (isCancel(choice)) {
1749
+ cancel("Cancelled — nothing run.");
1750
+ process.exitCode = 1;
1751
+ return;
1752
+ }
1753
+ if (choice === MigrationsRunner.allDatabasesValue) return configs;
1754
+ return configs.filter((config) => config.path === choice);
1755
+ }
1756
+ /**
1757
+ * Run one captured `drizzle-kit` invocation behind a spinner, relaying its output.
1758
+ *
1759
+ * @returns `true` on exit code 0, else `false` (the output is logged either way).
1760
+ */
1761
+ static async runStep(interactive, label, invoke) {
1762
+ const progress = CliSpinner.begin(interactive, `${label}…`);
1763
+ let result;
1764
+ try {
1765
+ result = await invoke();
1766
+ } catch (error) {
1767
+ progress?.stop(`${label} — failed to start.`);
1768
+ throw error;
1769
+ }
1770
+ const output = [result.stdout.trim(), result.stderr.trim()].filter((part) => part.length > 0);
1771
+ if (result.exitCode === 0) {
1772
+ progress?.stop(`${label} — done.`);
1773
+ if (output.length > 0) log.message(output.join("\n"));
1774
+ return true;
1775
+ }
1776
+ progress?.stop(`${label} — failed (exit ${result.exitCode}).`);
1777
+ if (output.length > 0) log.error(output.join("\n"));
1778
+ return false;
1779
+ }
1780
+ /**
1781
+ * Wrap a subcommand body so a thrown error (e.g. an unreadable `--env-file`) becomes a clean cancel +
1782
+ * non-zero exit rather than an unhandled stack trace.
1783
+ */
1784
+ static async guard(body) {
1785
+ try {
1786
+ await body();
1787
+ } catch (error) {
1788
+ process.exitCode = 1;
1789
+ cancel(error instanceof Error ? error.message : String(error));
1790
+ }
1791
+ }
1792
+ /**
1793
+ * A stand-in for an explicit `--config` that could not be classified (unloadable / not a drizzle config):
1794
+ * the command still drives `drizzle-kit` on it and surfaces drizzle-kit's own error, trusting the flag.
1795
+ */
1796
+ static syntheticConfig(path) {
1797
+ return {
1798
+ path,
1799
+ name: basename(path),
1800
+ dialect: "unknown",
1801
+ driver: void 0,
1802
+ out: void 0,
1803
+ applyMode: "drizzle-kit"
1804
+ };
1805
+ }
1806
+ /**
1807
+ * Report that no drizzle-kit config was detected — the actionable halt, listing any candidate that failed
1808
+ * to load (the likeliest cause of a "missing" config).
1809
+ */
1810
+ static reportNoConfig(detection) {
1811
+ process.exitCode = 1;
1812
+ cancel("No drizzle-kit config found — expected a `*.config.ts` that `export default`s a Drizzle config (e.g. src/database/database.config.ts), or pass --config <path>.");
1813
+ for (const failure of detection.loadErrors) log.warn(`Could not load ${failure.path}: ${failure.message}`);
1814
+ }
1815
+ };
1816
+ const generateCommand = defineCommand({
1817
+ meta: {
1818
+ name: "generate",
1819
+ description: "Author a migration from the schema diff (drizzle-kit generate)."
1820
+ },
1821
+ args: {
1822
+ ...MigrationsRunner.baseArgs,
1823
+ name: {
1824
+ type: "string",
1825
+ description: "Name the migration (drizzle-kit --name)."
1826
+ }
1827
+ },
1828
+ run: ({ args }) => MigrationsRunner.guard(async () => {
1829
+ const interactive = CliEnvironment.isInteractive();
1830
+ intro("heximon migrations generate");
1831
+ const { rootDir, detection } = await MigrationsRunner.prepare(args);
1832
+ const selected = await MigrationsRunner.selectConfigs(rootDir, detection, args, interactive);
1833
+ if (selected === void 0) return;
1834
+ const runner = new DrizzleKitRunner();
1835
+ let allSucceeded = true;
1836
+ for (const config of selected) allSucceeded = await MigrationsRunner.runStep(interactive, `Generating migration for ${config.name}`, () => runner.generate(config.path, { rootDir }, args.name)) && allSucceeded;
1837
+ if (!allSucceeded) {
1838
+ process.exitCode = 1;
1839
+ cancel("Some migrations failed to generate — see above.");
1840
+ return;
1841
+ }
1842
+ outro("Migration(s) generated — review the SQL, then `heximon migrations migrate`.");
1843
+ })
1844
+ });
1845
+ const migrateCommand = defineCommand({
1846
+ meta: {
1847
+ name: "migrate",
1848
+ description: "Apply pending migrations (drizzle-kit migrate)."
1849
+ },
1850
+ args: MigrationsRunner.baseArgs,
1851
+ run: ({ args }) => MigrationsRunner.guard(async () => {
1852
+ const interactive = CliEnvironment.isInteractive();
1853
+ intro("heximon migrations migrate");
1854
+ const { rootDir, detection } = await MigrationsRunner.prepare(args);
1855
+ const selected = await MigrationsRunner.selectConfigs(rootDir, detection, args, interactive);
1856
+ if (selected === void 0) return;
1857
+ const runner = new DrizzleKitRunner();
1858
+ let allSucceeded = true;
1859
+ for (const config of selected) {
1860
+ if (config.applyMode === "wrangler-d1") {
1861
+ log.warn(`${config.name}: a Cloudflare D1 database — apply it with \`wrangler d1 migrations apply <database> --remote\`, not drizzle-kit.`);
1862
+ continue;
1863
+ }
1864
+ if (config.applyMode === "skip") {
1865
+ log.info(`${config.name}: Durable Object SQLite — migrations bundle in-DO at init; no CLI apply step.`);
1866
+ continue;
1867
+ }
1868
+ allSucceeded = await MigrationsRunner.runStep(interactive, `Applying migrations for ${config.name}`, () => runner.migrate(config.path, { rootDir })) && allSucceeded;
1869
+ }
1870
+ if (!allSucceeded) {
1871
+ process.exitCode = 1;
1872
+ cancel("Some migrations failed to apply — see above.");
1873
+ return;
1874
+ }
1875
+ outro("Migrations applied.");
1876
+ })
1877
+ });
1878
+ const statusCommand = defineCommand({
1879
+ meta: {
1880
+ name: "status",
1881
+ description: "List the on-disk migrations per database."
1882
+ },
1883
+ args: MigrationsRunner.baseArgs,
1884
+ run: ({ args }) => MigrationsRunner.guard(async () => {
1885
+ const interactive = CliEnvironment.isInteractive();
1886
+ intro("heximon migrations status");
1887
+ const { rootDir, detection } = await MigrationsRunner.prepare(args);
1888
+ const selected = await MigrationsRunner.selectConfigs(rootDir, detection, args, interactive);
1889
+ if (selected === void 0) return;
1890
+ for (const config of selected) {
1891
+ const outFolder = config.out ?? DatabaseConfigDetector.defaultOutDirectory;
1892
+ const files = await MigrationInspector.collectSqlFiles(resolve(rootDir, outFolder));
1893
+ if (files.length === 0) {
1894
+ log.info(`${config.name}: no migrations in ${outFolder} — run \`heximon migrations generate\`.`);
1895
+ continue;
1896
+ }
1897
+ log.message(`${config.name} · ${files.length} migration(s) in ${outFolder}:\n` + files.map((file) => ` ${file.relativePath}`).join("\n"));
1898
+ }
1899
+ log.info("Applied vs. pending state needs a live database query — not shown in this view.");
1900
+ outro("Status listed.");
1901
+ })
1902
+ });
1903
+ const pushCommand = defineCommand({
1904
+ meta: {
1905
+ name: "push",
1906
+ description: "Push the schema straight to the database — destructive; dev only (drizzle-kit push)."
1907
+ },
1908
+ args: {
1909
+ ...MigrationsRunner.baseArgs,
1910
+ yes: {
1911
+ type: "boolean",
1912
+ description: "Confirm the destructive push non-interactively."
1913
+ }
1914
+ },
1915
+ run: ({ args }) => MigrationsRunner.guard(async () => {
1916
+ const interactive = CliEnvironment.isInteractive();
1917
+ intro("heximon migrations push");
1918
+ const { rootDir, detection } = await MigrationsRunner.prepare(args);
1919
+ const selected = await MigrationsRunner.selectConfigs(rootDir, detection, args, interactive);
1920
+ if (selected === void 0) return;
1921
+ let force = args.yes === true;
1922
+ if (!force) {
1923
+ if (!interactive) {
1924
+ process.exitCode = 1;
1925
+ cancel("`push` is destructive (it can drop columns/tables). Re-run with --yes to confirm in a non-interactive shell.");
1926
+ return;
1927
+ }
1928
+ const confirmed = await confirm({
1929
+ message: `Push schema directly to ${selected.map((config) => config.name).join(", ")}? This can DROP columns/tables.`,
1930
+ initialValue: false
1931
+ });
1932
+ if (isCancel(confirmed) || confirmed !== true) {
1933
+ cancel("Cancelled — nothing pushed.");
1934
+ process.exitCode = 1;
1935
+ return;
1936
+ }
1937
+ force = true;
1938
+ }
1939
+ const runner = new DrizzleKitRunner();
1940
+ let allSucceeded = true;
1941
+ for (const config of selected) allSucceeded = await MigrationsRunner.runStep(interactive, `Pushing schema for ${config.name}`, () => runner.push(config.path, { rootDir }, force)) && allSucceeded;
1942
+ if (!allSucceeded) {
1943
+ process.exitCode = 1;
1944
+ cancel("Some schema pushes failed — see above.");
1945
+ return;
1946
+ }
1947
+ outro("Schema pushed.");
1948
+ })
1949
+ });
1950
+ /**
1951
+ * The `heximon migrations` command — drive the app's local `drizzle-kit` against its auto-detected Drizzle
1952
+ * config with `.env` loaded, so schema authoring / applying needs no hand-written `drizzle.config.ts` or
1953
+ * per-app script.
1954
+ */
1955
+ const migrationsCommand = defineCommand({
1956
+ meta: {
1957
+ name: "migrations",
1958
+ description: "Author + apply database migrations via the app's local drizzle-kit, against its auto-detected config."
1959
+ },
1960
+ subCommands: {
1961
+ generate: generateCommand,
1962
+ migrate: migrateCommand,
1963
+ status: statusCommand,
1964
+ push: pushCommand
1965
+ }
1966
+ });
1967
+ //#endregion
1968
+ //#region src/provision-command.ts
1969
+ /**
1970
+ * `heximon provision` — the PRE-BUILD verb for resources whose id the build itself needs (a Cloudflare
1971
+ * Hyperdrive binding with an empty `id` fails the build outright, and provisioning is what mints that id).
1972
+ * It calls exactly one seam, `strategy.provisionInfrastructure(context)` — never the deploy-time
1973
+ * `strategy.provision(...)` that creates queues and pushes secrets.
1974
+ */
1975
+ const provisionCommand = defineCommand({
1976
+ meta: {
1977
+ name: "provision",
1978
+ description: "Create the foundational resources whose id the build needs (e.g. Cloudflare Hyperdrive), then report each id to record. Run once, before deploy."
1979
+ },
1980
+ args: {
1981
+ cwd: {
1982
+ type: "string",
1983
+ description: "Project root directory.",
1984
+ default: "."
1985
+ },
1986
+ env: {
1987
+ type: "string",
1988
+ description: "Deploy environment (production / staging / preview:<id>).",
1989
+ default: "production"
1990
+ },
1991
+ "dry-run": {
1992
+ type: "boolean",
1993
+ description: "Report what would be created, but create nothing.",
1994
+ default: false
1995
+ },
1996
+ "save-ids": {
1997
+ type: "boolean",
1998
+ description: "Record each created resource's id in the per-environment deploy ledger (.heximon/deploy-env.<env>.json) — the deploy injects it into the build, so a config reading the conventional env var needs no manual paste.",
1999
+ default: false
2000
+ },
2001
+ platform: {
2002
+ type: "string",
2003
+ description: "Override the platform (e.g. cloudflare) when heximon.config.ts does not declare it."
2004
+ },
2005
+ profile: {
2006
+ type: "string",
2007
+ description: "Not supported here: provisioning talks to the Cloudflare REST API with CLOUDFLARE_API_TOKEN, which a wrangler auth profile does not carry. (heximon deploy does accept --profile.)"
2008
+ }
2009
+ },
2010
+ async run({ args }) {
2011
+ const rootDir = resolve(process.cwd(), args.cwd);
2012
+ const environment = args.env;
2013
+ const interactive = CliEnvironment.isInteractive();
2014
+ intro(`heximon provision · ${environment}`);
2015
+ if (args.profile !== void 0) {
2016
+ cancel("--profile is not supported by `heximon provision` — provisioning talks to the Cloudflare REST API with CLOUDFLARE_API_TOKEN, which a wrangler auth profile does not carry. Set the token instead.");
2017
+ process.exitCode = 1;
2018
+ return;
2019
+ }
2020
+ try {
2021
+ const target = await DeployTargetResolver.resolve(rootDir, environment, args.platform);
2022
+ log.info(`Platform: ${target.platformName ?? "node (default)"} · target class: ${target.targetClass}`);
2023
+ const specs = CredentialSelection.inPlay(target.strategy.requiredCredentials(), { provisioning: true });
2024
+ const credentials = await CredentialResolver.resolve(specs, {
2025
+ interactive,
2026
+ prompter: interactive ? new ClackPrompter() : void 0
2027
+ });
2028
+ const progress = CliSpinner.begin(interactive, args["dry-run"] === true ? "Previewing the foundational resources…" : "Provisioning foundational resources…");
2029
+ let outcome;
2030
+ try {
2031
+ outcome = await DeployRunner.provisionInfrastructure({
2032
+ target,
2033
+ rootDir,
2034
+ environment,
2035
+ credentials,
2036
+ dryRun: args["dry-run"],
2037
+ saveIds: args["save-ids"]
2038
+ });
2039
+ } catch (provisionError) {
2040
+ progress?.stop("Provisioning failed — see the error below.");
2041
+ throw provisionError;
2042
+ }
2043
+ progress?.stop("Provision steps complete.");
2044
+ if (outcome.unsupported) {
2045
+ cancel(`Platform ${outcome.platformName ?? "node (default)"} has no foundational resources to provision.`);
2046
+ process.exitCode = 1;
2047
+ return;
2048
+ }
2049
+ const result = outcome.result;
2050
+ for (const message of result?.messages ?? []) log.message(message);
2051
+ for (const provisioned of result?.provisioned ?? []) log.success(`Provisioned ${provisioned.resource} → id ${provisioned.id}.`);
2052
+ if (outcome.savedIds !== void 0) log.success(`Recorded ${outcome.savedIds.names.join(", ")} in ${outcome.savedIds.path} — \`heximon deploy\` injects it into the build.`);
2053
+ if (result !== void 0 && result.failed.length > 0) {
2054
+ process.exitCode = 1;
2055
+ cancel(`Provisioning failed for: ${result.failed.join(", ")} — see above.`);
2056
+ return;
2057
+ }
2058
+ outro(outcome.savedIds !== void 0 ? "Provisioning complete — ids recorded in the deploy ledger; deploy when ready." : "Provisioning complete — record the ids above in your config, then deploy.");
2059
+ } catch (error) {
2060
+ if (DeployCommandFailure.report(error)) return;
2061
+ throw error;
2062
+ }
2063
+ }
2064
+ });
2065
+ //#endregion
2066
+ //#region src/route-table-report.ts
2067
+ /**
2068
+ * Renders a {@link RouteCensusRow} list as an aligned, columnar route table for the terminal —
2069
+ * `METHOD PATH SOURCE HANDLER`, one row per path the app answers, streams and sockets included. The
2070
+ * `HANDLER` column names the contract route a contract-mode row dispatches to (`UploadsApi.create`) and the
2071
+ * declaring class of a stream or socket row; it is blank for an inline route, which carries neither.
2072
+ *
2073
+ * Two more columns are appended only when some row carries the fact they report, so a table read off a
2074
+ * source that resolves neither stays exactly as wide as it always was: `SCOPES` for the authorization union,
2075
+ * and `MIDDLEWARE` for the connect/upgrade chain that guards a streaming path — the two are different
2076
+ * answers to "what stands between a caller and this path", so neither is rendered in the other's column.
2077
+ */
2078
+ var RouteTableReport = class RouteTableReport {
2079
+ static baseHeaders = [
2080
+ "METHOD",
2081
+ "PATH",
2082
+ "SOURCE",
2083
+ "HANDLER"
2084
+ ];
2085
+ /** The `SCOPES` column header, appended only when some row carries scope information. */
2086
+ static scopesHeader = "SCOPES";
2087
+ /** The `MIDDLEWARE` column header, appended only when some row carries a connect/upgrade chain. */
2088
+ static middlewareHeader = "MIDDLEWARE";
2089
+ /** The `SCOPES` cell for a route that resolved to no scopes at all — an unguarded route. */
2090
+ static noScopesCell = "-";
2091
+ /** The `SCOPES` cell for a route whose scopes could not be resolved — NOT the same fact as none. */
2092
+ static unresolvedScopesCell = "?";
2093
+ /** Render `rows` (already sorted by the caller) as the aligned table text — never a trailing newline. */
2094
+ static render(rows) {
2095
+ const withScopes = rows.some((row) => row.scopes !== void 0 || row.scopesUnresolved !== void 0);
2096
+ const withMiddleware = rows.some((row) => row.middlewares !== void 0);
2097
+ const headers = [
2098
+ ...RouteTableReport.baseHeaders,
2099
+ ...withScopes ? [RouteTableReport.scopesHeader] : [],
2100
+ ...withMiddleware ? [RouteTableReport.middlewareHeader] : []
2101
+ ];
2102
+ const cells = rows.map((row) => [
2103
+ row.method,
2104
+ row.path,
2105
+ row.source,
2106
+ RouteTableReport.handlerOf(row),
2107
+ ...withScopes ? [RouteTableReport.scopesOf(row)] : [],
2108
+ ...withMiddleware ? [(row.middlewares ?? []).join(", ")] : []
2109
+ ]);
2110
+ const widths = headers.map((header, column) => Math.max(header.length, ...cells.map((row) => row[column].length)));
2111
+ const line = (columns) => columns.map((value, column) => value.padEnd(widths[column])).join(" ").trimEnd();
2112
+ return [line(headers), ...cells.map(line)].join("\n");
2113
+ }
2114
+ /**
2115
+ * The `HANDLER` cell: `Contract.routeKey` for a contract route, the declaring class for a stream or socket
2116
+ * row (whose one path IS the class), else empty for an inline route, which names neither.
2117
+ */
2118
+ static handlerOf(row) {
2119
+ if (row.handler === void 0) return row.middlewares === void 0 ? "" : row.controller ?? "";
2120
+ return row.contract === void 0 ? row.handler : `${row.contract}.${row.handler}`;
2121
+ }
2122
+ /**
2123
+ * The `SCOPES` cell: the scope names, `-` for an unguarded route, `?` when they could not be resolved, and
2124
+ * EMPTY for a row that claims nothing on this axis at all — a streaming path, whose guard is the chain the
2125
+ * `MIDDLEWARE` column names. Rendering `-` there would assert an unguarded path the census never claimed.
2126
+ */
2127
+ static scopesOf(row) {
2128
+ if (row.scopes !== void 0) return row.scopes.length === 0 ? RouteTableReport.noScopesCell : row.scopes.join(", ");
2129
+ return row.scopesUnresolved === void 0 ? "" : RouteTableReport.unresolvedScopesCell;
2130
+ }
2131
+ };
2132
+ //#endregion
2133
+ //#region src/routes-command.ts
2134
+ /**
2135
+ * Boots an app in-process to read its live HTTP route table — the `--boot` fallback for a route surface the
2136
+ * compiler cannot describe statically. Booting imports the app's TypeScript source through `.js` specifiers,
2137
+ * and Heximon's parameter-property classes need a TypeScript TRANSFORM (not just type-stripping), so the boot
2138
+ * runs under a one-time re-exec with Node's transform-types enabled plus a resolve hook that maps those `.js`
2139
+ * specifiers to their `.ts` sources.
2140
+ */
2141
+ var RoutesBoot = class RoutesBoot {
2142
+ /** Set on the re-exec'd child so it boots rather than re-exec'ing again. */
2143
+ static transformMarker = "HEXIMON_ROUTES_TRANSFORM";
2144
+ /** The `.js`/extensionless → `.ts` ESM resolve hook, inlined as a `data:` module so it ships in no file. */
2145
+ static sourceResolverModule = RoutesBoot.buildSourceResolverModule();
2146
+ /** Whether this process is the transform-enabled child that performs the boot. */
2147
+ static isBootProcess() {
2148
+ return process.env[RoutesBoot.transformMarker] === "1";
2149
+ }
2150
+ /**
2151
+ * Re-run this exact CLI invocation under `node --experimental-transform-types`, inheriting stdio so the
2152
+ * child's output and prompts reach the terminal directly.
2153
+ *
2154
+ * A child that never started, or that a signal killed, reports no exit code. Both become `1` — but they
2155
+ * are NAMED on stderr first, because the child printed nothing of its own and a bare `exit 1` after a
2156
+ * silent run reads as "the command found a problem" rather than "the command never ran".
2157
+ *
2158
+ * @returns The child process exit code (1 when it did not report one).
2159
+ */
2160
+ static reexecUnderTransform() {
2161
+ const result = spawnSync(process.execPath, [
2162
+ "--experimental-transform-types",
2163
+ "--disable-warning=ExperimentalWarning",
2164
+ process.argv[1] ?? "",
2165
+ ...process.argv.slice(2)
2166
+ ], {
2167
+ stdio: "inherit",
2168
+ env: {
2169
+ ...process.env,
2170
+ [RoutesBoot.transformMarker]: "1"
2171
+ }
2172
+ });
2173
+ if (result.error !== void 0) process.stderr.write(`heximon routes --boot: could not start the transform-enabled child (${process.execPath}): ${result.error.message}\n`);
2174
+ else if (result.signal !== null && result.signal !== void 0) process.stderr.write(`heximon routes --boot: the transform-enabled child was killed by ${result.signal}.\n`);
2175
+ return result.status ?? 1;
2176
+ }
2177
+ /** Install the source resolve hook so the imminent boot can import the app's `.ts` source. */
2178
+ static registerSourceResolver() {
2179
+ register(RoutesBoot.sourceResolverModule);
2180
+ }
2181
+ /** Build the inline resolve-hook module — mirrors the compiler's own relative-import resolution. */
2182
+ static buildSourceResolverModule() {
2183
+ const source = [
2184
+ "import { existsSync } from \"node:fs\";",
2185
+ "import { fileURLToPath, pathToFileURL } from \"node:url\";",
2186
+ "export async function resolve(specifier, context, nextResolve) {",
2187
+ " if ((specifier.startsWith(\"./\") || specifier.startsWith(\"../\")) && context.parentURL) {",
2188
+ " const base = fileURLToPath(new URL(specifier, context.parentURL));",
2189
+ " const withoutJs = base.endsWith(\".js\") ? base.slice(0, -3) : base;",
2190
+ " for (const candidate of [withoutJs + \".ts\", base + \".ts\", base + \"/index.ts\"]) {",
2191
+ " if (candidate.endsWith(\".ts\") && existsSync(candidate)) {",
2192
+ " return { url: pathToFileURL(candidate).href, shortCircuit: true };",
2193
+ " }",
2194
+ " }",
2195
+ " }",
2196
+ " return nextResolve(specifier, context);",
2197
+ "}"
2198
+ ].join("\n");
2199
+ return `data:text/javascript,${encodeURIComponent(source)}`;
2200
+ }
2201
+ };
2202
+ /**
2203
+ * `heximon routes` — print the app's unified HTTP route table (inline-mode AND contract-mode routes
2204
+ * together), each row carrying the authorization scopes the app enforces on it.
2205
+ *
2206
+ * By default the table is a COMPILE-TIME artifact: the same from-disk compile `heximon check` runs emits a
2207
+ * route census, so the command needs no boot, no `.env`-backed infrastructure, and no running database. The
2208
+ * census is deterministic and path-free, so `--out` is a diffable CI artifact — two runs of the same source
2209
+ * produce byte-identical JSON, and a dropped authorization scope shows up as a changed `scopes` array.
2210
+ *
2211
+ * `--boot` selects the legacy in-process boot instead (compile + boot like the test harness, loading `.env`
2212
+ * first the way `vp dev` does). It is the fallback for a route the compiler names as unresolved — a
2213
+ * cross-package controller, or a contract whose route table is not statically readable.
2214
+ *
2215
+ * In a PUBLISHED MODULE PACKAGE the compile runs in library mode
2216
+ * ({@link ProjectCompileSession.compileLibrary}) and the table is the package's OWN — the paths its factory
2217
+ * bakes, over its own accumulated prefixes. The published root's default mount is a manifest fact, not a
2218
+ * baked path: a consuming host applies it, or replaces it at its import edge, so it is NOT folded in here.
2219
+ */
2220
+ const routesCommand = defineCommand({
2221
+ meta: {
2222
+ name: "routes",
2223
+ description: "Print the app's unified HTTP route table with its authorization scopes."
2224
+ },
2225
+ args: {
2226
+ cwd: {
2227
+ type: "string",
2228
+ description: "Project root directory.",
2229
+ default: "."
2230
+ },
2231
+ env: {
2232
+ type: "string",
2233
+ description: "The `.env.<mode>` overlay to load first; also the config `$env`. Default development.",
2234
+ default: "development"
2235
+ },
2236
+ json: {
2237
+ type: "boolean",
2238
+ description: "Emit the route table as JSON on stdout instead of a table.",
2239
+ default: false
2240
+ },
2241
+ out: {
2242
+ type: "string",
2243
+ description: "Write the JSON payload to this file instead of stdout, creating missing parent directories — the CI-artifact target."
2244
+ },
2245
+ boot: {
2246
+ type: "boolean",
2247
+ description: "Boot the app in-process and read its live route table, instead of reading the compile-time census.",
2248
+ default: false
2249
+ },
2250
+ src: {
2251
+ type: "string",
2252
+ description: "Source directories to scan, comma-separated. Defaults to `src/`, or `server/` for a Nitro/Nuxt host."
2253
+ },
2254
+ library: CompileModeResolver.flags.library,
2255
+ app: CompileModeResolver.flags.app
2256
+ },
2257
+ async run({ args }) {
2258
+ const asBoot = args.boot;
2259
+ if (asBoot && !RoutesBoot.isBootProcess()) {
2260
+ process.exitCode = RoutesBoot.reexecUnderTransform();
2261
+ return;
2262
+ }
2263
+ const rootDir = resolve(process.cwd(), args.cwd);
2264
+ const asJson = args.json;
2265
+ const jsonOutput = JsonOutputTarget.resolve(args.out, asJson);
2266
+ const resolved = CompileModeResolver.resolve("routes", rootDir, args, SourceDirectories.resolve(rootDir, args.src));
2267
+ if (resolved === void 0) return;
2268
+ if (!asJson) intro(`heximon routes — ${basename(rootDir)} · ${CompileModeResolver.stamp(resolved)}`);
2269
+ if (asBoot) {
2270
+ await RoutesCommand.runBoot(rootDir, args, asJson, jsonOutput);
2271
+ return;
2272
+ }
2273
+ await RoutesCommand.runCompile(rootDir, args, asJson, jsonOutput, resolved.mode);
2274
+ }
2275
+ });
2276
+ /** Output helpers for {@link routesCommand}: the two read paths, the table / JSON body, and the failure summary. */
2277
+ var RoutesCommand = class RoutesCommand {
2278
+ /**
2279
+ * The compile-time read: run the same from-disk compile `heximon check` runs and report the route census
2280
+ * the HTTP plugin emitted. No app boot, so an `OnBootstrap` hook never runs and no infrastructure is
2281
+ * touched. A compile ERROR is reported as a failure — a graph that did not resolve describes no route
2282
+ * surface, and half a census is worse than none for a security diff.
2283
+ *
2284
+ * In library mode the same census comes out of the package's own library emit, so a published module's
2285
+ * route table is readable without composing it into an app. The `requires` a library leaves open for its
2286
+ * host are not diagnostics there, so nothing floods the report.
2287
+ */
2288
+ static async runCompile(rootDir, args, asJson, jsonOutput, mode = CompileMode.App) {
2289
+ let census;
2290
+ try {
2291
+ const sourceDirectories = SourceDirectories.resolve(rootDir, args.src);
2292
+ DotenvCascade.loadInto(rootDir, args.env);
2293
+ const session = await ProjectCompileSession.open({
2294
+ rootDir,
2295
+ environment: args.env,
2296
+ ...sourceDirectories === void 0 ? {} : { sourceDirectories }
2297
+ });
2298
+ const output = mode === CompileMode.Library ? session.compileLibrary() : session.compile();
2299
+ const failure = output.diagnostics.find((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error);
2300
+ if (failure !== void 0) {
2301
+ RoutesCommand.reportFailure(new Error(failure.message), asJson, jsonOutput);
2302
+ process.exitCode = 1;
2303
+ return;
2304
+ }
2305
+ const file = output.files.find((generated) => generated.path === RouteCensus.fileName);
2306
+ census = (file === void 0 ? void 0 : RouteCensus.parse(file.content)) ?? {
2307
+ routes: [],
2308
+ unresolved: []
2309
+ };
2310
+ } catch (error) {
2311
+ RoutesCommand.reportFailure(error, asJson, jsonOutput);
2312
+ process.exitCode = 1;
2313
+ return;
2314
+ }
2315
+ RoutesCommand.report(census.routes, census.unresolved, asJson, jsonOutput, { libraryMode: mode === CompileMode.Library });
2316
+ }
2317
+ /**
2318
+ * The boot fallback: compile and boot the app in-process (like the test harness), then read its live route
2319
+ * table. A contract-mode row's scopes are read off its merged route descriptor, same as the compile-time
2320
+ * census; only an inline first-party row — whose handler is an opaque `WrappedHandler` with no descriptor to
2321
+ * read — is marked {@link RouteScopeGap.BootDerived} rather than reported as unguarded.
2322
+ *
2323
+ * `@heximon/testing` and `@heximon/http` are imported HERE rather than at module scope: they are the two
2324
+ * heaviest imports in the package and only this opt-in path uses them, so a default `heximon routes` — and
2325
+ * every other `heximon` verb, since citty builds the whole command tree on startup — no longer pays for
2326
+ * them. Deferring the SUBCOMMAND instead does not work: `@bomb.sh/tab`'s citty adapter awaits every
2327
+ * subcommand thunk while building the completion tree, so citty's lazy `Resolvable` form resolves the
2328
+ * module anyway.
2329
+ */
2330
+ static async runBoot(rootDir, args, asJson, jsonOutput) {
2331
+ let app;
2332
+ let inventory;
2333
+ try {
2334
+ RoutesBoot.registerSourceResolver();
2335
+ const sourceDirectories = SourceDirectories.resolve(rootDir, args.src);
2336
+ DotenvCascade.loadInto(rootDir, args.env);
2337
+ const config = await ConfigLoader.load(rootDir, args.env);
2338
+ const [{ createTestApp: bootApp }, { RouteInventory }] = await Promise.all([import("@heximon/testing"), import("@heximon/http")]);
2339
+ inventory = RouteInventory;
2340
+ app = await bootApp({
2341
+ root: rootDir,
2342
+ printWarnings: false,
2343
+ ...sourceDirectories === void 0 ? {} : { sourceDirectories },
2344
+ bindings: { InternalClient: { fetch: () => Promise.resolve(new Response(null, { status: 404 })) } },
2345
+ ...config?.plugins === void 0 ? {} : { plugins: config.plugins },
2346
+ ...config?.platform === void 0 ? {} : { platform: config.platform }
2347
+ });
2348
+ } catch (error) {
2349
+ RoutesCommand.reportFailure(error, asJson, jsonOutput);
2350
+ process.exitCode = 1;
2351
+ return;
2352
+ }
2353
+ try {
2354
+ const routes = inventory.describe(app).map(RoutesCommand.toCensusRow);
2355
+ RoutesCommand.report(routes, [], asJson, jsonOutput);
2356
+ } catch (error) {
2357
+ RoutesCommand.reportFailure(error, asJson, jsonOutput);
2358
+ process.exitCode = 1;
2359
+ } finally {
2360
+ try {
2361
+ await app.context.drain({ timeoutMs: 5e3 });
2362
+ await app[Symbol.asyncDispose]();
2363
+ } catch (disposeError) {
2364
+ process.exitCode = 1;
2365
+ if (!asJson) log.warn(`The route table was read, but disposing the booted app failed — a client may still be open: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}`);
2366
+ }
2367
+ }
2368
+ }
2369
+ /**
2370
+ * Map one booted route to a census row. An entry whose {@link RouteInventoryEntry.scopes} is defined (every
2371
+ * contract-mode row) carries it through as-is, `[]` included; an entry with none (an inline first-party row,
2372
+ * which names no descriptor to read) keeps the {@link RouteScopeGap.BootDerived} stamp.
2373
+ */
2374
+ static toCensusRow(entry) {
2375
+ return {
2376
+ method: entry.method,
2377
+ path: entry.path,
2378
+ source: entry.source,
2379
+ ...entry.contract === void 0 ? {} : { contract: entry.contract },
2380
+ ...entry.handler === void 0 ? {} : { handler: entry.handler },
2381
+ ...entry.scopes === void 0 ? { scopesUnresolved: RouteScopeGap.BootDerived } : { scopes: entry.scopes }
2382
+ };
2383
+ }
2384
+ /**
2385
+ * Print the resolved routes — the JSON payload to its target, and an aligned table unless `--json`.
2386
+ *
2387
+ * @param options - `libraryMode` adds the one fact a package's own table cannot show: the published root's
2388
+ * default mount is a manifest fact a consuming host applies or replaces, so it is not folded into these
2389
+ * paths.
2390
+ */
2391
+ static report(routes, unresolved, asJson, jsonOutput, options = {}) {
2392
+ const written = jsonOutput.emit({
2393
+ ok: true,
2394
+ routes,
2395
+ unresolved
2396
+ });
2397
+ if (!written) process.exitCode = 1;
2398
+ if (asJson) return;
2399
+ if (written && jsonOutput.isFile()) log.success(`Route JSON written to ${jsonOutput.filePath}.`);
2400
+ if (routes.length === 0 && unresolved.length === 0) {
2401
+ outro(options.libraryMode === true ? "No HTTP routes — this package serves none, or its HTTP plugin is not active." : "No HTTP routes — this app serves none, or its HTTP plugin is not active.");
2402
+ return;
2403
+ }
2404
+ if (routes.length > 0) log.message(RouteTableReport.render([...routes].sort(RouteCensus.byRoute)));
2405
+ if (options.libraryMode === true) log.info("These are the paths this package BAKES. Its published root's own `http: { prefix }` is a manifest fact, not a baked path — a composing host applies it, or replaces it at its import edge — so it is not folded in here.");
2406
+ if (unresolved.length === 0) {
2407
+ outro(`${routes.length} route${routes.length === 1 ? "" : "s"}.`);
2408
+ return;
2409
+ }
2410
+ log.warn(RoutesCommand.renderGaps(unresolved));
2411
+ outro(`${routes.length} route${routes.length === 1 ? "" : "s"}; ${unresolved.length} unresolved` + (options.libraryMode === true ? "." : " — re-run with --boot to enumerate them."));
2412
+ }
2413
+ /**
2414
+ * Report a compile / boot failure: `{ ok: false, error }` to the JSON target, and a clack error + outro
2415
+ * unless `--json`. The failure payload goes to `--out` too, so re-running replaces the previous artifact
2416
+ * rather than leaving a stale success; a failed write is treated exactly as on the success path.
2417
+ */
2418
+ static reportFailure(error, asJson, jsonOutput) {
2419
+ const message = error instanceof Error ? error.message : String(error);
2420
+ if (!jsonOutput.emit({
2421
+ ok: false,
2422
+ error: message
2423
+ })) process.exitCode = 1;
2424
+ if (asJson) return;
2425
+ log.error(`Could not read the app's routes:\n${message}`);
2426
+ outro("No route table to show.");
2427
+ }
2428
+ /**
2429
+ * The unresolved-gap block: one line per controller (or package) whose routes the census could not name.
2430
+ * A composed package whose manifest section IS readable contributes rows instead, so nothing here is a
2431
+ * placeholder for a route the table already shows.
2432
+ */
2433
+ static renderGaps(unresolved) {
2434
+ return ["Routes the compile-time census could not resolve:", ...unresolved.map((gap) => {
2435
+ const subject = gap.controller ?? gap.package ?? "(unknown)";
2436
+ const detail = [
2437
+ gap.contract === void 0 ? void 0 : `contract ${gap.contract}`,
2438
+ gap.handler === void 0 ? void 0 : `route "${gap.handler}"`,
2439
+ gap.package === void 0 || gap.controller === void 0 ? void 0 : `from ${gap.package}`
2440
+ ].filter((part) => part !== void 0);
2441
+ return `${subject}${detail.length === 0 ? "" : ` (${detail.join(", ")})`} — ${gap.kind}`;
2442
+ })].join("\n");
2443
+ }
2444
+ };
2445
+ //#endregion
2446
+ //#region src/workflow-status-report.ts
2447
+ /** How one workflow's current step graph relates to the committed baseline gate. */
2448
+ const WorkflowBaselineState = {
2449
+ /** The committed snapshot matches the current graph — the gate is armed and clean. */
2450
+ InSync: "in-sync",
2451
+ /**
2452
+ * The committed sites are an exact prefix of the current ones — the append-only change the upgrade check
2453
+ * accepts. In-flight runs replay through the recorded prefix unchanged.
2454
+ */
2455
+ Appended: "appended",
2456
+ /** The committed snapshot differs — `heximon check` gates the deploy until the change is re-acknowledged. */
2457
+ Diverged: "diverged",
2458
+ /** No committed snapshot for this workflow — the gate is unarmed until `heximon check --update` runs. */
2459
+ Unarmed: "unarmed"
2460
+ };
2461
+ /**
2462
+ * Parses, classifies, and renders the durable-workflow upgrade-safety report — pure functions over the emitted
2463
+ * baseline, the committed baseline, and the (optional) Cloudflare live-fleet inventories.
2464
+ */
2465
+ var WorkflowStatusReport = class WorkflowStatusReport {
2466
+ /** The workflow plugin's section key inside the committed baseline. */
2467
+ static sectionKey = "workflow";
2468
+ /** Where the workflow plugin emits its current baseline section inside the `.heximon` output directory. */
2469
+ static generatedBaselinePath = UpgradeBaselineFile.generatedPathFor(WorkflowStatusReport.sectionKey);
2470
+ /** The committed baseline's file name at the app root (`heximon check --update` writes it). */
2471
+ static committedBaselineFileName = UpgradeBaselineFile.fileName;
2472
+ /** How many stale instance ids the terminal report lists per workflow before truncating. */
2473
+ static maxRenderedStaleInstances = 10;
2474
+ /**
2475
+ * Parse the COMMITTED baseline file's text — the multi-section envelope at the app root — down to the
2476
+ * workflow section. `undefined` when it is malformed or carries an envelope version this build cannot read.
2477
+ */
2478
+ static parseBaselineText(text) {
2479
+ return WorkflowStatusReport.parseJson(text, (value) => WorkflowStatusReport.parseSection(UpgradeBaselineFile.section(value, WorkflowStatusReport.sectionKey)));
2480
+ }
2481
+ /**
2482
+ * Parse a freshly-EMITTED section artifact's text. The generated file is the bare section, with no envelope
2483
+ * around it — a plugin emits only what it owns and `heximon check --update` composes the envelope.
2484
+ */
2485
+ static parseGeneratedText(text) {
2486
+ return WorkflowStatusReport.parseJson(text, (value) => WorkflowStatusReport.parseSection(value));
2487
+ }
2488
+ /** `JSON.parse` guarded, then handed to `parse` — malformed text reads as no baseline, never a throw. */
2489
+ static parseJson(text, parse) {
2490
+ let value;
2491
+ try {
2492
+ value = JSON.parse(text);
2493
+ } catch {
2494
+ return;
2495
+ }
2496
+ return parse(value);
2497
+ }
2498
+ /** Classify every current workflow against the committed baseline, keyed by `resourceName`. */
2499
+ static classify(current, committed) {
2500
+ const committedByName = /* @__PURE__ */ new Map();
2501
+ for (const snapshot of committed?.workflows ?? []) committedByName.set(snapshot.resourceName, snapshot);
2502
+ return current.map((workflow) => ({
2503
+ resourceName: workflow.resourceName,
2504
+ fingerprint: workflow.fingerprint,
2505
+ stepCount: workflow.sites.length,
2506
+ state: WorkflowStatusReport.stateFor(workflow, committedByName.get(workflow.resourceName))
2507
+ }));
2508
+ }
2509
+ /** Render one workflow's status line — identity, fingerprint, step count, and its baseline state. */
2510
+ static renderEntry(entry) {
2511
+ const fingerprint = entry.fingerprint === "" ? "(not analyzable)" : entry.fingerprint;
2512
+ const steps = `${entry.stepCount} durable step${entry.stepCount === 1 ? "" : "s"}`;
2513
+ return `${entry.resourceName} — fingerprint ${fingerprint} · ${steps} — ${WorkflowStatusReport.stateDescription(entry.state)}`;
2514
+ }
2515
+ /** Render one workflow's live-fleet inventory block (multi-line, no trailing newline). */
2516
+ static renderInventory(inventory) {
2517
+ if (!inventory.comparable) return `live instances: ${inventory.liveInstanceCount} — the current graph is not analyzable, so staleness cannot be compared.`;
2518
+ const lines = [`live instances: ${inventory.liveInstanceCount} · stale (predate the current graph): ${inventory.staleInstances.length}`];
2519
+ const listed = inventory.staleInstances.slice(0, WorkflowStatusReport.maxRenderedStaleInstances);
2520
+ for (const instance of listed) lines.push(` ${instance.id} — ${instance.status}, created ${instance.createdOn}`);
2521
+ const truncated = inventory.staleInstances.length - listed.length;
2522
+ if (truncated > 0) lines.push(` … and ${truncated} more stale instance${truncated === 1 ? "" : "s"}.`);
2523
+ return lines.join("\n");
2524
+ }
2525
+ /** The outro summary line: total workflows plus any appended / diverged / unarmed / failure counts. */
2526
+ static renderSummary(entries, enumerationFailed) {
2527
+ const count = `${entries.length} workflow${entries.length === 1 ? "" : "s"}`;
2528
+ const diverged = entries.filter((entry) => entry.state === WorkflowBaselineState.Diverged).length;
2529
+ const unarmed = entries.filter((entry) => entry.state === WorkflowBaselineState.Unarmed).length;
2530
+ const appended = entries.filter((entry) => entry.state === WorkflowBaselineState.Appended).length;
2531
+ const parts = [];
2532
+ if (diverged > 0) parts.push(`${diverged} diverged`);
2533
+ if (appended > 0) parts.push(`${appended} append-only`);
2534
+ if (unarmed > 0) parts.push(`${unarmed} unarmed`);
2535
+ if (enumerationFailed) parts.push("live-instance enumeration incomplete");
2536
+ return parts.length === 0 ? `${count} — baseline armed & in sync.` : `${count} — ${parts.join(", ")}.`;
2537
+ }
2538
+ /**
2539
+ * The `--format json` body: the per-workflow entries with their inventories (`null` when skipped).
2540
+ *
2541
+ * @param options - The classified entries, their inventories, and any upgrade-verdict diagnostics the
2542
+ * compile raised. An upgrade verdict rides the report rather than replacing it — it is the outcome of a
2543
+ * complete analysis — but it still makes the run report `ok: false`.
2544
+ */
2545
+ static toJson(options) {
2546
+ const upgradeDiagnostics = options.upgradeDiagnostics ?? [];
2547
+ return {
2548
+ ok: options.enumerationErrors.size === 0 && upgradeDiagnostics.length === 0,
2549
+ workflows: options.entries.map((entry) => {
2550
+ const enumerationError = options.enumerationErrors.get(entry.resourceName);
2551
+ return {
2552
+ ...entry,
2553
+ inventory: options.inventories.get(entry.resourceName) ?? null,
2554
+ ...enumerationError === void 0 ? {} : { enumerationError }
2555
+ };
2556
+ }),
2557
+ cloudflare: {
2558
+ enumerated: options.cloudflareSkipped === void 0,
2559
+ ...options.cloudflareSkipped === void 0 ? {} : { skipped: options.cloudflareSkipped }
2560
+ },
2561
+ ...upgradeDiagnostics.length === 0 ? {} : { diagnostics: upgradeDiagnostics }
2562
+ };
2563
+ }
2564
+ /**
2565
+ * The `--format json` body for a run that produced NO per-workflow report — a workflow-free app, a failed
2566
+ * compile, or an unreadable committed baseline. Same shape as {@link toJson}, so one reader covers both.
2567
+ *
2568
+ * @param options - Why there is no report, and whether the run itself succeeded.
2569
+ */
2570
+ static noReportJson(options) {
2571
+ return {
2572
+ ok: options.ok,
2573
+ workflows: [],
2574
+ cloudflare: {
2575
+ enumerated: false,
2576
+ skipped: options.reason
2577
+ },
2578
+ ...options.error === void 0 ? {} : { error: options.error },
2579
+ ...options.diagnostics === void 0 ? {} : { diagnostics: options.diagnostics }
2580
+ };
2581
+ }
2582
+ /**
2583
+ * One workflow's state: unarmed without a committed snapshot, in-sync on exact graph equality, appended
2584
+ * when the recorded sites are an exact prefix of the current ones, else diverged.
2585
+ */
2586
+ static stateFor(current, committed) {
2587
+ if (committed === void 0) return WorkflowBaselineState.Unarmed;
2588
+ if (WorkflowStatusReport.snapshotEquals(current, committed)) return WorkflowBaselineState.InSync;
2589
+ return WorkflowStatusReport.isAppendOnly(committed, current) ? WorkflowBaselineState.Appended : WorkflowBaselineState.Diverged;
2590
+ }
2591
+ /**
2592
+ * Whether `current` only adds steps after every recorded site of `committed`. A recorded site with no
2593
+ * `name` is a dynamic step this report cannot compare, so it never reads as an append.
2594
+ */
2595
+ static isAppendOnly(committed, current) {
2596
+ if (committed.sites.length >= current.sites.length) return false;
2597
+ return committed.sites.every((site, index) => {
2598
+ const other = current.sites[index];
2599
+ return site.name !== void 0 && other !== void 0 && site.kind === other.kind && site.name === other.name;
2600
+ });
2601
+ }
2602
+ /** Exact graph equality: same fingerprint AND the same ordered site list (kind + name per site). */
2603
+ static snapshotEquals(left, right) {
2604
+ if (left.fingerprint !== right.fingerprint || left.sites.length !== right.sites.length) return false;
2605
+ return left.sites.every((site, index) => {
2606
+ const other = right.sites[index];
2607
+ return other !== void 0 && site.kind === other.kind && site.name === other.name;
2608
+ });
2609
+ }
2610
+ /** The human text for one baseline state. */
2611
+ static stateDescription(state) {
2612
+ switch (state) {
2613
+ case WorkflowBaselineState.InSync: return "armed & in sync";
2614
+ case WorkflowBaselineState.Appended: return "step(s) APPENDED since the committed baseline (append-only — in-flight runs replay unaffected; run `heximon check --update` to record the new graph)";
2615
+ case WorkflowBaselineState.Diverged: return "DIVERGED from the committed baseline (deploy gated — heximon check will fail)";
2616
+ case WorkflowBaselineState.Unarmed: return "no committed baseline (gate unarmed — run `heximon check --update`)";
2617
+ }
2618
+ }
2619
+ /**
2620
+ * Structural validation of a parsed workflow SECTION — a wrong shape reads as no baseline.
2621
+ *
2622
+ * Public because a caller that already pulled the section out of the envelope needs to tell an ABSENT
2623
+ * section (nothing acknowledged yet) from an unreadable one (an acknowledgement this build cannot read).
2624
+ */
2625
+ static parseSection(section) {
2626
+ if (typeof section !== "object" || section === null) return;
2627
+ const record = section;
2628
+ if (!Array.isArray(record["workflows"])) return;
2629
+ const workflows = [];
2630
+ for (const entry of record["workflows"]) {
2631
+ const snapshot = WorkflowStatusReport.parseSnapshot(entry);
2632
+ if (snapshot === void 0) return;
2633
+ workflows.push(snapshot);
2634
+ }
2635
+ return { workflows };
2636
+ }
2637
+ static parseSnapshot(entry) {
2638
+ if (typeof entry !== "object" || entry === null) return;
2639
+ const record = entry;
2640
+ const resourceName = record["resourceName"];
2641
+ const fingerprint = record["fingerprint"];
2642
+ if (typeof resourceName !== "string" || typeof fingerprint !== "string" || !Array.isArray(record["sites"])) return;
2643
+ const sites = [];
2644
+ for (const rawSite of record["sites"]) {
2645
+ const site = WorkflowStatusReport.parseSite(rawSite);
2646
+ if (site === void 0) return;
2647
+ sites.push(site);
2648
+ }
2649
+ return {
2650
+ resourceName,
2651
+ fingerprint,
2652
+ sites
2653
+ };
2654
+ }
2655
+ static parseSite(rawSite) {
2656
+ if (typeof rawSite !== "object" || rawSite === null) return;
2657
+ const record = rawSite;
2658
+ const kind = record["kind"];
2659
+ if (typeof kind !== "string") return;
2660
+ const name = record["name"];
2661
+ return typeof name === "string" ? {
2662
+ kind,
2663
+ name
2664
+ } : { kind };
2665
+ }
2666
+ };
2667
+ //#endregion
2668
+ //#region src/workflows-command.ts
2669
+ /**
2670
+ * How the committed `heximon.upgrade.json` read resolved. Three-valued on purpose: folding "unreadable" into
2671
+ * "absent" would report every workflow as `unarmed` and exit 0, steering the operator at
2672
+ * `heximon check --update` — the one command that would overwrite the gate a diverged app depends on.
2673
+ */
2674
+ const CommittedBaselineState = {
2675
+ /** No committed file at the app root — every workflow's gate is legitimately unarmed. */
2676
+ Absent: "absent",
2677
+ /** The file was read and its workflow section parsed. */
2678
+ Read: "read",
2679
+ /** The file exists but could not be read or parsed — this build cannot say what was acknowledged. */
2680
+ Unreadable: "unreadable"
2681
+ };
2682
+ /**
2683
+ * Loads the Cloudflare workflow inspector from the APP's own `@heximon/cloudflare` install.
2684
+ *
2685
+ * Resolved via `createRequire` at the app's package.json (the same pattern as `@heximon/deploy`'s
2686
+ * `PlatformDeployLoader`), so the CLI takes no dependency on the platform package.
2687
+ */
2688
+ var CloudflareInspectorLoader = class {
2689
+ /**
2690
+ * @param rootDir - The app root whose `@heximon/cloudflare` install is resolved from.
2691
+ * @param credentials - The REST credentials the instances client authenticates with.
2692
+ * @returns The inspector, or `undefined` when the package is absent or exports neither class.
2693
+ */
2694
+ static async load(rootDir, credentials) {
2695
+ try {
2696
+ const resolved = createRequire(join(rootDir, "package.json")).resolve("@heximon/cloudflare");
2697
+ const loaded = await import(pathToFileURL(resolved).href);
2698
+ const InstancesClient = loaded["CloudflareWorkflowInstancesClient"];
2699
+ const Inspector = loaded["CloudflareWorkflowInspector"];
2700
+ if (InstancesClient === void 0 || Inspector === void 0) return;
2701
+ return new Inspector(new InstancesClient(credentials));
2702
+ } catch {
2703
+ return;
2704
+ }
2705
+ }
2706
+ };
2707
+ /**
2708
+ * `heximon workflows` — the durable-workflow upgrade-safety report. It runs the same from-disk compile as
2709
+ * `heximon check`, reads the freshly-emitted per-workflow step-graph baseline, and reports each workflow's
2710
+ * state against the committed `heximon.upgrade.json` gate. With Cloudflare credentials in the environment
2711
+ * it also enumerates the live fleet and names the stale instances a step-graph change would endanger.
2712
+ */
2713
+ const workflowsCommand = defineCommand({
2714
+ meta: {
2715
+ name: "workflows",
2716
+ description: "Compile the project and report each durable workflow's upgrade-safety state (baseline + live instances)."
2717
+ },
2718
+ args: {
2719
+ cwd: {
2720
+ type: "string",
2721
+ description: "Project root directory.",
2722
+ default: "."
2723
+ },
2724
+ format: {
2725
+ type: "string",
2726
+ description: "Output format: 'pretty' (default) or 'json'.",
2727
+ default: "pretty"
2728
+ },
2729
+ env: {
2730
+ type: "string",
2731
+ description: "The heximon.config.ts `$env` name to resolve (matches `vp dev`).",
2732
+ default: "development"
2733
+ },
2734
+ src: {
2735
+ type: "string",
2736
+ description: "Source directories to scan, comma-separated. Defaults to `src/`, or `server/` for a Nitro/Nuxt host."
2737
+ }
2738
+ },
2739
+ async run({ args }) {
2740
+ const rootDir = resolve(process.cwd(), args.cwd);
2741
+ const asJson = args.format === "json";
2742
+ const sourceDirectories = SourceDirectories.resolve(rootDir, args.src);
2743
+ if (!asJson) intro(`heximon workflows — ${basename(rootDir)}`);
2744
+ const output = (await ProjectCompileSession.open({
2745
+ rootDir,
2746
+ environment: args.env,
2747
+ ...sourceDirectories === void 0 ? {} : { sourceDirectories }
2748
+ })).compile();
2749
+ const errors = output.diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error);
2750
+ const upgradeErrors = errors.filter((diagnostic) => UpgradeBaseline.isBreaking(diagnostic.code));
2751
+ const blockingErrors = errors.filter((diagnostic) => !UpgradeBaseline.isBreaking(diagnostic.code));
2752
+ if (blockingErrors.length > 0) {
2753
+ if (asJson) process.stdout.write(`${JSON.stringify(WorkflowStatusReport.noReportJson({
2754
+ ok: false,
2755
+ reason: "the compile failed — no workflow report to enumerate against.",
2756
+ error: `Compile failed — ${blockingErrors.length} error${blockingErrors.length === 1 ? "" : "s"}.`,
2757
+ diagnostics: blockingErrors
2758
+ }))}\n`);
2759
+ else {
2760
+ CompileDiagnostics.renderToStderr(rootDir, blockingErrors);
2761
+ outro(`Compile failed — ${blockingErrors.length} error${blockingErrors.length === 1 ? "" : "s"}; no workflow report.`);
2762
+ }
2763
+ process.exitCode = 1;
2764
+ return;
2765
+ }
2766
+ const generated = output.files.find((file) => file.path === WorkflowStatusReport.generatedBaselinePath);
2767
+ const current = generated === void 0 ? void 0 : WorkflowStatusReport.parseGeneratedText(generated.content);
2768
+ if (current === void 0 || current.workflows.length === 0) {
2769
+ if (asJson) process.stdout.write(`${JSON.stringify(WorkflowStatusReport.noReportJson({
2770
+ ok: true,
2771
+ reason: "this app declares no workflows — nothing to enumerate."
2772
+ }))}\n`);
2773
+ else outro("No workflows in this app.");
2774
+ return;
2775
+ }
2776
+ const committed = WorkflowsCommand.readCommittedBaseline(rootDir);
2777
+ if (committed.state === CommittedBaselineState.Unreadable) {
2778
+ const message = `${WorkflowStatusReport.committedBaselineFileName} exists but could not be read (${committed.reason ?? "unknown reason"}), so no workflow's upgrade-safety state is known — this is NOT an unarmed gate. Restore the committed file (e.g. from git) before deploying; \`heximon check --update\` would overwrite it with the current surface.`;
2779
+ if (asJson) process.stdout.write(`${JSON.stringify(WorkflowStatusReport.noReportJson({
2780
+ ok: false,
2781
+ reason: "the committed upgrade-safety baseline is unreadable — nothing to compare against.",
2782
+ error: message
2783
+ }))}\n`);
2784
+ else {
2785
+ log.error(message);
2786
+ outro("Upgrade-safety baseline unreadable — no workflow report.");
2787
+ }
2788
+ process.exitCode = 1;
2789
+ return;
2790
+ }
2791
+ const entries = WorkflowStatusReport.classify(current.workflows, committed.document);
2792
+ const inventories = /* @__PURE__ */ new Map();
2793
+ const enumerationErrors = /* @__PURE__ */ new Map();
2794
+ let cloudflareSkipped;
2795
+ const accountId = process.env["CLOUDFLARE_ACCOUNT_ID"];
2796
+ const apiToken = process.env["CLOUDFLARE_API_TOKEN"];
2797
+ if (accountId === void 0 || accountId === "" || apiToken === void 0 || apiToken === "") cloudflareSkipped = "CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN are not both set — Cloudflare live-instance enumeration skipped.";
2798
+ else {
2799
+ const inspector = await CloudflareInspectorLoader.load(rootDir, {
2800
+ accountId,
2801
+ apiToken
2802
+ });
2803
+ if (inspector === void 0) cloudflareSkipped = "@heximon/cloudflare is not installed in this app — Cloudflare live-instance enumeration skipped.";
2804
+ else for (const entry of entries) try {
2805
+ inventories.set(entry.resourceName, await inspector.inspect({
2806
+ workflowName: entry.resourceName,
2807
+ currentFingerprint: entry.fingerprint
2808
+ }));
2809
+ } catch (error) {
2810
+ enumerationErrors.set(entry.resourceName, error instanceof Error ? error.message : String(error));
2811
+ }
2812
+ }
2813
+ if (asJson) process.stdout.write(`${JSON.stringify(WorkflowStatusReport.toJson({
2814
+ entries,
2815
+ inventories,
2816
+ enumerationErrors,
2817
+ cloudflareSkipped,
2818
+ ...upgradeErrors.length === 0 ? {} : { upgradeDiagnostics: upgradeErrors }
2819
+ }))}\n`);
2820
+ else {
2821
+ WorkflowsCommand.report(entries, inventories, enumerationErrors, cloudflareSkipped);
2822
+ if (upgradeErrors.length > 0) CompileDiagnostics.renderToStderr(rootDir, upgradeErrors);
2823
+ }
2824
+ if (enumerationErrors.size > 0 || upgradeErrors.length > 0) process.exitCode = 1;
2825
+ }
2826
+ });
2827
+ /** Output helpers for {@link workflowsCommand}: the committed-baseline read and the pretty report. */
2828
+ var WorkflowsCommand = class {
2829
+ /**
2830
+ * Read the committed `heximon.upgrade.json`, keeping "absent" and "unreadable" apart.
2831
+ *
2832
+ * An absent file, and a present file that simply carries no `workflow` section yet, both mean nothing has
2833
+ * been acknowledged — the honest unarmed state. A file that exists but cannot be parsed, carries an
2834
+ * envelope version this build does not read, or holds a `workflow` section of the wrong shape, is a gate
2835
+ * whose contents this build cannot see; that must never read as "never baselined".
2836
+ */
2837
+ static readCommittedBaseline(rootDir) {
2838
+ const path = join(rootDir, WorkflowStatusReport.committedBaselineFileName);
2839
+ let text;
2840
+ try {
2841
+ text = readFileSync(path, "utf8");
2842
+ } catch (error) {
2843
+ if (error.code === "ENOENT") return { state: CommittedBaselineState.Absent };
2844
+ return {
2845
+ state: CommittedBaselineState.Unreadable,
2846
+ reason: error instanceof Error ? error.message : String(error)
2847
+ };
2848
+ }
2849
+ let parsed;
2850
+ try {
2851
+ parsed = JSON.parse(text);
2852
+ } catch (error) {
2853
+ return {
2854
+ state: CommittedBaselineState.Unreadable,
2855
+ reason: `not valid JSON — ${error instanceof Error ? error.message : String(error)}`
2856
+ };
2857
+ }
2858
+ if (!UpgradeBaselineFile.envelopeReadable(parsed)) return {
2859
+ state: CommittedBaselineState.Unreadable,
2860
+ reason: `its envelope is not version ${UpgradeBaselineFile.version} — written by a different heximon, or hand-edited`
2861
+ };
2862
+ if (!UpgradeBaselineFile.sectionPresent(parsed, WorkflowStatusReport.sectionKey)) return { state: CommittedBaselineState.Read };
2863
+ const document = WorkflowStatusReport.parseSection(UpgradeBaselineFile.section(parsed, WorkflowStatusReport.sectionKey));
2864
+ return document === void 0 ? {
2865
+ state: CommittedBaselineState.Unreadable,
2866
+ reason: `its \`${WorkflowStatusReport.sectionKey}\` section does not have the recorded shape`
2867
+ } : {
2868
+ state: CommittedBaselineState.Read,
2869
+ document
2870
+ };
2871
+ }
2872
+ /** Print the per-workflow report wrapped in clack framing: one status line + inventory block each. */
2873
+ static report(entries, inventories, enumerationErrors, cloudflareSkipped) {
2874
+ for (const entry of entries) {
2875
+ const line = WorkflowStatusReport.renderEntry(entry);
2876
+ if (entry.state === WorkflowBaselineState.Diverged) log.error(line);
2877
+ else if (entry.state === WorkflowBaselineState.InSync) log.success(line);
2878
+ else log.warn(line);
2879
+ const inventory = inventories.get(entry.resourceName);
2880
+ if (inventory !== void 0) log.message(WorkflowStatusReport.renderInventory(inventory));
2881
+ const enumerationError = enumerationErrors.get(entry.resourceName);
2882
+ if (enumerationError !== void 0) log.error(`Cloudflare enumeration failed for ${entry.resourceName}: ${enumerationError}`);
2883
+ }
2884
+ if (cloudflareSkipped !== void 0) log.info(cloudflareSkipped);
2885
+ outro(WorkflowStatusReport.renderSummary(entries, enumerationErrors.size > 0));
2886
+ }
2887
+ };
2888
+ //#endregion
2889
+ //#region src/bin.ts
2890
+ const main = defineCommand({
2891
+ meta: {
2892
+ name: "heximon",
2893
+ description: "The Heximon CLI.",
2894
+ version: CreateDefaults.cliVersion()
2895
+ },
2896
+ subCommands: {
2897
+ create: createCommand,
2898
+ deploy: deployCommand,
2899
+ check: checkCommand,
2900
+ routes: routesCommand,
2901
+ graph: graphCommand,
2902
+ doctor: doctorCommand,
2903
+ provision: provisionCommand,
2904
+ migrations: migrationsCommand,
2905
+ workflows: workflowsCommand,
2906
+ skills: skillsCommand
2907
+ }
2908
+ });
2909
+ await tab(main);
2910
+ await runMain(main);
2911
+ //#endregion
2912
+ export {};