heximon 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.mjs CHANGED
@@ -1,21 +1,28 @@
1
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";
2
+ import { _ as StdoutRedirect, d as RegistryAccessStatus, f as CommandOutput, g as CliEntry, i as createCommand, m as PromptRefusedError, n as ProjectRoot, p as CommandFailure, r as CreateDefaults, t as skillsCommand, u as RegistryAccess, v as CliDiagnosticCode, x as PromptCancelledError, y as CliDiagnostics } from "./skills-command-Czsv_c74.mjs";
3
3
  import { createRequire, register } from "node:module";
4
4
  import { cancel, confirm, intro, isCancel, log, outro, select, spinner } from "@clack/prompts";
5
5
  import process from "node:process";
6
+ import { defineCommand } from "citty";
7
+ import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
8
+ import { AstNodeType, CompileEnvironment, CompileMode, DeployPlatform, DiagnosticCode, DiagnosticRenderMode, DiagnosticRenderer, DiagnosticSeverity, ImportKind, ReexportIndex, RuntimeConfigFile, RuntimeSingletons, TerminalStyle, UpgradeBaselineFile } from "@heximon/compiler";
6
9
  import { spawn } from "node:child_process";
7
- import { defineCommand, runMain } from "citty";
8
10
  import { basename, dirname, isAbsolute, join, relative, resolve } from "pathe";
9
11
  import tab from "@bomb.sh/tab/citty";
10
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
11
- import { AstNodeType, CompileEnvironment, CompileMode, DeployPlatform, DiagnosticCode, DiagnosticRenderMode, DiagnosticRenderer, DiagnosticSeverity, ImportKind, ReexportIndex, RuntimeConfigFile, RuntimeSingletons, TerminalStyle, UpgradeBaselineFile } from "@heximon/compiler";
12
- import { ConfigLoader, LibraryEmitter, LibraryFactoryPreflight, LibraryFreshness, ProjectCompiler, RuntimeConfigLoader, SourceModuleResolution, SourceParser, WorkspaceTopologyReader } from "@heximon/compiler/project";
12
+ import { ConfigLoader, LibraryEmitter, LibraryFactoryPreflight, LibraryFreshness, LibraryFreshnessStatus, PnpmWorkspaceManifest, ProjectCompiler, RuntimeConfigLoader, SourceModuleResolution, SourceParser, WorkspaceTopologyReader } from "@heximon/compiler/project";
13
13
  import { NodeCompileSeam } from "@heximon/node/compiler";
14
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";
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, SecretMask, WorkspaceConsumerLocator } from "@heximon/deploy";
16
+ import { matchesGlob } from "node:path";
16
17
  import { RouteCensus, RouteScopeGap } from "@heximon/http/compiler";
17
18
  import { pathToFileURL } from "node:url";
18
19
  import { homedir } from "node:os";
20
+ //#region src/check-json-document.ts
21
+ /** The stable codes of the failures `heximon check` raises itself, beside the compiler's own. */
22
+ const CheckDiagnosticCode = {
23
+ /** A consumed workspace library changed since its last `vp pack`, so the compile read an old manifest. */
24
+ StaleLibrary: "check.stale-library" };
25
+ //#endregion
19
26
  //#region src/compile-diagnostics.ts
20
27
  /**
21
28
  * Renders compile diagnostics to stderr with the same codeframe layout the dev server shows — a box-drawing
@@ -63,29 +70,19 @@ var CompileModeResolver = class {
63
70
  }
64
71
  };
65
72
  /**
66
- * Resolve one command's compile mode.
73
+ * Resolve one command's compile mode, or throw the refusal.
67
74
  *
68
- * @param commandName - The verb, for the refusal messages (`routes`, `graph`, `check`).
69
75
  * @param rootDir - The absolute project root the shape is detected from.
70
76
  * @param args - The parsed `--library` / `--app` flags.
71
77
  * @param sourceDirectories - The resolved `--src` directories, checked against the library single-root rule.
72
- * @returns The resolved mode, or `undefined` when the command must stop — the reason is already on stderr
73
- * and `process.exitCode` is set.
78
+ * @returns The resolved mode.
79
+ * @throws CommandFailure with {@link CliDiagnosticCode.InvalidOptions} when no mode can honour the flags.
74
80
  */
75
- static resolve(commandName, rootDir, args, sourceDirectories) {
76
- if (args.library === true && args.app === true) {
77
- process.stderr.write(`heximon ${commandName}: --library and --app are mutually exclusive — pass at most one.\n`);
78
- process.exitCode = 1;
79
- return;
80
- }
81
+ static resolve(rootDir, args, sourceDirectories) {
82
+ if (args.library === true && args.app === true) throw CommandFailure.of(CliDiagnosticCode.InvalidOptions, "--library and --app are mutually exclusive — pass at most one.");
81
83
  const forcedMode = args.library === true ? CompileMode.Library : args.app === true ? CompileMode.App : void 0;
82
84
  const mode = forcedMode ?? (LibraryEmitter.isLibraryPackage(rootDir) ? CompileMode.Library : CompileMode.App);
83
- if (mode === CompileMode.Library && (sourceDirectories?.length ?? 0) > 1) {
84
- 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.
85
- `);
86
- process.exitCode = 1;
87
- return;
88
- }
85
+ if (mode === CompileMode.Library && (sourceDirectories?.length ?? 0) > 1) throw CommandFailure.of(CliDiagnosticCode.InvalidOptions, "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.");
89
86
  return {
90
87
  mode,
91
88
  forced: forcedMode !== void 0
@@ -97,6 +94,65 @@ var CompileModeResolver = class {
97
94
  }
98
95
  };
99
96
  //#endregion
97
+ //#region src/config-sidecar.ts
98
+ /**
99
+ * Loads the `heximon.config.ts` sidecar for a command. A sidecar that throws is the same failure in every
100
+ * command: one {@link CliDiagnosticCode.ConfigUnreadable} diagnostic whose `file` is the sidecar.
101
+ */
102
+ var ConfigSidecar = class ConfigSidecar {
103
+ /** The fix hint of every config failure. */
104
+ static hint = "Fix the error in the config file, then run the command again.";
105
+ /**
106
+ * Load the sidecar.
107
+ *
108
+ * @param rootDir - The absolute project root.
109
+ * @param environment - The `$env` name to resolve; `NODE_ENV` when omitted.
110
+ * @returns The resolved config, or `undefined` when the project has no sidecar.
111
+ * @throws CommandFailure with {@link CliDiagnosticCode.ConfigUnreadable} when the sidecar throws.
112
+ */
113
+ static async load(rootDir, environment) {
114
+ try {
115
+ return await ConfigLoader.load(rootDir, environment);
116
+ } catch (error) {
117
+ throw new CommandFailure([await ConfigSidecar.diagnosticOf(rootDir, error, DiagnosticSeverity.Error)]);
118
+ }
119
+ }
120
+ /**
121
+ * Run work that loads the sidecar inside another library (a deploy target, a Nitro host). When the work
122
+ * throws, the sidecar is loaded again: if it throws too, the shared failure replaces the error.
123
+ *
124
+ * @param rootDir - The absolute project root.
125
+ * @param environment - The `$env` name the work resolves.
126
+ * @param work - The work.
127
+ * @returns What the work returned.
128
+ */
129
+ static async during(rootDir, environment, work) {
130
+ try {
131
+ return await work();
132
+ } catch (error) {
133
+ if (!(error instanceof CommandFailure)) await ConfigSidecar.load(rootDir, environment);
134
+ throw error;
135
+ }
136
+ }
137
+ /**
138
+ * The diagnostic of a sidecar that threw.
139
+ *
140
+ * @param rootDir - The absolute project root.
141
+ * @param error - What the load threw.
142
+ * @param severity - `error` when the command stops; `warning` when it goes on without the config.
143
+ * @param consequence - What the command does without the config, added to the message.
144
+ * @returns The diagnostic, with `file` set when the sidecar file is found.
145
+ */
146
+ static async diagnosticOf(rootDir, error, severity, consequence) {
147
+ const file = await ConfigLoader.resolveFile(rootDir);
148
+ const reason = error instanceof Error ? error.message : String(error);
149
+ return CliDiagnostics.of(severity, CliDiagnosticCode.ConfigUnreadable, `heximon.config.ts could not be loaded${consequence === void 0 ? "" : `, ${consequence}`}: ${reason}`, {
150
+ hint: ConfigSidecar.hint,
151
+ ...file === void 0 ? {} : { file }
152
+ });
153
+ }
154
+ };
155
+ //#endregion
100
156
  //#region src/project-compile-session.ts
101
157
  /**
102
158
  * The from-disk compile prologue every inspection command shares (`check`, `graph`, `routes`, `workflows`):
@@ -144,9 +200,10 @@ var ProjectCompileSession = class ProjectCompileSession {
144
200
  *
145
201
  * @param options - The project root, the `$env` name, and the resolved source directories.
146
202
  * @returns The session the caller compiles through.
203
+ * @throws CommandFailure with `cli.config-unreadable` when the sidecar throws.
147
204
  */
148
205
  static async open(options) {
149
- const config = await ConfigLoader.load(options.rootDir, options.environment);
206
+ const config = await ConfigSidecar.load(options.rootDir, options.environment);
150
207
  const strategy = NodeCompileSeam.resolveStrategy(config?.platform);
151
208
  return new ProjectCompileSession({
152
209
  rootDir: options.rootDir,
@@ -519,9 +576,10 @@ const checkCommand = defineCommand({
519
576
  description: "Project root directory.",
520
577
  default: "."
521
578
  },
579
+ json: CommandOutput.jsonFlag,
522
580
  format: {
523
581
  type: "string",
524
- description: "Output format: 'pretty' (default) or 'json'.",
582
+ description: "Output format: 'pretty' (default) or 'json' (the same as --json).",
525
583
  default: "pretty"
526
584
  },
527
585
  strict: {
@@ -557,120 +615,123 @@ const checkCommand = defineCommand({
557
615
  }
558
616
  },
559
617
  async run({ args }) {
560
- const rootDir = resolve(process.cwd(), args.cwd);
561
- const asJson = args.format === "json";
562
- const sourceDirectories = SourceDirectories.resolve(rootDir, args.src);
563
- const resolved = CompileModeResolver.resolve("check", rootDir, args, sourceDirectories);
564
- if (resolved === void 0) return;
565
- const { mode } = resolved;
566
- const verifyBaseline = args["verify-baseline"] === true;
567
- if (verifyBaseline && args.update === true) {
568
- 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");
569
- process.exitCode = 1;
570
- return;
571
- }
572
- if (!asJson) intro(`heximon check — ${basename(rootDir)} · ${CompileModeResolver.stamp(resolved)}`);
573
- const session = await ProjectCompileSession.open({
574
- rootDir,
575
- environment: args.env,
576
- ...sourceDirectories === void 0 ? {} : { sourceDirectories }
577
- });
578
- let checked;
579
- if (mode === CompileMode.Library) {
580
- const emitted = session.compileLibrary({ pruneVerdicts: true });
581
- const staleLibraries = await session.staleLibraryDiagnostics();
582
- checked = staleLibraries.length > 0 ? {
583
- diagnostics: staleLibraries,
584
- files: [],
585
- ...emitted.prunedVerdicts === void 0 ? {} : { prunedVerdicts: emitted.prunedVerdicts }
586
- } : {
587
- diagnostics: emitted.diagnostics,
588
- files: emitted.files,
589
- ...emitted.prunedVerdicts === void 0 ? {} : { prunedVerdicts: emitted.prunedVerdicts }
590
- };
591
- } else {
592
- const output = session.compile(true, { verifyBaseline });
593
- const staleLibraries = await session.staleLibraryDiagnostics();
594
- if (staleLibraries.length > 0) checked = {
595
- diagnostics: staleLibraries,
596
- files: []
597
- };
598
- else {
599
- session.compiler.writeTypeVerdicts(output);
600
- session.compiler.healTsconfigInclude(session.typeVerdictTarget());
601
- const emitted = args.emit || session.config?.debug === true;
602
- if (emitted) {
603
- session.compiler.write(session.outputDirectory, output.files);
604
- await session.compiler.formatOnDisk(rootDir, session.outputDirectory, output.files);
605
- }
606
- checked = {
607
- diagnostics: output.diagnostics,
608
- files: output.files,
609
- ...emitted ? { emittedTo: session.outputDirectory } : {}
610
- };
611
- }
612
- }
613
- const blockingErrors = checked.diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error && diagnostic.escalatedFrom === void 0 && !UpgradeBaseline.isBreaking(diagnostic.code)).length;
614
- const rebaselined = mode === CompileMode.App && args.update && blockingErrors === 0 ? UpgradeBaseline.update(rootDir, checked.files) : void 0;
615
- if (rebaselined?.changed === true && checked.diagnostics.some((diagnostic) => UpgradeBaseline.isBreaking(diagnostic.code))) session.compiler.writeTypeVerdicts(session.compile(true, { verifyBaseline }));
616
- const diagnostics = rebaselined !== void 0 ? checked.diagnostics.filter((diagnostic) => !UpgradeBaseline.isBreaking(diagnostic.code)) : checked.diagnostics;
617
- const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error).length;
618
- const warningCount = diagnostics.length - errorCount;
619
- const failed = errorCount > 0 || args.strict && warningCount > 0;
620
- if (asJson) {
621
- const census = TransactionCensusReport.read(checked.files);
622
- process.stdout.write(`${JSON.stringify({
623
- ok: !failed,
624
- mode,
625
- errors: errorCount,
626
- warnings: warningCount,
627
- diagnostics,
628
- ...census === void 0 ? {} : { transactionCensus: census },
629
- ...checked.prunedVerdicts === void 0 ? {} : { prunedVerdicts: checked.prunedVerdicts },
630
- ...checked.emittedTo === void 0 ? {} : { emittedTo: checked.emittedTo },
631
- ...args.update === true ? { upgradeBaseline: rebaselined === void 0 ? {
632
- written: false,
633
- blockingErrors
618
+ const commandOutput = CommandOutput.resolve("check", args);
619
+ await commandOutput.run(async () => {
620
+ const rootDir = ProjectRoot.resolve(args.cwd);
621
+ const sourceDirectories = SourceDirectories.resolve(rootDir, args.src);
622
+ const resolved = CompileModeResolver.resolve(rootDir, args, sourceDirectories);
623
+ const { mode } = resolved;
624
+ const verifyBaseline = args["verify-baseline"] === true;
625
+ if (verifyBaseline && args.update === true) throw CommandFailure.of(CliDiagnosticCode.InvalidOptions, "--verify-baseline reports what the committed baseline has not acknowledged, and --update acknowledges everything — pass one or the other.");
626
+ if (!commandOutput.isJson()) intro(`heximon check — ${basename(rootDir)} · ${CompileModeResolver.stamp(resolved)}`);
627
+ const session = await ProjectCompileSession.open({
628
+ rootDir,
629
+ environment: args.env,
630
+ ...sourceDirectories === void 0 ? {} : { sourceDirectories }
631
+ });
632
+ let checked;
633
+ if (mode === CompileMode.Library) {
634
+ const emitted = session.compileLibrary({ pruneVerdicts: true });
635
+ const staleLibraries = await session.staleLibraryDiagnostics();
636
+ checked = staleLibraries.length > 0 ? {
637
+ diagnostics: staleLibraries,
638
+ files: [],
639
+ staleLibraries: true,
640
+ ...emitted.prunedVerdicts === void 0 ? {} : { prunedVerdicts: emitted.prunedVerdicts }
634
641
  } : {
635
- written: true,
636
- changed: rebaselined.changed,
637
- path: rebaselined.target
638
- } } : {}
639
- })}\n`);
640
- } else {
641
- if (checked.emittedTo !== void 0) log.info(`Wrote the generated wiring to ${checked.emittedTo}`);
642
- 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.`);
643
- if (mode === CompileMode.Library && (args.emit === true || session.config?.debug === true)) log.warn(`${args.emit === true ? "`--emit`" : "`debug: true`"} writes app wiring only. A library's generated factory is written by \`vp pack\`.`);
644
- 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.`);
645
- 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.`);
646
- 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.`);
647
- 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.`);
648
- else if (args.update === true) log.info("Nothing durable to baseline — no workflows, queue channels or other durable surface found.");
649
- const census = TransactionCensusReport.read(checked.files);
650
- const censusSummary = census === void 0 ? void 0 : TransactionCensusReport.summarize(census);
651
- if (census !== void 0 && censusSummary !== void 0) {
652
- const sections = [censusSummary];
653
- const composed = TransactionCensusReport.composedWraps(census);
654
- const unwrapped = TransactionCensusReport.unwrapped(census);
655
- if (composed.length > 0) sections.push(`Composed, wrapped on the database the package handed over:\n${composed.map((line) => ` ${line}`).join("\n")}`);
656
- if (unwrapped.length > 0) sections.push(`Not wrapped:\n${unwrapped.map((line) => ` ${line}`).join("\n")}`);
657
- log.info(sections.join("\n"));
642
+ diagnostics: emitted.diagnostics,
643
+ files: emitted.files,
644
+ ...emitted.prunedVerdicts === void 0 ? {} : { prunedVerdicts: emitted.prunedVerdicts }
645
+ };
646
+ } else {
647
+ const output = session.compile(true, { verifyBaseline });
648
+ const staleLibraries = await session.staleLibraryDiagnostics();
649
+ if (staleLibraries.length > 0) checked = {
650
+ diagnostics: staleLibraries,
651
+ files: [],
652
+ staleLibraries: true
653
+ };
654
+ else {
655
+ session.compiler.writeTypeVerdicts(output);
656
+ session.compiler.healTsconfigInclude(session.typeVerdictTarget());
657
+ const emitted = args.emit || session.config?.debug === true;
658
+ if (emitted) {
659
+ session.compiler.write(session.outputDirectory, output.files);
660
+ await session.compiler.formatOnDisk(rootDir, session.outputDirectory, output.files);
661
+ }
662
+ checked = {
663
+ diagnostics: output.diagnostics,
664
+ files: output.files,
665
+ ...emitted ? { emittedTo: session.outputDirectory } : {}
666
+ };
667
+ }
658
668
  }
659
- 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.`);
660
- if (diagnostics.length > 0) CompileDiagnostics.renderToStderr(rootDir, diagnostics);
661
- if (failed) {
662
- const parts = [`${errorCount} error${errorCount === 1 ? "" : "s"}`];
663
- if (warningCount > 0) parts.push(`${warningCount} warning${warningCount === 1 ? "" : "s"}`);
664
- outro(`Check failed — ${parts.join(", ")}.`);
669
+ const blockingErrors = checked.diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error && diagnostic.escalatedFrom === void 0 && !UpgradeBaseline.isBreaking(diagnostic.code)).length;
670
+ const rebaselined = mode === CompileMode.App && args.update && blockingErrors === 0 ? UpgradeBaseline.update(rootDir, checked.files) : void 0;
671
+ if (rebaselined?.changed === true && checked.diagnostics.some((diagnostic) => UpgradeBaseline.isBreaking(diagnostic.code))) session.compiler.writeTypeVerdicts(session.compile(true, { verifyBaseline }));
672
+ const diagnostics = rebaselined !== void 0 ? checked.diagnostics.filter((diagnostic) => !UpgradeBaseline.isBreaking(diagnostic.code)) : checked.diagnostics;
673
+ const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error).length;
674
+ const warningCount = diagnostics.length - errorCount;
675
+ const failed = errorCount > 0 || args.strict && warningCount > 0;
676
+ if (commandOutput.isJson()) {
677
+ const census = TransactionCensusReport.read(checked.files);
678
+ const document = {
679
+ ok: !failed,
680
+ mode,
681
+ errors: errorCount,
682
+ warnings: warningCount,
683
+ diagnostics: CliDiagnostics.fromCompiler(diagnostics).map((diagnostic) => checked.staleLibraries === true ? {
684
+ ...diagnostic,
685
+ code: CheckDiagnosticCode.StaleLibrary
686
+ } : diagnostic),
687
+ ...census === void 0 ? {} : { transactionCensus: census },
688
+ ...checked.prunedVerdicts === void 0 ? {} : { prunedVerdicts: checked.prunedVerdicts },
689
+ ...checked.emittedTo === void 0 ? {} : { emittedTo: checked.emittedTo },
690
+ ...args.update === true ? { upgradeBaseline: rebaselined === void 0 ? {
691
+ written: false,
692
+ blockingErrors
693
+ } : {
694
+ written: true,
695
+ changed: rebaselined.changed,
696
+ path: rebaselined.target
697
+ } } : {}
698
+ };
699
+ commandOutput.emit(document);
665
700
  } else {
666
- const closing = mode === CompileMode.Library ? "Library compiled." : "Verdicts refreshed.";
667
- if (warningCount > 0) {
668
- log.warn(`${warningCount} warning${warningCount === 1 ? "" : "s"} — not failing (pass --strict to fail on warnings too).`);
669
- outro(closing);
670
- } else outro(`No issues — ${closing.toLowerCase()}`);
701
+ if (checked.emittedTo !== void 0) log.info(`Wrote the generated wiring to ${checked.emittedTo}`);
702
+ 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.`);
703
+ if (mode === CompileMode.Library && (args.emit === true || session.config?.debug === true)) log.warn(`${args.emit === true ? "`--emit`" : "`debug: true`"} writes app wiring only. A library's generated factory is written by \`vp pack\`.`);
704
+ 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.`);
705
+ 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.`);
706
+ 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.`);
707
+ 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.`);
708
+ else if (args.update === true) log.info("Nothing durable to baseline — no workflows, queue channels or other durable surface found.");
709
+ const census = TransactionCensusReport.read(checked.files);
710
+ const censusSummary = census === void 0 ? void 0 : TransactionCensusReport.summarize(census);
711
+ if (census !== void 0 && censusSummary !== void 0) {
712
+ const sections = [censusSummary];
713
+ const composed = TransactionCensusReport.composedWraps(census);
714
+ const unwrapped = TransactionCensusReport.unwrapped(census);
715
+ if (composed.length > 0) sections.push(`Composed, wrapped on the database the package handed over:\n${composed.map((line) => ` ${line}`).join("\n")}`);
716
+ if (unwrapped.length > 0) sections.push(`Not wrapped:\n${unwrapped.map((line) => ` ${line}`).join("\n")}`);
717
+ log.info(sections.join("\n"));
718
+ }
719
+ 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.`);
720
+ if (diagnostics.length > 0) CompileDiagnostics.renderToStderr(rootDir, diagnostics);
721
+ if (failed) {
722
+ const parts = [`${errorCount} error${errorCount === 1 ? "" : "s"}`];
723
+ if (warningCount > 0) parts.push(`${warningCount} warning${warningCount === 1 ? "" : "s"}`);
724
+ outro(`Check failed — ${parts.join(", ")}.`);
725
+ } else {
726
+ const closing = mode === CompileMode.Library ? "Library compiled." : "Verdicts refreshed.";
727
+ if (warningCount > 0) {
728
+ log.warn(`${warningCount} warning${warningCount === 1 ? "" : "s"} — not failing (pass --strict to fail on warnings too).`);
729
+ outro(closing);
730
+ } else outro(`No issues — ${closing.toLowerCase()}`);
731
+ }
671
732
  }
672
- }
673
- if (failed) process.exitCode = 1;
733
+ if (failed) process.exitCode = 1;
734
+ });
674
735
  }
675
736
  });
676
737
  //#endregion
@@ -711,10 +772,13 @@ var CliSpinner = class {
711
772
  * message rather than hanging on a prompt no one can answer). Optional creds (e.g. a Cloudflare token when
712
773
  * `wrangler login` already authenticates) are never prompted, so a logged-in deploy is not interrupted.
713
774
  *
775
+ * Under `--json` the prompter refuses every prompt: the resolver then fails once, with one `cli.option-required`
776
+ * diagnostic for each required credential that is not set.
777
+ *
714
778
  * This whole ladder is CLI-specific. A non-CLI caller (a dashboard) resolves the same `CredentialSpec`s its own way
715
779
  * — from a vault / secret store — and hands `DeployRunner` an equivalent map; it never constructs this resolver.
716
780
  */
717
- var CredentialResolver = class {
781
+ var CredentialResolver = class CredentialResolver {
718
782
  /**
719
783
  * Resolve the given credential specs into a value map.
720
784
  *
@@ -722,10 +786,12 @@ var CredentialResolver = class {
722
786
  * @param options - Interactivity, the prompter, and the environment source.
723
787
  * @returns The resolved `envVar → value` map (holds secrets — never log it).
724
788
  * @throws PromptCancelledError when the operator cancels an interactive prompt.
789
+ * @throws CommandFailure when the prompter refused one or more prompts (`--json`).
725
790
  */
726
791
  static async resolve(specs, options) {
727
792
  const env = options.env ?? process.env;
728
793
  const values = /* @__PURE__ */ new Map();
794
+ const refused = [];
729
795
  for (const spec of specs) {
730
796
  const fromEnv = env[spec.envVar];
731
797
  if (fromEnv !== void 0 && fromEnv.length > 0) {
@@ -733,13 +799,47 @@ var CredentialResolver = class {
733
799
  continue;
734
800
  }
735
801
  if (spec.required && options.interactive && options.prompter !== void 0) {
736
- const label = `${spec.name} (${spec.envVar})`;
737
- const prompted = spec.secret ? await options.prompter.secret(label) : await options.prompter.text(label);
802
+ const label = CredentialResolver.labelOf(spec);
803
+ let prompted;
804
+ try {
805
+ prompted = spec.secret ? await options.prompter.secret(label) : await options.prompter.text(label);
806
+ } catch (error) {
807
+ if (!(error instanceof PromptRefusedError)) throw error;
808
+ refused.push(spec);
809
+ continue;
810
+ }
738
811
  if (prompted.length > 0) values.set(spec.envVar, prompted);
739
812
  }
740
813
  }
814
+ if (refused.length > 0) throw new CommandFailure(refused.map((spec) => CredentialResolver.missing(spec)));
741
815
  return values;
742
816
  }
817
+ /**
818
+ * The prompt label of one credential: its name and its environment variable.
819
+ *
820
+ * @param spec - The credential.
821
+ * @returns The label, for example `Cloudflare API token (CLOUDFLARE_API_TOKEN)`.
822
+ */
823
+ static labelOf(spec) {
824
+ return `${spec.name} (${spec.envVar})`;
825
+ }
826
+ /**
827
+ * Map a prompt label back to the environment variable that answers it, for a refusing prompter.
828
+ *
829
+ * @param specs - The credentials the run resolves.
830
+ * @returns The map function; an unknown label gives the label itself.
831
+ */
832
+ static optionFor(specs) {
833
+ const envVarByLabel = new Map(specs.map((spec) => [CredentialResolver.labelOf(spec), spec.envVar]));
834
+ return (message) => envVarByLabel.get(message) ?? message;
835
+ }
836
+ /** The `cli.option-required` diagnostic of one required credential that is not set. */
837
+ static missing(spec) {
838
+ return CliDiagnostics.error(CliDiagnosticCode.OptionRequired, `${spec.name} is not set, and the command never prompts for it under --json.`, {
839
+ hint: `Set ${spec.envVar}.`,
840
+ option: spec.envVar
841
+ });
842
+ }
743
843
  };
744
844
  //#endregion
745
845
  //#region src/deploy-command-failure.ts
@@ -764,6 +864,48 @@ var DeployCommandFailure = class {
764
864
  }
765
865
  return false;
766
866
  }
867
+ /**
868
+ * The `--json` diagnostic of an expected halt. A cancelled prompt has none: `--json` never prompts.
869
+ *
870
+ * @param error - The error {@link report} accepted.
871
+ * @param codes - The codes of the command.
872
+ * @returns The error diagnostic, or `undefined` when the error has no code in `codes`.
873
+ */
874
+ static diagnosticOf(error, codes) {
875
+ const code = error instanceof DeployStrategyUnavailableError ? codes.StrategyUnavailable : error instanceof HostManagedDeployUnsupportedError ? codes.HostManagedUnsupported : error instanceof EnvFileUnreadableError ? codes.EnvFileUnreadable : void 0;
876
+ return code === void 0 || !(error instanceof Error) ? void 0 : CliDiagnostics.error(code, error.message);
877
+ }
878
+ };
879
+ //#endregion
880
+ //#region src/deploy-json-document.ts
881
+ /**
882
+ * The stable codes of the `heximon deploy --json` diagnostics. A flag conflict uses
883
+ * `cli.invalid-options`, and a missing answer (`--yes`, a credential variable) uses `cli.option-required`.
884
+ */
885
+ const DeployDiagnosticCode = {
886
+ /**
887
+ * A finding of the deploy run (preflight, migrations, build, infrastructure, provisioning, ship), at its own
888
+ * severity. A dry run that goes on past an error finding reports it as a warning, so `ok` stays `true`.
889
+ */
890
+ Finding: "deploy.finding",
891
+ /** The run stopped before the upload. Work that ran before the stop is not rolled back. */
892
+ Halted: "deploy.halted",
893
+ /** The vendor rejected the upload, so the artifact is not live. */
894
+ ShipFailed: "deploy.ship-failed",
895
+ /** The app shipped, but the `--verify` liveness probe got no 2xx answer. */
896
+ NotLive: "deploy.not-live",
897
+ /** The app shipped, but a migration after the ship failed to apply. */
898
+ MigrationFailed: "deploy.migration-failed",
899
+ /** `--migrate` found no database config to apply. */
900
+ NoMigrationConfig: "deploy.no-migration-config",
901
+ /** `--destroy` on a platform that has no teardown step. */
902
+ DestroyUnsupported: "deploy.destroy-unsupported",
903
+ /** No deploy strategy is installed for the platform. */
904
+ StrategyUnavailable: "deploy.strategy-unavailable",
905
+ /** The `--env-file` file cannot be read. */
906
+ EnvFileUnreadable: "deploy.env-file-unreadable",
907
+ /** A Nitro or Nuxt build uses a preset that the deploy cannot ship. */
908
+ HostManagedUnsupported: "deploy.host-managed-unsupported"
767
909
  };
768
910
  //#endregion
769
911
  //#region src/deploy-command.ts
@@ -818,6 +960,8 @@ var DeployArgumentGuards = class DeployArgumentGuards {
818
960
  * failure, which a local test cannot stage.
819
961
  */
820
962
  var DeployReport = class DeployReport {
963
+ /** The hint of an error finding that a dry run went on past. */
964
+ static dryRunFindingHint = "The dry run went on, but a deploy without --dry-run stops on this finding.";
821
965
  /**
822
966
  * Print the whole outcome and decide the command's exit.
823
967
  *
@@ -837,8 +981,7 @@ var DeployReport = class DeployReport {
837
981
  return false;
838
982
  }
839
983
  if (outcome.halted || outcome.result === void 0) {
840
- const completed = DeployReport.completedBeforeHalt(outcome);
841
- 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("; ")}.`);
984
+ cancel(DeployReport.haltMessage(outcome));
842
985
  return false;
843
986
  }
844
987
  for (const message of outcome.result.messages) log.message(message);
@@ -866,6 +1009,112 @@ var DeployReport = class DeployReport {
866
1009
  static hasFailedMigration(outcome) {
867
1010
  return (outcome.migrations ?? []).some((migrated) => migrated.diagnostics.some((diagnostic) => diagnostic.severity === DeployDiagnosticSeverity.Error));
868
1011
  }
1012
+ /**
1013
+ * The `--json` diagnostics of an outcome: each finding of the runner, each missing credential, and one
1014
+ * closing error when {@link render} fails the run.
1015
+ *
1016
+ * A run that succeeds exits 0, so it has no error diagnostic: a dry run goes on past a finding that would
1017
+ * stop a real deploy, and that finding is a warning here, with a hint that says so.
1018
+ *
1019
+ * @param outcome - What the runner reported.
1020
+ * @param succeeded - What {@link render} returned for the outcome.
1021
+ * @returns The diagnostics, in the order the text report prints them.
1022
+ */
1023
+ static diagnostics(outcome, succeeded) {
1024
+ const diagnostics = outcome.diagnostics.map((diagnostic) => DeployReport.finding(diagnostic, succeeded));
1025
+ for (const credential of outcome.missingCredentials) diagnostics.push({
1026
+ ...CliDiagnostics.error(CliDiagnosticCode.OptionRequired, `${credential.name} is not set.`, {
1027
+ hint: `Set ${credential.envVar}.`,
1028
+ option: credential.envVar
1029
+ }),
1030
+ severity: outcome.halted ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning
1031
+ });
1032
+ for (const diagnostic of outcome.verification?.diagnostics ?? []) diagnostics.push(DeployReport.finding(diagnostic, succeeded));
1033
+ const closing = DeployReport.closingDiagnostic(outcome);
1034
+ if (closing !== void 0) diagnostics.push(closing);
1035
+ return diagnostics;
1036
+ }
1037
+ /**
1038
+ * The `ship` section of the `--json` document.
1039
+ *
1040
+ * @param outcome - What the runner reported.
1041
+ * @param run - The facts the command recorded around the runner.
1042
+ * @returns The section.
1043
+ */
1044
+ static ship(outcome, run) {
1045
+ return {
1046
+ halted: outcome.halted,
1047
+ ...run,
1048
+ missingCredentials: outcome.missingCredentials.map((credential) => ({
1049
+ name: credential.name,
1050
+ envVar: credential.envVar
1051
+ })),
1052
+ ...outcome.targetEnsure === void 0 ? {} : { targetBootstrap: DeployReport.resources(outcome.targetEnsure) },
1053
+ ...outcome.provision === void 0 ? {} : { provision: DeployReport.resources(outcome.provision) },
1054
+ migrations: (outcome.migrations ?? []).map((migrated) => ({
1055
+ target: migrated.target,
1056
+ phase: migrated.phase,
1057
+ applied: migrated.applied,
1058
+ failed: migrated.diagnostics.some((diagnostic) => diagnostic.severity === DeployDiagnosticSeverity.Error)
1059
+ })),
1060
+ ...outcome.result === void 0 ? {} : { result: {
1061
+ platform: outcome.result.platform,
1062
+ shipped: outcome.result.shipped,
1063
+ failed: outcome.result.failed === true,
1064
+ ...outcome.result.url === void 0 ? {} : { url: outcome.result.url },
1065
+ messages: outcome.result.messages
1066
+ } },
1067
+ ...outcome.verification === void 0 ? {} : { verification: {
1068
+ url: outcome.verification.url,
1069
+ live: outcome.verification.live
1070
+ } },
1071
+ completedBeforeHalt: outcome.halted ? DeployReport.completedBeforeHalt(outcome) : []
1072
+ };
1073
+ }
1074
+ /**
1075
+ * The error that closes a failed run, in the same order of checks as {@link render}; `undefined` when
1076
+ * the run succeeded.
1077
+ */
1078
+ static closingDiagnostic(outcome) {
1079
+ if (outcome.result?.failed === true) return CliDiagnostics.error(DeployDiagnosticCode.ShipFailed, `The ship to ${outcome.result.platform} failed, so the artifact is not live.`);
1080
+ if (outcome.halted || outcome.result === void 0) return CliDiagnostics.error(DeployDiagnosticCode.Halted, DeployReport.haltMessage(outcome));
1081
+ if (outcome.verification !== void 0 && !outcome.verification.live) return CliDiagnostics.error(DeployDiagnosticCode.NotLive, `Deployed to ${outcome.result.platform}, but ${outcome.verification.url} does not respond.`);
1082
+ if (DeployReport.hasFailedMigration(outcome)) return CliDiagnostics.error(DeployDiagnosticCode.MigrationFailed, `Deployed to ${outcome.result.platform}, but a migration failed to apply.`);
1083
+ }
1084
+ /**
1085
+ * The halt line. Only the UPLOAD is known not to have happened: target bootstrap, provisioning and a
1086
+ * pre-ship migration run earlier and are never rolled back, so the line names them instead of denying them.
1087
+ */
1088
+ static haltMessage(outcome) {
1089
+ const completed = DeployReport.completedBeforeHalt(outcome);
1090
+ return 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("; ")}.`;
1091
+ }
1092
+ /**
1093
+ * One runner finding as a `deploy.finding` diagnostic, at its own severity. In a run that succeeds, an
1094
+ * error finding is a warning, because the run went on past it.
1095
+ */
1096
+ static finding(diagnostic, succeeded) {
1097
+ if (diagnostic.severity !== DeployDiagnosticSeverity.Error) return {
1098
+ severity: DiagnosticSeverity.Warning,
1099
+ code: DeployDiagnosticCode.Finding,
1100
+ message: diagnostic.message
1101
+ };
1102
+ return {
1103
+ severity: succeeded ? DiagnosticSeverity.Warning : DiagnosticSeverity.Error,
1104
+ code: DeployDiagnosticCode.Finding,
1105
+ message: diagnostic.message,
1106
+ ...succeeded ? { hint: DeployReport.dryRunFindingHint } : {}
1107
+ };
1108
+ }
1109
+ /** Copy a phase's resource outcome into its document shape. */
1110
+ static resources(result) {
1111
+ return {
1112
+ created: result.created,
1113
+ existing: result.existing,
1114
+ failed: result.failed,
1115
+ messages: result.messages
1116
+ };
1117
+ }
869
1118
  /** The runner's own findings, at the severity each carries. */
870
1119
  static logDiagnostics(diagnostics) {
871
1120
  for (const diagnostic of diagnostics ?? []) if (diagnostic.severity === DeployDiagnosticSeverity.Error) log.error(diagnostic.message);
@@ -923,18 +1172,70 @@ var DeployReport = class DeployReport {
923
1172
  return false;
924
1173
  }
925
1174
  };
1175
+ /**
1176
+ * Builds the `heximon deploy --json` document. The run fields are set as the run reaches them, so a
1177
+ * failure document still says how far the run got.
1178
+ */
1179
+ var DeployDocument = class {
1180
+ args;
1181
+ target;
1182
+ credentials;
1183
+ /**
1184
+ * @param args - The parsed deploy flags.
1185
+ */
1186
+ constructor(args) {
1187
+ this.args = args;
1188
+ }
1189
+ /** Record the resolved target. */
1190
+ resolved(target) {
1191
+ this.target = {
1192
+ platform: target.platformName ?? "node",
1193
+ targetClass: target.targetClass,
1194
+ hostManaged: target.hostManaged === true,
1195
+ ...target.artifactDir === void 0 ? {} : { artifactDir: target.artifactDir }
1196
+ };
1197
+ }
1198
+ /** Record the names of the resolved credential variables. */
1199
+ resolvedCredentials(credentials) {
1200
+ this.credentials = [...credentials.keys()];
1201
+ }
1202
+ /**
1203
+ * The document of the run.
1204
+ *
1205
+ * @param ok - Whether the command exits 0.
1206
+ * @param diagnostics - The diagnostics of the run.
1207
+ * @param sections - The `ship` or `destroy` section, once the run has one.
1208
+ * @returns The document.
1209
+ */
1210
+ document(ok, diagnostics, sections = {}) {
1211
+ return {
1212
+ ok,
1213
+ diagnostics,
1214
+ action: this.args.destroy ? "destroy" : "ship",
1215
+ environment: this.args.env,
1216
+ dryRun: this.args["dry-run"],
1217
+ ...this.target === void 0 ? {} : { target: this.target },
1218
+ ...this.credentials === void 0 ? {} : { credentials: this.credentials },
1219
+ ...sections
1220
+ };
1221
+ }
1222
+ };
926
1223
  /** The `--destroy` teardown and the pre-ship discovery/migration composition the ship path runs. */
927
- var DeployPhases = class {
1224
+ var DeployPhases = class DeployPhases {
1225
+ /** The halt line of a `--migrate` run that found no database config. */
1226
+ static noMigrationConfig = "--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.";
928
1227
  /**
929
- * Confirm and run the teardown.
1228
+ * Confirm and run the teardown, and record its document.
930
1229
  *
931
1230
  * @returns Whether the command should exit non-zero.
932
1231
  */
933
- static async destroy(args, target, rootDir, credentials, interactive) {
1232
+ static async destroy(args, target, rootDir, credentials, commandOutput, report) {
934
1233
  const platformName = target.platformName ?? "node";
1234
+ const interactive = commandOutput.isInteractive();
1235
+ const question = `Remove the deployed app on ${platformName} (env: ${args.env})? This cannot be undone.`;
935
1236
  if (interactive) {
936
1237
  const answer = await confirm({
937
- message: `Remove the deployed app on ${platformName} (env: ${args.env})? This cannot be undone.`,
1238
+ message: question,
938
1239
  initialValue: false
939
1240
  });
940
1241
  if (isCancel(answer)) {
@@ -946,6 +1247,7 @@ var DeployPhases = class {
946
1247
  return false;
947
1248
  }
948
1249
  } else if (!args.yes) {
1250
+ commandOutput.assertCanPrompt("--yes", question);
949
1251
  cancel("Refusing to --destroy non-interactively without --yes.");
950
1252
  return true;
951
1253
  }
@@ -965,12 +1267,21 @@ var DeployPhases = class {
965
1267
  throw teardownError;
966
1268
  }
967
1269
  removing?.stop(args["dry-run"] ? "Dry run — nothing removed." : "Teardown step complete.");
1270
+ const destroy = {
1271
+ platform: teardown.result?.platform ?? teardown.platformName ?? "node",
1272
+ unsupported: teardown.unsupported,
1273
+ destroyed: teardown.result?.destroyed === true,
1274
+ messages: teardown.result?.messages ?? []
1275
+ };
968
1276
  if (teardown.unsupported) {
969
- cancel(`Platform ${teardown.platformName ?? "node (default)"} has nothing to remove (no destroy step).`);
1277
+ const message = `Platform ${teardown.platformName ?? "node (default)"} has nothing to remove (no destroy step).`;
1278
+ cancel(message);
1279
+ commandOutput.emit(report.document(false, [CliDiagnostics.error(DeployDiagnosticCode.DestroyUnsupported, message)], { destroy }));
970
1280
  return true;
971
1281
  }
972
1282
  for (const message of teardown.result?.messages ?? []) log.message(message);
973
1283
  outro(teardown.result?.destroyed === true ? `Removed from ${teardown.result.platform}.` : `Dry run — nothing removed (${teardown.result?.platform ?? target.platformName ?? "node"}).`);
1284
+ commandOutput.emit(report.document(true, [], { destroy }));
974
1285
  return false;
975
1286
  }
976
1287
  /**
@@ -1046,7 +1357,7 @@ var DeployPhases = class {
1046
1357
  };
1047
1358
  } 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.");
1048
1359
  else {
1049
- 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.");
1360
+ cancel(DeployPhases.noMigrationConfig);
1050
1361
  return null;
1051
1362
  }
1052
1363
  if (args.migrations !== void 0) log.info("--migrations <dir> is superseded by the auto-detected database config(s) under --migrate.");
@@ -1070,6 +1381,7 @@ const deployCommand = defineCommand({
1070
1381
  description: "Project root directory.",
1071
1382
  default: "."
1072
1383
  },
1384
+ json: CommandOutput.jsonFlag,
1073
1385
  env: {
1074
1386
  type: "string",
1075
1387
  description: "Deploy environment (production / staging / preview:<id>).",
@@ -1141,145 +1453,668 @@ const deployCommand = defineCommand({
1141
1453
  }
1142
1454
  },
1143
1455
  async run({ args }) {
1144
- const deployArgs = args;
1145
- const rootDir = resolve(process.cwd(), deployArgs.cwd);
1146
- const environment = deployArgs.env;
1147
- const interactive = CliEnvironment.isInteractive();
1148
- intro(`heximon deploy · ${environment}`);
1149
- const incoherence = DeployArgumentGuards.incoherence(deployArgs);
1150
- if (incoherence !== void 0) {
1151
- cancel(incoherence);
1152
- process.exitCode = 1;
1153
- return;
1154
- }
1155
- try {
1156
- const target = await DeployTargetResolver.resolve(rootDir, environment, deployArgs.platform);
1157
- log.info(`Platform: ${target.platformName ?? "node (default)"} · target class: ${target.targetClass}`);
1158
- if (target.hostManaged) log.info(`Host-managed build detected (${target.artifactDir}) — shipping it, skipping \`vp build\`.`);
1159
- const profileMismatch = DeployArgumentGuards.profileMismatch(deployArgs.profile, target.platformName);
1160
- if (profileMismatch !== void 0) {
1161
- cancel(profileMismatch);
1162
- process.exitCode = 1;
1163
- return;
1164
- }
1165
- const specs = CredentialSelection.inPlay(target.strategy.requiredCredentials(), { provisioning: deployArgs.provision });
1166
- const credentials = await CredentialResolver.resolve(specs, {
1167
- interactive,
1168
- prompter: interactive ? new ClackPrompter() : void 0
1169
- });
1170
- if (credentials.size > 0) log.success(`Resolved ${credentials.size} credential(s).`);
1171
- if (deployArgs.destroy) {
1172
- if (await DeployPhases.destroy(deployArgs, target, rootDir, credentials, interactive)) process.exitCode = 1;
1173
- return;
1174
- }
1175
- let secrets;
1176
- if (deployArgs["env-file"] !== void 0) {
1177
- secrets = await EnvFileReader.read(resolve(rootDir, deployArgs["env-file"]));
1178
- log.info(`Loaded ${secrets.size} secret(s) from ${deployArgs["env-file"]}.`);
1179
- }
1180
- if (!(deployArgs["skip-build"] || target.hostManaged === true)) {
1181
- const injectable = DeployLedger.injectableEntries(await DeployLedger.read(rootDir, environment));
1182
- if (injectable.size > 0) log.info(`Injecting ${injectable.size} deploy-ledger value(s) into the build: ${[...injectable.keys()].join(", ")}.`);
1183
- }
1184
- DeployPhases.logHarvest(await DeployPhases.harvest(rootDir), deployArgs.migrate);
1185
- const migrations = await DeployPhases.composeMigrations(deployArgs, rootDir, secrets);
1186
- if (migrations === null) {
1456
+ const commandOutput = CommandOutput.resolve("deploy", args);
1457
+ await commandOutput.run(async () => {
1458
+ const deployArgs = args;
1459
+ const rootDir = ProjectRoot.resolve(deployArgs.cwd);
1460
+ const environment = deployArgs.env;
1461
+ const interactive = commandOutput.isInteractive();
1462
+ const report = new DeployDocument(deployArgs);
1463
+ intro(`heximon deploy · ${environment}`);
1464
+ const incoherence = DeployArgumentGuards.incoherence(deployArgs);
1465
+ if (incoherence !== void 0) {
1466
+ cancel(incoherence);
1187
1467
  process.exitCode = 1;
1468
+ commandOutput.emit(report.document(false, [CliDiagnostics.error(CliDiagnosticCode.InvalidOptions, incoherence)]));
1188
1469
  return;
1189
1470
  }
1190
- const progress = CliSpinner.begin(interactive, "Preparing the deploy…");
1191
- const onProgress = (event) => {
1192
- if (progress === void 0) log.info(event.message);
1193
- else progress.message(event.message);
1194
- };
1195
- let outcome;
1196
1471
  try {
1197
- outcome = await DeployRunner.run({
1198
- target,
1199
- rootDir,
1200
- environment,
1201
- credentials,
1202
- dryRun: deployArgs["dry-run"],
1203
- skipBuild: deployArgs["skip-build"] || target.hostManaged === true,
1204
- ...target.artifactDir === void 0 ? {} : { artifactDir: target.artifactDir },
1205
- provision: deployArgs.provision,
1206
- ...secrets === void 0 ? {} : { secrets },
1207
- ...deployArgs.create === void 0 ? {} : { createTarget: {
1208
- name: deployArgs.create,
1209
- ...deployArgs.team === void 0 ? {} : { team: deployArgs.team }
1210
- } },
1211
- verify: deployArgs.verify,
1212
- ...deployArgs.profile === void 0 ? {} : { profile: deployArgs.profile },
1213
- onProgress,
1214
- ...migrations === void 0 ? {} : { migrations }
1472
+ const target = await ConfigSidecar.during(rootDir, environment, () => DeployTargetResolver.resolve(rootDir, environment, deployArgs.platform));
1473
+ report.resolved(target);
1474
+ log.info(`Platform: ${target.platformName ?? "node (default)"} · target class: ${target.targetClass}`);
1475
+ if (target.hostManaged) log.info(`Host-managed build detected (${target.artifactDir}) — shipping it, skipping \`vp build\`.`);
1476
+ const profileMismatch = DeployArgumentGuards.profileMismatch(deployArgs.profile, target.platformName);
1477
+ if (profileMismatch !== void 0) {
1478
+ cancel(profileMismatch);
1479
+ process.exitCode = 1;
1480
+ commandOutput.emit(report.document(false, [CliDiagnostics.error(CliDiagnosticCode.InvalidOptions, profileMismatch)]));
1481
+ return;
1482
+ }
1483
+ const specs = CredentialSelection.inPlay(target.strategy.requiredCredentials(), { provisioning: deployArgs.provision });
1484
+ const credentials = await CredentialResolver.resolve(specs, {
1485
+ interactive: interactive || commandOutput.isJson() && !deployArgs["dry-run"],
1486
+ prompter: commandOutput.prompter(CredentialResolver.optionFor(specs))
1215
1487
  });
1216
- } catch (runError) {
1217
- progress?.stop("Deploy failed — see the error below.");
1218
- throw runError;
1488
+ report.resolvedCredentials(credentials);
1489
+ if (credentials.size > 0) log.success(`Resolved ${credentials.size} credential(s).`);
1490
+ if (deployArgs.destroy) {
1491
+ if (await DeployPhases.destroy(deployArgs, target, rootDir, credentials, commandOutput, report)) process.exitCode = 1;
1492
+ return;
1493
+ }
1494
+ let secrets;
1495
+ if (deployArgs["env-file"] !== void 0) {
1496
+ secrets = await EnvFileReader.read(resolve(rootDir, deployArgs["env-file"]));
1497
+ log.info(`Loaded ${secrets.size} secret(s) from ${deployArgs["env-file"]}.`);
1498
+ }
1499
+ const buildSkipped = deployArgs["skip-build"] || target.hostManaged === true;
1500
+ let ledgerValues = [];
1501
+ if (!buildSkipped) {
1502
+ const injectable = DeployLedger.injectableEntries(await DeployLedger.read(rootDir, environment));
1503
+ ledgerValues = [...injectable.keys()];
1504
+ if (injectable.size > 0) log.info(`Injecting ${injectable.size} deploy-ledger value(s) into the build: ${ledgerValues.join(", ")}.`);
1505
+ }
1506
+ DeployPhases.logHarvest(await DeployPhases.harvest(rootDir), deployArgs.migrate);
1507
+ const migrations = await DeployPhases.composeMigrations(deployArgs, rootDir, secrets);
1508
+ if (migrations === null) {
1509
+ process.exitCode = 1;
1510
+ commandOutput.emit(report.document(false, [CliDiagnostics.error(DeployDiagnosticCode.NoMigrationConfig, DeployPhases.noMigrationConfig)]));
1511
+ return;
1512
+ }
1513
+ const progress = CliSpinner.begin(interactive, "Preparing the deploy…");
1514
+ const phases = [];
1515
+ const onProgress = (event) => {
1516
+ phases.push(event.phase);
1517
+ if (progress === void 0) log.info(event.message);
1518
+ else progress.message(event.message);
1519
+ };
1520
+ let outcome;
1521
+ try {
1522
+ outcome = await DeployRunner.run({
1523
+ target,
1524
+ rootDir,
1525
+ environment,
1526
+ credentials,
1527
+ dryRun: deployArgs["dry-run"],
1528
+ skipBuild: buildSkipped,
1529
+ ...target.artifactDir === void 0 ? {} : { artifactDir: target.artifactDir },
1530
+ provision: deployArgs.provision,
1531
+ ...secrets === void 0 ? {} : { secrets },
1532
+ ...deployArgs.create === void 0 ? {} : { createTarget: {
1533
+ name: deployArgs.create,
1534
+ ...deployArgs.team === void 0 ? {} : { team: deployArgs.team }
1535
+ } },
1536
+ verify: deployArgs.verify,
1537
+ ...deployArgs.profile === void 0 ? {} : { profile: deployArgs.profile },
1538
+ onProgress,
1539
+ ...migrations === void 0 ? {} : { migrations }
1540
+ });
1541
+ } catch (runError) {
1542
+ progress?.stop("Deploy failed — see the error below.");
1543
+ throw runError;
1544
+ }
1545
+ progress?.stop(outcome.halted ? "Stopped — details below." : "Deploy steps complete.");
1546
+ const succeeded = DeployReport.render(outcome, interactive);
1547
+ if (!succeeded) process.exitCode = 1;
1548
+ commandOutput.emit(report.document(succeeded, DeployReport.diagnostics(outcome, succeeded), { ship: DeployReport.ship(outcome, {
1549
+ phases,
1550
+ buildSkipped,
1551
+ ledgerValues,
1552
+ migrationTargets: migrations?.apply === true ? migrations.targets.map((migrationTarget) => migrationTarget.name) : []
1553
+ }) }));
1554
+ } catch (error) {
1555
+ if (DeployCommandFailure.report(error)) {
1556
+ const diagnostic = DeployCommandFailure.diagnosticOf(error, DeployDiagnosticCode);
1557
+ if (diagnostic !== void 0) commandOutput.emit(report.document(false, [diagnostic]));
1558
+ return;
1559
+ }
1560
+ if (error instanceof CommandFailure && commandOutput.isJson()) {
1561
+ commandOutput.emit(report.document(false, error.diagnostics));
1562
+ return;
1563
+ }
1564
+ throw error;
1219
1565
  }
1220
- progress?.stop(outcome.halted ? "Stopped — details below." : "Deploy steps complete.");
1221
- if (!DeployReport.render(outcome, interactive)) process.exitCode = 1;
1222
- } catch (error) {
1223
- if (DeployCommandFailure.report(error)) return;
1224
- throw error;
1225
- }
1566
+ });
1226
1567
  }
1227
1568
  });
1228
1569
  //#endregion
1229
- //#region src/doctor-command.ts
1230
- /** The rendering layer behind {@link doctorCommand}: every audit's output, and the verdicts the exit reads. */
1231
- var DoctorReport = class DoctorReport {
1232
- /** Renders one audited boundary as a status line. `self` reframes the wording for the current package. */
1233
- static boundaryLine(report, self = false) {
1234
- const name = self ? `${report.specifier} (this package)` : report.specifier;
1235
- const version = report.packageVersion === void 0 ? "" : ` v${report.packageVersion}`;
1236
- switch (report.status) {
1237
- case BoundaryProvenanceStatus.Fresh: return self ? `✔ ${name}${version} — dist is current with src/ (${report.manifestSourceHash})` : `✔ ${name}${version} — up to date (${report.manifestSourceHash})`;
1238
- case BoundaryProvenanceStatus.Stale: return self && report.missingSource === void 0 ? `✖ ${name}${version} — STALE: your src/ (${report.recomputedSourceHash}) no longer matches your own dist manifest (${report.manifestSourceHash}). Repack (\`vp pack\`) before publishing or consuming it.` : LibraryFreshness.staleLine(self ? name : report.specifier, report);
1239
- case BoundaryProvenanceStatus.NoProvenance: return `● ${name}${version} — manifest predates the provenance stamp; repack to enable the check.`;
1240
- case BoundaryProvenanceStatus.NoSource: return `● ${name}${version} — published package (no local src/); provenance is display-only.`;
1570
+ //#region src/doctor-json-document.ts
1571
+ /** Which audit set one `heximon doctor` run ran. */
1572
+ const DoctorMode = {
1573
+ /** The default run: the audits of an app and the libraries it consumes. */
1574
+ App: "app",
1575
+ /** `--self`: the pre-publish check of the current library package. */
1576
+ Self: "self"
1577
+ };
1578
+ /** The verdict on one audited package in a {@link DoctorJsonDocument}. */
1579
+ const DoctorPackageStatus = {
1580
+ /** The packed manifest matches the local source of the package. */
1581
+ Fresh: "fresh",
1582
+ /**
1583
+ * The local source changed since the last pack, some recorded source is gone, or a file the bundle read outside
1584
+ * the source changed or is gone.
1585
+ */
1586
+ Stale: "stale",
1587
+ /** The packed manifest has no provenance stamp, so nothing was compared. */
1588
+ NoProvenance: "no-provenance",
1589
+ /** The package has no local source (a published package), so nothing was compared. */
1590
+ NoSource: "no-source",
1591
+ /** The package resolves to more than one physical copy. */
1592
+ Fork: "fork"
1593
+ };
1594
+ /** The outcome of the physical-copy census in a {@link DoctorJsonDocument}. */
1595
+ const DoctorCopyCensusStatus = {
1596
+ /** Every probed package resolves to one copy. */
1597
+ SingleCopies: "single-copies",
1598
+ /** At least one probed package resolves to more than one copy. */
1599
+ Forked: "forked",
1600
+ /** The census ran but found no `node_modules` to resolve from, so it could not look. */
1601
+ NothingProbed: "nothing-probed",
1602
+ /** `--self` found no pnpm workspace, so the census did not run. */
1603
+ NotRun: "not-run"
1604
+ };
1605
+ /**
1606
+ * The stable codes of the `heximon doctor` diagnostics. A `heximon.config.ts` that `--self` cannot load is the
1607
+ * shared `cli.config-unreadable`, as a warning: the run goes on without its `expectedOpenPeers`.
1608
+ */
1609
+ const DoctorDiagnosticCode = {
1610
+ /** `@heximon/*` cannot be read from GitHub Packages. Error. */
1611
+ RegistryAccessFailed: "doctor.registry-access-failed",
1612
+ /** A packed library no longer matches its local source. Error. */
1613
+ StalePackage: "doctor.stale-package",
1614
+ /** A package resolves to more than one physical copy. Error when it fails the run, else warning. */
1615
+ PackageFork: "doctor.package-fork",
1616
+ /** The copy census found nothing to resolve from. Warning. */
1617
+ CopiesNotProbed: "doctor.copies-not-probed",
1618
+ /** The deployed wrangler config does not carry the tracing directives. Warning. */
1619
+ PlatformTracingNotCarried: "doctor.platform-tracing-not-carried",
1620
+ /** A `@heximon/*` peer of the dependency closure is declared nowhere in `package.json`. Warning. */
1621
+ PeerPivotUndeclared: "doctor.peer-pivot-undeclared",
1622
+ /** An `expectedOpenPeers` name matches no undeclared peer. Warning. */
1623
+ StalePeerAcknowledgement: "doctor.stale-peer-acknowledgement"
1624
+ };
1625
+ //#endregion
1626
+ //#region src/doctor-audit.ts
1627
+ /** Runs the `heximon doctor` audits, and owns the verdicts that the exit status and both renderers read. */
1628
+ var DoctorAudit = class DoctorAudit {
1629
+ /**
1630
+ * Run every audit of one mode. Read-only.
1631
+ *
1632
+ * @param rootDir - The absolute project root.
1633
+ * @param self - Run the `--self` pre-publish check instead of the app audits.
1634
+ * @returns The audit result.
1635
+ */
1636
+ static async run(rootDir, self) {
1637
+ const registry = await new RegistryAccess().probe();
1638
+ return self ? DoctorAudit.runSelf(rootDir, registry) : DoctorAudit.runApp(rootDir, registry);
1639
+ }
1640
+ /** Whether the registry probe failed. */
1641
+ static isRegistryFailed(result) {
1642
+ return result.registry.status !== RegistryAccessStatus.Ok;
1643
+ }
1644
+ /** Whether the run has a finding that exits 1. */
1645
+ static isFailure(result) {
1646
+ if (DoctorAudit.isRegistryFailed(result)) return true;
1647
+ if (result.mode === DoctorMode.App) return DoctorAudit.staleCount(result.boundaries) > 0;
1648
+ return DoctorAudit.gatingForks(result).length > 0 || result.selfReport?.status === BoundaryProvenanceStatus.Stale;
1649
+ }
1650
+ /** The census reports of packages that resolve to more than one copy. */
1651
+ static forkedReports(audit) {
1652
+ return audit.reports.filter((report) => report.copies.length > 1);
1653
+ }
1654
+ /**
1655
+ * The forks that fail a `--self` run: forks of a runtime-reachable package. A default run has none, because
1656
+ * its census is reported and never fails the run.
1657
+ */
1658
+ static gatingForks(result) {
1659
+ if (result.mode === DoctorMode.App || result.copyAudit === void 0) return [];
1660
+ if (result.copyAudit.originsProbed === 0) return [];
1661
+ const gatingPackages = result.gatingPackages;
1662
+ return DoctorAudit.forkedReports(result.copyAudit).filter((report) => gatingPackages === void 0 || gatingPackages.has(report.packageName));
1663
+ }
1664
+ /** How many reports are stale. */
1665
+ static staleCount(reports) {
1666
+ return reports.filter((report) => report.status === BoundaryProvenanceStatus.Stale).length;
1667
+ }
1668
+ /** The closing sentence of the run. */
1669
+ static summary(result) {
1670
+ if (result.mode === DoctorMode.App) {
1671
+ if (result.boundaries.length === 0) return "No consumed Heximon library packages found — no staleness audit to run.";
1672
+ const staleCount = DoctorAudit.staleCount(result.boundaries);
1673
+ return staleCount > 0 ? `${staleCount} stale package(s) — rebuild them before trusting this app's build.` : `${result.boundaries.length} package(s) audited — no staleness detected.`;
1241
1674
  }
1675
+ const parts = [];
1676
+ const forked = DoctorAudit.gatingForks(result).length > 0;
1677
+ if (forked) {
1678
+ const hasConsumers = (result.workspaceConsumers?.consumers.length ?? 0) > 0;
1679
+ parts.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.");
1680
+ }
1681
+ if (result.selfReport === null) parts.push("This directory is not a packed Heximon library (no dist/heximon.manifest.json and no dist/heximon.inputs.json) — the peer-closure and copy census above still ran; run `vp pack` to enable the staleness check too.");
1682
+ else if (result.selfReport.status === BoundaryProvenanceStatus.Stale || !forked) parts.push(DoctorAudit.selfProvenanceSummary(result.selfReport));
1683
+ return parts.join(" ");
1242
1684
  }
1243
- /** Logs one report at the severity its status warrants (error for stale, success for fresh, info otherwise). */
1244
- static logBoundary(report, self = false) {
1245
- const line = DoctorReport.boundaryLine(report, self);
1246
- if (report.status === BoundaryProvenanceStatus.Stale) log.error(line);
1247
- else if (report.status === BoundaryProvenanceStatus.Fresh) log.success(line);
1248
- else log.info(line);
1685
+ /**
1686
+ * The package names a fork of which is RUNTIME-reachable from the library at `rootDir`: the library itself,
1687
+ * its installed `@heximon/*` dependency closure ({@link PeerClosureReport.closurePackages}), every named peer
1688
+ * pivot of that closure, the curated {@link RuntimeSingletons}, and every installed boundary package
1689
+ * ({@link PackageCopyAuditor.installedBoundaryPackages}). A fork of any other name is confined to build-time
1690
+ * tooling (for example a devDependency-only bundler plugin), so it is reported and does not fail the run.
1691
+ */
1692
+ static runtimeReachablePackageNames(rootDir, closureReport) {
1693
+ const names = new Set(RuntimeSingletons.all());
1694
+ const libraryName = DoctorAudit.readPackageName(rootDir);
1695
+ if (libraryName !== null) names.add(libraryName);
1696
+ for (const packageName of PackageCopyAuditor.installedBoundaryPackages(rootDir)) names.add(packageName);
1697
+ if (closureReport !== null) {
1698
+ for (const packageName of closureReport.closurePackages) names.add(packageName);
1699
+ for (const pivot of closureReport.pivots) names.add(pivot.packageName);
1700
+ }
1701
+ return names;
1249
1702
  }
1250
1703
  /**
1251
- * The `--self` outro sentence for a NON-stale own-dist verdict.
1704
+ * The `--self` sentence for the own-dist verdict.
1252
1705
  *
1253
- * Only {@link BoundaryProvenanceStatus.Fresh} actually compared anything. The other two verdicts mean the
1254
- * comparison did not run at all, and summarising them as "dist is current with its src/" would report a
1255
- * check that never happened as a pass — the one thing a pre-publish gate must never do.
1706
+ * Only {@link BoundaryProvenanceStatus.Fresh} compared anything. The other non-stale verdicts mean the
1707
+ * comparison did not run, so they must never read as a pass.
1256
1708
  */
1257
1709
  static selfProvenanceSummary(report) {
1258
1710
  switch (report.status) {
1259
- case BoundaryProvenanceStatus.Fresh: return "This package's dist is current with its src/.";
1711
+ case BoundaryProvenanceStatus.Fresh: return report.manifestSourceHash === void 0 ? "This package's dist is current with its build inputs." : "This package's dist is current with its src/.";
1260
1712
  case BoundaryProvenanceStatus.NoProvenance: return "Its packed manifest predates the provenance stamp, so nothing was compared — repack (`vp pack`) to enable the staleness check.";
1261
1713
  case BoundaryProvenanceStatus.NoSource: return "It ships no local src/, so its packed manifest was not compared with anything.";
1262
1714
  case BoundaryProvenanceStatus.Stale: return "Your dist is STALE — repack (`vp pack`) before publishing or consuming this package.";
1263
1715
  }
1264
1716
  }
1717
+ /** The default run: the copy census, the platform-tracing check, then the staleness audit. */
1718
+ static async runApp(rootDir, registry) {
1719
+ const copyAudit = PackageCopyAuditor.audit(rootDir);
1720
+ const platformTracing = await PlatformTracingAuditor.audit(rootDir);
1721
+ const boundaries = await ConfigSidecar.during(rootDir, void 0, () => BoundaryProvenanceAuditor.audit(rootDir));
1722
+ return {
1723
+ mode: DoctorMode.App,
1724
+ rootDir,
1725
+ registry,
1726
+ copyAudit,
1727
+ platformTracing,
1728
+ boundaries
1729
+ };
1730
+ }
1731
+ /** The `--self` run: the peer-closure roster, the library-side census, then the own-dist staleness check. */
1732
+ static async runSelf(rootDir, registry) {
1733
+ let configError;
1734
+ let configDiagnostic;
1735
+ let expectedOpenPeers = [];
1736
+ try {
1737
+ expectedOpenPeers = (await ConfigLoader.load(rootDir))?.expectedOpenPeers ?? [];
1738
+ } catch (error) {
1739
+ configError = error instanceof Error ? error.message : String(error);
1740
+ configDiagnostic = await ConfigSidecar.diagnosticOf(rootDir, error, DiagnosticSeverity.Warning, "so its `expectedOpenPeers` acknowledgements are not applied");
1741
+ }
1742
+ const closureReport = PeerClosureAuditor.audit(rootDir, expectedOpenPeers);
1743
+ const workspaceConsumers = WorkspaceConsumerLocator.locate(rootDir);
1744
+ const census = workspaceConsumers === null ? {} : {
1745
+ copyAudit: PackageCopyAuditor.auditLibrary(rootDir, workspaceConsumers.consumers),
1746
+ gatingPackages: DoctorAudit.runtimeReachablePackageNames(rootDir, closureReport)
1747
+ };
1748
+ const selfReport = await ConfigSidecar.during(rootDir, void 0, () => BoundaryProvenanceAuditor.auditSelf(rootDir));
1749
+ return {
1750
+ mode: DoctorMode.Self,
1751
+ rootDir,
1752
+ registry,
1753
+ ...configError === void 0 ? {} : { configError },
1754
+ ...configDiagnostic === void 0 ? {} : { configDiagnostic },
1755
+ closureReport,
1756
+ workspaceConsumers,
1757
+ ...census,
1758
+ selfReport
1759
+ };
1760
+ }
1761
+ /** The `name` field of a directory's `package.json`, or `null` when absent, unreadable or malformed. */
1762
+ static readPackageName(directory) {
1763
+ try {
1764
+ const parsed = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
1765
+ if (typeof parsed === "object" && parsed !== null && "name" in parsed) {
1766
+ const { name } = parsed;
1767
+ return typeof name === "string" ? name : null;
1768
+ }
1769
+ } catch {
1770
+ return null;
1771
+ }
1772
+ return null;
1773
+ }
1774
+ };
1775
+ /** Renders a {@link DoctorAuditResult} as the `heximon doctor --json` document. */
1776
+ var DoctorJsonReport = class DoctorJsonReport {
1777
+ /** The fix of a fork: no single command removes one. */
1778
+ static forkHint = "Deduplicate to one copy (align versions, or hoist it).";
1779
+ /** Shell words that need no quotes. */
1780
+ static plainShellWord = /^[\w./@+:=,-]+$/;
1265
1781
  /**
1266
- * Logs the physical-copy census: a line per forked package naming every copy and who resolves to it, or one
1267
- * summary line when nothing forked. The summary always states how many origins were probed — an audit that
1268
- * could not look at anything must not read as a clean bill of health. `gatingPackages`, when given, scopes
1269
- * which forks count as GATING ({@link DoctorReport.runtimeReachablePackageNames}) — every fork is still
1270
- * printed above either way, and a fork outside the set gets one extra line noting it is reported, not gated.
1782
+ * Build the document of one run.
1271
1783
  *
1272
- * @returns Whether any GATING fork was found (any fork at all, when `gatingPackages` is omitted).
1784
+ * @param result - The audit result.
1785
+ * @returns The document. Its `ok` is `false` exactly when {@link DoctorAudit.isFailure} is `true`.
1273
1786
  */
1274
- static logCopyAudit(audit, gatingPackages) {
1275
- const forked = audit.reports.filter((report) => report.copies.length > 1);
1276
- if (audit.originsProbed === 0) {
1277
- log.warn("Package copies — nothing probed (no resolvable node_modules from this directory), so no fork check ran.");
1278
- return false;
1787
+ static toJson(result) {
1788
+ const packages = DoctorJsonReport.packages(result);
1789
+ const diagnostics = [
1790
+ ...DoctorJsonReport.registryDiagnostics(result),
1791
+ ...DoctorJsonReport.modeDiagnostics(result),
1792
+ ...DoctorJsonReport.packageDiagnostics(packages)
1793
+ ];
1794
+ const common = {
1795
+ ok: !DoctorAudit.isFailure(result),
1796
+ diagnostics,
1797
+ mode: result.mode,
1798
+ rootDir: result.rootDir,
1799
+ registry: DoctorJsonReport.registry(result.registry),
1800
+ packages,
1801
+ packageCopies: DoctorJsonReport.copyCensus(result),
1802
+ summary: DoctorAudit.summary(result)
1803
+ };
1804
+ if (result.mode === DoctorMode.App) {
1805
+ const tracing = result.platformTracing;
1806
+ return {
1807
+ ...common,
1808
+ platformTracing: {
1809
+ status: tracing.status,
1810
+ ...tracing.wranglerConfigPath === void 0 ? {} : { wranglerConfigPath: tracing.wranglerConfigPath },
1811
+ tracingNotEnabled: tracing.tracingNotEnabled,
1812
+ ...tracing.droppedSamplerRatio === void 0 ? {} : { droppedSamplerRatio: tracing.droppedSamplerRatio }
1813
+ }
1814
+ };
1279
1815
  }
1280
- if (forked.length === 0) {
1281
- log.success(`Package copies — ${String(audit.packagesProbed)} package(s) probed from ${String(audit.originsProbed)} origin(s); each resolves to a single copy.`);
1282
- return false;
1816
+ const closure = result.closureReport;
1817
+ return {
1818
+ ...common,
1819
+ ...closure === null ? {} : { peerClosure: {
1820
+ closurePackages: closure.closurePackages,
1821
+ pivots: closure.pivots.map((pivot) => DoctorJsonReport.pivot(pivot)),
1822
+ uncovered: closure.uncovered.map((pivot) => DoctorJsonReport.pivot(pivot)),
1823
+ expectedOpen: closure.expectedOpen.map((pivot) => DoctorJsonReport.pivot(pivot)),
1824
+ staleExpectedOpen: closure.staleExpectedOpen
1825
+ } },
1826
+ packed: result.selfReport !== null
1827
+ };
1828
+ }
1829
+ /** The registry section. */
1830
+ static registry(report) {
1831
+ return {
1832
+ registry: RegistryAccess.gitHubPackagesRegistry,
1833
+ probePackage: RegistryAccess.probePackage,
1834
+ status: report.status,
1835
+ ...report.detail === void 0 ? {} : { detail: report.detail },
1836
+ ...report.status === RegistryAccessStatus.Ok ? {} : { instructions: RegistryAccess.fixInstructions(report.status) }
1837
+ };
1838
+ }
1839
+ /** The census section. */
1840
+ static copyCensus(result) {
1841
+ const workspace = result.mode === DoctorMode.Self && result.workspaceConsumers !== null ? {
1842
+ workspaceRoot: result.workspaceConsumers.workspaceRoot,
1843
+ consumers: result.workspaceConsumers.consumers.map((consumer) => consumer.name)
1844
+ } : {};
1845
+ if (result.copyAudit === void 0) return {
1846
+ status: DoctorCopyCensusStatus.NotRun,
1847
+ originsProbed: 0,
1848
+ packagesProbed: 0,
1849
+ forkedPackages: 0,
1850
+ ...workspace
1851
+ };
1852
+ const forkedPackages = result.copyAudit.originsProbed === 0 ? 0 : DoctorAudit.forkedReports(result.copyAudit).length;
1853
+ let status = DoctorCopyCensusStatus.SingleCopies;
1854
+ if (result.copyAudit.originsProbed === 0) status = DoctorCopyCensusStatus.NothingProbed;
1855
+ else if (forkedPackages > 0) status = DoctorCopyCensusStatus.Forked;
1856
+ return {
1857
+ status,
1858
+ originsProbed: result.copyAudit.originsProbed,
1859
+ packagesProbed: result.copyAudit.packagesProbed,
1860
+ forkedPackages,
1861
+ ...workspace
1862
+ };
1863
+ }
1864
+ /** Every audited package, in the order of the text report: the forks first, then the staleness verdicts. */
1865
+ static packages(result) {
1866
+ const packages = [];
1867
+ if (result.copyAudit !== void 0 && result.copyAudit.originsProbed > 0) {
1868
+ const gating = new Set(DoctorAudit.gatingForks(result));
1869
+ for (const report of DoctorAudit.forkedReports(result.copyAudit)) packages.push(DoctorJsonReport.forkEntry(report, gating.has(report)));
1870
+ }
1871
+ if (result.mode === DoctorMode.App) for (const report of result.boundaries) packages.push(DoctorJsonReport.staleness(report, false));
1872
+ else if (result.selfReport !== null) packages.push(DoctorJsonReport.staleness(result.selfReport, true));
1873
+ return packages;
1874
+ }
1875
+ /** One fork entry. */
1876
+ static forkEntry(report, failing) {
1877
+ return {
1878
+ name: report.packageName,
1879
+ status: DoctorPackageStatus.Fork,
1880
+ reason: `${String(report.copies.length)} physical copies. Class identity is the DI token, so these bind nominally yet split at runtime.`,
1881
+ hint: DoctorJsonReport.forkHint,
1882
+ self: false,
1883
+ failing,
1884
+ copies: report.copies.map((copy) => ({
1885
+ directory: copy.directory,
1886
+ ...copy.version === void 0 ? {} : { version: copy.version },
1887
+ origins: copy.origins
1888
+ }))
1889
+ };
1890
+ }
1891
+ /** One staleness entry. `self` marks the package that `--self` audits. */
1892
+ static staleness(report, self) {
1893
+ const freshness = report;
1894
+ const repack = DoctorJsonReport.repackCommand(report.packageDirectory);
1895
+ const verdict = DoctorJsonReport.verdictOf(report.status, self ? "Repack (`vp pack`) before publishing or consuming it." : `Rebuild it (\`vp pack\` in ${report.packageDirectory}) before trusting this app's build.`, repack);
1896
+ return {
1897
+ name: report.specifier,
1898
+ status: verdict.status,
1899
+ reason: DoctorJsonReport.reasonOf(freshness),
1900
+ ...verdict.hint === void 0 ? {} : { hint: verdict.hint },
1901
+ ...verdict.fix === void 0 ? {} : { fix: verdict.fix },
1902
+ failing: verdict.failing,
1903
+ self,
1904
+ ...report.packageVersion === void 0 ? {} : { version: report.packageVersion },
1905
+ directory: report.packageDirectory,
1906
+ ...DoctorJsonReport.freshnessDetails(freshness)
1907
+ };
1908
+ }
1909
+ /**
1910
+ * The status, the hint, the fix and whether it fails the run, for a verdict: a stale package fails and is
1911
+ * repacked, a package with no provenance is repacked, and the others need nothing.
1912
+ *
1913
+ * @param status - The status of a {@link LibraryFreshness} verdict.
1914
+ * @param staleHint - The hint of a stale package.
1915
+ * @param repack - The command that repacks the package.
1916
+ * @returns The fields.
1917
+ */
1918
+ static verdictOf(status, staleHint, repack) {
1919
+ switch (status) {
1920
+ case LibraryFreshnessStatus.Fresh: return {
1921
+ status: DoctorPackageStatus.Fresh,
1922
+ failing: false
1923
+ };
1924
+ case LibraryFreshnessStatus.Stale: return {
1925
+ status: DoctorPackageStatus.Stale,
1926
+ hint: staleHint,
1927
+ fix: repack,
1928
+ failing: true
1929
+ };
1930
+ case LibraryFreshnessStatus.NoProvenance: return {
1931
+ status: DoctorPackageStatus.NoProvenance,
1932
+ hint: "Repack (`vp pack`) to enable the staleness check.",
1933
+ fix: repack,
1934
+ failing: false
1935
+ };
1936
+ case LibraryFreshnessStatus.NoSource: return {
1937
+ status: DoctorPackageStatus.NoSource,
1938
+ failing: false
1939
+ };
1940
+ }
1941
+ }
1942
+ /**
1943
+ * The fields of a verdict that the JSON documents print beside its status: the hashes, the missing and the
1944
+ * current source, and the stale build entries. A field the verdict does not have is left out.
1945
+ *
1946
+ * @param report - A {@link LibraryFreshness} verdict.
1947
+ * @returns The fields.
1948
+ */
1949
+ static freshnessDetails(report) {
1950
+ return {
1951
+ ...report.manifestSourceHash === void 0 ? {} : { manifestSourceHash: report.manifestSourceHash },
1952
+ ...report.recomputedSourceHash === void 0 ? {} : { recomputedSourceHash: report.recomputedSourceHash },
1953
+ ...report.missingSource === void 0 ? {} : { missingSource: report.missingSource },
1954
+ ...report.currentSource === void 0 ? {} : { currentSource: report.currentSource },
1955
+ ...report.manifestBundleInputHash === void 0 ? {} : { manifestBundleInputHash: report.manifestBundleInputHash },
1956
+ ...report.recomputedBundleInputHash === void 0 ? {} : { recomputedBundleInputHash: report.recomputedBundleInputHash },
1957
+ ...report.missingBundleInputs === void 0 ? {} : { missingBundleInputs: report.missingBundleInputs },
1958
+ ...report.staleBuildEntries === void 0 ? {} : { staleBuildEntries: report.staleBuildEntries }
1959
+ };
1960
+ }
1961
+ /**
1962
+ * Why a package has the status of its verdict, as one sentence.
1963
+ *
1964
+ * @param report - A {@link LibraryFreshness} verdict.
1965
+ * @returns The reason.
1966
+ */
1967
+ static reasonOf(report) {
1968
+ switch (report.status) {
1969
+ case LibraryFreshnessStatus.Fresh: return report.manifestSourceHash === void 0 ? "The pack inputs file matches the local build inputs." : `The packed manifest matches the local source (${report.manifestSourceHash}).`;
1970
+ case LibraryFreshnessStatus.Stale: return DoctorJsonReport.staleReason(report);
1971
+ case LibraryFreshnessStatus.NoProvenance: return "The packed manifest predates the provenance stamp, so nothing was compared.";
1972
+ case LibraryFreshnessStatus.NoSource: return "A published package with no local src/, so its provenance is display-only.";
1973
+ }
1974
+ }
1975
+ /**
1976
+ * The shell command that repacks a package.
1977
+ *
1978
+ * @param packageDirectory - The package root.
1979
+ * @returns `cd <directory> && vp pack`, with the directory quoted when it needs quotes.
1980
+ */
1981
+ static repackCommand(packageDirectory) {
1982
+ return `cd ${DoctorJsonReport.shellWord(packageDirectory)} && vp pack`;
1983
+ }
1984
+ /** Why a stale report is stale, in the words and the order of the text report. */
1985
+ static staleReason(report) {
1986
+ const entries = LibraryFreshness.staleBuildEntriesClause(report);
1987
+ if (report.currentSource !== void 0) return `The source folders it was packed from (${(report.missingSource ?? []).join(", ")}) no longer exist, and its source is now in ${report.currentSource}${entries}.`;
1988
+ if (report.missingSource !== void 0 && report.missingSource.length > 0) return `The source it was packed from (${report.missingSource.join(", ")}) is no longer on disk${entries}.`;
1989
+ if (report.recomputedSourceHash !== void 0 && report.manifestSourceHash !== void 0 && report.recomputedSourceHash !== report.manifestSourceHash) return DoctorJsonReport.sourceChangedReason(report);
1990
+ if (report.staleBuildEntries !== void 0 && report.staleBuildEntries.length > 0) {
1991
+ const reason = LibraryFreshness.staleBuildEntriesReason(report.staleBuildEntries);
1992
+ return `${reason.charAt(0).toUpperCase()}${reason.slice(1)}.`;
1993
+ }
1994
+ if (report.missingBundleInputs !== void 0) return `Files its build read outside its source (${report.missingBundleInputs.join(", ")}) are no longer on disk.`;
1995
+ if (report.recomputedBundleInputHash !== void 0) return `A file its build read outside its source changed: the build inputs (${report.recomputedBundleInputHash}) no longer match the packed manifest (${String(report.manifestBundleInputHash)}).`;
1996
+ return DoctorJsonReport.sourceChangedReason(report);
1997
+ }
1998
+ /** The reason for a source fingerprint that no longer matches the manifest. */
1999
+ static sourceChangedReason(report) {
2000
+ return `Its src/ (${String(report.recomputedSourceHash)}) no longer matches the packed manifest (${String(report.manifestSourceHash)})${LibraryFreshness.staleBuildEntriesClause(report)}.`;
2001
+ }
2002
+ /** The registry diagnostic, when the probe failed. */
2003
+ static registryDiagnostics(result) {
2004
+ if (!DoctorAudit.isRegistryFailed(result)) return [];
2005
+ return [CliDiagnostics.error(DoctorDiagnosticCode.RegistryAccessFailed, `@heximon/* cannot be read from GitHub Packages (${result.registry.status}).`, { hint: RegistryAccess.fixInstructions(result.registry.status) })];
2006
+ }
2007
+ /** The advisory diagnostics of the mode: the census, the tracing check, the roster and the sidecar. */
2008
+ static modeDiagnostics(result) {
2009
+ const diagnostics = [];
2010
+ if (result.mode === DoctorMode.Self && result.configDiagnostic !== void 0) diagnostics.push(result.configDiagnostic);
2011
+ if (result.mode === DoctorMode.Self && result.closureReport !== null) {
2012
+ for (const pivot of result.closureReport.uncovered) diagnostics.push(DoctorJsonReport.warning(DoctorDiagnosticCode.PeerPivotUndeclared, `${pivot.packageName} is a peer of ${pivot.declaredBy.join(", ")}, and this package's package.json declares it nowhere.`, "Equalize the installed peer sets of this package's consumers, or acknowledge the name in `expectedOpenPeers` (heximon.config.ts)."));
2013
+ for (const name of result.closureReport.staleExpectedOpen) diagnostics.push(DoctorJsonReport.warning(DoctorDiagnosticCode.StalePeerAcknowledgement, `expectedOpenPeers names ${name}, which matches no undeclared peer.`, "Remove the name from `expectedOpenPeers` (heximon.config.ts)."));
2014
+ }
2015
+ if (result.copyAudit !== void 0 && result.copyAudit.originsProbed === 0) diagnostics.push(DoctorJsonReport.warning(DoctorDiagnosticCode.CopiesNotProbed, "The copy census found no resolvable node_modules from this directory, so no fork check ran.", "Install the dependencies, then run heximon doctor again."));
2016
+ if (result.mode === DoctorMode.App && result.platformTracing.status === PlatformTracingStatus.NotCarried) diagnostics.push(DoctorJsonReport.tracingDiagnostic(result.platformTracing));
2017
+ return diagnostics;
2018
+ }
2019
+ /** One diagnostic per failing or forked package, in package order. */
2020
+ static packageDiagnostics(packages) {
2021
+ const diagnostics = [];
2022
+ for (const entry of packages) if (entry.status === DoctorPackageStatus.Stale) diagnostics.push(CliDiagnostics.error(DoctorDiagnosticCode.StalePackage, `${entry.name}: ${entry.reason}`, entry.hint === void 0 ? {} : { hint: entry.hint }));
2023
+ else if (entry.status === DoctorPackageStatus.Fork) {
2024
+ const message = `${entry.name} resolves to ${String(entry.copies?.length ?? 0)} physical copies.`;
2025
+ diagnostics.push(entry.failing ? CliDiagnostics.error(DoctorDiagnosticCode.PackageFork, message, { hint: DoctorJsonReport.forkHint }) : DoctorJsonReport.warning(DoctorDiagnosticCode.PackageFork, message, DoctorJsonReport.forkHint));
2026
+ }
2027
+ return diagnostics;
2028
+ }
2029
+ /** The platform-tracing warning. */
2030
+ static tracingDiagnostic(report) {
2031
+ const target = report.wranglerConfigPath ?? "the committed wrangler config";
2032
+ const hints = [];
2033
+ if (report.tracingNotEnabled) hints.push(`Add "observability": { "traces": { "enabled": true } } to ${target} by hand.`);
2034
+ if (report.droppedSamplerRatio !== void 0) hints.push(`Set observability.traces.head_sampling_rate to ${String(report.droppedSamplerRatio)} in ${target} by hand.`);
2035
+ return DoctorJsonReport.warning(DoctorDiagnosticCode.PlatformTracingNotCarried, "otel.exporter \"platform\" writes its tracing directives into a generated wrangler config, but this host-managed Cloudflare build produced none, so the deployed worker does not get them.", hints.join(" "));
2036
+ }
2037
+ /** One warning diagnostic. */
2038
+ static warning(code, message, hint) {
2039
+ return {
2040
+ severity: DiagnosticSeverity.Warning,
2041
+ code,
2042
+ message,
2043
+ ...hint === "" ? {} : { hint }
2044
+ };
2045
+ }
2046
+ /** One pivot, copied out of the deploy report. */
2047
+ static pivot(pivot) {
2048
+ return {
2049
+ packageName: pivot.packageName,
2050
+ declaredBy: pivot.declaredBy
2051
+ };
2052
+ }
2053
+ /** A path as one POSIX shell word: as it is when it is plain, else in single quotes. */
2054
+ static shellWord(path) {
2055
+ return DoctorJsonReport.plainShellWord.test(path) ? path : `'${path.replaceAll("'", "'\\''")}'`;
2056
+ }
2057
+ };
2058
+ //#endregion
2059
+ //#region src/doctor-command.ts
2060
+ /** The text renderer behind {@link doctorCommand}: every audit of a {@link DoctorAuditResult} as clack lines. */
2061
+ var DoctorReport = class DoctorReport {
2062
+ /**
2063
+ * Print the whole report of one run, ending with its summary as the outro.
2064
+ *
2065
+ * @param result - The audit result.
2066
+ */
2067
+ static render(result) {
2068
+ if (DoctorAudit.isRegistryFailed(result)) log.error(RegistryAccess.fixInstructions(result.registry.status));
2069
+ else log.success("✔ GitHub Packages — @heximon/* readable");
2070
+ if (result.mode === DoctorMode.Self) {
2071
+ if (result.configError !== void 0) log.warn(`heximon.config.ts could not be loaded, so its \`expectedOpenPeers\` acknowledgements are not applied — every open pivot below is reported: ${result.configError}`);
2072
+ DoctorReport.logPeerClosureRoster(result.closureReport);
2073
+ if (result.copyAudit === void 0) log.info("Package copies — no pnpm workspace found from this directory; census not run.");
2074
+ else DoctorReport.logCopyAudit(result.copyAudit, DoctorAudit.gatingForks(result));
2075
+ if (result.selfReport !== null) DoctorReport.logBoundary(result.selfReport, true);
2076
+ } else {
2077
+ DoctorReport.logCopyAudit(result.copyAudit);
2078
+ DoctorReport.logPlatformTracing(result.platformTracing);
2079
+ for (const report of result.boundaries) DoctorReport.logBoundary(report);
2080
+ }
2081
+ outro(DoctorAudit.summary(result));
2082
+ }
2083
+ /** Renders one audited boundary as a status line. `self` reframes the wording for the current package. */
2084
+ static boundaryLine(report, self = false) {
2085
+ const name = self ? `${report.specifier} (this package)` : report.specifier;
2086
+ const version = report.packageVersion === void 0 ? "" : ` v${report.packageVersion}`;
2087
+ switch (report.status) {
2088
+ case BoundaryProvenanceStatus.Fresh:
2089
+ if (report.manifestSourceHash === void 0) return `✔ ${name}${version} — dist is current with its build inputs`;
2090
+ return self ? `✔ ${name}${version} — dist is current with src/ (${report.manifestSourceHash})` : `✔ ${name}${version} — up to date (${report.manifestSourceHash})`;
2091
+ case BoundaryProvenanceStatus.Stale: return self && report.missingSource === void 0 && !LibraryFreshness.isBundleStale(report) ? `✖ ${name}${version} — STALE: your src/ (${report.recomputedSourceHash}) no longer matches your own dist manifest (${report.manifestSourceHash})${LibraryFreshness.staleBuildEntriesClause(report)}. Repack (\`vp pack\`) before publishing or consuming it.` : LibraryFreshness.staleLine(self ? name : report.specifier, report);
2092
+ case BoundaryProvenanceStatus.NoProvenance: return `● ${name}${version} — manifest predates the provenance stamp; repack to enable the check.`;
2093
+ case BoundaryProvenanceStatus.NoSource: return `● ${name}${version} — published package (no local src/); provenance is display-only.`;
2094
+ }
2095
+ }
2096
+ /** Logs one report at the severity its status warrants (error for stale, success for fresh, info otherwise). */
2097
+ static logBoundary(report, self = false) {
2098
+ const line = DoctorReport.boundaryLine(report, self);
2099
+ if (report.status === BoundaryProvenanceStatus.Stale) log.error(line);
2100
+ else if (report.status === BoundaryProvenanceStatus.Fresh) log.success(line);
2101
+ else log.info(line);
2102
+ }
2103
+ /**
2104
+ * Logs the physical-copy census: a line per forked package naming every copy and who resolves to it, or one
2105
+ * summary line when nothing forked. The summary always states how many origins were probed — an audit that
2106
+ * could not look at anything must not read as a clean bill of health. `gatingForks`, given by `--self`, are
2107
+ * the forks that fail the run ({@link DoctorAudit.gatingForks}); every fork is printed either way.
2108
+ */
2109
+ static logCopyAudit(audit, gatingForks) {
2110
+ const forked = DoctorAudit.forkedReports(audit);
2111
+ if (audit.originsProbed === 0) {
2112
+ log.warn("Package copies — nothing probed (no resolvable node_modules from this directory), so no fork check ran.");
2113
+ return;
2114
+ }
2115
+ if (forked.length === 0) {
2116
+ log.success(`Package copies — ${String(audit.packagesProbed)} package(s) probed from ${String(audit.originsProbed)} origin(s); each resolves to a single copy.`);
2117
+ return;
1283
2118
  }
1284
2119
  for (const report of forked) {
1285
2120
  const copies = report.copies.map((copy) => {
@@ -1287,17 +2122,15 @@ var DoctorReport = class DoctorReport {
1287
2122
  }).join("\n");
1288
2123
  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).`);
1289
2124
  }
1290
- const gatingForks = gatingPackages === void 0 ? forked : forked.filter((report) => gatingPackages.has(report.packageName));
1291
- if (gatingPackages !== void 0 && gatingForks.length === 0) {
1292
- 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.`);
1293
- return false;
1294
- }
1295
- if (gatingPackages === void 0) {
2125
+ if (gatingForks === void 0) {
1296
2126
  log.info(`${String(forked.length)} forked package(s) across ${String(audit.originsProbed)} origin(s). Reported, not gated — this does not fail the command.`);
1297
- return true;
2127
+ return;
2128
+ }
2129
+ if (gatingForks.length === 0) {
2130
+ 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.`);
2131
+ return;
1298
2132
  }
1299
2133
  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.`);
1300
- return true;
1301
2134
  }
1302
2135
  /**
1303
2136
  * Logs the `@heximon/*` peer-pivot roster ({@link PeerClosureAuditor}): an info line for pivots acknowledged
@@ -1337,40 +2170,6 @@ var DoctorReport = class DoctorReport {
1337
2170
  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.`);
1338
2171
  log.warn(lines.join("\n"));
1339
2172
  }
1340
- /**
1341
- * The package names a fork of which is RUNTIME-reachable from the library at `rootDir`: the library itself,
1342
- * every package its installed `@heximon/*` dependency closure actually resolved
1343
- * ({@link PeerClosureReport.closurePackages}), every named peer pivot of that closure
1344
- * ({@link PeerClosureReport.pivots}), the curated {@link RuntimeSingletons}, and every installed boundary
1345
- * package ({@link PackageCopyAuditor.installedBoundaryPackages}) — a fork of a non-`@heximon` boundary
1346
- * package (e.g. a consumed `@acme/*` library) is exactly as much a DI-identity split as a fork of a
1347
- * framework package, since the rule is not scope-specific. A fork of a name outside this set is confined to
1348
- * build-time tooling (e.g. a devDependency-only bundler/pack plugin) and must be reported, never gated.
1349
- */
1350
- static runtimeReachablePackageNames(rootDir, closureReport) {
1351
- const names = new Set(RuntimeSingletons.all());
1352
- const libraryName = DoctorReport.readPackageName(rootDir);
1353
- if (libraryName !== null) names.add(libraryName);
1354
- for (const packageName of PackageCopyAuditor.installedBoundaryPackages(rootDir)) names.add(packageName);
1355
- if (closureReport !== null) {
1356
- for (const packageName of closureReport.closurePackages) names.add(packageName);
1357
- for (const pivot of closureReport.pivots) names.add(pivot.packageName);
1358
- }
1359
- return names;
1360
- }
1361
- /** The `name` field of a directory's `package.json`, or `null` when absent/unreadable/malformed. */
1362
- static readPackageName(directory) {
1363
- try {
1364
- const parsed = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
1365
- if (typeof parsed === "object" && parsed !== null && "name" in parsed) {
1366
- const { name } = parsed;
1367
- return typeof name === "string" ? name : null;
1368
- }
1369
- } catch {
1370
- return null;
1371
- }
1372
- return null;
1373
- }
1374
2173
  };
1375
2174
  /**
1376
2175
  * `heximon doctor` — three read-only audits.
@@ -1413,6 +2212,9 @@ var DoctorReport = class DoctorReport {
1413
2212
  * Compile-free and filesystem-local (the `heximon.config.ts` sidecar is loaded only to read
1414
2213
  * `expectedOpenPeers`); exits non-zero when this package forks a runtime-reachable package against a
1415
2214
  * workspace consumer OR its own dist is stale.
2215
+ *
2216
+ * `--json` prints the same audit result as one `DoctorJsonDocument` on stdout, with each finding as a
2217
+ * diagnostic. Both modes exit 1 exactly when the document has `ok: false`.
1416
2218
  */
1417
2219
  const doctorCommand = defineCommand({
1418
2220
  meta: {
@@ -1425,6 +2227,7 @@ const doctorCommand = defineCommand({
1425
2227
  description: "Project root directory.",
1426
2228
  default: "."
1427
2229
  },
2230
+ json: CommandOutput.jsonFlag,
1428
2231
  self: {
1429
2232
  type: "boolean",
1430
2233
  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.",
@@ -1432,112 +2235,512 @@ const doctorCommand = defineCommand({
1432
2235
  }
1433
2236
  },
1434
2237
  async run({ args }) {
1435
- const rootDir = resolve(process.cwd(), args.cwd);
1436
- intro(`heximon doctor — ${basename(rootDir)}`);
1437
- const registryReport = await new RegistryAccess().probe();
1438
- const registryFailed = registryReport.status !== RegistryAccessStatus.Ok;
1439
- if (registryFailed) log.error(RegistryAccess.fixInstructions(registryReport.status));
1440
- else log.success("✔ GitHub Packages — @heximon/* readable");
1441
- if (args.self) {
1442
- const expectedOpenPeers = await ConfigLoader.load(rootDir).then((sidecar) => sidecar?.expectedOpenPeers ?? []).catch((error) => {
1443
- 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)}`);
1444
- return [];
1445
- });
1446
- const closureReport = PeerClosureAuditor.audit(rootDir, expectedOpenPeers);
1447
- DoctorReport.logPeerClosureRoster(closureReport);
1448
- const workspaceConsumers = WorkspaceConsumerLocator.locate(rootDir);
1449
- let forked = false;
1450
- if (workspaceConsumers === null) log.info("Package copies — no pnpm workspace found from this directory; census not run.");
1451
- else forked = DoctorReport.logCopyAudit(PackageCopyAuditor.auditLibrary(rootDir, workspaceConsumers.consumers), DoctorReport.runtimeReachablePackageNames(rootDir, closureReport));
1452
- const selfReport = await BoundaryProvenanceAuditor.auditSelf(rootDir);
1453
- const outroParts = [];
1454
- let exitFailure = forked || registryFailed;
1455
- if (forked) {
1456
- const hasConsumers = (workspaceConsumers?.consumers.length ?? 0) > 0;
1457
- 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.");
2238
+ const commandOutput = CommandOutput.resolve("doctor", args);
2239
+ await commandOutput.run(async () => {
2240
+ const rootDir = ProjectRoot.resolve(args.cwd);
2241
+ if (!commandOutput.isJson()) intro(`heximon doctor — ${basename(rootDir)}`);
2242
+ const result = await DoctorAudit.run(rootDir, args.self);
2243
+ if (DoctorAudit.isFailure(result)) process.exitCode = 1;
2244
+ if (commandOutput.isJson()) commandOutput.emit(DoctorJsonReport.toJson(result));
2245
+ else DoctorReport.render(result);
2246
+ });
2247
+ }
2248
+ });
2249
+ //#endregion
2250
+ //#region src/freshness-json-document.ts
2251
+ /**
2252
+ * The verdict on one workspace package in a {@link FreshnessJsonDocument}. The first four are the staleness
2253
+ * values of `DoctorPackageStatus`.
2254
+ */
2255
+ const FreshnessPackageStatus = {
2256
+ /** Every recorded input of the package matches the files on disk. */
2257
+ Fresh: "fresh",
2258
+ /** The source or another recorded input changed since the last pack, or is gone. */
2259
+ Stale: "stale",
2260
+ /** The packed manifest has no provenance stamp, so nothing was compared. */
2261
+ NoProvenance: "no-provenance",
2262
+ /** None of the recorded source is on disk, so nothing was compared. */
2263
+ NoSource: "no-source",
2264
+ /** The check of the package failed, for example because its `heximon.config.ts` threw. */
2265
+ Error: "error"
2266
+ };
2267
+ /** The stable codes of the `heximon freshness` diagnostics. */
2268
+ const FreshnessDiagnosticCode = {
2269
+ /** A workspace package changed since its last pack. Error. */
2270
+ StalePackage: "freshness.stale-package",
2271
+ /** The check of one package failed; the message has the error. Error. */
2272
+ CheckFailed: "freshness.check-failed",
2273
+ /** The `pnpm-workspace.yaml` or the root `package.json` cannot be read or parsed. Error. */
2274
+ WorkspaceUnreadable: "freshness.workspace-unreadable"
2275
+ };
2276
+ //#endregion
2277
+ //#region src/workspace-packages.ts
2278
+ /** Where the member list of a {@link Workspace} comes from. */
2279
+ const WorkspaceKind = {
2280
+ /** The `packages:` list of a `pnpm-workspace.yaml`. */
2281
+ Pnpm: "pnpm",
2282
+ /** The `workspaces` field of a root `package.json` (npm, Yarn, Bun). */
2283
+ PackageJson: "package-json",
2284
+ /** No workspace file: the nearest package is the only member. */
2285
+ SinglePackage: "single-package",
2286
+ /** No workspace file and no `package.json` from the start directory up. */
2287
+ None: "none"
2288
+ };
2289
+ /**
2290
+ * Finds the workspace that holds a directory and lists its packages. It walks up to the nearest directory with a
2291
+ * `pnpm-workspace.yaml`, or with a `package.json` that has a `workspaces` field (an array, or Yarn's
2292
+ * `{ packages }` form). With neither, the nearest package is a one-package workspace. The root package is a
2293
+ * member when it has a `package.json`. A `!` pattern removes the members it matches, and `node_modules` is never
2294
+ * searched. A folder that two paths reach (a symlink) is one member, under the path that sorts first.
2295
+ */
2296
+ var WorkspacePackages = class WorkspacePackages {
2297
+ /** The package manifest file name. */
2298
+ static packageJsonFileName = "package.json";
2299
+ /** The folder name the member search never enters. */
2300
+ static installedDirectoryName = "node_modules";
2301
+ /**
2302
+ * Find the workspace of a directory.
2303
+ *
2304
+ * @param startDirectory - The directory to start the walk from.
2305
+ * @returns The workspace and its member packages.
2306
+ * @throws When a `pnpm-workspace.yaml` or a root `package.json` on the walk cannot be read or parsed.
2307
+ */
2308
+ static locate(startDirectory) {
2309
+ const start = resolve(startDirectory);
2310
+ let nearestPackage;
2311
+ let current = start;
2312
+ while (true) {
2313
+ if (existsSync(join(current, PnpmWorkspaceManifest.fileName))) {
2314
+ const manifestPath = join(current, PnpmWorkspaceManifest.fileName);
2315
+ const patterns = PnpmWorkspaceManifest.parsePackagePatterns(readFileSync(manifestPath, "utf8"), manifestPath);
2316
+ return WorkspacePackages.workspaceOf(current, WorkspaceKind.Pnpm, patterns);
1458
2317
  }
1459
- 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.");
1460
- else {
1461
- DoctorReport.logBoundary(selfReport, true);
1462
- if (selfReport.status === BoundaryProvenanceStatus.Stale) {
1463
- exitFailure = true;
1464
- outroParts.push(DoctorReport.selfProvenanceSummary(selfReport));
1465
- } else if (!forked) outroParts.push(DoctorReport.selfProvenanceSummary(selfReport));
2318
+ const packageJsonPath = join(current, WorkspacePackages.packageJsonFileName);
2319
+ if (existsSync(packageJsonPath)) {
2320
+ nearestPackage ??= current;
2321
+ const patterns = WorkspacePackages.workspacePatternsOf(packageJsonPath);
2322
+ if (patterns !== void 0) return WorkspacePackages.workspaceOf(current, WorkspaceKind.PackageJson, patterns);
2323
+ }
2324
+ const parent = dirname(current);
2325
+ if (parent === current) break;
2326
+ current = parent;
2327
+ }
2328
+ return nearestPackage === void 0 ? {
2329
+ root: start,
2330
+ kind: WorkspaceKind.None,
2331
+ members: []
2332
+ } : {
2333
+ root: nearestPackage,
2334
+ kind: WorkspaceKind.SinglePackage,
2335
+ members: [{
2336
+ directory: WorkspacePackages.realPathOf(nearestPackage),
2337
+ path: "."
2338
+ }]
2339
+ };
2340
+ }
2341
+ /**
2342
+ * The `workspaces` patterns of a `package.json`.
2343
+ *
2344
+ * @returns The patterns, or `undefined` when the file has no `workspaces` field in a known form.
2345
+ * @throws When the file is not valid JSON.
2346
+ */
2347
+ static workspacePatternsOf(packageJsonPath) {
2348
+ let parsed;
2349
+ try {
2350
+ parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
2351
+ } catch (cause) {
2352
+ const reason = cause instanceof Error ? cause.message : String(cause);
2353
+ throw new Error(`${packageJsonPath} could not be parsed as JSON: ${reason}`, { cause });
2354
+ }
2355
+ if (typeof parsed !== "object" || parsed === null || !("workspaces" in parsed)) return;
2356
+ const workspaces = parsed.workspaces;
2357
+ const patterns = typeof workspaces === "object" && workspaces !== null && "packages" in workspaces ? workspaces.packages : workspaces;
2358
+ return Array.isArray(patterns) ? patterns.filter((pattern) => typeof pattern === "string") : void 0;
2359
+ }
2360
+ /** The workspace at a root: the root package, then every directory the patterns name that has a package. */
2361
+ static workspaceOf(root, kind, patterns) {
2362
+ const includes = patterns.filter((pattern) => !pattern.trim().startsWith("!")).map((pattern) => WorkspacePackages.normalizedPattern(pattern)).filter((pattern) => pattern !== "");
2363
+ const excludes = patterns.filter((pattern) => pattern.trim().startsWith("!")).map((pattern) => WorkspacePackages.normalizedPattern(pattern.trim().slice(1))).filter((pattern) => pattern !== "");
2364
+ const members = (includes.length === 0 ? [] : globSync(includes, {
2365
+ cwd: root,
2366
+ exclude: (entry) => basename(String(entry)) === WorkspacePackages.installedDirectoryName
2367
+ })).map((match) => relative(root, resolve(root, match))).filter((path) => !WorkspacePackages.isExcluded(path, excludes)).sort();
2368
+ const workspaceMembers = [];
2369
+ const seen = /* @__PURE__ */ new Set();
2370
+ for (const memberPath of ["", ...members]) {
2371
+ const directory = resolve(root, memberPath);
2372
+ if (!WorkspacePackages.isPackageDirectory(directory)) continue;
2373
+ const realDirectory = WorkspacePackages.realPathOf(directory);
2374
+ if (!seen.has(realDirectory)) {
2375
+ seen.add(realDirectory);
2376
+ workspaceMembers.push({
2377
+ directory: realDirectory,
2378
+ path: memberPath || "."
2379
+ });
1466
2380
  }
1467
- if (exitFailure) process.exitCode = 1;
1468
- outro(outroParts.join(" "));
1469
- return;
1470
2381
  }
1471
- DoctorReport.logCopyAudit(PackageCopyAuditor.audit(rootDir));
1472
- DoctorReport.logPlatformTracing(await PlatformTracingAuditor.audit(rootDir));
1473
- const reports = await BoundaryProvenanceAuditor.audit(rootDir);
1474
- if (reports.length === 0) {
1475
- if (registryFailed) process.exitCode = 1;
1476
- outro("No consumed Heximon library packages found — no staleness audit to run.");
1477
- return;
2382
+ return {
2383
+ root,
2384
+ kind,
2385
+ members: workspaceMembers
2386
+ };
2387
+ }
2388
+ /** A pattern without a leading `./` and a trailing `/`, so it matches the paths the glob returns. */
2389
+ static normalizedPattern(pattern) {
2390
+ return pattern.trim().replace(/^(?:\.\/)+/, "").replace(/\/+$/, "");
2391
+ }
2392
+ /** Whether a `!` pattern removes a member path. A pattern that ends in a globstar also removes its folder. */
2393
+ static isExcluded(memberPath, excludes) {
2394
+ return excludes.some((pattern) => matchesGlob(memberPath, pattern) || matchesGlob(`${memberPath}/`, pattern));
2395
+ }
2396
+ /** Whether a path is a directory that holds a `package.json`. */
2397
+ static isPackageDirectory(directory) {
2398
+ try {
2399
+ return statSync(directory).isDirectory() && existsSync(join(directory, WorkspacePackages.packageJsonFileName));
2400
+ } catch {
2401
+ return false;
2402
+ }
2403
+ }
2404
+ /** The real path of a directory, or the path itself when it cannot be resolved. */
2405
+ static realPathOf(directory) {
2406
+ try {
2407
+ return realpathSync(directory);
2408
+ } catch {
2409
+ return directory;
2410
+ }
2411
+ }
2412
+ };
2413
+ //#endregion
2414
+ //#region src/workspace-freshness.ts
2415
+ /**
2416
+ * Judges every package of a workspace with {@link LibraryFreshness}: each package that has a Heximon manifest or
2417
+ * a `dist/heximon.inputs.json`. It compiles nothing and runs no other audit, so it only stats and hashes files.
2418
+ * A package whose check throws (a `heximon.config.ts` that fails to load) gets an `error` verdict.
2419
+ */
2420
+ var WorkspaceFreshness = class WorkspaceFreshness {
2421
+ /** The fix hint of a package whose check failed. */
2422
+ static checkFailedHint = "Fix the error, for example in the package's heximon.config.ts, then run heximon freshness again.";
2423
+ /**
2424
+ * Find the workspace of a directory and judge each of its packages.
2425
+ *
2426
+ * @param startDirectory - Any directory inside the workspace.
2427
+ * @returns The verdicts.
2428
+ * @throws CommandFailure with {@link FreshnessDiagnosticCode.WorkspaceUnreadable} when the workspace file
2429
+ * cannot be read.
2430
+ */
2431
+ static async run(startDirectory) {
2432
+ const workspace = WorkspaceFreshness.locate(startDirectory);
2433
+ const freshness = new LibraryFreshness();
2434
+ const packages = [];
2435
+ for (const member of workspace.members) {
2436
+ const identity = WorkspaceFreshness.identityOf(member);
2437
+ const recordProblem = LibraryFreshness.recordProblemOf(member.directory);
2438
+ if (recordProblem !== void 0) {
2439
+ packages.push({
2440
+ ...identity,
2441
+ error: recordProblem,
2442
+ recordUnreadable: true
2443
+ });
2444
+ continue;
2445
+ }
2446
+ try {
2447
+ const report = await freshness.inspect(member.directory);
2448
+ if (report !== null) packages.push({
2449
+ ...identity,
2450
+ report
2451
+ });
2452
+ } catch (error) {
2453
+ packages.push({
2454
+ ...identity,
2455
+ error: error instanceof Error ? error.message : String(error)
2456
+ });
2457
+ }
2458
+ }
2459
+ return {
2460
+ workspace,
2461
+ packages,
2462
+ skippedPackages: workspace.members.length - packages.length
2463
+ };
2464
+ }
2465
+ /** Whether a verdict fails the run: a stale package, or one whose check failed. */
2466
+ static isFailing(verdict) {
2467
+ return verdict.error !== void 0 || verdict.report?.status === LibraryFreshnessStatus.Stale;
2468
+ }
2469
+ /** Whether the run exits 1. */
2470
+ static isFailure(result) {
2471
+ return result.packages.some((verdict) => WorkspaceFreshness.isFailing(verdict));
2472
+ }
2473
+ /** The status of a verdict. */
2474
+ static statusOf(verdict) {
2475
+ return verdict.report === void 0 ? FreshnessPackageStatus.Error : verdict.report.status;
2476
+ }
2477
+ /**
2478
+ * The text line of one package: its name, version and path, its status, and for a stale package the
2479
+ * {@link LibraryFreshness.staleLine} that names the changed and missing inputs.
2480
+ *
2481
+ * @param verdict - The verdict.
2482
+ * @returns The line.
2483
+ */
2484
+ static lineOf(verdict) {
2485
+ const version = verdict.version === void 0 ? "" : ` v${verdict.version}`;
2486
+ const label = `${verdict.name}${version} (${verdict.path})`;
2487
+ const report = verdict.report;
2488
+ if (report === void 0) return `✖ ${label} — ERROR: ${String(verdict.error)}`;
2489
+ switch (report.status) {
2490
+ case LibraryFreshnessStatus.Fresh: return `✔ ${label} — fresh`;
2491
+ case LibraryFreshnessStatus.Stale: return LibraryFreshness.staleLine(label, {
2492
+ ...report,
2493
+ packageVersion: void 0
2494
+ }, { forApp: false });
2495
+ case LibraryFreshnessStatus.NoProvenance: return `● ${label} — no provenance: the manifest predates the provenance stamp. Repack to enable the check.`;
2496
+ case LibraryFreshnessStatus.NoSource: return `● ${label} — no source: none of its recorded source is on disk, so nothing was compared.`;
2497
+ }
2498
+ }
2499
+ /** The closing sentence of the run. */
2500
+ static summary(result) {
2501
+ const skipped = result.skippedPackages === 0 ? "" : ` ${String(result.skippedPackages)} package(s) without a record were skipped.`;
2502
+ if (result.packages.length === 0) return result.workspace.members.length === 0 ? `No package found from ${result.workspace.root}.` : `No workspace package has a freshness record.${skipped}`;
2503
+ const count = (status) => result.packages.filter((verdict) => WorkspaceFreshness.statusOf(verdict) === status).length;
2504
+ const parts = [`${String(count(FreshnessPackageStatus.Stale))} stale`, `${String(count(FreshnessPackageStatus.Fresh))} fresh`];
2505
+ const notCompared = count(FreshnessPackageStatus.NoProvenance) + count(FreshnessPackageStatus.NoSource);
2506
+ if (notCompared > 0) parts.push(`${String(notCompared)} not compared`);
2507
+ if (count(FreshnessPackageStatus.Error) > 0) parts.push(`${String(count(FreshnessPackageStatus.Error))} failed`);
2508
+ return `${String(result.packages.length)} package(s) with a record: ${parts.join(", ")}.${skipped}`;
2509
+ }
2510
+ /**
2511
+ * Build the `--json` document of one run.
2512
+ *
2513
+ * @param result - The verdicts.
2514
+ * @param staleOnly - List only the entries that fail the run.
2515
+ * @returns The document. Its `ok` is `false` exactly when {@link isFailure} is `true`.
2516
+ */
2517
+ static toJson(result, staleOnly) {
2518
+ const packages = result.packages.map((verdict) => WorkspaceFreshness.entryOf(verdict));
2519
+ return {
2520
+ ok: !WorkspaceFreshness.isFailure(result),
2521
+ diagnostics: packages.flatMap((entry) => WorkspaceFreshness.diagnosticsOf(entry)),
2522
+ workspaceRoot: result.workspace.root,
2523
+ workspaceKind: result.workspace.kind,
2524
+ packages: staleOnly ? packages.filter((entry) => entry.failing) : packages,
2525
+ skippedPackages: result.skippedPackages,
2526
+ summary: WorkspaceFreshness.summary(result)
2527
+ };
2528
+ }
2529
+ /** The workspace of a directory, with a file that cannot be read as the command's failure. */
2530
+ static locate(startDirectory) {
2531
+ try {
2532
+ return WorkspacePackages.locate(startDirectory);
2533
+ } catch (error) {
2534
+ throw new CommandFailure([CliDiagnostics.error(FreshnessDiagnosticCode.WorkspaceUnreadable, error instanceof Error ? error.message : String(error), { hint: "Fix the workspace file, then run heximon freshness again." })]);
2535
+ }
2536
+ }
2537
+ /** One JSON package entry. */
2538
+ static entryOf(verdict) {
2539
+ const location = {
2540
+ ...verdict.version === void 0 ? {} : { version: verdict.version },
2541
+ path: verdict.path,
2542
+ directory: verdict.directory
2543
+ };
2544
+ if (verdict.report === void 0) return {
2545
+ name: verdict.name,
2546
+ status: FreshnessPackageStatus.Error,
2547
+ reason: String(verdict.error),
2548
+ ...verdict.recordUnreadable === true ? {
2549
+ hint: `Repack it (\`vp pack\` in ${verdict.directory}) to write the record again.`,
2550
+ fix: DoctorJsonReport.repackCommand(verdict.directory)
2551
+ } : { hint: WorkspaceFreshness.checkFailedHint },
2552
+ failing: true,
2553
+ ...location
2554
+ };
2555
+ const fields = DoctorJsonReport.verdictOf(verdict.report.status, `Rebuild it (\`vp pack\` in ${verdict.directory}).`, DoctorJsonReport.repackCommand(verdict.directory));
2556
+ return {
2557
+ name: verdict.name,
2558
+ status: verdict.report.status,
2559
+ reason: DoctorJsonReport.reasonOf(verdict.report),
2560
+ ...fields.hint === void 0 ? {} : { hint: fields.hint },
2561
+ ...fields.fix === void 0 ? {} : { fix: fields.fix },
2562
+ failing: fields.failing,
2563
+ ...location,
2564
+ ...DoctorJsonReport.freshnessDetails(verdict.report)
2565
+ };
2566
+ }
2567
+ /** The error diagnostic of an entry that fails the run. */
2568
+ static diagnosticsOf(entry) {
2569
+ if (!entry.failing) return [];
2570
+ return [CliDiagnostics.error(entry.status === FreshnessPackageStatus.Error ? FreshnessDiagnosticCode.CheckFailed : FreshnessDiagnosticCode.StalePackage, `${entry.name} (${entry.path}): ${entry.reason}`, entry.hint === void 0 ? {} : { hint: entry.hint })];
2571
+ }
2572
+ /** The name, version and paths of a package, from its `package.json`. */
2573
+ static identityOf(member) {
2574
+ const directory = member.directory;
2575
+ let name;
2576
+ let version;
2577
+ try {
2578
+ const parsed = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
2579
+ if (typeof parsed === "object" && parsed !== null) {
2580
+ const fields = parsed;
2581
+ name = typeof fields.name === "string" ? fields.name : void 0;
2582
+ version = typeof fields.version === "string" ? fields.version : void 0;
2583
+ }
2584
+ } catch {}
2585
+ return {
2586
+ name: name ?? basename(directory),
2587
+ ...version === void 0 ? {} : { version },
2588
+ path: member.path,
2589
+ directory
2590
+ };
2591
+ }
2592
+ };
2593
+ //#endregion
2594
+ //#region src/freshness-command.ts
2595
+ /** The text renderer behind {@link freshnessCommand}: one clack line per package, then the summary. */
2596
+ var FreshnessReport = class {
2597
+ /**
2598
+ * Print the report of one run.
2599
+ *
2600
+ * @param result - The verdicts.
2601
+ * @param staleOnly - Print only the packages that fail the run.
2602
+ */
2603
+ static render(result, staleOnly) {
2604
+ for (const verdict of result.packages) {
2605
+ const failing = WorkspaceFreshness.isFailing(verdict);
2606
+ if (staleOnly && !failing) continue;
2607
+ const line = WorkspaceFreshness.lineOf(verdict);
2608
+ if (failing) log.error(line);
2609
+ else if (verdict.report?.status === LibraryFreshnessStatus.Fresh) log.success(line);
2610
+ else log.info(line);
2611
+ }
2612
+ outro(WorkspaceFreshness.summary(result));
2613
+ }
2614
+ };
2615
+ /**
2616
+ * `heximon freshness` — lists every package of the workspace that has a freshness record, with its status. It
2617
+ * finds the workspace root from any directory inside it, judges each package with the rule of `heximon doctor`
2618
+ * ({@link LibraryFreshness}), and exits 1 when a package is stale or its check failed. It compiles nothing and
2619
+ * runs no other audit.
2620
+ */
2621
+ const freshnessCommand = defineCommand({
2622
+ meta: {
2623
+ name: "freshness",
2624
+ description: "List every workspace package with a freshness record (a Heximon manifest or dist/heximon.inputs.json), its status, and the input that made it stale. Works from any directory of the workspace. Exits 1 when a package is stale."
2625
+ },
2626
+ args: {
2627
+ cwd: {
2628
+ type: "string",
2629
+ description: "A directory inside the workspace; the command walks up to the workspace root.",
2630
+ default: "."
2631
+ },
2632
+ json: CommandOutput.jsonFlag,
2633
+ stale: {
2634
+ type: "boolean",
2635
+ description: "List only the packages that fail the run: the stale ones, and the ones whose check failed.",
2636
+ default: false
1478
2637
  }
1479
- for (const report of reports) DoctorReport.logBoundary(report);
1480
- const staleCount = reports.filter((report) => report.status === BoundaryProvenanceStatus.Stale).length;
1481
- if (staleCount > 0 || registryFailed) process.exitCode = 1;
1482
- if (staleCount > 0) outro(`${staleCount} stale package(s) — rebuild them before trusting this app's build.`);
1483
- else outro(`${reports.length} package(s) audited — no staleness detected.`);
2638
+ },
2639
+ async run({ args }) {
2640
+ const commandOutput = CommandOutput.resolve("freshness", args);
2641
+ await commandOutput.run(async () => {
2642
+ const startDirectory = ProjectRoot.resolve(args.cwd);
2643
+ const result = await WorkspaceFreshness.run(startDirectory);
2644
+ const staleOnly = args.stale === true;
2645
+ if (WorkspaceFreshness.isFailure(result)) process.exitCode = 1;
2646
+ if (commandOutput.isJson()) commandOutput.emit(WorkspaceFreshness.toJson(result, staleOnly));
2647
+ else {
2648
+ intro(`heximon freshness — ${basename(result.workspace.root)}`);
2649
+ FreshnessReport.render(result, staleOnly);
2650
+ }
2651
+ });
1484
2652
  }
1485
2653
  });
1486
2654
  //#endregion
2655
+ //#region src/graph-json-document.ts
2656
+ /** The stable codes of the failures `heximon graph` raises itself. */
2657
+ const GraphDiagnosticCode = {
2658
+ /** The compile threw before the command could read a module graph. */
2659
+ ReadFailed: "graph.read-failed",
2660
+ /** The `--out` file could not be written. */
2661
+ OutWriteFailed: "graph.out-write-failed"
2662
+ };
2663
+ //#endregion
1487
2664
  //#region src/json-output-target.ts
1488
2665
  /**
1489
- * Where a JSON-emitting inspection command (`routes`, `graph`) sends its payload: an `--out` file, stdout under
1490
- * `--json`, or nowhere when neither flag was passed. One owner for that whole decision — both commands hand it
1491
- * a payload and never branch on the flags themselves.
2666
+ * The `--out` file of an inspection command (`routes`, `graph`): it gets the same document the command prints
2667
+ * on stdout under `--json`. {@link CommandOutput} owns stdout; this class owns only the file.
1492
2668
  */
1493
2669
  var JsonOutputTarget = class JsonOutputTarget {
1494
2670
  filePath;
1495
- toStdout;
1496
2671
  /**
1497
2672
  * @param filePath - The absolute `--out` path, or `undefined` when no file was requested.
1498
- * @param toStdout - Whether the payload is printed on stdout instead.
1499
2673
  */
1500
- constructor(filePath, toStdout) {
2674
+ constructor(filePath) {
1501
2675
  this.filePath = filePath;
1502
- this.toStdout = toStdout;
1503
2676
  }
1504
2677
  /**
1505
- * Resolve the destination. `--out` wins and redirects the payload off stdout into a file; a relative path
1506
- * resolves against the current directory (where the command was typed), not the `--cwd` project root.
2678
+ * Resolve the file. A relative path resolves against the current directory (where the command was typed),
2679
+ * not the `--cwd` project root.
1507
2680
  *
1508
2681
  * @param outOption - The raw `--out` argument, when the caller passed one.
1509
- * @param asJson - Whether `--json` was passed, i.e. whether stdout should carry the payload.
2682
+ * @returns The target; a blank or missing `--out` requests no file.
1510
2683
  */
1511
- static resolve(outOption, asJson) {
2684
+ static resolve(outOption) {
1512
2685
  const requested = outOption?.trim();
1513
- const filePath = requested === void 0 || requested === "" ? void 0 : resolve(process.cwd(), requested);
1514
- return new JsonOutputTarget(filePath, filePath === void 0 && asJson);
2686
+ return new JsonOutputTarget(requested === void 0 || requested === "" ? void 0 : resolve(process.cwd(), requested));
1515
2687
  }
1516
- /** Whether the payload goes to a file — the caller's cue to report where it landed. */
2688
+ /** Whether a file was requested — the caller's cue to report where it landed. */
1517
2689
  isFile() {
1518
2690
  return this.filePath !== void 0;
1519
2691
  }
1520
2692
  /**
1521
- * Emit the payload. A file gets indented JSON with its parent directory created, so a run can target a
1522
- * not-yet-existing CI artifact directory and consecutive runs diff line by line; stdout gets the compact
1523
- * single line `jq` and pipes expect.
2693
+ * Run the body of a command. A failure it throws is written to the file as a `{ ok: false, diagnostics }`
2694
+ * document before it propagates, so the file never keeps the document of an earlier run.
1524
2695
  *
1525
- * @returns `false` only when a requested file write failed (the reason is reported on stderr) — the caller's
1526
- * cue to exit non-zero. A target that emits nowhere reports success.
2696
+ * @param body - The work of the command.
1527
2697
  */
1528
- emit(payload) {
1529
- if (this.filePath === void 0) {
1530
- if (this.toStdout) process.stdout.write(`${JSON.stringify(payload)}\n`);
1531
- return true;
2698
+ async run(body) {
2699
+ try {
2700
+ await body();
2701
+ } catch (error) {
2702
+ this.write({
2703
+ ok: false,
2704
+ diagnostics: error instanceof CommandFailure ? error.diagnostics : [CliDiagnostics.error(CliDiagnosticCode.UnexpectedError, error instanceof Error ? error.message : String(error))]
2705
+ });
2706
+ throw error;
1532
2707
  }
2708
+ }
2709
+ /**
2710
+ * Write the document to the file, and record it as the stdout document of the run. When the write fails, the
2711
+ * recorded document gets `ok: false` and one more error diagnostic that names the file.
2712
+ *
2713
+ * @param document - The document of the run.
2714
+ * @param commandOutput - The output owner of the run.
2715
+ * @param writeFailureCode - The diagnostic code of a failed write.
2716
+ * @returns Whether the file was written; `true` when no file was requested.
2717
+ */
2718
+ publish(document, commandOutput, writeFailureCode) {
2719
+ const failure = this.write(document);
2720
+ commandOutput.emit(failure === void 0 ? document : {
2721
+ ...document,
2722
+ ok: false,
2723
+ diagnostics: [...document.diagnostics, CliDiagnostics.error(writeFailureCode, `Could not write ${this.filePath}: ${failure}`, { option: "--out" })]
2724
+ });
2725
+ return failure === void 0;
2726
+ }
2727
+ /**
2728
+ * Write indented JSON to the file, with its parent directory created, so a run can target a CI artifact
2729
+ * directory that does not exist yet and consecutive runs diff line by line.
2730
+ *
2731
+ * @returns The reason of a failed write (also written to stderr), or `undefined` when the write worked or
2732
+ * no file was requested.
2733
+ */
2734
+ write(document) {
2735
+ if (this.filePath === void 0) return;
1533
2736
  try {
1534
2737
  mkdirSync(dirname(this.filePath), { recursive: true });
1535
- writeFileSync(this.filePath, `${JSON.stringify(payload, null, 2)}\n`);
1536
- return true;
2738
+ writeFileSync(this.filePath, `${JSON.stringify(document, null, 2)}\n`);
2739
+ return;
1537
2740
  } catch (error) {
1538
2741
  const reason = error instanceof Error ? error.message : String(error);
1539
2742
  process.stderr.write(`Could not write ${this.filePath} — ${reason}\n`);
1540
- return false;
2743
+ return reason;
1541
2744
  }
1542
2745
  }
1543
2746
  };
@@ -1572,16 +2775,13 @@ var ModuleGraphReport = class ModuleGraphReport {
1572
2775
  return lines.join("\n");
1573
2776
  }
1574
2777
  /**
1575
- * The serializable `--json` shape: the raw module view plus (when present) the boundary summaries.
2778
+ * The graph fields of the `--json` document: the raw module view plus (when present) the boundary summaries.
1576
2779
  *
1577
2780
  * @param view - The compile's module-graph projection.
1578
2781
  * @param boundaries - The cross-package boundary summaries, when `--boundaries` asked for them.
1579
- * @param ok - Whether the compile that produced `view` was clean. A graph built from a failed compile has
1580
- * unresolved edges, so a consumer reading only `ok` must not read it as a complete answer.
1581
2782
  */
1582
- static toJson(view, boundaries = [], ok = true) {
2783
+ static toJson(view, boundaries = []) {
1583
2784
  return {
1584
- ok,
1585
2785
  modules: view.modules,
1586
2786
  ...boundaries.length > 0 ? { boundaries } : {}
1587
2787
  };
@@ -1647,11 +2847,7 @@ const graphCommand = defineCommand({
1647
2847
  description: "The heximon.config.ts `$env` name to resolve (matches `vp dev`). Default development.",
1648
2848
  default: "development"
1649
2849
  },
1650
- json: {
1651
- type: "boolean",
1652
- description: "Emit the graph as JSON on stdout instead of a tree.",
1653
- default: false
1654
- },
2850
+ json: CommandOutput.jsonFlag,
1655
2851
  boundaries: {
1656
2852
  type: "boolean",
1657
2853
  description: "Also list the cross-package boundary nodes the app composes.",
@@ -1659,7 +2855,7 @@ const graphCommand = defineCommand({
1659
2855
  },
1660
2856
  out: {
1661
2857
  type: "string",
1662
- description: "Write the JSON payload to this file instead of stdout, creating missing parent directories — the CI-artifact target."
2858
+ description: "Also write the JSON document to this file, creating missing parent directories — the CI-artifact target."
1663
2859
  },
1664
2860
  src: {
1665
2861
  type: "string",
@@ -1669,73 +2865,85 @@ const graphCommand = defineCommand({
1669
2865
  app: CompileModeResolver.flags.app
1670
2866
  },
1671
2867
  async run({ args }) {
1672
- const rootDir = resolve(process.cwd(), args.cwd);
1673
- const asJson = args.json;
1674
- const withBoundaries = args.boundaries;
1675
- const jsonOutput = JsonOutputTarget.resolve(args.out, asJson);
1676
- const sourceDirectories = SourceDirectories.resolve(rootDir, args.src);
1677
- const resolved = CompileModeResolver.resolve("graph", rootDir, args, sourceDirectories);
1678
- if (resolved === void 0) return;
1679
- if (!asJson) intro(`heximon graph — ${basename(rootDir)} · ${CompileModeResolver.stamp(resolved)}`);
1680
- let output;
1681
- try {
1682
- const session = await ProjectCompileSession.open({
1683
- rootDir,
1684
- environment: args.env,
1685
- ...sourceDirectories === void 0 ? {} : { sourceDirectories }
2868
+ const commandOutput = CommandOutput.resolve("graph", args);
2869
+ const outFile = JsonOutputTarget.resolve(args.out);
2870
+ await commandOutput.run(async () => {
2871
+ const rootDir = ProjectRoot.resolve(args.cwd);
2872
+ await outFile.run(async () => {
2873
+ const sourceDirectories = SourceDirectories.resolve(rootDir, args.src);
2874
+ const resolved = CompileModeResolver.resolve(rootDir, args, sourceDirectories);
2875
+ if (!commandOutput.isJson()) intro(`heximon graph — ${basename(rootDir)} · ${CompileModeResolver.stamp(resolved)}`);
2876
+ let output;
2877
+ try {
2878
+ const session = await ProjectCompileSession.open({
2879
+ rootDir,
2880
+ environment: args.env,
2881
+ ...sourceDirectories === void 0 ? {} : { sourceDirectories }
2882
+ });
2883
+ output = resolved.mode === CompileMode.Library ? session.compileLibrary() : session.compile();
2884
+ } catch (error) {
2885
+ GraphCommand.reportFailure(error instanceof CommandFailure ? error.diagnostics : [CliDiagnostics.error(GraphDiagnosticCode.ReadFailed, error instanceof Error ? error.message : String(error))], commandOutput, outFile);
2886
+ return;
2887
+ }
2888
+ GraphCommand.report(output, {
2889
+ commandOutput,
2890
+ outFile,
2891
+ withBoundaries: args.boundaries,
2892
+ libraryMode: resolved.mode === CompileMode.Library
2893
+ });
1686
2894
  });
1687
- output = resolved.mode === CompileMode.Library ? session.compileLibrary() : session.compile();
1688
- } catch (error) {
1689
- GraphCommand.reportFailure(error, asJson, jsonOutput);
1690
- process.exitCode = 1;
1691
- return;
1692
- }
1693
- if (!GraphCommand.report(output, {
1694
- asJson,
1695
- withBoundaries,
1696
- jsonOutput,
1697
- libraryMode: resolved.mode === CompileMode.Library
1698
- })) process.exitCode = 1;
2895
+ });
1699
2896
  }
1700
2897
  });
1701
- /** Output helpers for {@link graphCommand}: the module tree / JSON body and the boundary projection. */
2898
+ /** Output helpers for {@link graphCommand}: the module tree / JSON document and the boundary projection. */
1702
2899
  var GraphCommand = class GraphCommand {
1703
2900
  /**
1704
- * Print the resolved graph — the JSON payload to its target, and a module-nested tree unless `--json`.
2901
+ * Report the resolved graph: the JSON document to stdout under `--json` and to the `--out` file, and a
2902
+ * module-nested tree unless `--json`. A failed compile or a failed `--out` write sets exit status 1.
1705
2903
  *
1706
- * @returns Whether the run succeeded; `false` for a failed compile or a failed `--out` write.
2904
+ * @param output - The compile facts.
2905
+ * @param options - The output owners, and what to print.
1707
2906
  */
1708
2907
  static report(output, options) {
1709
2908
  const view = output.moduleGraph ?? { modules: [] };
1710
2909
  const boundaries = options.withBoundaries ? GraphCommand.boundarySummaries(output) : [];
1711
2910
  const errors = output.diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error).length;
1712
- const written = options.jsonOutput.emit({
1713
- ...ModuleGraphReport.toJson(view, boundaries, errors === 0),
2911
+ const document = {
2912
+ ok: errors === 0,
2913
+ diagnostics: CliDiagnostics.fromCompiler(output.diagnostics),
2914
+ ...ModuleGraphReport.toJson(view, boundaries),
1714
2915
  errors
1715
- });
1716
- if (options.asJson) return written && errors === 0;
1717
- if (written && options.jsonOutput.isFile()) log.success(`Graph JSON written to ${options.jsonOutput.filePath}.`);
2916
+ };
2917
+ const written = options.outFile.publish(document, options.commandOutput, GraphDiagnosticCode.OutWriteFailed);
2918
+ if (!written || errors > 0) process.exitCode = 1;
2919
+ if (options.commandOutput.isJson()) return;
2920
+ if (written && options.outFile.isFile()) log.success(`Graph JSON written to ${options.outFile.filePath}.`);
1718
2921
  log.message(ModuleGraphReport.renderTree(view, boundaries));
1719
2922
  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.");
1720
2923
  const moduleCount = view.modules.length;
1721
2924
  const summary = `${moduleCount} module${moduleCount === 1 ? "" : "s"}`;
1722
2925
  if (errors > 0) outro(`${summary} — ${errors} compile error${errors === 1 ? "" : "s"}; the graph may be incomplete.`);
1723
2926
  else outro(`${summary}.`);
1724
- return written && errors === 0;
1725
2927
  }
1726
2928
  /**
1727
- * Report a config-load / compile failure: `{ ok: false, error }` to the JSON target, and a clack error +
1728
- * outro unless `--json`. The failure payload goes to `--out` too, so re-running replaces the previous
1729
- * artifact rather than leaving a stale success.
2929
+ * Report a config-load / compile failure: a {@link CliFailureDocument} to stdout under `--json` and to the
2930
+ * `--out` file, and a clack error + outro unless `--json`. The file gets the failure too, so a new run
2931
+ * replaces the previous artifact rather than leaving a stale success. Sets exit status 1.
2932
+ *
2933
+ * @param diagnostics - The diagnostics of the failure.
2934
+ * @param commandOutput - The stdout owner of the run.
2935
+ * @param outFile - The `--out` file.
1730
2936
  */
1731
- static reportFailure(error, asJson, jsonOutput) {
1732
- const message = error instanceof Error ? error.message : String(error);
1733
- jsonOutput.emit({
2937
+ static reportFailure(diagnostics, commandOutput, outFile) {
2938
+ const document = {
1734
2939
  ok: false,
1735
- error: message
1736
- });
1737
- if (asJson) return;
1738
- log.error(`Could not read the app's module graph:\n${message}`);
2940
+ diagnostics
2941
+ };
2942
+ outFile.publish(document, commandOutput, GraphDiagnosticCode.OutWriteFailed);
2943
+ process.exitCode = 1;
2944
+ if (commandOutput.isJson()) return;
2945
+ const messages = diagnostics.map((diagnostic) => diagnostic.message);
2946
+ log.error(`Could not read the app's module graph:\n${messages.join("\n")}`);
1739
2947
  outro("No graph to show.");
1740
2948
  }
1741
2949
  /** Project the compile's cross-package boundary nodes into display summaries. */
@@ -1749,12 +2957,45 @@ var GraphCommand = class GraphCommand {
1749
2957
  }
1750
2958
  };
1751
2959
  //#endregion
2960
+ //#region src/migrations-json-document.ts
2961
+ /** The stable codes of the failures and warnings `heximon migrations` raises itself. */
2962
+ const MigrationsDiagnosticCode = {
2963
+ /** No drizzle-kit config was detected, and `--config` was not given. */
2964
+ NoConfig: "migrations.no-config",
2965
+ /** A candidate config file could not be loaded. A warning beside {@link NoConfig}; `file` names it. */
2966
+ ConfigLoadFailed: "migrations.config-load-failed",
2967
+ /** The `--env-file` cannot be read. */
2968
+ EnvFileUnreadable: "migrations.env-file-unreadable",
2969
+ /** `drizzle-kit` exited with a non-zero status for one database. */
2970
+ DrizzleKitFailed: "migrations.drizzle-kit-failed",
2971
+ /** `drizzle-kit` could not start, for example because it is not installed. The run stops there. */
2972
+ DrizzleKitNotStarted: "migrations.drizzle-kit-not-started",
2973
+ /** A Cloudflare D1 database was not migrated: `wrangler d1 migrations apply` applies it. A warning. */
2974
+ D1ApplySkipped: "migrations.d1-apply-skipped"
2975
+ };
2976
+ /** What happened to one database in a `generate`, `migrate` or `push` run. */
2977
+ const MigrationsStepStatus = {
2978
+ /** `drizzle-kit` exited 0. */
2979
+ Succeeded: "succeeded",
2980
+ /** `drizzle-kit` exited with a non-zero status. */
2981
+ Failed: "failed",
2982
+ /** `drizzle-kit` could not start. The run stopped, and the databases after this one have no entry. */
2983
+ NotStarted: "not-started",
2984
+ /** The command did not run `drizzle-kit` for this database; {@link MigrationsStepEntry.reason} says why. */
2985
+ Skipped: "skipped"
2986
+ };
2987
+ //#endregion
1752
2988
  //#region src/migrations-command.ts
1753
2989
  /**
1754
- * The plumbing every `heximon migrations` subcommand shares: the `.env` + detection prologue, the config
1755
- * selection, one captured `drizzle-kit` invocation, and the throw-to-cancel guard.
2990
+ * The plumbing every `heximon migrations` subcommand shares: the output mode, the `.env` + detection prologue,
2991
+ * the config selection, one captured `drizzle-kit` invocation, and the diagnostics of the `--json` document.
1756
2992
  */
1757
2993
  var MigrationsRunner = class MigrationsRunner {
2994
+ commandOutput;
2995
+ rootDir;
2996
+ detection;
2997
+ args;
2998
+ mask;
1758
2999
  /** The flags every `heximon migrations` subcommand accepts. */
1759
3000
  static baseArgs = {
1760
3001
  cwd: {
@@ -1774,42 +3015,80 @@ var MigrationsRunner = class MigrationsRunner {
1774
3015
  config: {
1775
3016
  type: "string",
1776
3017
  description: "Target one drizzle-kit config file, bypassing auto-detection."
1777
- }
3018
+ },
3019
+ json: CommandOutput.jsonFlag
1778
3020
  };
1779
3021
  /** The `select` sentinel for "run every detected database". */
1780
3022
  static allDatabasesValue = "__all__";
3023
+ /** The message of the "no drizzle-kit config" halt. */
3024
+ static noConfigMessage = "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>.";
3025
+ /** The password of a URL with userinfo (`scheme://user:password@host`), as the `secret` group. */
3026
+ static urlPassword = /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]*:(?<secret>[^\s@/]+)@/dgiu;
3027
+ /** The value of a `password` query parameter (`?password=…`, `&password=…`), as the `secret` group. */
3028
+ static queryPassword = /[?&]password=(?<secret>[^&#\s]+)/dgiu;
3029
+ /** Every diagnostic of the run, in the order the run raised them. */
3030
+ diagnostics = [];
1781
3031
  /**
1782
- * Resolve the project root, load the `.env` cascade into `process.env` (so the spawned `drizzle-kit`
1783
- * inherits `DATABASE_URL`), and detect the app's drizzle-kit config file(s). An explicit `--env-file`
1784
- * overlays the cascade.
3032
+ * @param mask - The credentials of the run: each `--env-file` value of at least
3033
+ * {@link SecretMask.minimumFragmentLength} characters, and the `dbCredentials` secrets of each config at any
3034
+ * length. drizzle-kit can echo them, and its output reaches the terminal and the `--json` document.
1785
3035
  */
1786
- static async prepare(args) {
1787
- const rootDir = resolve(process.cwd(), args.cwd);
1788
- const overlay = args["env-file"] === void 0 ? void 0 : await EnvFileReader.read(resolve(rootDir, args["env-file"]));
1789
- DotenvCascade.loadInto(rootDir, args.env, overlay);
1790
- return {
1791
- rootDir,
1792
- detection: await DatabaseConfigDetector.detect(rootDir)
1793
- };
3036
+ constructor(commandOutput, rootDir, detection, args, mask) {
3037
+ this.commandOutput = commandOutput;
3038
+ this.rootDir = rootDir;
3039
+ this.detection = detection;
3040
+ this.args = args;
3041
+ this.mask = mask;
3042
+ }
3043
+ /**
3044
+ * The options of one `drizzle-kit` call. They carry no mask: {@link redact} masks the original captured output
3045
+ * with the registered secrets and the URL rules together.
3046
+ */
3047
+ drizzleKitOptions() {
3048
+ return { rootDir: this.rootDir };
3049
+ }
3050
+ /**
3051
+ * Run one subcommand: resolve the output mode, print the intro, load the `.env` cascade, detect the
3052
+ * configs, then run the body.
3053
+ *
3054
+ * @param subcommand - The subcommand verb (`generate`, `migrate`, …).
3055
+ * @param args - The parsed arguments.
3056
+ * @param body - The work of the subcommand. It records its `--json` document on the runner's output.
3057
+ */
3058
+ static async execute(subcommand, args, body) {
3059
+ const commandOutput = CommandOutput.resolve(`migrations ${subcommand}`, args);
3060
+ await commandOutput.run(async () => {
3061
+ const rootDir = ProjectRoot.resolve(args.cwd);
3062
+ await MigrationsRunner.guard(commandOutput, async () => {
3063
+ intro(`heximon migrations ${subcommand}`);
3064
+ await body(await MigrationsRunner.prepare(commandOutput, args, rootDir));
3065
+ });
3066
+ });
1794
3067
  }
1795
3068
  /**
1796
3069
  * Choose which detected config(s) to act on: an explicit `--config` wins; a single detected config is used
1797
3070
  * silently; multiple prompt an interactive `select`, or — in a non-TTY run — fan out to ALL of them, which
1798
- * is named out loud because that is several databases the operator never chose. Reports the failure itself
1799
- * (no config / cancelled) and returns `undefined` so the caller just returns.
3071
+ * is named out loud because that is several databases the operator never chose. Under `--json` several
3072
+ * configs fail with {@link CliDiagnosticCode.OptionRequired}, because `--json` never chooses for the caller.
3073
+ * Reports a text-mode failure itself (no config / cancelled) and returns `undefined` so the caller just
3074
+ * returns.
1800
3075
  */
1801
- static async selectConfigs(rootDir, detection, args, interactive) {
1802
- if (args.config !== void 0) {
1803
- const path = resolve(rootDir, args.config);
1804
- return [await DatabaseConfigDetector.classifyFile(rootDir, path) ?? MigrationsRunner.syntheticConfig(path)];
1805
- }
1806
- const { configs } = detection;
3076
+ async selectConfigs() {
3077
+ if (this.args.config !== void 0) {
3078
+ const path = resolve(this.rootDir, this.args.config);
3079
+ const classified = await DatabaseConfigDetector.classifyFile(this.rootDir, path);
3080
+ if (classified !== null) MigrationsRunner.registerCredentials(this.mask, classified);
3081
+ return [classified ?? MigrationsRunner.syntheticConfig(path)];
3082
+ }
3083
+ const { configs } = this.detection;
1807
3084
  if (configs.length === 0) {
1808
- MigrationsRunner.reportNoConfig(detection);
3085
+ if (this.commandOutput.isJson()) throw new CommandFailure(MigrationsRunner.noConfigDiagnostics(this.detection));
3086
+ MigrationsRunner.reportNoConfig(this.detection);
1809
3087
  return;
1810
3088
  }
1811
3089
  if (configs.length === 1) return configs;
1812
- if (!interactive) {
3090
+ if (this.commandOutput.isJson()) throw new CommandFailure([this.selectionRefusal(configs)]);
3091
+ if (!this.commandOutput.isInteractive()) {
1813
3092
  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.`);
1814
3093
  return configs;
1815
3094
  }
@@ -1832,34 +3111,158 @@ var MigrationsRunner = class MigrationsRunner {
1832
3111
  return configs.filter((config) => config.path === choice);
1833
3112
  }
1834
3113
  /**
1835
- * Run one captured `drizzle-kit` invocation behind a spinner, relaying its output.
3114
+ * Run one captured `drizzle-kit` invocation behind a spinner, relaying its output. A failure also records
3115
+ * an error diagnostic.
1836
3116
  *
1837
- * @returns `true` on exit code 0, else `false` (the output is logged either way).
3117
+ * @param config - The database the invocation acts on.
3118
+ * @param label - The progress label, for example `Applying migrations for database.config.ts`.
3119
+ * @param invoke - Starts the invocation.
3120
+ * @returns The entry of the database. A `not-started` status means the caller stops its loop.
1838
3121
  */
1839
- static async runStep(interactive, label, invoke) {
1840
- const progress = CliSpinner.begin(interactive, `${label}…`);
3122
+ async runStep(config, label, invoke) {
3123
+ const progress = CliSpinner.begin(this.commandOutput.isInteractive(), `${label}…`);
1841
3124
  let result;
1842
3125
  try {
1843
3126
  result = await invoke();
1844
3127
  } catch (error) {
3128
+ const reason = error instanceof Error ? error.message : String(error);
1845
3129
  progress?.stop(`${label} — failed to start.`);
1846
- throw error;
3130
+ log.error(reason);
3131
+ this.diagnostics.push(CliDiagnostics.error(MigrationsDiagnosticCode.DrizzleKitNotStarted, `${label}: drizzle-kit could not start. ${reason}`, { hint: "Add drizzle-kit to the devDependencies of the app, then run the command again." }));
3132
+ return {
3133
+ ...this.viewOf(config),
3134
+ status: MigrationsStepStatus.NotStarted,
3135
+ reason
3136
+ };
1847
3137
  }
1848
- const output = [result.stdout.trim(), result.stderr.trim()].filter((part) => part.length > 0);
3138
+ const stdout = this.redact(result.stdout);
3139
+ const stderr = this.redact(result.stderr);
3140
+ const output = [stdout.trim(), stderr.trim()].filter((part) => part.length > 0);
3141
+ const drizzleKit = {
3142
+ exitCode: result.exitCode,
3143
+ stdout,
3144
+ stderr
3145
+ };
1849
3146
  if (result.exitCode === 0) {
1850
3147
  progress?.stop(`${label} — done.`);
1851
3148
  if (output.length > 0) log.message(output.join("\n"));
1852
- return true;
3149
+ return {
3150
+ ...this.viewOf(config),
3151
+ status: MigrationsStepStatus.Succeeded,
3152
+ drizzleKit
3153
+ };
1853
3154
  }
1854
3155
  progress?.stop(`${label} — failed (exit ${result.exitCode}).`);
1855
3156
  if (output.length > 0) log.error(output.join("\n"));
1856
- return false;
3157
+ this.diagnostics.push(CliDiagnostics.error(MigrationsDiagnosticCode.DrizzleKitFailed, `${label}: drizzle-kit exited with status ${result.exitCode}.`, { hint: "Read the drizzle-kit output in the drizzleKit field of the database entry." }));
3158
+ return {
3159
+ ...this.viewOf(config),
3160
+ status: MigrationsStepStatus.Failed,
3161
+ drizzleKit
3162
+ };
3163
+ }
3164
+ /**
3165
+ * Record a database that `migrate` does not apply.
3166
+ *
3167
+ * @param config - The skipped database.
3168
+ * @param reason - Why it is skipped, and what applies it.
3169
+ * @param warning - A warning diagnostic to record beside the entry, when the operator must act.
3170
+ * @returns The entry of the database.
3171
+ */
3172
+ skip(config, reason, warning) {
3173
+ if (warning !== void 0) this.diagnostics.push(warning);
3174
+ return {
3175
+ ...this.viewOf(config),
3176
+ status: MigrationsStepStatus.Skipped,
3177
+ reason
3178
+ };
3179
+ }
3180
+ /** The `.sql` files in the migrations folder of a config, relative to that folder, sorted. */
3181
+ async sqlFilesOf(config) {
3182
+ return (await MigrationInspector.collectSqlFiles(resolve(this.rootDir, MigrationsRunner.migrationsDirectoryOf(config)))).map((file) => file.relativePath);
3183
+ }
3184
+ /** The JSON view of one config. */
3185
+ viewOf(config) {
3186
+ return {
3187
+ name: config.name,
3188
+ configPath: config.path,
3189
+ dialect: config.dialect,
3190
+ ...config.driver === void 0 ? {} : { driver: config.driver },
3191
+ applyMode: config.applyMode,
3192
+ migrationsDirectory: MigrationsRunner.migrationsDirectoryOf(config)
3193
+ };
3194
+ }
3195
+ /** Whether the run recorded no error diagnostic. */
3196
+ isSuccessful() {
3197
+ return !this.diagnostics.some((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error);
3198
+ }
3199
+ /**
3200
+ * The `--json` document of the run.
3201
+ *
3202
+ * @param databases - One entry for each database the run acted on.
3203
+ * @returns The document, with `ok` from the recorded diagnostics.
3204
+ */
3205
+ document(databases) {
3206
+ return {
3207
+ ok: this.isSuccessful(),
3208
+ diagnostics: [...this.diagnostics],
3209
+ rootDir: this.rootDir,
3210
+ databases
3211
+ };
3212
+ }
3213
+ /** The migrations folder of a config: its `out`, else the drizzle-kit default. */
3214
+ static migrationsDirectoryOf(config) {
3215
+ return config.out ?? DatabaseConfigDetector.defaultOutDirectory;
3216
+ }
3217
+ /**
3218
+ * Load the `.env` cascade into `process.env` (so the spawned `drizzle-kit` inherits `DATABASE_URL`), and
3219
+ * detect the app's drizzle-kit config file(s). An explicit `--env-file` overlays the cascade.
3220
+ *
3221
+ * @param rootDir - The project root {@link ProjectRoot.resolve} accepted.
3222
+ */
3223
+ static async prepare(commandOutput, args, rootDir) {
3224
+ const overlay = args["env-file"] === void 0 ? void 0 : await MigrationsRunner.readEnvFile(resolve(rootDir, args["env-file"]));
3225
+ DotenvCascade.loadInto(rootDir, args.env, overlay);
3226
+ const mask = new SecretMask();
3227
+ const detection = await DatabaseConfigDetector.detect(rootDir);
3228
+ for (const value of overlay?.values() ?? []) if (value.length >= SecretMask.minimumFragmentLength) mask.register(value);
3229
+ for (const config of detection.configs) MigrationsRunner.registerCredentials(mask, config);
3230
+ return new MigrationsRunner(commandOutput, rootDir, detection, args, mask);
3231
+ }
3232
+ /** Register the `dbCredentials` secrets of one config, and the password inside each URL among them. */
3233
+ static registerCredentials(mask, config) {
3234
+ for (const credential of config.credentials ?? []) {
3235
+ mask.register(credential);
3236
+ for (const match of credential.matchAll(MigrationsRunner.urlPassword)) mask.register(match.groups?.["secret"] ?? "");
3237
+ }
3238
+ }
3239
+ /**
3240
+ * Hide the registered secrets, the password of any URL with userinfo, and any `password` query parameter in
3241
+ * captured output. Every range is found on the original text and replaced in one step, so no rule changes the
3242
+ * text another rule reads.
3243
+ */
3244
+ redact(text) {
3245
+ return this.mask.redactWithPatterns(text, [MigrationsRunner.urlPassword, MigrationsRunner.queryPassword]);
3246
+ }
3247
+ /** Read the `--env-file`; an unreadable file is an expected failure with its own code. */
3248
+ static async readEnvFile(path) {
3249
+ try {
3250
+ return await EnvFileReader.read(path);
3251
+ } catch (error) {
3252
+ if (error instanceof EnvFileUnreadableError) throw CommandFailure.of(MigrationsDiagnosticCode.EnvFileUnreadable, error.message);
3253
+ throw error;
3254
+ }
1857
3255
  }
1858
3256
  /**
1859
3257
  * Wrap a subcommand body so a thrown error (e.g. an unreadable `--env-file`) becomes a clean cancel +
1860
- * non-zero exit rather than an unhandled stack trace.
3258
+ * non-zero exit rather than an unhandled stack trace. Under `--json` the error reaches
3259
+ * {@link CommandOutput.run}, which prints it as the failure document.
1861
3260
  */
1862
- static async guard(body) {
3261
+ static async guard(commandOutput, body) {
3262
+ if (commandOutput.isJson()) {
3263
+ await body();
3264
+ return;
3265
+ }
1863
3266
  try {
1864
3267
  await body();
1865
3268
  } catch (error) {
@@ -1867,6 +3270,14 @@ var MigrationsRunner = class MigrationsRunner {
1867
3270
  cancel(error instanceof Error ? error.message : String(error));
1868
3271
  }
1869
3272
  }
3273
+ /** The `--json` refusal of the database `select`, naming every choice. */
3274
+ selectionRefusal(configs) {
3275
+ const paths = configs.map((config) => relative(this.rootDir, config.path));
3276
+ return CliDiagnostics.error(CliDiagnosticCode.OptionRequired, `${configs.length} databases were detected (${paths.join(", ")}), and --json never chooses one.`, {
3277
+ hint: `Pass --config <path> with one of: ${paths.join(", ")}. Run once for each database to act on all.`,
3278
+ option: "--config"
3279
+ });
3280
+ }
1870
3281
  /**
1871
3282
  * A stand-in for an explicit `--config` that could not be classified (unloadable / not a drizzle config):
1872
3283
  * the command still drives `drizzle-kit` on it and surfaces drizzle-kit's own error, trusting the flag.
@@ -1887,9 +3298,18 @@ var MigrationsRunner = class MigrationsRunner {
1887
3298
  */
1888
3299
  static reportNoConfig(detection) {
1889
3300
  process.exitCode = 1;
1890
- 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>.");
3301
+ cancel(MigrationsRunner.noConfigMessage);
1891
3302
  for (const failure of detection.loadErrors) log.warn(`Could not load ${failure.path}: ${failure.message}`);
1892
3303
  }
3304
+ /** The `--json` diagnostics of the "no drizzle-kit config" halt: the error, then one warning per candidate. */
3305
+ static noConfigDiagnostics(detection) {
3306
+ return [CliDiagnostics.error(MigrationsDiagnosticCode.NoConfig, MigrationsRunner.noConfigMessage, { hint: "Add a drizzle-kit config, or pass --config <path>." }), ...detection.loadErrors.map((failure) => ({
3307
+ severity: DiagnosticSeverity.Warning,
3308
+ code: MigrationsDiagnosticCode.ConfigLoadFailed,
3309
+ message: `Could not load ${failure.path}: ${failure.message}`,
3310
+ file: failure.path
3311
+ }))];
3312
+ }
1893
3313
  };
1894
3314
  const generateCommand = defineCommand({
1895
3315
  meta: {
@@ -1903,16 +3323,24 @@ const generateCommand = defineCommand({
1903
3323
  description: "Name the migration (drizzle-kit --name)."
1904
3324
  }
1905
3325
  },
1906
- run: ({ args }) => MigrationsRunner.guard(async () => {
1907
- const interactive = CliEnvironment.isInteractive();
1908
- intro("heximon migrations generate");
1909
- const { rootDir, detection } = await MigrationsRunner.prepare(args);
1910
- const selected = await MigrationsRunner.selectConfigs(rootDir, detection, args, interactive);
3326
+ run: ({ args }) => MigrationsRunner.execute("generate", args, async (runner) => {
3327
+ const selected = await runner.selectConfigs();
1911
3328
  if (selected === void 0) return;
1912
- const runner = new DrizzleKitRunner();
1913
- let allSucceeded = true;
1914
- for (const config of selected) allSucceeded = await MigrationsRunner.runStep(interactive, `Generating migration for ${config.name}`, () => runner.generate(config.path, { rootDir }, args.name)) && allSucceeded;
1915
- if (!allSucceeded) {
3329
+ const drizzleKit = new DrizzleKitRunner();
3330
+ const entries = [];
3331
+ for (const config of selected) {
3332
+ const filesBefore = new Set(await runner.sqlFilesOf(config));
3333
+ const entry = await runner.runStep(config, `Generating migration for ${config.name}`, () => drizzleKit.generate(config.path, runner.drizzleKitOptions(), args.name));
3334
+ const filesAfter = await runner.sqlFilesOf(config);
3335
+ entries.push({
3336
+ ...entry,
3337
+ generatedFiles: filesAfter.filter((file) => !filesBefore.has(file))
3338
+ });
3339
+ if (entry.status === MigrationsStepStatus.NotStarted) break;
3340
+ }
3341
+ const document = runner.document(entries);
3342
+ runner.commandOutput.emit(document);
3343
+ if (!runner.isSuccessful()) {
1916
3344
  process.exitCode = 1;
1917
3345
  cancel("Some migrations failed to generate — see above.");
1918
3346
  return;
@@ -1926,26 +3354,37 @@ const migrateCommand = defineCommand({
1926
3354
  description: "Apply pending migrations (drizzle-kit migrate)."
1927
3355
  },
1928
3356
  args: MigrationsRunner.baseArgs,
1929
- run: ({ args }) => MigrationsRunner.guard(async () => {
1930
- const interactive = CliEnvironment.isInteractive();
1931
- intro("heximon migrations migrate");
1932
- const { rootDir, detection } = await MigrationsRunner.prepare(args);
1933
- const selected = await MigrationsRunner.selectConfigs(rootDir, detection, args, interactive);
3357
+ run: ({ args }) => MigrationsRunner.execute("migrate", args, async (runner) => {
3358
+ const selected = await runner.selectConfigs();
1934
3359
  if (selected === void 0) return;
1935
- const runner = new DrizzleKitRunner();
1936
- let allSucceeded = true;
3360
+ const drizzleKit = new DrizzleKitRunner();
3361
+ const entries = [];
1937
3362
  for (const config of selected) {
1938
3363
  if (config.applyMode === "wrangler-d1") {
1939
- log.warn(`${config.name}: a Cloudflare D1 database — apply it with \`wrangler d1 migrations apply <database> --remote\`, not drizzle-kit.`);
3364
+ const message = `${config.name}: a Cloudflare D1 database — apply it with \`wrangler d1 migrations apply <database> --remote\`, not drizzle-kit.`;
3365
+ log.warn(message);
3366
+ entries.push(runner.skip(config, message, {
3367
+ severity: DiagnosticSeverity.Warning,
3368
+ code: MigrationsDiagnosticCode.D1ApplySkipped,
3369
+ message,
3370
+ hint: "Run `wrangler d1 migrations apply <database> --remote`.",
3371
+ file: config.path
3372
+ }));
1940
3373
  continue;
1941
3374
  }
1942
3375
  if (config.applyMode === "skip") {
1943
- log.info(`${config.name}: Durable Object SQLite — migrations bundle in-DO at init; no CLI apply step.`);
3376
+ const message = `${config.name}: Durable Object SQLite — migrations bundle in-DO at init; no CLI apply step.`;
3377
+ log.info(message);
3378
+ entries.push(runner.skip(config, message));
1944
3379
  continue;
1945
3380
  }
1946
- allSucceeded = await MigrationsRunner.runStep(interactive, `Applying migrations for ${config.name}`, () => runner.migrate(config.path, { rootDir })) && allSucceeded;
3381
+ const entry = await runner.runStep(config, `Applying migrations for ${config.name}`, () => drizzleKit.migrate(config.path, runner.drizzleKitOptions()));
3382
+ entries.push(entry);
3383
+ if (entry.status === MigrationsStepStatus.NotStarted) break;
1947
3384
  }
1948
- if (!allSucceeded) {
3385
+ const document = runner.document(entries);
3386
+ runner.commandOutput.emit(document);
3387
+ if (!runner.isSuccessful()) {
1949
3388
  process.exitCode = 1;
1950
3389
  cancel("Some migrations failed to apply — see above.");
1951
3390
  return;
@@ -1959,21 +3398,25 @@ const statusCommand = defineCommand({
1959
3398
  description: "List the on-disk migrations per database."
1960
3399
  },
1961
3400
  args: MigrationsRunner.baseArgs,
1962
- run: ({ args }) => MigrationsRunner.guard(async () => {
1963
- const interactive = CliEnvironment.isInteractive();
1964
- intro("heximon migrations status");
1965
- const { rootDir, detection } = await MigrationsRunner.prepare(args);
1966
- const selected = await MigrationsRunner.selectConfigs(rootDir, detection, args, interactive);
3401
+ run: ({ args }) => MigrationsRunner.execute("status", args, async (runner) => {
3402
+ const selected = await runner.selectConfigs();
1967
3403
  if (selected === void 0) return;
3404
+ const entries = [];
1968
3405
  for (const config of selected) {
1969
- const outFolder = config.out ?? DatabaseConfigDetector.defaultOutDirectory;
1970
- const files = await MigrationInspector.collectSqlFiles(resolve(rootDir, outFolder));
3406
+ const outFolder = MigrationsRunner.migrationsDirectoryOf(config);
3407
+ const files = await runner.sqlFilesOf(config);
3408
+ entries.push({
3409
+ ...runner.viewOf(config),
3410
+ migrations: files
3411
+ });
1971
3412
  if (files.length === 0) {
1972
3413
  log.info(`${config.name}: no migrations in ${outFolder} — run \`heximon migrations generate\`.`);
1973
3414
  continue;
1974
3415
  }
1975
- log.message(`${config.name} · ${files.length} migration(s) in ${outFolder}:\n` + files.map((file) => ` ${file.relativePath}`).join("\n"));
3416
+ log.message(`${config.name} · ${files.length} migration(s) in ${outFolder}:\n` + files.map((file) => ` ${file}`).join("\n"));
1976
3417
  }
3418
+ const document = runner.document(entries);
3419
+ runner.commandOutput.emit(document);
1977
3420
  log.info("Applied vs. pending state needs a live database query — not shown in this view.");
1978
3421
  outro("Status listed.");
1979
3422
  })
@@ -1990,21 +3433,20 @@ const pushCommand = defineCommand({
1990
3433
  description: "Confirm the destructive push non-interactively."
1991
3434
  }
1992
3435
  },
1993
- run: ({ args }) => MigrationsRunner.guard(async () => {
1994
- const interactive = CliEnvironment.isInteractive();
1995
- intro("heximon migrations push");
1996
- const { rootDir, detection } = await MigrationsRunner.prepare(args);
1997
- const selected = await MigrationsRunner.selectConfigs(rootDir, detection, args, interactive);
3436
+ run: ({ args }) => MigrationsRunner.execute("push", args, async (runner) => {
3437
+ const selected = await runner.selectConfigs();
1998
3438
  if (selected === void 0) return;
1999
3439
  let force = args.yes === true;
2000
3440
  if (!force) {
2001
- if (!interactive) {
3441
+ const question = `Push schema directly to ${selected.map((config) => config.name).join(", ")}? This can DROP columns/tables.`;
3442
+ runner.commandOutput.assertCanPrompt("--yes", question);
3443
+ if (!runner.commandOutput.isInteractive()) {
2002
3444
  process.exitCode = 1;
2003
3445
  cancel("`push` is destructive (it can drop columns/tables). Re-run with --yes to confirm in a non-interactive shell.");
2004
3446
  return;
2005
3447
  }
2006
3448
  const confirmed = await confirm({
2007
- message: `Push schema directly to ${selected.map((config) => config.name).join(", ")}? This can DROP columns/tables.`,
3449
+ message: question,
2008
3450
  initialValue: false
2009
3451
  });
2010
3452
  if (isCancel(confirmed) || confirmed !== true) {
@@ -2014,10 +3456,16 @@ const pushCommand = defineCommand({
2014
3456
  }
2015
3457
  force = true;
2016
3458
  }
2017
- const runner = new DrizzleKitRunner();
2018
- let allSucceeded = true;
2019
- for (const config of selected) allSucceeded = await MigrationsRunner.runStep(interactive, `Pushing schema for ${config.name}`, () => runner.push(config.path, { rootDir }, force)) && allSucceeded;
2020
- if (!allSucceeded) {
3459
+ const drizzleKit = new DrizzleKitRunner();
3460
+ const entries = [];
3461
+ for (const config of selected) {
3462
+ const entry = await runner.runStep(config, `Pushing schema for ${config.name}`, () => drizzleKit.push(config.path, runner.drizzleKitOptions(), force));
3463
+ entries.push(entry);
3464
+ if (entry.status === MigrationsStepStatus.NotStarted) break;
3465
+ }
3466
+ const document = runner.document(entries);
3467
+ runner.commandOutput.emit(document);
3468
+ if (!runner.isSuccessful()) {
2021
3469
  process.exitCode = 1;
2022
3470
  cancel("Some schema pushes failed — see above.");
2023
3471
  return;
@@ -2043,8 +3491,92 @@ const migrationsCommand = defineCommand({
2043
3491
  }
2044
3492
  });
2045
3493
  //#endregion
3494
+ //#region src/provision-json-document.ts
3495
+ /**
3496
+ * The stable codes of the `heximon provision --json` diagnostics. `--profile` uses `cli.invalid-options`,
3497
+ * and a missing credential uses `cli.option-required`.
3498
+ */
3499
+ const ProvisionDiagnosticCode = {
3500
+ /** The platform has no foundational resources to provision. */
3501
+ Unsupported: "provision.unsupported",
3502
+ /** One or more resources failed to provision; `result.failed` names them. */
3503
+ Failed: "provision.failed",
3504
+ /** No deploy strategy is installed for the platform. */
3505
+ StrategyUnavailable: "provision.strategy-unavailable",
3506
+ /** A Nitro or Nuxt build uses a preset that the deploy cannot ship. */
3507
+ HostManagedUnsupported: "provision.host-managed-unsupported"
3508
+ };
3509
+ //#endregion
2046
3510
  //#region src/provision-command.ts
2047
3511
  /**
3512
+ * Builds the `heximon provision --json` document. The run fields are set as the run reaches them, so a
3513
+ * failure document still says how far the run got.
3514
+ */
3515
+ var ProvisionDocument = class ProvisionDocument {
3516
+ environment;
3517
+ dryRun;
3518
+ target;
3519
+ credentials;
3520
+ /**
3521
+ * @param environment - The deploy environment.
3522
+ * @param dryRun - Whether the run creates nothing.
3523
+ */
3524
+ constructor(environment, dryRun) {
3525
+ this.environment = environment;
3526
+ this.dryRun = dryRun;
3527
+ }
3528
+ /** Record the resolved target. */
3529
+ resolved(target) {
3530
+ this.target = {
3531
+ platform: target.platformName ?? "node",
3532
+ targetClass: target.targetClass,
3533
+ hostManaged: target.hostManaged === true,
3534
+ ...target.artifactDir === void 0 ? {} : { artifactDir: target.artifactDir }
3535
+ };
3536
+ }
3537
+ /** Record the names of the resolved credential variables. */
3538
+ resolvedCredentials(credentials) {
3539
+ this.credentials = [...credentials.keys()];
3540
+ }
3541
+ /**
3542
+ * The document of the run.
3543
+ *
3544
+ * @param ok - Whether the command exits 0.
3545
+ * @param diagnostics - The diagnostics of the run.
3546
+ * @param outcome - The provisioning outcome, once the run has one.
3547
+ * @returns The document.
3548
+ */
3549
+ document(ok, diagnostics, outcome) {
3550
+ return {
3551
+ ok,
3552
+ diagnostics,
3553
+ environment: this.environment,
3554
+ dryRun: this.dryRun,
3555
+ ...this.target === void 0 ? {} : { target: this.target },
3556
+ ...this.credentials === void 0 ? {} : { credentials: this.credentials },
3557
+ ...outcome?.result === void 0 ? {} : { result: ProvisionDocument.resultOf(outcome.result) },
3558
+ ...outcome?.savedIds === void 0 ? {} : { savedIds: {
3559
+ path: outcome.savedIds.path,
3560
+ names: outcome.savedIds.names
3561
+ } }
3562
+ };
3563
+ }
3564
+ /** Copy the provisioning result into its document shape. */
3565
+ static resultOf(result) {
3566
+ return {
3567
+ platform: result.platform,
3568
+ provisioned: result.provisioned.map((provisioned) => ({
3569
+ resource: provisioned.resource,
3570
+ id: provisioned.id,
3571
+ ...provisioned.envVar === void 0 ? {} : { envVar: provisioned.envVar }
3572
+ })),
3573
+ existing: result.existing,
3574
+ failed: result.failed,
3575
+ messages: result.messages
3576
+ };
3577
+ }
3578
+ };
3579
+ /**
2048
3580
  * `heximon provision` — the PRE-BUILD verb for resources whose id the build itself needs (a Cloudflare
2049
3581
  * Hyperdrive binding with an empty `id` fails the build outright, and provisioning is what mints that id).
2050
3582
  * It calls exactly one seam, `strategy.provisionInfrastructure(context)` — never the deploy-time
@@ -2061,6 +3593,7 @@ const provisionCommand = defineCommand({
2061
3593
  description: "Project root directory.",
2062
3594
  default: "."
2063
3595
  },
3596
+ json: CommandOutput.jsonFlag,
2064
3597
  env: {
2065
3598
  type: "string",
2066
3599
  description: "Deploy environment (production / staging / preview:<id>).",
@@ -2086,61 +3619,97 @@ const provisionCommand = defineCommand({
2086
3619
  }
2087
3620
  },
2088
3621
  async run({ args }) {
2089
- const rootDir = resolve(process.cwd(), args.cwd);
2090
- const environment = args.env;
2091
- const interactive = CliEnvironment.isInteractive();
2092
- intro(`heximon provision · ${environment}`);
2093
- if (args.profile !== void 0) {
2094
- 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.");
2095
- process.exitCode = 1;
2096
- return;
2097
- }
2098
- try {
2099
- const target = await DeployTargetResolver.resolve(rootDir, environment, args.platform);
2100
- log.info(`Platform: ${target.platformName ?? "node (default)"} · target class: ${target.targetClass}`);
2101
- const specs = CredentialSelection.inPlay(target.strategy.requiredCredentials(), { provisioning: true });
2102
- const credentials = await CredentialResolver.resolve(specs, {
2103
- interactive,
2104
- prompter: interactive ? new ClackPrompter() : void 0
2105
- });
2106
- const progress = CliSpinner.begin(interactive, args["dry-run"] === true ? "Previewing the foundational resources…" : "Provisioning foundational resources…");
2107
- let outcome;
2108
- try {
2109
- outcome = await DeployRunner.provisionInfrastructure({
2110
- target,
2111
- rootDir,
2112
- environment,
2113
- credentials,
2114
- dryRun: args["dry-run"],
2115
- saveIds: args["save-ids"]
2116
- });
2117
- } catch (provisionError) {
2118
- progress?.stop("Provisioning failed — see the error below.");
2119
- throw provisionError;
2120
- }
2121
- progress?.stop("Provision steps complete.");
2122
- if (outcome.unsupported) {
2123
- cancel(`Platform ${outcome.platformName ?? "node (default)"} has no foundational resources to provision.`);
3622
+ const commandOutput = CommandOutput.resolve("provision", args);
3623
+ await commandOutput.run(async () => {
3624
+ const rootDir = ProjectRoot.resolve(args.cwd);
3625
+ const environment = args.env;
3626
+ const dryRun = args["dry-run"];
3627
+ const interactive = commandOutput.isInteractive();
3628
+ const report = new ProvisionDocument(environment, dryRun);
3629
+ intro(`heximon provision · ${environment}`);
3630
+ if (args.profile !== void 0) {
3631
+ const message = "--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.";
3632
+ cancel(message);
2124
3633
  process.exitCode = 1;
3634
+ commandOutput.emit(report.document(false, [CliDiagnostics.error(CliDiagnosticCode.InvalidOptions, message, { hint: "Remove --profile and set CLOUDFLARE_API_TOKEN." })]));
2125
3635
  return;
2126
3636
  }
2127
- const result = outcome.result;
2128
- for (const message of result?.messages ?? []) log.message(message);
2129
- for (const provisioned of result?.provisioned ?? []) log.success(`Provisioned ${provisioned.resource} → id ${provisioned.id}.`);
2130
- if (outcome.savedIds !== void 0) log.success(`Recorded ${outcome.savedIds.names.join(", ")} in ${outcome.savedIds.path} — \`heximon deploy\` injects it into the build.`);
2131
- if (result !== void 0 && result.failed.length > 0) {
2132
- process.exitCode = 1;
2133
- cancel(`Provisioning failed for: ${result.failed.join(", ")} — see above.`);
2134
- return;
3637
+ try {
3638
+ const target = await ConfigSidecar.during(rootDir, environment, () => DeployTargetResolver.resolve(rootDir, environment, args.platform));
3639
+ report.resolved(target);
3640
+ log.info(`Platform: ${target.platformName ?? "node (default)"} · target class: ${target.targetClass}`);
3641
+ const specs = CredentialSelection.inPlay(target.strategy.requiredCredentials(), { provisioning: true });
3642
+ const credentials = await CredentialResolver.resolve(specs, {
3643
+ interactive: interactive || commandOutput.isJson(),
3644
+ prompter: commandOutput.prompter(CredentialResolver.optionFor(specs))
3645
+ });
3646
+ report.resolvedCredentials(credentials);
3647
+ const progress = CliSpinner.begin(interactive, dryRun ? "Previewing the foundational resources…" : "Provisioning foundational resources…");
3648
+ let outcome;
3649
+ try {
3650
+ outcome = await DeployRunner.provisionInfrastructure({
3651
+ target,
3652
+ rootDir,
3653
+ environment,
3654
+ credentials,
3655
+ dryRun,
3656
+ saveIds: args["save-ids"]
3657
+ });
3658
+ } catch (provisionError) {
3659
+ progress?.stop("Provisioning failed — see the error below.");
3660
+ throw provisionError;
3661
+ }
3662
+ progress?.stop("Provision steps complete.");
3663
+ if (outcome.unsupported) {
3664
+ const message = `Platform ${outcome.platformName ?? "node (default)"} has no foundational resources to provision.`;
3665
+ cancel(message);
3666
+ process.exitCode = 1;
3667
+ commandOutput.emit(report.document(false, [CliDiagnostics.error(ProvisionDiagnosticCode.Unsupported, message)], outcome));
3668
+ return;
3669
+ }
3670
+ const result = outcome.result;
3671
+ for (const message of result?.messages ?? []) log.message(message);
3672
+ for (const provisioned of result?.provisioned ?? []) log.success(`Provisioned ${provisioned.resource} → id ${provisioned.id}.`);
3673
+ if (outcome.savedIds !== void 0) log.success(`Recorded ${outcome.savedIds.names.join(", ")} in ${outcome.savedIds.path} — \`heximon deploy\` injects it into the build.`);
3674
+ if (result !== void 0 && result.failed.length > 0) {
3675
+ process.exitCode = 1;
3676
+ cancel(`Provisioning failed for: ${result.failed.join(", ")} — see above.`);
3677
+ commandOutput.emit(report.document(false, [CliDiagnostics.error(ProvisionDiagnosticCode.Failed, `Provisioning failed for: ${result.failed.join(", ")}.`, { hint: "The result messages name the cause for each resource." })], outcome));
3678
+ return;
3679
+ }
3680
+ 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.");
3681
+ commandOutput.emit(report.document(true, [], outcome));
3682
+ } catch (error) {
3683
+ if (DeployCommandFailure.report(error)) {
3684
+ const diagnostic = DeployCommandFailure.diagnosticOf(error, ProvisionDiagnosticCode);
3685
+ if (diagnostic !== void 0) commandOutput.emit(report.document(false, [diagnostic]));
3686
+ return;
3687
+ }
3688
+ if (error instanceof CommandFailure && commandOutput.isJson()) {
3689
+ commandOutput.emit(report.document(false, error.diagnostics));
3690
+ return;
3691
+ }
3692
+ throw error;
2135
3693
  }
2136
- 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.");
2137
- } catch (error) {
2138
- if (DeployCommandFailure.report(error)) return;
2139
- throw error;
2140
- }
3694
+ });
2141
3695
  }
2142
3696
  });
2143
3697
  //#endregion
3698
+ //#region src/routes-json-document.ts
3699
+ /** The stable codes of the failures `heximon routes` raises itself. */
3700
+ const RoutesDiagnosticCode = {
3701
+ /** The compile threw before the command could read a route census. */
3702
+ ReadFailed: "routes.read-failed",
3703
+ /** `--boot` could not load or boot the app. A compile error of the boot gives the compiler diagnostics instead. */
3704
+ BootFailed: "routes.boot-failed",
3705
+ /** `--boot` booted the app, but its live route table could not be read. */
3706
+ TableUnreadable: "routes.table-unreadable",
3707
+ /** `--boot` read the route table, but the booted app did not dispose cleanly; a client can still be open. */
3708
+ DisposeFailed: "routes.dispose-failed",
3709
+ /** The `--out` file could not be written. */
3710
+ OutWriteFailed: "routes.out-write-failed"
3711
+ };
3712
+ //#endregion
2144
3713
  //#region src/route-table-report.ts
2145
3714
  /**
2146
3715
  * Renders a {@link RouteCensusRow} list as an aligned, columnar route table for the terminal —
@@ -2228,18 +3797,39 @@ var TransformBoot = class TransformBoot {
2228
3797
  return process.env[TransformBoot.transformMarker] === "1";
2229
3798
  }
2230
3799
  /**
3800
+ * Re-run this exact CLI invocation under `node --experimental-transform-types`, and set the exit status of
3801
+ * the child. The child prints the whole output, `--json` document included.
3802
+ *
3803
+ * A child that does not start printed nothing, so this process prints the failure: a document with
3804
+ * {@link CliDiagnosticCode.ReexecFailed} under `--json` (also written to the `--out` file), else one stderr
3805
+ * line. A child that a signal stops has no result, so it gets a stderr line and no document.
3806
+ *
3807
+ * @param commandLabel - The command name for the stderr lines (`routes --boot`, `task run`).
3808
+ * @param commandOutput - The output owner of the run.
3809
+ * @param outFile - The `--out` file of a command that has one.
3810
+ */
3811
+ static async relaunch(commandLabel, commandOutput, outFile) {
3812
+ const exit = await TransformBoot.reexecUnderTransform(commandLabel);
3813
+ if (exit.startError === void 0) {
3814
+ process.exitCode = exit.exitCode;
3815
+ return;
3816
+ }
3817
+ const failure = CommandFailure.of(CliDiagnosticCode.ReexecFailed, `Could not start the child process that runs the app's TypeScript source (${process.execPath}): ` + exit.startError.message, "Check that this Node.js executable can run, then run the command again.");
3818
+ const fail = () => Promise.reject(failure);
3819
+ await commandOutput.run(() => outFile === void 0 ? fail() : outFile.run(fail));
3820
+ }
3821
+ /**
2231
3822
  * Re-run this exact CLI invocation under `node --experimental-transform-types`, inheriting stdio so the
2232
3823
  * child's output and prompts reach the terminal directly.
2233
3824
  *
2234
3825
  * The child owns an interrupt: a terminal sends Ctrl-C to both processes, and the child cleans up. A
2235
3826
  * `SIGTERM` or `SIGHUP` that reaches only this process is passed on, so the child never outlives it.
2236
3827
  *
2237
- * A child that never started, or that a signal killed, reports no exit code. Both become `1` — but they
2238
- * are NAMED on stderr first, because the child printed nothing of its own and a bare `exit 1` after a
2239
- * silent run reads as "the command found a problem" rather than "the command never ran".
3828
+ * A child that a signal killed reports no exit code. It becomes `1`, NAMED on stderr first, because a bare
3829
+ * `exit 1` after a silent run reads as "the command found a problem" rather than "the command was stopped".
2240
3830
  *
2241
- * @param commandLabel - The command name for the failure messages (`routes --boot`, `task run`).
2242
- * @returns The child process exit code (1 when it did not report one).
3831
+ * @param commandLabel - The command name for the stderr line of a stopped child.
3832
+ * @returns The exit code of the child (1 when it did not report one), or the error of a child that did not start.
2243
3833
  */
2244
3834
  static reexecUnderTransform(commandLabel) {
2245
3835
  return new Promise((resolve) => {
@@ -2263,19 +3853,24 @@ var TransformBoot = class TransformBoot {
2263
3853
  process.on(signal, forward);
2264
3854
  return [signal, forward];
2265
3855
  });
3856
+ let settled = false;
2266
3857
  process.on("SIGINT", ignoreInterrupt);
2267
- const finish = (code) => {
3858
+ const finish = (exit) => {
3859
+ if (settled) return;
3860
+ settled = true;
2268
3861
  process.off("SIGINT", ignoreInterrupt);
2269
3862
  for (const [signal, forward] of forwarded) process.off(signal, forward);
2270
- resolve(code);
3863
+ resolve(exit);
2271
3864
  };
2272
3865
  child.once("error", (error) => {
2273
- process.stderr.write(`heximon ${commandLabel}: could not start the transform-enabled child (${process.execPath}): ${error.message}\n`);
2274
- finish(1);
3866
+ if (child.pid === void 0) finish({
3867
+ exitCode: 1,
3868
+ startError: error
3869
+ });
2275
3870
  });
2276
3871
  child.once("close", (code, signal) => {
2277
- if (signal !== null) process.stderr.write(`heximon ${commandLabel}: the transform-enabled child was killed by ${signal}.\n`);
2278
- finish(code ?? 1);
3872
+ if (signal !== null && !settled) process.stderr.write(`heximon ${commandLabel}: the transform-enabled child was killed by ${signal}.\n`);
3873
+ finish({ exitCode: code ?? 1 });
2279
3874
  });
2280
3875
  });
2281
3876
  }
@@ -2397,14 +3992,10 @@ const routesCommand = defineCommand({
2397
3992
  description: "The `.env.<mode>` overlay to load first; also the config `$env`. Default development.",
2398
3993
  default: "development"
2399
3994
  },
2400
- json: {
2401
- type: "boolean",
2402
- description: "Emit the route table as JSON on stdout instead of a table.",
2403
- default: false
2404
- },
3995
+ json: CommandOutput.jsonFlag,
2405
3996
  out: {
2406
3997
  type: "string",
2407
- description: "Write the JSON payload to this file instead of stdout, creating missing parent directories — the CI-artifact target."
3998
+ description: "Also write the JSON document to this file, creating missing parent directories — the CI-artifact target."
2408
3999
  },
2409
4000
  boot: {
2410
4001
  type: "boolean",
@@ -2420,24 +4011,27 @@ const routesCommand = defineCommand({
2420
4011
  },
2421
4012
  async run({ args }) {
2422
4013
  const asBoot = args.boot;
4014
+ const commandOutput = CommandOutput.resolve("routes", args);
4015
+ const outFile = JsonOutputTarget.resolve(args.out);
2423
4016
  if (asBoot && !TransformBoot.isBootProcess()) {
2424
- process.exitCode = await TransformBoot.reexecUnderTransform("routes --boot");
2425
- return;
2426
- }
2427
- const rootDir = resolve(process.cwd(), args.cwd);
2428
- const asJson = args.json;
2429
- const jsonOutput = JsonOutputTarget.resolve(args.out, asJson);
2430
- const resolved = CompileModeResolver.resolve("routes", rootDir, args, SourceDirectories.resolve(rootDir, args.src));
2431
- if (resolved === void 0) return;
2432
- if (!asJson) intro(`heximon routes — ${basename(rootDir)} · ${CompileModeResolver.stamp(resolved)}`);
2433
- if (asBoot) {
2434
- await RoutesCommand.runBoot(rootDir, args, asJson, jsonOutput);
4017
+ if (await ProjectRoot.checkBeforeRelaunch(args.cwd, commandOutput)) await TransformBoot.relaunch("routes --boot", commandOutput, outFile);
2435
4018
  return;
2436
4019
  }
2437
- await RoutesCommand.runCompile(rootDir, args, asJson, jsonOutput, resolved.mode);
4020
+ await commandOutput.run(async () => {
4021
+ const rootDir = ProjectRoot.resolve(args.cwd);
4022
+ await outFile.run(async () => {
4023
+ const resolved = CompileModeResolver.resolve(rootDir, args, SourceDirectories.resolve(rootDir, args.src));
4024
+ if (!commandOutput.isJson()) intro(`heximon routes — ${basename(rootDir)} · ${CompileModeResolver.stamp(resolved)}`);
4025
+ if (asBoot) {
4026
+ await RoutesCommand.runBoot(rootDir, args, commandOutput, outFile);
4027
+ return;
4028
+ }
4029
+ await RoutesCommand.runCompile(rootDir, args, commandOutput, outFile, resolved.mode);
4030
+ });
4031
+ });
2438
4032
  }
2439
4033
  });
2440
- /** Output helpers for {@link routesCommand}: the two read paths, the table / JSON body, and the failure summary. */
4034
+ /** Output helpers for {@link routesCommand}: the two read paths, the table / JSON document, and the failure summary. */
2441
4035
  var RoutesCommand = class RoutesCommand {
2442
4036
  /**
2443
4037
  * The compile-time read: run the same from-disk compile `heximon check` runs and report the route census
@@ -2449,8 +4043,8 @@ var RoutesCommand = class RoutesCommand {
2449
4043
  * route table is readable without composing it into an app. The `requires` a library leaves open for its
2450
4044
  * host are not diagnostics there, so nothing floods the report.
2451
4045
  */
2452
- static async runCompile(rootDir, args, asJson, jsonOutput, mode = CompileMode.App) {
2453
- let census;
4046
+ static async runCompile(rootDir, args, commandOutput, outFile, mode = CompileMode.App) {
4047
+ let compiled;
2454
4048
  try {
2455
4049
  const sourceDirectories = SourceDirectories.resolve(rootDir, args.src);
2456
4050
  DotenvCascade.loadInto(rootDir, args.env);
@@ -2459,24 +4053,21 @@ var RoutesCommand = class RoutesCommand {
2459
4053
  environment: args.env,
2460
4054
  ...sourceDirectories === void 0 ? {} : { sourceDirectories }
2461
4055
  });
2462
- const output = mode === CompileMode.Library ? session.compileLibrary() : session.compile();
2463
- const failure = output.diagnostics.find((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error);
2464
- if (failure !== void 0) {
2465
- RoutesCommand.reportFailure(new Error(failure.message), asJson, jsonOutput);
2466
- process.exitCode = 1;
2467
- return;
2468
- }
2469
- const file = output.files.find((generated) => generated.path === RouteCensus.fileName);
2470
- census = (file === void 0 ? void 0 : RouteCensus.parse(file.content)) ?? {
2471
- routes: [],
2472
- unresolved: []
2473
- };
4056
+ compiled = mode === CompileMode.Library ? session.compileLibrary() : session.compile();
2474
4057
  } catch (error) {
2475
- RoutesCommand.reportFailure(error, asJson, jsonOutput);
2476
- process.exitCode = 1;
4058
+ RoutesCommand.reportFailure(RoutesCommand.diagnosticsOf(RoutesDiagnosticCode.ReadFailed, error), commandOutput, outFile);
2477
4059
  return;
2478
4060
  }
2479
- RoutesCommand.report(census.routes, census.unresolved, asJson, jsonOutput, {
4061
+ if (compiled.diagnostics.some((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error)) {
4062
+ RoutesCommand.reportFailure(CliDiagnostics.fromCompiler(compiled.diagnostics), commandOutput, outFile);
4063
+ return;
4064
+ }
4065
+ const file = compiled.files.find((generated) => generated.path === RouteCensus.fileName);
4066
+ const census = (file === void 0 ? void 0 : RouteCensus.parse(file.content)) ?? {
4067
+ routes: [],
4068
+ unresolved: []
4069
+ };
4070
+ RoutesCommand.report(census.routes, census.unresolved, commandOutput, outFile, {
2480
4071
  libraryMode: mode === CompileMode.Library,
2481
4072
  ...census.scopeEnforcement === void 0 ? {} : { scopeEnforcement: census.scopeEnforcement }
2482
4073
  });
@@ -2494,17 +4085,19 @@ var RoutesCommand = class RoutesCommand {
2494
4085
  * subcommand thunk while building the completion tree, so citty's lazy `Resolvable` form resolves the
2495
4086
  * module anyway.
2496
4087
  */
2497
- static async runBoot(rootDir, args, asJson, jsonOutput) {
4088
+ static async runBoot(rootDir, args, commandOutput, outFile) {
2498
4089
  let app;
2499
4090
  let inventory;
4091
+ let testing;
2500
4092
  try {
2501
4093
  TransformBoot.registerSourceResolver();
2502
4094
  const sourceDirectories = SourceDirectories.resolve(rootDir, args.src);
2503
4095
  DotenvCascade.loadInto(rootDir, args.env);
2504
- const config = await ConfigLoader.load(rootDir, args.env);
2505
- const [{ createTestApp: bootApp }, { RouteInventory }] = await Promise.all([import("@heximon/testing"), import("@heximon/http")]);
4096
+ const config = await ConfigSidecar.load(rootDir, args.env);
4097
+ const [testingModule, { RouteInventory }] = await Promise.all([import("@heximon/testing"), import("@heximon/http")]);
4098
+ testing = testingModule;
2506
4099
  inventory = RouteInventory;
2507
- app = await bootApp({
4100
+ app = await testingModule.createTestApp({
2508
4101
  root: rootDir,
2509
4102
  printWarnings: false,
2510
4103
  ...sourceDirectories === void 0 ? {} : { sourceDirectories },
@@ -2513,25 +4106,28 @@ var RoutesCommand = class RoutesCommand {
2513
4106
  ...config?.platform === void 0 ? {} : { platform: config.platform }
2514
4107
  });
2515
4108
  } catch (error) {
2516
- RoutesCommand.reportFailure(error, asJson, jsonOutput);
2517
- process.exitCode = 1;
4109
+ RoutesCommand.reportFailure(testing !== void 0 && error instanceof testing.TestAppCompileError ? CliDiagnostics.fromCompiler(error.diagnostics) : RoutesCommand.diagnosticsOf(RoutesDiagnosticCode.BootFailed, error), commandOutput, outFile);
2518
4110
  return;
2519
4111
  }
4112
+ let routes;
4113
+ const failures = [];
2520
4114
  try {
2521
- const routes = inventory.describe(app).map(RoutesCommand.toCensusRow);
2522
- RoutesCommand.report(routes, [], asJson, jsonOutput);
4115
+ routes = inventory.describe(app).map(RoutesCommand.toCensusRow);
2523
4116
  } catch (error) {
2524
- RoutesCommand.reportFailure(error, asJson, jsonOutput);
2525
- process.exitCode = 1;
4117
+ failures.push(RoutesCommand.diagnosticOf(RoutesDiagnosticCode.TableUnreadable, error));
2526
4118
  } finally {
2527
4119
  try {
2528
4120
  await app.context.drain({ timeoutMs: 5e3 });
2529
4121
  await app[Symbol.asyncDispose]();
2530
4122
  } catch (disposeError) {
2531
- process.exitCode = 1;
2532
- 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)}`);
4123
+ failures.push(CliDiagnostics.error(RoutesDiagnosticCode.DisposeFailed, `Disposing the booted app failed — a client may still be open: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}`));
2533
4124
  }
2534
4125
  }
4126
+ if (routes === void 0) {
4127
+ RoutesCommand.reportFailure(failures, commandOutput, outFile);
4128
+ return;
4129
+ }
4130
+ RoutesCommand.report(routes, [], commandOutput, outFile, { failures });
2535
4131
  }
2536
4132
  /**
2537
4133
  * Map one booted route to a census row. An entry whose {@link RouteInventoryEntry.scopes} is defined (every
@@ -2549,23 +4145,29 @@ var RoutesCommand = class RoutesCommand {
2549
4145
  };
2550
4146
  }
2551
4147
  /**
2552
- * Print the resolved routes — the JSON payload to its target, and an aligned table unless `--json`.
4148
+ * Report the resolved routes: the JSON document to stdout under `--json` and to the `--out` file, and an
4149
+ * aligned table unless `--json`. A failure or a failed `--out` write sets exit status 1.
2553
4150
  *
2554
4151
  * @param options - `libraryMode` adds the one fact a package's own table cannot show: the published root's
2555
4152
  * default mount is a manifest fact a consuming host applies or replaces, so it is not folded into these
2556
- * paths. `scopeEnforcement` is the census section of the same name, passed through to the JSON payload
2557
- * so a CI job reads the scope-enforcer verdict from the same artifact.
4153
+ * paths. `scopeEnforcement` is the census section of the same name, passed through to the JSON document
4154
+ * so a CI job reads the scope-enforcer verdict from the same artifact. `failures` are errors that fail
4155
+ * the run but keep the table it read.
2558
4156
  */
2559
- static report(routes, unresolved, asJson, jsonOutput, options = {}) {
2560
- const written = jsonOutput.emit({
2561
- ok: true,
4157
+ static report(routes, unresolved, commandOutput, outFile, options = {}) {
4158
+ const failures = options.failures ?? [];
4159
+ const document = {
4160
+ ok: failures.length === 0,
4161
+ diagnostics: failures,
2562
4162
  routes,
2563
4163
  unresolved,
2564
4164
  ...options.scopeEnforcement === void 0 ? {} : { scopeEnforcement: options.scopeEnforcement }
2565
- });
2566
- if (!written) process.exitCode = 1;
2567
- if (asJson) return;
2568
- if (written && jsonOutput.isFile()) log.success(`Route JSON written to ${jsonOutput.filePath}.`);
4165
+ };
4166
+ const written = outFile.publish(document, commandOutput, RoutesDiagnosticCode.OutWriteFailed);
4167
+ if (!written || failures.length > 0) process.exitCode = 1;
4168
+ if (commandOutput.isJson()) return;
4169
+ if (written && outFile.isFile()) log.success(`Route JSON written to ${outFile.filePath}.`);
4170
+ for (const failure of failures) log.warn(failure.message);
2569
4171
  if (routes.length === 0 && unresolved.length === 0) {
2570
4172
  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.");
2571
4173
  return;
@@ -2580,20 +4182,34 @@ var RoutesCommand = class RoutesCommand {
2580
4182
  outro(`${routes.length} route${routes.length === 1 ? "" : "s"}; ${unresolved.length} unresolved` + (options.libraryMode === true ? "." : " — re-run with --boot to enumerate them."));
2581
4183
  }
2582
4184
  /**
2583
- * Report a compile / boot failure: `{ ok: false, error }` to the JSON target, and a clack error + outro
2584
- * unless `--json`. The failure payload goes to `--out` too, so re-running replaces the previous artifact
2585
- * rather than leaving a stale success; a failed write is treated exactly as on the success path.
4185
+ * Report a compile / boot failure: a {@link CliFailureDocument} to stdout under `--json` and to the `--out`
4186
+ * file, and a clack error + outro unless `--json`. The file gets the failure too, so a new run
4187
+ * replaces the previous artifact rather than leaving a stale success. Sets exit status 1.
4188
+ *
4189
+ * @param diagnostics - The diagnostics of the failure: the compiler's, or one the command raised.
4190
+ * @param commandOutput - The stdout owner of the run.
4191
+ * @param outFile - The `--out` file.
2586
4192
  */
2587
- static reportFailure(error, asJson, jsonOutput) {
2588
- const message = error instanceof Error ? error.message : String(error);
2589
- if (!jsonOutput.emit({
4193
+ static reportFailure(diagnostics, commandOutput, outFile) {
4194
+ const document = {
2590
4195
  ok: false,
2591
- error: message
2592
- })) process.exitCode = 1;
2593
- if (asJson) return;
2594
- log.error(`Could not read the app's routes:\n${message}`);
4196
+ diagnostics
4197
+ };
4198
+ outFile.publish(document, commandOutput, RoutesDiagnosticCode.OutWriteFailed);
4199
+ process.exitCode = 1;
4200
+ if (commandOutput.isJson()) return;
4201
+ const messages = diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error).map((diagnostic) => diagnostic.message);
4202
+ log.error(`Could not read the app's routes:\n${messages.join("\n")}`);
2595
4203
  outro("No route table to show.");
2596
4204
  }
4205
+ /** One error diagnostic of the command, with the message of what was thrown. */
4206
+ static diagnosticOf(code, error) {
4207
+ return CliDiagnostics.error(code, error instanceof Error ? error.message : String(error));
4208
+ }
4209
+ /** The diagnostics of a thrown value: those of a {@link CommandFailure}, else one with `code`. */
4210
+ static diagnosticsOf(code, error) {
4211
+ return error instanceof CommandFailure ? error.diagnostics : [RoutesCommand.diagnosticOf(code, error)];
4212
+ }
2597
4213
  /**
2598
4214
  * The unresolved-gap block: one line per controller (or package) whose routes the census could not name.
2599
4215
  * A composed package whose manifest section IS readable contributes rows instead, so nothing here is a
@@ -3460,38 +5076,52 @@ var PlatformBindingCensus = class PlatformBindingCensus {
3460
5076
  }
3461
5077
  };
3462
5078
  //#endregion
3463
- //#region src/stdout-redirect.ts
3464
- /**
3465
- * Sends everything written to stdout to stderr for a while, so the log lines of an app that runs in this
3466
- * process cannot mix with the JSON the command prints. The original writer stays reachable through
3467
- * {@link write} for that JSON.
3468
- */
3469
- var StdoutRedirect = class {
3470
- original;
3471
- /** Start the redirect. A second call does nothing. */
3472
- start() {
3473
- if (this.original !== void 0) return;
3474
- this.original = process.stdout.write.bind(process.stdout);
3475
- process.stdout.write = ((...parameters) => process.stderr.write(...parameters));
3476
- }
3477
- /** Write to the real stdout, redirected or not. */
3478
- write(text) {
3479
- (this.original ?? process.stdout.write.bind(process.stdout))(text);
3480
- }
3481
- /** Stop the redirect. */
3482
- stop() {
3483
- if (this.original !== void 0) {
3484
- process.stdout.write = this.original;
3485
- this.original = void 0;
3486
- }
3487
- }
5079
+ //#region src/task-run-json-document.ts
5080
+ /** The stable codes of the failures `heximon task run --json` reports. */
5081
+ const TaskDiagnosticCode = {
5082
+ /** The `run()` of the task threw. The run exits 2, and the document names the error. */
5083
+ TaskFailed: "task.failed",
5084
+ /** No module or no task of the compiled graph has the name, or the source file does not export the class. */
5085
+ TargetNotFound: "task.target-not-found",
5086
+ /** More than one module, or more than one task of the module, has the name. */
5087
+ TargetAmbiguous: "task.target-ambiguous",
5088
+ /** The named class has no `run()` method. */
5089
+ NotATask: "task.not-a-task",
5090
+ /** The production compile failed. The compiler diagnostics are in the same document. */
5091
+ CompileFailed: "task.compile-failed",
5092
+ /** The project has no app: the compile gave no module graph, or no wiring file. */
5093
+ AppNotFound: "task.app-not-found",
5094
+ /** A Nitro option is computed, so the command cannot read the preset or the source directories. */
5095
+ NitroConfigComputed: "task.nitro-config-computed",
5096
+ /** A packed library that the app imports does not have the factories a production build needs. */
5097
+ LibraryPreflightFailed: "task.library-preflight-failed",
5098
+ /** The graph needs bindings that only its platform has, so nothing was booted. */
5099
+ PlatformBindingsRequired: "task.platform-bindings-required",
5100
+ /** The `@heximon/runtime` of the project is older than the command. */
5101
+ RuntimeOutdated: "task.runtime-outdated",
5102
+ /** The app did not boot. The stack is on stderr. */
5103
+ BootFailed: "task.boot-failed",
5104
+ /** The app could not create the task. */
5105
+ CreateFailed: "task.create-failed",
5106
+ /** The frame of the task did not open, or a context seed threw before `run()`. */
5107
+ StartFailed: "task.start-failed",
5108
+ /** The task returned a value that is not JSON. */
5109
+ ResultNotJson: "task.result-not-json",
5110
+ /** The app did not shut down cleanly after the run. */
5111
+ ShutdownFailed: "task.shutdown-failed"
3488
5112
  };
3489
5113
  //#endregion
3490
5114
  //#region src/task-target-resolver.ts
3491
5115
  /** Thrown when the names do not pick exactly one module and one task. The message is ready to print. */
3492
5116
  var TaskTargetError = class extends Error {
3493
- constructor(message) {
5117
+ code;
5118
+ /**
5119
+ * @param code - {@link TaskDiagnosticCode.TargetNotFound} or {@link TaskDiagnosticCode.TargetAmbiguous}.
5120
+ * @param message - The reason, with the names that come close.
5121
+ */
5122
+ constructor(code, message) {
3494
5123
  super(message);
5124
+ this.code = code;
3495
5125
  this.name = "TaskTargetError";
3496
5126
  }
3497
5127
  };
@@ -3517,16 +5147,16 @@ var TaskTargetResolver = class TaskTargetResolver {
3517
5147
  static resolve(graph, moduleName, taskName, boundaries = []) {
3518
5148
  const moduleReferences = [...graph.modules.map((node) => node.reference), ...boundaries.map((boundary) => boundary.reference)];
3519
5149
  const modules = TaskTargetResolver.distinct(moduleReferences, moduleName);
3520
- if (modules.length === 0) throw new TaskTargetError(`No module class named "${moduleName}" is in the compiled graph.` + TaskTargetResolver.suggestions(moduleName, moduleReferences.map((reference) => TaskTargetResolver.nameOf(reference)), "Modules in the graph"));
3521
- if (modules.length > 1) throw new TaskTargetError(`More than one module class is named "${moduleName}":\n` + modules.map((reference) => ` - ${reference.sourceFile}`).join("\n"));
5150
+ if (modules.length === 0) throw new TaskTargetError(TaskDiagnosticCode.TargetNotFound, `No module class named "${moduleName}" is in the compiled graph.` + TaskTargetResolver.suggestions(moduleName, moduleReferences.map((reference) => TaskTargetResolver.nameOf(reference)), "Modules in the graph"));
5151
+ if (modules.length > 1) throw new TaskTargetError(TaskDiagnosticCode.TargetAmbiguous, `More than one module class is named "${moduleName}":\n` + modules.map((reference) => ` - ${reference.sourceFile}`).join("\n"));
3522
5152
  const module = modules[0];
3523
5153
  const nodes = graph.modules.filter((node) => TaskTargetResolver.isSame(node.reference, module));
3524
5154
  const libraries = boundaries.filter((boundary) => TaskTargetResolver.isSame(boundary.reference, module));
3525
5155
  const isLibrary = nodes.length === 0 && libraries.length > 0;
3526
5156
  const candidates = isLibrary ? libraries.flatMap((boundary) => boundary.exports.map((exported) => exported.token)) : nodes.flatMap((node) => node.providers.map((provider) => provider.token));
3527
5157
  const tasks = TaskTargetResolver.distinct(candidates, taskName);
3528
- if (tasks.length === 0) throw new TaskTargetError((isLibrary ? `The library module ${moduleName} does not export a class named "${taskName}". \`heximon task run\` reaches the tasks that a library module exports: export the task from the module and pack the library again.` : `The module ${moduleName} does not provide a class named "${taskName}".`) + TaskTargetResolver.otherOwner(graph, boundaries, module, taskName) + TaskTargetResolver.suggestions(taskName, candidates.map((token) => TaskTargetResolver.nameOf(token)), isLibrary ? `Exports of ${moduleName}` : `Providers of ${moduleName}`));
3529
- if (tasks.length > 1) throw new TaskTargetError(`The module ${moduleName} ${isLibrary ? "exports" : "provides"} more than one class named "${taskName}":\n` + tasks.map((reference) => ` - ${reference.sourceFile}`).join("\n"));
5158
+ if (tasks.length === 0) throw new TaskTargetError(TaskDiagnosticCode.TargetNotFound, (isLibrary ? `The library module ${moduleName} does not export a class named "${taskName}". \`heximon task run\` reaches the tasks that a library module exports: export the task from the module and pack the library again.` : `The module ${moduleName} does not provide a class named "${taskName}".`) + TaskTargetResolver.otherOwner(graph, boundaries, module, taskName) + TaskTargetResolver.suggestions(taskName, candidates.map((token) => TaskTargetResolver.nameOf(token)), isLibrary ? `Exports of ${moduleName}` : `Providers of ${moduleName}`));
5159
+ if (tasks.length > 1) throw new TaskTargetError(TaskDiagnosticCode.TargetAmbiguous, `The module ${moduleName} ${isLibrary ? "exports" : "provides"} more than one class named "${taskName}":\n` + tasks.map((reference) => ` - ${reference.sourceFile}`).join("\n"));
3530
5160
  return {
3531
5161
  module,
3532
5162
  task: tasks[0]
@@ -3687,8 +5317,25 @@ var RefusingInternalClient = class {
3687
5317
  return Promise.reject(/* @__PURE__ */ new Error(`InternalClient.fetch(${JSON.stringify(input)}) is not available in \`heximon task run\`: the run boots no HTTP surface, so a task cannot call the app's own routes.`));
3688
5318
  }
3689
5319
  };
3690
- /** The failure a run reports to the caller; the message is ready to print. */
3691
- var TaskRunFailure = class extends Error {};
5320
+ /** The failure a run reports to the caller; the message is ready to print on stderr. */
5321
+ var TaskRunFailure = class extends Error {
5322
+ code;
5323
+ /** The one-paragraph text of the `--json` diagnostic: the message without a stack trace. */
5324
+ summary;
5325
+ /** How to fix the failure, or where to read more. */
5326
+ hint;
5327
+ /**
5328
+ * @param code - The stable code of the `--json` diagnostic.
5329
+ * @param message - The text for stderr.
5330
+ * @param details - The thrown value, the diagnostic text when it differs from the message, and the hint.
5331
+ */
5332
+ constructor(code, message, details = {}) {
5333
+ super(message, "cause" in details ? { cause: details.cause } : void 0);
5334
+ this.code = code;
5335
+ this.summary = details.summary ?? message;
5336
+ this.hint = details.hint;
5337
+ }
5338
+ };
3692
5339
  /** The task itself threw. The message is ready to print, and the thrown value is the cause. */
3693
5340
  var TaskRefusal = class extends TaskRunFailure {};
3694
5341
  /**
@@ -3710,6 +5357,8 @@ var TaskRunner = class TaskRunner {
3710
5357
  static interruptShutdownMilliseconds = 5e3;
3711
5358
  /** The exit code of a run whose task itself threw. Every other failure exits with `1`. */
3712
5359
  static refusalExitCode = 2;
5360
+ /** The hint of a diagnostic whose full text, with the stack trace, is on stderr. */
5361
+ static stackHint = "The stack trace is on stderr.";
3713
5362
  static wiringFilePattern = /^apps\/.+\.wiring\.js$/;
3714
5363
  outputDirectory;
3715
5364
  app;
@@ -3726,6 +5375,10 @@ var TaskRunner = class TaskRunner {
3726
5375
  stdout = new StdoutRedirect();
3727
5376
  /** The result of the task as one line of JSON, printed after the shutdown. */
3728
5377
  serialized;
5378
+ /** The diagnostics of the compile, for the `--json` document. */
5379
+ compileDiagnostics = [];
5380
+ /** Why the shutdown failed, when it failed. */
5381
+ shutdownFailure;
3729
5382
  disposing;
3730
5383
  /** How many package specifiers the run imported, for the file names of {@link ProjectModules.import}. */
3731
5384
  packageImports = 0;
@@ -3733,13 +5386,31 @@ var TaskRunner = class TaskRunner {
3733
5386
  this.options = options;
3734
5387
  }
3735
5388
  /**
3736
- * Run the task.
5389
+ * Run the task and print the text output. Everything the app logs goes to stderr: stdout carries the result
5390
+ * alone.
3737
5391
  *
3738
5392
  * @returns The process exit code: `0` when the task returned, `2` when the task itself threw (stdout then
3739
5393
  * holds one `{"error":{"name":…,"message":…}}` line), `1` for every other outcome. The reason is already
3740
5394
  * on stderr.
3741
5395
  */
3742
5396
  async run() {
5397
+ let outcome;
5398
+ this.stdout.start();
5399
+ try {
5400
+ outcome = await this.perform();
5401
+ } finally {
5402
+ this.stdout.stop();
5403
+ }
5404
+ if (outcome.line !== void 0) this.stdout.write(`${outcome.line}\n`);
5405
+ return outcome.exitCode;
5406
+ }
5407
+ /**
5408
+ * Run the task without a write to stdout. The caller sends stdout to stderr for the run, because the app
5409
+ * and the host log there; the reason of a failure is already on stderr when this returns.
5410
+ *
5411
+ * @returns The exit code, the text line, and the `--json` document of the run.
5412
+ */
5413
+ async perform() {
3743
5414
  let interrupts = 0;
3744
5415
  const interrupt = (signal) => {
3745
5416
  interrupts += 1;
@@ -3748,35 +5419,69 @@ var TaskRunner = class TaskRunner {
3748
5419
  process.on("SIGINT", interrupt);
3749
5420
  process.on("SIGTERM", interrupt);
3750
5421
  let exitCode = 0;
3751
- this.stdout.start();
5422
+ const diagnostics = [];
5423
+ let thrown;
3752
5424
  try {
3753
5425
  this.outputDirectory = mkdtempSync(join(this.options.rootDir, TaskRunner.outputDirectoryPrefix));
3754
- this.runtime = await TaskRunner.projectRuntime(this.outputDirectory);
3755
- const output = await this.compile();
5426
+ this.runtime = await TaskRunner.during(TaskDiagnosticCode.BootFailed, () => TaskRunner.projectRuntime(this.outputDirectory));
5427
+ const output = await TaskRunner.during(TaskDiagnosticCode.CompileFailed, () => this.compile());
3756
5428
  const target = this.pickTarget(output);
3757
5429
  TaskRunner.refuseBindings(output, this.platformName, this.outputDirectory);
3758
5430
  this.materialize(output);
3759
- await this.boot(output);
3760
- await this.execute(target);
5431
+ await TaskRunner.during(TaskDiagnosticCode.BootFailed, () => this.boot(output));
5432
+ await this.runTask(target);
3761
5433
  } catch (error) {
3762
5434
  TaskRunner.report(error);
5435
+ diagnostics.push(...TaskRunner.diagnosticsOf(error));
3763
5436
  if (error instanceof TaskRefusal) {
3764
5437
  exitCode = TaskRunner.refusalExitCode;
3765
- this.serialized = TaskRunner.errorLine(error.cause);
5438
+ thrown = TaskRunner.errorOf(error.cause);
5439
+ this.serialized = JSON.stringify({ error: thrown });
3766
5440
  } else exitCode = 1;
3767
5441
  } finally {
3768
5442
  if (!await this.dispose() && exitCode === 0) exitCode = 1;
3769
- this.stdout.stop();
3770
- if (this.serialized !== void 0) this.stdout.write(`${this.serialized}\n`);
3771
5443
  process.off("SIGINT", interrupt);
3772
5444
  process.off("SIGTERM", interrupt);
3773
5445
  }
3774
- return exitCode;
5446
+ if (this.shutdownFailure !== void 0) diagnostics.push(CliDiagnostics.error(TaskDiagnosticCode.ShutdownFailed, this.shutdownFailure));
5447
+ return {
5448
+ exitCode,
5449
+ ...this.serialized === void 0 ? {} : { line: this.serialized },
5450
+ document: {
5451
+ ok: exitCode === 0,
5452
+ diagnostics: [...this.compileDiagnostics, ...diagnostics],
5453
+ module: this.options.moduleName,
5454
+ task: this.options.taskName,
5455
+ ...thrown === void 0 ? TaskRunner.resultField(this.serialized) : { error: thrown }
5456
+ }
5457
+ };
5458
+ }
5459
+ /**
5460
+ * Run one step of the run. A failure the step does not name itself becomes a {@link TaskRunFailure} with
5461
+ * `code`; its stderr text stays the stack trace of the thrown value.
5462
+ */
5463
+ static async during(code, step) {
5464
+ try {
5465
+ return await step();
5466
+ } catch (error) {
5467
+ if (error instanceof TaskRunFailure || error instanceof TaskTargetError || error instanceof CommandFailure) throw error;
5468
+ throw new TaskRunFailure(code, TaskRunner.describe(error), {
5469
+ cause: error,
5470
+ summary: TaskRunner.messageOf(error),
5471
+ hint: TaskRunner.stackHint
5472
+ });
5473
+ }
5474
+ }
5475
+ /** The `result` field of the document, read back from the printed result so both outputs agree. */
5476
+ static resultField(serialized) {
5477
+ if (serialized === void 0) return {};
5478
+ const parsed = JSON.parse(serialized);
5479
+ return typeof parsed === "object" && parsed !== null && "result" in parsed ? { result: parsed.result } : {};
3775
5480
  }
3776
5481
  /** The `@heximon/runtime` of the project, the copy the generated wiring imports. */
3777
5482
  static async projectRuntime(directory) {
3778
5483
  const runtime = await ProjectModules.runtime(directory);
3779
- if (typeof runtime.StandingWork?.suspend !== "function") throw new TaskRunFailure("The @heximon/runtime of this project is older than this command. Update @heximon/runtime and heximon to one version.");
5484
+ if (typeof runtime.StandingWork?.suspend !== "function") throw new TaskRunFailure(TaskDiagnosticCode.RuntimeOutdated, "The @heximon/runtime of this project is older than this command. Update @heximon/runtime and heximon to one version.");
3780
5485
  return runtime;
3781
5486
  }
3782
5487
  /**
@@ -3796,13 +5501,13 @@ var TaskRunner = class TaskRunner {
3796
5501
  if (nitro.isNitroHost) {
3797
5502
  const sourceDirectories = TaskRunner.sourceDirectoriesOf(nitro, this.options.isSourceExplicit === true ? this.options.sourceDirectories ?? [] : void 0);
3798
5503
  const preset = nitro.preset;
3799
- const host = await NitroHost.open(rootDir, outputDirectory, {
5504
+ const host = await ConfigSidecar.during(rootDir, "production", () => NitroHost.open(rootDir, outputDirectory, {
3800
5505
  preset: preset.kind === "literal" ? preset.value : void 0,
3801
5506
  platformFromSidecar: preset.kind === "computed",
3802
5507
  aliases: this.aliases,
3803
5508
  ...sourceDirectories
3804
- });
3805
- if (preset.kind === "computed" && !host.hasSidecarPlatform()) throw new TaskRunFailure(`The Nitro preset is computed (${preset.reason}, in ${preset.file}), and the command does not run your config files, so it cannot read the platform: declare \`platform\` in heximon.config.ts (for example \`platform: new CloudflareWorkersStrategy()\`), or set NITRO_PRESET. Nothing was booted.`);
5509
+ }));
5510
+ if (preset.kind === "computed" && !host.hasSidecarPlatform()) throw new TaskRunFailure(TaskDiagnosticCode.NitroConfigComputed, `The Nitro preset is computed (${preset.reason}, in ${preset.file}), and the command does not run your config files, so it cannot read the platform: declare \`platform\` in heximon.config.ts (for example \`platform: new CloudflareWorkersStrategy()\`), or set NITRO_PRESET. Nothing was booted.`);
3806
5511
  const compiled = host.compile(rootDir, outputDirectory, this.options.isSourceExplicit === true ? this.options.sourceDirectories : void 0);
3807
5512
  ({output, compiler} = compiled);
3808
5513
  this.platformName = compiled.platformName;
@@ -3817,10 +5522,13 @@ var TaskRunner = class TaskRunner {
3817
5522
  compiler = session.compiler;
3818
5523
  }
3819
5524
  const errors = output.diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error);
3820
- if (output.diagnostics.length > 0) CompileDiagnostics.renderToStderr(rootDir, output.diagnostics);
3821
- if (errors.length > 0) throw new TaskRunFailure(`The production graph has ${errors.length} compile error${errors.length === 1 ? "" : "s"}. Nothing was booted.`);
5525
+ if (output.diagnostics.length > 0) {
5526
+ CompileDiagnostics.renderToStderr(rootDir, output.diagnostics);
5527
+ this.compileDiagnostics = CliDiagnostics.fromCompiler(output.diagnostics);
5528
+ }
5529
+ if (errors.length > 0) throw new TaskRunFailure(TaskDiagnosticCode.CompileFailed, `The production graph has ${errors.length} compile error${errors.length === 1 ? "" : "s"}. Nothing was booted.`);
3822
5530
  const findings = LibraryFactoryPreflight.inspect(compiler.consumedBoundaryPackages(), LibraryFactoryPreflight.productionBuildOptions);
3823
- if (findings.length > 0) throw new TaskRunFailure(LibraryFactoryPreflight.explain(findings));
5531
+ if (findings.length > 0) throw new TaskRunFailure(TaskDiagnosticCode.LibraryPreflightFailed, LibraryFactoryPreflight.explain(findings));
3824
5532
  return output;
3825
5533
  }
3826
5534
  /**
@@ -3829,7 +5537,7 @@ var TaskRunner = class TaskRunner {
3829
5537
  */
3830
5538
  static sourceDirectoriesOf(nitro, explicit) {
3831
5539
  if (explicit !== void 0) return { scanDirs: explicit };
3832
- for (const [name, value] of [["serverDir", nitro.serverDir], ["scanDirs", nitro.scanDirs]]) if (value.kind === "computed") throw new TaskRunFailure(`The Nitro \`${name}\` is computed (${value.reason}, in ${value.file}), and the command does not run your config files, so it cannot read the source directories. Name them with \`--src <dir>\` (repeat the option, or separate directories with commas). Nothing was booted.`);
5540
+ for (const [name, value] of [["serverDir", nitro.serverDir], ["scanDirs", nitro.scanDirs]]) if (value.kind === "computed") throw new TaskRunFailure(TaskDiagnosticCode.NitroConfigComputed, `The Nitro \`${name}\` is computed (${value.reason}, in ${value.file}), and the command does not run your config files, so it cannot read the source directories. Name them with \`--src <dir>\` (repeat the option, or separate directories with commas). Nothing was booted.`);
3833
5541
  return {
3834
5542
  ...nitro.serverDir.kind === "literal" ? { serverDir: nitro.serverDir.value } : {},
3835
5543
  ...nitro.scanDirs.kind === "literal" ? { scanDirs: nitro.scanDirs.value } : {}
@@ -3837,13 +5545,13 @@ var TaskRunner = class TaskRunner {
3837
5545
  }
3838
5546
  /** Pick the module and the task from the compiled graph. */
3839
5547
  pickTarget(output) {
3840
- if (output.moduleGraph === void 0) throw new TaskRunFailure("The compile produced no module graph, so the task cannot be found.");
5548
+ if (output.moduleGraph === void 0) throw new TaskRunFailure(TaskDiagnosticCode.AppNotFound, "The compile produced no module graph, so the task cannot be found.");
3841
5549
  return TaskTargetResolver.resolve(output.moduleGraph, this.options.moduleName, this.options.taskName, output.boundaryNodes);
3842
5550
  }
3843
5551
  /** Refuse a graph that needs bindings only its platform has, before anything boots. */
3844
5552
  static refuseBindings(output, platformName, outputDirectory) {
3845
5553
  const needs = PlatformBindingCensus.collect(output, platformName, outputDirectory);
3846
- if (needs.length > 0) throw new TaskRunFailure(PlatformBindingCensus.explain(needs, platformName));
5554
+ if (needs.length > 0) throw new TaskRunFailure(TaskDiagnosticCode.PlatformBindingsRequired, PlatformBindingCensus.explain(needs, platformName));
3847
5555
  }
3848
5556
  /** Write the generated files into the temporary directory. */
3849
5557
  materialize(output) {
@@ -3856,7 +5564,7 @@ var TaskRunner = class TaskRunner {
3856
5564
  /** Boot the app with no HTTP surface and without the standing work of the deployed app. */
3857
5565
  async boot(output) {
3858
5566
  const wiring = output.files.find((file) => TaskRunner.wiringFilePattern.test(file.path));
3859
- if (wiring === void 0) throw new TaskRunFailure("No app wiring file was generated. Make sure the project has a root module (a module that no other module imports).");
5567
+ if (wiring === void 0) throw new TaskRunFailure(TaskDiagnosticCode.AppNotFound, "No app wiring file was generated. Make sure the project has a root module (a module that no other module imports).");
3860
5568
  const runtime = this.runtime;
3861
5569
  if (TransformBoot.isBootProcess()) TransformBoot.registerSourceResolver(new Map(output.sourceRewrites.map((rewrite) => [rewrite.filePath, rewrite.content])), this.aliases);
3862
5570
  runtime.StandingWork.suspend(runtime.StandingWork.kinds().filter((kind) => !(this.options.withOutboxRelay && kind === runtime.StandingWorkKind.OutboxRelay)));
@@ -3876,17 +5584,17 @@ var TaskRunner = class TaskRunner {
3876
5584
  this.app = await wiringModule.createApp(bindings);
3877
5585
  this.context = await this.app.from(runtime.CoreModule).get(runtime.Context);
3878
5586
  }
3879
- /** Run the task and print its result. */
3880
- async execute(target) {
5587
+ /** Run the task and keep its result as one line of JSON. */
5588
+ async runTask(target) {
3881
5589
  const runtime = this.runtime;
3882
5590
  const moduleClass = await this.importClass(target.module.sourceFile, target.module.className);
3883
5591
  const taskClass = await this.importClass(target.task.sourceFile, target.task.className);
3884
- if (typeof taskClass.prototype.run !== "function") throw new TaskRunFailure(`${this.options.taskName} is not a task: the class has no run() method. A task extends Task.`);
5592
+ if (typeof taskClass.prototype.run !== "function") throw new TaskRunFailure(TaskDiagnosticCode.NotATask, `${this.options.taskName} is not a task: the class has no run() method. A task extends Task.`);
3885
5593
  let task;
3886
5594
  try {
3887
5595
  task = await this.app.from(moduleClass).get(taskClass);
3888
5596
  } catch (error) {
3889
- throw new TaskRunFailure(`The task ${this.options.taskName} could not be created:\n${TaskRunner.describe(error)}`, { cause: error });
5597
+ throw TaskRunner.failureOf(TaskDiagnosticCode.CreateFailed, `The task ${this.options.taskName} could not be created`, error);
3890
5598
  }
3891
5599
  const event = {
3892
5600
  name: this.options.taskName,
@@ -3899,22 +5607,22 @@ var TaskRunner = class TaskRunner {
3899
5607
  try {
3900
5608
  await seeds?.apply(event, this.context);
3901
5609
  } catch (error) {
3902
- throw new TaskRunFailure(`The task ${this.options.taskName} did not start:\n${TaskRunner.describe(error)}`, { cause: error });
5610
+ throw TaskRunner.failureOf(TaskDiagnosticCode.StartFailed, `The task ${this.options.taskName} did not start`, error);
3903
5611
  }
3904
5612
  try {
3905
5613
  return await task.run(event);
3906
5614
  } catch (error) {
3907
- throw new TaskRefusal(`The task ${this.options.taskName} failed:\n${TaskRunner.describe(error)}`, { cause: error });
5615
+ throw TaskRunner.failureOf(TaskDiagnosticCode.TaskFailed, `The task ${this.options.taskName} failed`, error, TaskRefusal);
3908
5616
  }
3909
5617
  });
3910
5618
  } catch (error) {
3911
5619
  if (error instanceof TaskRunFailure) throw error;
3912
- throw new TaskRunFailure(`The task ${this.options.taskName} did not start:\n${TaskRunner.describe(error)}`, { cause: error });
5620
+ throw TaskRunner.failureOf(TaskDiagnosticCode.StartFailed, `The task ${this.options.taskName} did not start`, error);
3913
5621
  }
3914
5622
  try {
3915
5623
  this.serialized = JSON.stringify(outcome);
3916
5624
  } catch (error) {
3917
- throw new TaskRunFailure(`The task ran, but its result is not JSON: ${error instanceof Error ? error.message : String(error)}`);
5625
+ throw new TaskRunFailure(TaskDiagnosticCode.ResultNotJson, `The task ran, but its result is not JSON: ${error instanceof Error ? error.message : String(error)}`);
3918
5626
  }
3919
5627
  }
3920
5628
  /** The seeds the app declares for every background frame, or none. */
@@ -3934,7 +5642,7 @@ var TaskRunner = class TaskRunner {
3934
5642
  async importClass(sourceFile, className) {
3935
5643
  this.packageImports += 1;
3936
5644
  const found = (isAbsolute(sourceFile) ? await import(pathToFileURL(sourceFile).href) : await ProjectModules.import(this.outputDirectory, `project-package-${this.packageImports}.js`, `export * from ${JSON.stringify(sourceFile)};\n`))[className];
3937
- if (found === void 0) throw new TaskRunFailure(`${sourceFile} does not export a class named ${className}.`);
5645
+ if (found === void 0) throw new TaskRunFailure(TaskDiagnosticCode.TargetNotFound, `${sourceFile} does not export a class named ${className}.`);
3938
5646
  return found;
3939
5647
  }
3940
5648
  /**
@@ -3954,7 +5662,8 @@ var TaskRunner = class TaskRunner {
3954
5662
  await this.app?.shutdown();
3955
5663
  } catch (error) {
3956
5664
  clean = false;
3957
- process.stderr.write(`The task finished, but shutting the app down failed — a client may still be open: ${error instanceof Error ? error.message : String(error)}\n`);
5665
+ this.shutdownFailure = "The task finished, but shutting the app down failed — a client may still be open: " + TaskRunner.messageOf(error);
5666
+ process.stderr.write(`${this.shutdownFailure}\n`);
3958
5667
  }
3959
5668
  this.restoreStandingWork?.();
3960
5669
  this.restoreStandingWork = void 0;
@@ -3988,25 +5697,46 @@ var TaskRunner = class TaskRunner {
3988
5697
  static describe(error) {
3989
5698
  return error instanceof Error ? error.stack ?? error.message : String(error);
3990
5699
  }
5700
+ /** The message of a thrown value: the error message, else its string form. */
5701
+ static messageOf(error) {
5702
+ return error instanceof Error ? error.message : String(error);
5703
+ }
5704
+ /**
5705
+ * A failure that wraps a thrown value: stderr gets the lead and the stack trace, the `--json` diagnostic the
5706
+ * lead and the message.
5707
+ */
5708
+ static failureOf(code, lead, error, Failure = TaskRunFailure) {
5709
+ return new Failure(code, `${lead}:\n${TaskRunner.describe(error)}`, {
5710
+ cause: error,
5711
+ summary: `${lead}: ${TaskRunner.messageOf(error)}`,
5712
+ hint: TaskRunner.stackHint
5713
+ });
5714
+ }
3991
5715
  /**
3992
- * The one stdout line of a refused run: `{"error":{"name":…,"message":…}}`. The name is the error name, or the
3993
- * class name when the class leaves the generic `Error` name.
5716
+ * The error of a refused run, as its stdout line `{"error":{"name":…,"message":…}}` names it. The name is the
5717
+ * error name, or the class name when the class leaves the generic `Error` name.
3994
5718
  */
3995
- static errorLine(thrown) {
3996
- if (!(thrown instanceof Error)) return JSON.stringify({ error: {
5719
+ static errorOf(thrown) {
5720
+ if (!(thrown instanceof Error)) return {
3997
5721
  name: "Error",
3998
5722
  message: String(thrown)
3999
- } });
5723
+ };
4000
5724
  const className = thrown.constructor?.name;
4001
- const name = thrown.name === "Error" && className !== void 0 && className !== "" ? className : thrown.name;
4002
- return JSON.stringify({ error: {
4003
- name,
5725
+ return {
5726
+ name: thrown.name === "Error" && className !== void 0 && className !== "" ? className : thrown.name,
4004
5727
  message: thrown.message
4005
- } });
5728
+ };
5729
+ }
5730
+ /** The `--json` diagnostics of a failure. */
5731
+ static diagnosticsOf(error) {
5732
+ if (error instanceof CommandFailure) return error.diagnostics;
5733
+ if (error instanceof TaskRunFailure) return [CliDiagnostics.error(error.code, error.summary, error.hint === void 0 ? {} : { hint: error.hint })];
5734
+ if (error instanceof TaskTargetError) return [CliDiagnostics.error(error.code, error.message)];
5735
+ return [CliDiagnostics.error(CliDiagnosticCode.UnexpectedError, TaskRunner.messageOf(error), { hint: TaskRunner.stackHint })];
4006
5736
  }
4007
5737
  /** Print a failure on stderr. */
4008
5738
  static report(error) {
4009
- if (error instanceof TaskRunFailure || error instanceof TaskTargetError) {
5739
+ if (error instanceof TaskRunFailure || error instanceof TaskTargetError || error instanceof CommandFailure) {
4010
5740
  process.stderr.write(`${error.message}\n`);
4011
5741
  return;
4012
5742
  }
@@ -4092,10 +5822,46 @@ var TaskCommand = class TaskCommand {
4092
5822
  process.exitCode = exitCode;
4093
5823
  setTimeout(() => process.exit(exitCode), TaskCommand.exitGraceMilliseconds).unref();
4094
5824
  }
4095
- /** Print a usage failure and set the exit code. */
4096
- static refuse(reason) {
4097
- process.stderr.write(`heximon task run: ${reason}\n`);
4098
- process.exitCode = 1;
5825
+ /**
5826
+ * Run the command in this process: the transform-enabled child, or a test. Without `--json` stdout gets the
5827
+ * text line of the result; with it, one `--json` document. Sets the exit code.
5828
+ *
5829
+ * @param args - The parsed arguments.
5830
+ * @param rawArgs - The arguments after `task run`, for every `--src`.
5831
+ */
5832
+ static async runInProcess(args, rawArgs) {
5833
+ const commandOutput = CommandOutput.resolve("task run", args);
5834
+ await commandOutput.run(async () => {
5835
+ const rootDir = ProjectRoot.resolve(args.cwd);
5836
+ const parsed = TaskCommand.parsePayload(args.payload);
5837
+ if (!parsed.ok) throw TaskCommand.invalidOption("--payload", parsed.reason);
5838
+ const envFile = args["env-file"];
5839
+ if (envFile !== void 0) try {
5840
+ await TaskCommand.loadEnvironmentFile(resolve(process.cwd(), envFile));
5841
+ } catch (error) {
5842
+ throw TaskCommand.invalidOption("--env-file", error instanceof Error ? error.message : String(error));
5843
+ }
5844
+ const sourceOption = TaskCommand.sourceOption(rawArgs);
5845
+ const sourceDirectories = SourceDirectories.resolve(rootDir, sourceOption);
5846
+ const runner = new TaskRunner({
5847
+ rootDir,
5848
+ moduleName: args.module,
5849
+ taskName: args.task,
5850
+ withOutboxRelay: args["with-outbox-relay"] === true,
5851
+ isSourceExplicit: sourceOption !== void 0 && sourceOption.trim() !== "",
5852
+ ...parsed.payload === void 0 ? {} : { payload: parsed.payload },
5853
+ ...sourceDirectories === void 0 ? {} : { sourceDirectories }
5854
+ });
5855
+ if (commandOutput.isJson()) {
5856
+ const outcome = await runner.perform();
5857
+ process.exitCode = outcome.exitCode;
5858
+ commandOutput.emit(outcome.document);
5859
+ } else process.exitCode = await runner.run();
5860
+ });
5861
+ }
5862
+ /** A failure of one option; the text output prints the reason alone, as before. */
5863
+ static invalidOption(option, reason) {
5864
+ return new CommandFailure([CliDiagnostics.error(CliDiagnosticCode.InvalidOptions, reason, { option })]);
4099
5865
  }
4100
5866
  };
4101
5867
  const taskRunCommand = defineCommand({
@@ -4114,6 +5880,7 @@ const taskRunCommand = defineCommand({
4114
5880
  description: "The class name of the task.",
4115
5881
  required: true
4116
5882
  },
5883
+ json: CommandOutput.jsonFlag,
4117
5884
  cwd: {
4118
5885
  type: "string",
4119
5886
  description: "Project root directory.",
@@ -4139,34 +5906,12 @@ const taskRunCommand = defineCommand({
4139
5906
  },
4140
5907
  async run({ args, rawArgs }) {
4141
5908
  if (!TransformBoot.isBootProcess()) {
4142
- process.exitCode = await TransformBoot.reexecUnderTransform("task run");
4143
- return;
4144
- }
4145
- const rootDir = resolve(process.cwd(), args.cwd);
4146
- const parsed = TaskCommand.parsePayload(args.payload);
4147
- if (!parsed.ok) {
4148
- TaskCommand.refuse(parsed.reason);
4149
- return;
4150
- }
4151
- const envFile = args["env-file"];
4152
- if (envFile !== void 0) try {
4153
- await TaskCommand.loadEnvironmentFile(resolve(process.cwd(), envFile));
4154
- } catch (error) {
4155
- TaskCommand.refuse(error instanceof Error ? error.message : String(error));
5909
+ const commandOutput = CommandOutput.resolve("task run", args);
5910
+ if (await ProjectRoot.checkBeforeRelaunch(args.cwd, commandOutput)) await TransformBoot.relaunch("task run", commandOutput);
4156
5911
  return;
4157
5912
  }
4158
- const sourceOption = TaskCommand.sourceOption(rawArgs);
4159
- const sourceDirectories = SourceDirectories.resolve(rootDir, sourceOption);
4160
- const exitCode = await new TaskRunner({
4161
- rootDir,
4162
- moduleName: args.module,
4163
- taskName: args.task,
4164
- withOutboxRelay: args["with-outbox-relay"],
4165
- isSourceExplicit: sourceOption !== void 0 && sourceOption.trim() !== "",
4166
- ...parsed.payload === void 0 ? {} : { payload: parsed.payload },
4167
- ...sourceDirectories === void 0 ? {} : { sourceDirectories }
4168
- }).run();
4169
- TaskCommand.finish(exitCode);
5913
+ await TaskCommand.runInProcess(args, rawArgs);
5914
+ TaskCommand.finish(Number(process.exitCode ?? 0));
4170
5915
  }
4171
5916
  });
4172
5917
  /** `heximon task` — run one-off work of the app. */
@@ -4193,6 +5938,13 @@ const WorkflowBaselineState = {
4193
5938
  /** No committed snapshot for this workflow — the gate is unarmed until `heximon check --update` runs. */
4194
5939
  Unarmed: "unarmed"
4195
5940
  };
5941
+ /** The stable codes of the failures `heximon workflows` raises itself, beside the compiler's own. */
5942
+ const WorkflowsDiagnosticCode = {
5943
+ /** The committed `heximon.upgrade.json` exists but cannot be read, so no workflow state is known. */
5944
+ BaselineUnreadable: "workflows.baseline-unreadable",
5945
+ /** The Cloudflare live instances of one workflow could not be listed, so the fleet picture is incomplete. */
5946
+ EnumerationFailed: "workflows.enumeration-failed"
5947
+ };
4196
5948
  /**
4197
5949
  * Parses, classifies, and renders the durable-workflow upgrade-safety report — pure functions over the emitted
4198
5950
  * baseline, the committed baseline, and the (optional) Cloudflare live-fleet inventories.
@@ -4271,16 +6023,18 @@ var WorkflowStatusReport = class WorkflowStatusReport {
4271
6023
  return parts.length === 0 ? `${count} — baseline armed & in sync.` : `${count} — ${parts.join(", ")}.`;
4272
6024
  }
4273
6025
  /**
4274
- * The `--format json` body: the per-workflow entries with their inventories (`null` when skipped).
6026
+ * The `--json` document: the per-workflow entries with their inventories (`null` when skipped).
4275
6027
  *
4276
6028
  * @param options - The classified entries, their inventories, and any upgrade-verdict diagnostics the
4277
6029
  * compile raised. An upgrade verdict rides the report rather than replacing it — it is the outcome of a
4278
- * complete analysis — but it still makes the run report `ok: false`.
6030
+ * complete analysis — but it still makes the run report `ok: false`. So does each enumeration error,
6031
+ * which also becomes an error diagnostic.
4279
6032
  */
4280
6033
  static toJson(options) {
4281
6034
  const upgradeDiagnostics = options.upgradeDiagnostics ?? [];
6035
+ const enumerationDiagnostics = [...options.enumerationErrors].map(([workflowName, reason]) => CliDiagnostics.error(WorkflowsDiagnosticCode.EnumerationFailed, `Cloudflare enumeration failed for ${workflowName}: ${reason}`, { hint: "Check CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, then run the command again." }));
4282
6036
  return {
4283
- ok: options.enumerationErrors.size === 0 && upgradeDiagnostics.length === 0,
6037
+ ok: enumerationDiagnostics.length === 0 && upgradeDiagnostics.length === 0,
4284
6038
  workflows: options.entries.map((entry) => {
4285
6039
  const enumerationError = options.enumerationErrors.get(entry.resourceName);
4286
6040
  return {
@@ -4293,25 +6047,26 @@ var WorkflowStatusReport = class WorkflowStatusReport {
4293
6047
  enumerated: options.cloudflareSkipped === void 0,
4294
6048
  ...options.cloudflareSkipped === void 0 ? {} : { skipped: options.cloudflareSkipped }
4295
6049
  },
4296
- ...upgradeDiagnostics.length === 0 ? {} : { diagnostics: upgradeDiagnostics }
6050
+ diagnostics: [...upgradeDiagnostics, ...enumerationDiagnostics]
4297
6051
  };
4298
6052
  }
4299
6053
  /**
4300
- * The `--format json` body for a run that produced NO per-workflow report — a workflow-free app, a failed
6054
+ * The `--json` document for a run that produced NO per-workflow report — a workflow-free app, a failed
4301
6055
  * compile, or an unreadable committed baseline. Same shape as {@link toJson}, so one reader covers both.
4302
6056
  *
4303
- * @param options - Why there is no report, and whether the run itself succeeded.
6057
+ * @param options - Why there is no report, and the diagnostics of a run that failed. `ok` is `false`
6058
+ * exactly when one of them is an error.
4304
6059
  */
4305
6060
  static noReportJson(options) {
6061
+ const diagnostics = options.diagnostics ?? [];
4306
6062
  return {
4307
- ok: options.ok,
6063
+ ok: !diagnostics.some((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error),
4308
6064
  workflows: [],
4309
6065
  cloudflare: {
4310
6066
  enumerated: false,
4311
6067
  skipped: options.reason
4312
6068
  },
4313
- ...options.error === void 0 ? {} : { error: options.error },
4314
- ...options.diagnostics === void 0 ? {} : { diagnostics: options.diagnostics }
6069
+ diagnostics
4315
6070
  };
4316
6071
  }
4317
6072
  /**
@@ -4456,9 +6211,10 @@ const workflowsCommand = defineCommand({
4456
6211
  description: "Project root directory.",
4457
6212
  default: "."
4458
6213
  },
6214
+ json: CommandOutput.jsonFlag,
4459
6215
  format: {
4460
6216
  type: "string",
4461
- description: "Output format: 'pretty' (default) or 'json'.",
6217
+ description: "Output format: 'pretty' (default) or 'json' (the same as --json).",
4462
6218
  default: "pretty"
4463
6219
  },
4464
6220
  env: {
@@ -4472,91 +6228,87 @@ const workflowsCommand = defineCommand({
4472
6228
  }
4473
6229
  },
4474
6230
  async run({ args }) {
4475
- const rootDir = resolve(process.cwd(), args.cwd);
4476
- const asJson = args.format === "json";
4477
- const sourceDirectories = SourceDirectories.resolve(rootDir, args.src);
4478
- if (!asJson) intro(`heximon workflows — ${basename(rootDir)}`);
4479
- const output = (await ProjectCompileSession.open({
4480
- rootDir,
4481
- environment: args.env,
4482
- ...sourceDirectories === void 0 ? {} : { sourceDirectories }
4483
- })).compile();
4484
- const errors = output.diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error);
4485
- const upgradeErrors = errors.filter((diagnostic) => UpgradeBaseline.isBreaking(diagnostic.code));
4486
- const blockingErrors = errors.filter((diagnostic) => !UpgradeBaseline.isBreaking(diagnostic.code));
4487
- if (blockingErrors.length > 0) {
4488
- if (asJson) process.stdout.write(`${JSON.stringify(WorkflowStatusReport.noReportJson({
4489
- ok: false,
4490
- reason: "the compile failed — no workflow report to enumerate against.",
4491
- error: `Compile failed — ${blockingErrors.length} error${blockingErrors.length === 1 ? "" : "s"}.`,
4492
- diagnostics: blockingErrors
4493
- }))}\n`);
4494
- else {
4495
- CompileDiagnostics.renderToStderr(rootDir, blockingErrors);
4496
- outro(`Compile failed — ${blockingErrors.length} error${blockingErrors.length === 1 ? "" : "s"}; no workflow report.`);
6231
+ const commandOutput = CommandOutput.resolve("workflows", args);
6232
+ await commandOutput.run(async () => {
6233
+ const rootDir = ProjectRoot.resolve(args.cwd);
6234
+ const sourceDirectories = SourceDirectories.resolve(rootDir, args.src);
6235
+ if (!commandOutput.isJson()) intro(`heximon workflows — ${basename(rootDir)}`);
6236
+ const output = (await ProjectCompileSession.open({
6237
+ rootDir,
6238
+ environment: args.env,
6239
+ ...sourceDirectories === void 0 ? {} : { sourceDirectories }
6240
+ })).compile();
6241
+ const errors = output.diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error);
6242
+ const upgradeErrors = errors.filter((diagnostic) => UpgradeBaseline.isBreaking(diagnostic.code));
6243
+ const blockingErrors = errors.filter((diagnostic) => !UpgradeBaseline.isBreaking(diagnostic.code));
6244
+ if (blockingErrors.length > 0) {
6245
+ if (commandOutput.isJson()) commandOutput.emit(WorkflowStatusReport.noReportJson({
6246
+ reason: "the compile failed — no workflow report to enumerate against.",
6247
+ diagnostics: CliDiagnostics.fromCompiler(blockingErrors)
6248
+ }));
6249
+ else {
6250
+ CompileDiagnostics.renderToStderr(rootDir, blockingErrors);
6251
+ outro(`Compile failed — ${blockingErrors.length} error${blockingErrors.length === 1 ? "" : "s"}; no workflow report.`);
6252
+ }
6253
+ process.exitCode = 1;
6254
+ return;
4497
6255
  }
4498
- process.exitCode = 1;
4499
- return;
4500
- }
4501
- const generated = output.files.find((file) => file.path === WorkflowStatusReport.generatedBaselinePath);
4502
- const current = generated === void 0 ? void 0 : WorkflowStatusReport.parseGeneratedText(generated.content);
4503
- if (current === void 0 || current.workflows.length === 0) {
4504
- if (asJson) process.stdout.write(`${JSON.stringify(WorkflowStatusReport.noReportJson({
4505
- ok: true,
4506
- reason: "this app declares no workflows — nothing to enumerate."
4507
- }))}\n`);
4508
- else outro("No workflows in this app.");
4509
- return;
4510
- }
4511
- const committed = WorkflowsCommand.readCommittedBaseline(rootDir);
4512
- if (committed.state === CommittedBaselineState.Unreadable) {
4513
- 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.`;
4514
- if (asJson) process.stdout.write(`${JSON.stringify(WorkflowStatusReport.noReportJson({
4515
- ok: false,
4516
- reason: "the committed upgrade-safety baseline is unreadable — nothing to compare against.",
4517
- error: message
4518
- }))}\n`);
4519
- else {
4520
- log.error(message);
4521
- outro("Upgrade-safety baseline unreadable — no workflow report.");
6256
+ const generated = output.files.find((file) => file.path === WorkflowStatusReport.generatedBaselinePath);
6257
+ const current = generated === void 0 ? void 0 : WorkflowStatusReport.parseGeneratedText(generated.content);
6258
+ if (current === void 0 || current.workflows.length === 0) {
6259
+ if (commandOutput.isJson()) commandOutput.emit(WorkflowStatusReport.noReportJson({ reason: "this app declares no workflows — nothing to enumerate." }));
6260
+ else outro("No workflows in this app.");
6261
+ return;
4522
6262
  }
4523
- process.exitCode = 1;
4524
- return;
4525
- }
4526
- const entries = WorkflowStatusReport.classify(current.workflows, committed.document);
4527
- const inventories = /* @__PURE__ */ new Map();
4528
- const enumerationErrors = /* @__PURE__ */ new Map();
4529
- let cloudflareSkipped;
4530
- const accountId = process.env["CLOUDFLARE_ACCOUNT_ID"];
4531
- const apiToken = process.env["CLOUDFLARE_API_TOKEN"];
4532
- 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.";
4533
- else {
4534
- const inspector = await CloudflareInspectorLoader.load(rootDir, {
4535
- accountId,
4536
- apiToken
4537
- });
4538
- if (inspector === void 0) cloudflareSkipped = "@heximon/cloudflare is not installed in this app — Cloudflare live-instance enumeration skipped.";
4539
- else for (const entry of entries) try {
4540
- inventories.set(entry.resourceName, await inspector.inspect({
4541
- workflowName: entry.resourceName,
4542
- currentFingerprint: entry.fingerprint
6263
+ const committed = WorkflowsCommand.readCommittedBaseline(rootDir);
6264
+ if (committed.state === CommittedBaselineState.Unreadable) {
6265
+ 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.`;
6266
+ if (commandOutput.isJson()) commandOutput.emit(WorkflowStatusReport.noReportJson({
6267
+ reason: "the committed upgrade-safety baseline is unreadable — nothing to compare against.",
6268
+ diagnostics: [CliDiagnostics.error(WorkflowsDiagnosticCode.BaselineUnreadable, message, { hint: "Restore the committed file, for example from git, before you deploy." })]
4543
6269
  }));
4544
- } catch (error) {
4545
- enumerationErrors.set(entry.resourceName, error instanceof Error ? error.message : String(error));
6270
+ else {
6271
+ log.error(message);
6272
+ outro("Upgrade-safety baseline unreadable — no workflow report.");
6273
+ }
6274
+ process.exitCode = 1;
6275
+ return;
4546
6276
  }
4547
- }
4548
- if (asJson) process.stdout.write(`${JSON.stringify(WorkflowStatusReport.toJson({
4549
- entries,
4550
- inventories,
4551
- enumerationErrors,
4552
- cloudflareSkipped,
4553
- ...upgradeErrors.length === 0 ? {} : { upgradeDiagnostics: upgradeErrors }
4554
- }))}\n`);
4555
- else {
4556
- WorkflowsCommand.report(entries, inventories, enumerationErrors, cloudflareSkipped);
4557
- if (upgradeErrors.length > 0) CompileDiagnostics.renderToStderr(rootDir, upgradeErrors);
4558
- }
4559
- if (enumerationErrors.size > 0 || upgradeErrors.length > 0) process.exitCode = 1;
6277
+ const entries = WorkflowStatusReport.classify(current.workflows, committed.document);
6278
+ const inventories = /* @__PURE__ */ new Map();
6279
+ const enumerationErrors = /* @__PURE__ */ new Map();
6280
+ let cloudflareSkipped;
6281
+ const accountId = process.env["CLOUDFLARE_ACCOUNT_ID"];
6282
+ const apiToken = process.env["CLOUDFLARE_API_TOKEN"];
6283
+ 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.";
6284
+ else {
6285
+ const inspector = await CloudflareInspectorLoader.load(rootDir, {
6286
+ accountId,
6287
+ apiToken
6288
+ });
6289
+ if (inspector === void 0) cloudflareSkipped = "@heximon/cloudflare is not installed in this app — Cloudflare live-instance enumeration skipped.";
6290
+ else for (const entry of entries) try {
6291
+ inventories.set(entry.resourceName, await inspector.inspect({
6292
+ workflowName: entry.resourceName,
6293
+ currentFingerprint: entry.fingerprint
6294
+ }));
6295
+ } catch (error) {
6296
+ enumerationErrors.set(entry.resourceName, error instanceof Error ? error.message : String(error));
6297
+ }
6298
+ }
6299
+ if (commandOutput.isJson()) commandOutput.emit(WorkflowStatusReport.toJson({
6300
+ entries,
6301
+ inventories,
6302
+ enumerationErrors,
6303
+ cloudflareSkipped,
6304
+ upgradeDiagnostics: CliDiagnostics.fromCompiler(upgradeErrors)
6305
+ }));
6306
+ else {
6307
+ WorkflowsCommand.report(entries, inventories, enumerationErrors, cloudflareSkipped);
6308
+ if (upgradeErrors.length > 0) CompileDiagnostics.renderToStderr(rootDir, upgradeErrors);
6309
+ }
6310
+ if (enumerationErrors.size > 0 || upgradeErrors.length > 0) process.exitCode = 1;
6311
+ });
4560
6312
  }
4561
6313
  });
4562
6314
  /** Output helpers for {@link workflowsCommand}: the committed-baseline read and the pretty report. */
@@ -4635,6 +6387,7 @@ const main = defineCommand({
4635
6387
  routes: routesCommand,
4636
6388
  graph: graphCommand,
4637
6389
  doctor: doctorCommand,
6390
+ freshness: freshnessCommand,
4638
6391
  provision: provisionCommand,
4639
6392
  migrations: migrationsCommand,
4640
6393
  workflows: workflowsCommand,
@@ -4643,6 +6396,6 @@ const main = defineCommand({
4643
6396
  }
4644
6397
  });
4645
6398
  await tab(main);
4646
- await runMain(main);
6399
+ await CliEntry.run(main);
4647
6400
  //#endregion
4648
6401
  export {};