@velajs/cli 1.23.0 → 1.24.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/index.js CHANGED
@@ -1,15 +1,46 @@
1
1
  #!/usr/bin/env node
2
- import { defineVelaConfig, loadConfig } from "./config.js";
2
+ import { defineVelaConfig, loadConfig, resolveConfig } from "./config.js";
3
3
  import { t as generateClientContract } from "./client-contract-C7P2btFE.js";
4
4
  import { Builtins, Cli, Command, Option, UsageError } from "clipanion";
5
5
  import { lstat, mkdir, open, readFile, readdir, rmdir, unlink, writeFile } from "node:fs/promises";
6
- import { createOpenApiDocument, describeToken, getEntrypointKinds } from "@velajs/vela";
7
- import { dirname, join } from "node:path";
6
+ import { createOpenApiDocument, describeToken, getEntrypointKinds, parseCron, parseCronMetadata } from "@velajs/vela";
7
+ import { dirname, extname, join, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { z } from "zod";
10
- import { runSeeders } from "@velajs/vela/seeder";
10
+ import { SeederRegistry, runSeeders } from "@velajs/vela/seeder";
11
+ import { execFile } from "node:child_process";
12
+ import { createHash } from "node:crypto";
13
+ import { promisify } from "node:util";
14
+ import { parse } from "jsonc-parser";
15
+ import { parse as parse$1 } from "smol-toml";
11
16
  //#region package.json
12
- var version = "1.23.0";
17
+ var version = "1.24.0";
18
+ //#endregion
19
+ //#region src/with-app.ts
20
+ /** Own one app for the whole command, including output and long-lived transports. */
21
+ async function withApp(config, work, warn) {
22
+ const app = await config.createApp();
23
+ const report = (error) => {
24
+ try {
25
+ warn(`Warning: teardown failed: ${String(error)}`);
26
+ } catch {}
27
+ };
28
+ try {
29
+ return await work(app);
30
+ } finally {
31
+ try {
32
+ if (typeof app.dispose === "function") await app.dispose();
33
+ else await app.getContainer().dispose();
34
+ } catch (error) {
35
+ report(error);
36
+ try {
37
+ await app.getContainer().dispose();
38
+ } catch (cleanupError) {
39
+ report(cleanupError);
40
+ }
41
+ }
42
+ }
43
+ }
13
44
  //#endregion
14
45
  //#region src/format.ts
15
46
  /**
@@ -148,17 +179,9 @@ var AppCommand = class extends Command {
148
179
  config = Option.String("--config", { description: "Path to the vela config file." });
149
180
  json = Option.Boolean("--json", false, { description: "Emit machine-readable JSON." });
150
181
  async execute() {
151
- const app = await (await loadConfig(process.cwd(), this.config)).createApp();
152
- try {
153
- return await this.run(app);
154
- } finally {
155
- const dispose = app.dispose;
156
- if (typeof dispose === "function") try {
157
- await dispose.call(app);
158
- } catch (error) {
159
- this.context.stderr.write(`Warning: teardown failed: ${String(error)}\n`);
160
- }
161
- }
182
+ return withApp(await loadConfig(process.cwd(), this.config), (app) => this.run(app), (message) => {
183
+ this.context.stderr.write(`${message}\n`);
184
+ });
162
185
  }
163
186
  print(text) {
164
187
  this.context.stdout.write(`${text}\n`);
@@ -261,12 +284,12 @@ var OpenApiDumpCommand = class extends Command {
261
284
  this.context.stderr.write("openapi dump needs the root module. Add it to your vela.config:\n\n export default defineVelaConfig({\n rootModule: AppModule,\n async createApp() { ... },\n });\n");
262
285
  return 1;
263
286
  }
264
- const app = await velaConfig.createApp();
265
- try {
287
+ const rootModule = velaConfig.rootModule;
288
+ return withApp(velaConfig, async (app) => {
266
289
  const info = {};
267
290
  if (this.title) info.title = this.title;
268
291
  if (this.apiVersion) info.version = this.apiVersion;
269
- const document = createOpenApiDocument(velaConfig.rootModule, {
292
+ const document = createOpenApiDocument(rootModule, {
270
293
  globalPrefix: this.globalPrefix ?? app.getGlobalPrefix(),
271
294
  ...Object.keys(info).length > 0 ? { info } : {}
272
295
  });
@@ -276,14 +299,7 @@ var OpenApiDumpCommand = class extends Command {
276
299
  this.context.stdout.write(`Wrote ${this.out}\n`);
277
300
  } else this.context.stdout.write(`${text}\n`);
278
301
  return 0;
279
- } finally {
280
- const dispose = app.dispose;
281
- if (typeof dispose === "function") try {
282
- await dispose.call(app);
283
- } catch (error) {
284
- this.context.stderr.write(`Warning: teardown failed: ${String(error)}\n`);
285
- }
286
- }
302
+ }, (message) => this.context.stderr.write(`${message}\n`));
287
303
  }
288
304
  };
289
305
  //#endregion
@@ -354,10 +370,8 @@ function describeToken$1(app, token) {
354
370
  * introspection as the `route`/`module`/`entrypoint`/`openapi` commands, so an
355
371
  * AI agent can query a Vela app's shape over the Model Context Protocol.
356
372
  *
357
- * Deliberately does NOT extend `AppCommand`: that base disposes the app in its
358
- * `finally` the moment `run()` returns, but an MCP server must stay alive until
359
- * the transport closes. stdout is reserved for JSON-RPC framing; every human
360
- * message goes to stderr.
373
+ * The application lifetime includes the transport's close promise. stdout is
374
+ * reserved for JSON-RPC framing; every human message goes to stderr.
361
375
  */
362
376
  var McpServeCommand = class extends Command {
363
377
  static paths = [["mcp", "serve"]];
@@ -375,10 +389,10 @@ var McpServeCommand = class extends Command {
375
389
  this.context.stderr.write(`${message}\n`);
376
390
  };
377
391
  const velaConfig = await loadConfig(process.cwd(), this.config);
378
- const app = await velaConfig.createApp();
379
392
  const rootModule = velaConfig.rootModule;
380
- try {
381
- const server = new McpServer(await readCliIdentity());
393
+ return withApp(velaConfig, async (app) => {
394
+ const identity = await readCliIdentity();
395
+ const server = new McpServer(identity);
382
396
  server.registerTool("route_list", {
383
397
  description: "The app's HTTP route table: framework-composed controller routes (method, full path, Controller#handler) plus everything else mounted on the router, labeled (mounted). Empty when the app builds no HTTP routes.",
384
398
  inputSchema: {}
@@ -434,14 +448,7 @@ var McpServeCommand = class extends Command {
434
448
  log(`vela mcp serve — ready (5 tools${rootModule ? " + vela://openapi resource" : ""}). Awaiting client on stdio; stdout is JSON-RPC only.`);
435
449
  await closed;
436
450
  return 0;
437
- } finally {
438
- const dispose = app.dispose;
439
- if (typeof dispose === "function") try {
440
- await dispose.call(app);
441
- } catch (error) {
442
- this.context.stderr.write(`Warning: teardown failed: ${String(error)}\n`);
443
- }
444
- }
451
+ }, log);
445
452
  }
446
453
  };
447
454
  //#endregion
@@ -453,22 +460,44 @@ var SeedCommand = class extends Command {
453
460
  category: "Database",
454
461
  description: "Run database seeders for the Vela app.",
455
462
  details: "Loads vela.config.{js,mjs,ts}, builds the app, and runs all @Seeder() classes in order.",
456
- examples: [["Run all seeders", "vela db seed"], ["Use a specific config", "vela db seed --config ./config/vela.config.js"]]
463
+ examples: [
464
+ ["Run all seeders", "vela db seed"],
465
+ ["Use a specific config", "vela db seed --config ./config/vela.config.js"],
466
+ ["List seeders and their module owners", "vela db seed --list --json"]
467
+ ]
457
468
  });
458
469
  config = Option.String("--config", { description: "Path to the vela config file." });
459
470
  continueOnError = Option.Boolean("--continue-on-error", false, { description: "Run all seeders even if one fails." });
471
+ list = Option.Boolean("--list", false, { description: "List registered seeders and their owners without running them." });
472
+ json = Option.Boolean("--json", false, { description: "Emit --list inventory as JSON." });
460
473
  async execute() {
461
- const { createApp } = await loadConfig(process.cwd(), this.config);
462
- const app = await createApp();
463
- this.context.stdout.write("Running seeders…\n");
464
- const code = formatSeedResults(await runSeeders(app, { stopOnError: !this.continueOnError }), (message) => this.context.stdout.write(`${message}\n`));
465
- const dispose = app.dispose;
466
- if (typeof dispose === "function") try {
467
- await dispose.call(app);
468
- } catch (error) {
469
- this.context.stderr.write(`Warning: teardown failed after seeding: ${String(error)}\n`);
470
- }
471
- return code;
474
+ if (this.json && !this.list) throw new UsageError("--json requires --list.");
475
+ if (this.list && this.continueOnError) throw new UsageError("--list cannot be combined with --continue-on-error.");
476
+ return withApp(await loadConfig(process.cwd(), this.config), async (app) => {
477
+ if (this.list) {
478
+ const inventory = app.get(SeederRegistry).list().map((seeder) => ({
479
+ name: seeder.name,
480
+ order: seeder.order,
481
+ target: describeToken(seeder.target),
482
+ moduleId: seeder.moduleId ?? null
483
+ }));
484
+ const output = this.json ? JSON.stringify(inventory, null, 2) : renderTable([
485
+ "ORDER",
486
+ "NAME",
487
+ "MODULE",
488
+ "TARGET"
489
+ ], inventory.map((entry) => [
490
+ String(entry.order),
491
+ entry.name,
492
+ entry.moduleId ?? "(unknown)",
493
+ entry.target
494
+ ])).join("\n");
495
+ this.context.stdout.write(`${output}\n`);
496
+ return 0;
497
+ }
498
+ this.context.stdout.write("Running seeders…\n");
499
+ return formatSeedResults(await runSeeders(app, { stopOnError: !this.continueOnError }), (message) => this.context.stdout.write(`${message}\n`));
500
+ }, (message) => this.context.stderr.write(`${message}\n`));
472
501
  }
473
502
  };
474
503
  //#endregion
@@ -482,7 +511,7 @@ var SeedCommand = class extends Command {
482
511
  const HOST_PACKAGE = "@velajs/studio-host";
483
512
  /** True for a failed dynamic `import()` of a missing module (ESM or CJS code). */
484
513
  function isModuleNotFound(error, specifier) {
485
- const code = error.code;
514
+ const code = error !== null && typeof error === "object" && "code" in error ? error.code : void 0;
486
515
  if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") return true;
487
516
  return (error instanceof Error ? error.message : "").includes(specifier);
488
517
  }
@@ -520,9 +549,9 @@ var StudioCommand = class extends Command {
520
549
  }
521
550
  let port;
522
551
  if (this.port !== void 0) {
523
- port = Number.parseInt(this.port, 10);
524
- if (Number.isNaN(port) || port < 0 || port > 65535) {
525
- this.context.stderr.write(`vela studio: --port must be a number 0-65535, got: ${this.port}\n`);
552
+ port = Number(this.port);
553
+ if (!/^\d+$/.test(this.port) || !Number.isInteger(port) || port < 0 || port > 65535) {
554
+ this.context.stderr.write(`vela studio: --port must be a decimal integer 0-65535, got: ${this.port}\n`);
526
555
  return 1;
527
556
  }
528
557
  }
@@ -582,7 +611,7 @@ var ClientGenerateCommand = class extends Command {
582
611
  async execute() {
583
612
  if (this.input && this.config) throw new Error("Use either --input or --config, not both.");
584
613
  if (this.check && !this.out) throw new Error("--check requires --out.");
585
- const document = this.input ? await this.readDocument(this.input) : await this.fromApp();
614
+ const document = this.input ? await this.#readDocument(this.input) : await this.#fromApp();
586
615
  const { source, warnings } = generateClientContract(document);
587
616
  for (const warning of warnings) this.context.stderr.write(`Warning: ${warning}\n`);
588
617
  if (this.strict && warnings.length) return 1;
@@ -604,28 +633,22 @@ var ClientGenerateCommand = class extends Command {
604
633
  } else this.context.stdout.write(source);
605
634
  return 0;
606
635
  }
607
- async readDocument(file) {
636
+ async #readDocument(file) {
608
637
  return JSON.parse(await readFile(file, "utf8"));
609
638
  }
610
- async fromApp() {
639
+ async #fromApp() {
611
640
  const config = await loadConfig(process.cwd(), this.config);
612
641
  if (!config.rootModule) throw new Error("client generate needs rootModule in vela.config, or pass --input openapi.json.");
613
- const app = await config.createApp();
614
- try {
615
- const document = createOpenApiDocument(config.rootModule, { globalPrefix: app.getGlobalPrefix() });
642
+ const rootModule = config.rootModule;
643
+ return withApp(config, async (app) => {
644
+ const document = createOpenApiDocument(rootModule, { globalPrefix: app.getGlobalPrefix() });
616
645
  for (const route of app.describeRoutes()) {
617
646
  const path = route.path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, "{$1}");
618
647
  const item = document.paths[path];
619
648
  if (!item || !Object.hasOwn(item, route.method.toLowerCase())) throw new Error(`OpenAPI is missing ${route.method} ${route.path}. Update Vela or pass a complete document with --input.`);
620
649
  }
621
650
  return document;
622
- } finally {
623
- try {
624
- await app.dispose();
625
- } catch (error) {
626
- this.context.stderr.write(`Warning: teardown failed: ${String(error)}\n`);
627
- }
628
- }
651
+ }, (message) => this.context.stderr.write(`${message}\n`));
629
652
  }
630
653
  };
631
654
  //#endregion
@@ -637,6 +660,7 @@ const files = [
637
660
  "tsconfig.json",
638
661
  ".swcrc",
639
662
  "wrangler.jsonc",
663
+ "vela.config.mjs",
640
664
  "gitignore",
641
665
  "README.md",
642
666
  "src/worker.ts",
@@ -707,6 +731,423 @@ var NewCommand = class extends Command {
707
731
  }
708
732
  };
709
733
  //#endregion
734
+ //#region src/commands/doctor.command.ts
735
+ /** Read only app-owned description APIs; never resolve providers or serialize their values. */
736
+ function describeApplication(app) {
737
+ return {
738
+ globalPrefix: app.getGlobalPrefix(),
739
+ modules: collectModules(app),
740
+ routes: collectRoutes(app),
741
+ entrypoints: app.entrypoints.kinds().flatMap((kind) => app.entrypoints.ofKind(kind).map((entry) => ({
742
+ kind,
743
+ target: `${describeToken(entry.token)}${entry.methodName === void 0 ? "" : `#${String(entry.methodName)}`}`,
744
+ ..."moduleId" in entry && typeof entry.moduleId === "string" ? { moduleId: entry.moduleId } : {}
745
+ })))
746
+ };
747
+ }
748
+ var DoctorCommand = class extends Command {
749
+ static paths = [["doctor"]];
750
+ static usage = Command.Usage({
751
+ category: "Introspection",
752
+ description: "Explain config resolution and optionally inspect the application graph.",
753
+ details: "Checks config file resolution without importing it. --app opts into config import and application bootstrap, then reads app-local module, route and entrypoint snapshots and disposes the app. No files are written, providers are not resolved by the snapshot, and entrypoint metadata is omitted.",
754
+ examples: [["Explain config selection", "vela doctor --json"], ["Inspect a built app", "vela doctor --app --config vela.config.mjs --json"]]
755
+ });
756
+ config = Option.String("--config", { description: "Path to the Vela config file." });
757
+ app = Option.Boolean("--app", false, { description: "Import config, bootstrap the app and inspect its graph." });
758
+ json = Option.Boolean("--json", false, { description: "Emit machine-readable diagnostics." });
759
+ async execute() {
760
+ const report = {
761
+ schemaVersion: 1,
762
+ cwd: process.cwd(),
763
+ nodeVersion: process.versions.node,
764
+ config: null,
765
+ issues: []
766
+ };
767
+ try {
768
+ report.config = await resolveConfig(report.cwd, this.config);
769
+ if (this.app) report.application = await withApp(await loadConfig(report.cwd, report.config.path), describeApplication, (message) => {
770
+ report.issues.push(message);
771
+ });
772
+ } catch (error) {
773
+ report.issues.push(error instanceof Error ? error.message : String(error));
774
+ }
775
+ if (this.json) this.context.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
776
+ else {
777
+ this.context.stdout.write(`Node ${report.nodeVersion}\nWorking directory: ${report.cwd}\n`);
778
+ if (report.config) {
779
+ this.context.stdout.write(`Config: ${report.config.path} (${report.config.source})\n`);
780
+ for (const candidate of report.config.candidates) this.context.stdout.write(` Checked: ${candidate}\n`);
781
+ }
782
+ if (report.application) {
783
+ const { modules, routes, entrypoints } = report.application;
784
+ this.context.stdout.write(`Application: ${modules.length} modules, ${routes?.length ?? 0} routes, ${entrypoints.length} entrypoints\n`);
785
+ this.context.stdout.write("Use --json for the full graph.\n");
786
+ } else if (!this.app) this.context.stdout.write("Config was not imported. Use --app to bootstrap and inspect a built application.\n");
787
+ for (const issue of report.issues) this.context.stderr.write(`${issue}\n`);
788
+ }
789
+ return report.issues.length ? 1 : 0;
790
+ }
791
+ };
792
+ //#endregion
793
+ //#region src/commands/deploy-check.config.ts
794
+ const record = z.record(z.string(), z.unknown());
795
+ const text = z.string().min(1).refine((value) => value.trim() === value && !/\p{Cc}/u.test(value));
796
+ const bindingName = text.regex(/^[A-Za-z_$][A-Za-z0-9_$]*$/u);
797
+ const workerName = text.max(255).regex(/^[a-zA-Z0-9-]+$/u);
798
+ const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/u).refine((value) => {
799
+ const parsed = new Date(value);
800
+ return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
801
+ });
802
+ /** Decode only data; parser errors deliberately omit configuration values. */
803
+ function parseDeploymentConfig(source, path) {
804
+ const extension = extname(path).toLowerCase();
805
+ if (extension === ".toml") try {
806
+ return parse$1(source);
807
+ } catch {
808
+ throw new Error("Invalid Wrangler TOML configuration.");
809
+ }
810
+ if (extension !== ".json" && extension !== ".jsonc") throw new Error("Wrangler configuration must be a .json, .jsonc or .toml file.");
811
+ const errors = [];
812
+ const result = parse(source, errors, {
813
+ allowTrailingComma: extension === ".jsonc",
814
+ disallowComments: extension === ".json"
815
+ });
816
+ if (errors.length > 0) throw new Error("Invalid Wrangler JSON configuration.");
817
+ return result;
818
+ }
819
+ function checked(schema, value, field) {
820
+ const result = schema.safeParse(value);
821
+ if (!result.success) throw new Error(`Invalid Wrangler field: ${field}.`);
822
+ return result.data;
823
+ }
824
+ /** A projection, not a replacement for Wrangler's full configuration validator. */
825
+ function selectDeploymentTarget(raw, environment) {
826
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/u.test(environment)) throw new Error("An explicit environment name using letters, digits, underscores or dashes is required.");
827
+ const root = checked(record, raw, "configuration");
828
+ const environments = checked(record, root.env, "env");
829
+ if (!Object.hasOwn(environments, environment)) throw new Error("The requested environment is not declared in Wrangler configuration.");
830
+ const selected = checked(record, environments[environment], `env.${environment}`);
831
+ const inherit = (key) => Object.hasOwn(selected, key) ? selected[key] : root[key];
832
+ const worker = checked(workerName, Object.hasOwn(selected, "name") ? selected.name : `${checked(workerName, root.name, "name")}-${environment}`, "name");
833
+ const main = checked(text.optional(), inherit("main"), "main") ?? null;
834
+ if (main === null) {
835
+ const assets = checked(record, inherit("assets"), "assets (required without main)");
836
+ checked(text, assets.directory, "assets.directory");
837
+ }
838
+ const compatibilityDate = checked(date, inherit("compatibility_date"), "compatibility_date");
839
+ const compatibilityFlags = checked(z.array(text).optional(), inherit("compatibility_flags"), "compatibility_flags") ?? [];
840
+ const triggers = checked(z.object({ crons: z.array(text) }).optional(), inherit("triggers"), "triggers");
841
+ const build = checked(z.object({ command: text.optional() }).optional(), inherit("build"), "build");
842
+ const bindings = [];
843
+ const names = /* @__PURE__ */ new Set();
844
+ const add = (name, kind) => {
845
+ if (names.has(name)) throw new Error(`Duplicate Worker binding name: ${name}.`);
846
+ names.add(name);
847
+ bindings.push({
848
+ name,
849
+ kind
850
+ });
851
+ };
852
+ const variables = checked(record.optional(), selected.vars, "vars") ?? {};
853
+ for (const name of Object.keys(variables)) add(checked(bindingName, name, "vars binding name"), "var");
854
+ for (const kind of [
855
+ "kv_namespaces",
856
+ "d1_databases",
857
+ "r2_buckets",
858
+ "services",
859
+ "hyperdrive",
860
+ "vectorize",
861
+ "workflows",
862
+ "analytics_engine_datasets"
863
+ ]) {
864
+ const rows = checked(z.array(record).optional(), selected[kind], kind) ?? [];
865
+ for (const row of rows) {
866
+ add(checked(bindingName, row.binding, `${kind}.binding`), kind);
867
+ for (const field of [
868
+ "id",
869
+ "database_id",
870
+ "database_name",
871
+ "bucket_name"
872
+ ]) if (Object.hasOwn(row, field)) checked(text, row[field], `${kind}.${field}`);
873
+ if (kind === "services") checked(workerName, row.service, "services.service");
874
+ if (kind === "workflows") checked(text, row.class_name, "workflows.class_name");
875
+ }
876
+ }
877
+ const durable = checked(z.object({ bindings: z.array(z.object({
878
+ name: bindingName,
879
+ class_name: text,
880
+ script_name: workerName.optional()
881
+ })) }).optional(), selected.durable_objects, "durable_objects");
882
+ for (const row of durable?.bindings ?? []) add(row.name, "durable_objects");
883
+ const queues = checked(z.object({
884
+ producers: z.array(z.object({
885
+ binding: bindingName,
886
+ queue: text.optional()
887
+ })).optional(),
888
+ consumers: z.array(z.object({ queue: text })).optional()
889
+ }).optional(), selected.queues, "queues");
890
+ for (const producer of queues?.producers ?? []) add(producer.binding, "queues");
891
+ const queueConsumers = queues?.consumers?.map((row) => row.queue) ?? [];
892
+ if (new Set(queueConsumers).size !== queueConsumers.length) throw new Error("Duplicate queue consumer configuration.");
893
+ return {
894
+ environment,
895
+ worker,
896
+ main,
897
+ compatibilityDate,
898
+ compatibilityFlags,
899
+ crons: triggers?.crons ?? [],
900
+ bindings,
901
+ queueConsumers,
902
+ customBuild: build?.command !== void 0
903
+ };
904
+ }
905
+ //#endregion
906
+ //#region src/commands/deploy-check.plan.ts
907
+ const rowsSchema = z.array(z.object({
908
+ kind: z.string().min(1),
909
+ target: z.string().min(1),
910
+ meta: z.unknown()
911
+ }));
912
+ const metadataSchema = z.record(z.string(), z.unknown());
913
+ /** Accept existing `vela entrypoint list --json` output without loading an application. */
914
+ function entrypoints(value) {
915
+ const parsed = rowsSchema.safeParse(value);
916
+ if (!parsed.success) throw new Error("Entrypoint snapshot must be an array of { kind, target, meta } rows.");
917
+ return parsed.data.filter((row) => !(row.target === "(no entrypoints)" && row.meta === "")).map((row) => {
918
+ let meta = row.meta;
919
+ if (typeof meta === "string") try {
920
+ meta = JSON.parse(meta);
921
+ } catch {
922
+ throw new Error("Invalid JSON metadata in entrypoint snapshot.");
923
+ }
924
+ return {
925
+ kind: row.kind,
926
+ meta
927
+ };
928
+ });
929
+ }
930
+ /** Compare literal dispatch keys; equivalent cron expressions are not interchangeable. */
931
+ function checkDeployment(rawConfig, environment, snapshot) {
932
+ const target = selectDeploymentTarget(rawConfig, environment);
933
+ const errors = [];
934
+ const warnings = [];
935
+ const report = (code, message) => errors.push({
936
+ code,
937
+ message
938
+ });
939
+ const validateCron = (cron) => {
940
+ if (!parseCron(cron, {
941
+ dialect: "cloudflare",
942
+ timeZone: "UTC"
943
+ })) report("invalid-cron", "A trigger or handler has an invalid Cloudflare cron expression.");
944
+ };
945
+ const crons = new Set(target.crons);
946
+ if (crons.size !== target.crons.length) report("duplicate-cron", "Duplicate cron trigger configuration.");
947
+ for (const cron of crons) validateCron(cron);
948
+ const handlerCrons = /* @__PURE__ */ new Set();
949
+ const handlerQueues = /* @__PURE__ */ new Set();
950
+ for (const row of entrypoints(snapshot)) {
951
+ if (row.kind === "schedule:interval") {
952
+ report("unsupported-interval", "Workers cannot drive @Interval handlers; use a scheduled trigger or a separate Node adapter.");
953
+ continue;
954
+ }
955
+ if (![
956
+ "cf:scheduled",
957
+ "cf:vela-cron",
958
+ "schedule:cron",
959
+ "cf:queue",
960
+ "websocket"
961
+ ].includes(row.kind)) continue;
962
+ const parsed = metadataSchema.safeParse(row.meta);
963
+ if (!parsed.success) {
964
+ report("invalid-metadata", `Invalid ${row.kind} metadata.`);
965
+ continue;
966
+ }
967
+ const meta = parsed.data;
968
+ if (row.kind === "cf:vela-cron" || row.kind === "schedule:cron") try {
969
+ const cron = parseCronMetadata(meta);
970
+ if (cron.dialect !== void 0 && cron.dialect !== "cloudflare" || cron.timeZone !== void 0 && cron.timeZone !== "UTC") report("incompatible-cron-options", "A cron handler explicitly requests options incompatible with Cloudflare UTC delivery.");
971
+ } catch {
972
+ report("invalid-metadata", `Invalid ${row.kind} metadata.`);
973
+ continue;
974
+ }
975
+ if (row.kind === "websocket") {
976
+ const options = Object.hasOwn(meta, "options") ? metadataSchema.safeParse(meta.options) : parsed;
977
+ const binding = options.success ? options.data.binding : void 0;
978
+ if (typeof binding !== "string" || !target.bindings.some((b) => b.kind === "durable_objects" && b.name === binding)) report("missing-durable-binding", "A WebSocket gateway requires a Durable Object binding in the selected environment.");
979
+ continue;
980
+ }
981
+ const key = row.kind === "cf:scheduled" ? "cron" : row.kind === "cf:queue" ? "queueName" : "expression";
982
+ const value = meta[key];
983
+ if (typeof value !== "string" || value.trim().length === 0) {
984
+ report("invalid-metadata", `Invalid ${row.kind}.${key} metadata.`);
985
+ continue;
986
+ }
987
+ if (row.kind === "cf:queue") handlerQueues.add(value);
988
+ else {
989
+ handlerCrons.add(value);
990
+ validateCron(value);
991
+ }
992
+ }
993
+ for (const cron of handlerCrons) if (!crons.has(cron)) report("missing-cron-trigger", `No exact Wrangler trigger for handler cron ${JSON.stringify(cron)}.`);
994
+ for (const cron of crons) if (!handlerCrons.has(cron)) report("unhandled-cron-trigger", `No metadata handler for Wrangler cron ${JSON.stringify(cron)}.`);
995
+ for (const queue of handlerQueues) if (!target.queueConsumers.includes(queue)) report("missing-queue-consumer", `No selected queue consumer for handler queue ${JSON.stringify(queue)}.`);
996
+ for (const queue of target.queueConsumers) if (!handlerQueues.has(queue)) report("unhandled-queue-consumer", `No metadata handler for selected queue ${JSON.stringify(queue)}.`);
997
+ warnings.push({
998
+ code: "static-only",
999
+ message: "Snapshot freshness, custom platform handlers, deployed resources, secrets and bundle/runtime behavior are not verified. Run Wrangler and native tests separately."
1000
+ });
1001
+ if (target.customBuild) warnings.push({
1002
+ code: "custom-build",
1003
+ message: "Wrangler has a custom build command. It was not executed by this check."
1004
+ });
1005
+ return {
1006
+ status: errors.length ? "failed" : "passed",
1007
+ target,
1008
+ errors,
1009
+ warnings
1010
+ };
1011
+ }
1012
+ //#endregion
1013
+ //#region src/commands/deploy-check.command.ts
1014
+ const exec = promisify(execFile);
1015
+ const MAX_INPUT_BYTES = 1048576;
1016
+ async function readInput(path) {
1017
+ const file = await open(path, "r");
1018
+ try {
1019
+ if (!(await file.stat()).isFile()) throw new Error("Input must be a regular file.");
1020
+ const buffer = Buffer.alloc(1048577);
1021
+ let length = 0;
1022
+ while (length < buffer.length) {
1023
+ const { bytesRead } = await file.read(buffer, length, buffer.length - length, length);
1024
+ if (bytesRead === 0) break;
1025
+ length += bytesRead;
1026
+ }
1027
+ if (length > MAX_INPUT_BYTES) throw new Error("Deployment inputs must not exceed 1 MiB.");
1028
+ return buffer.subarray(0, length).toString("utf8");
1029
+ } finally {
1030
+ await file.close();
1031
+ }
1032
+ }
1033
+ async function gitProvenance(cwd) {
1034
+ try {
1035
+ const options = {
1036
+ cwd,
1037
+ timeout: 5e3,
1038
+ maxBuffer: 1048576
1039
+ };
1040
+ const [head, status] = await Promise.all([exec("git", [
1041
+ "rev-parse",
1042
+ "--verify",
1043
+ "HEAD"
1044
+ ], options), exec("git", [
1045
+ "--no-optional-locks",
1046
+ "-c",
1047
+ "core.fsmonitor=false",
1048
+ "status",
1049
+ "--porcelain=v1",
1050
+ "--untracked-files=normal"
1051
+ ], options)]);
1052
+ const commit = head.stdout.trim();
1053
+ if (!/^[a-f0-9]{40,64}$/u.test(commit)) throw new Error("Invalid git commit.");
1054
+ return {
1055
+ commit,
1056
+ dirty: status.stdout.length > 0
1057
+ };
1058
+ } catch {
1059
+ return {
1060
+ commit: null,
1061
+ dirty: null
1062
+ };
1063
+ }
1064
+ }
1065
+ const digest = (text) => createHash("sha256").update(text).digest("hex");
1066
+ /** Static application deployment preflight; never invokes Wrangler or application code. */
1067
+ var DeployCheckCommand = class extends Command {
1068
+ static paths = [["deploy", "check"]];
1069
+ static usage = Command.Usage({
1070
+ category: "Deployment",
1071
+ description: "Check an explicit Wrangler target against a saved entrypoint snapshot.",
1072
+ details: "Read-only: no app bootstrap, custom build, credential loading or upload. Compares cron/queue dispatch keys and WebSocket Durable Object bindings. Wrangler remains the deployment tool.",
1073
+ examples: [["Check staging", "vela deploy check --config wrangler.jsonc --env staging --entrypoints entrypoints.json"]]
1074
+ });
1075
+ config = Option.String("--config", {
1076
+ required: true,
1077
+ description: "Explicit Wrangler .json/.jsonc/.toml file."
1078
+ });
1079
+ environment = Option.String("--env", {
1080
+ required: true,
1081
+ description: "Exact named environment in the Wrangler file."
1082
+ });
1083
+ entrypoints = Option.String("--entrypoints", {
1084
+ required: true,
1085
+ description: "Saved vela entrypoint list --json array."
1086
+ });
1087
+ json = Option.Boolean("--json", false, { description: "Emit the redacted report as JSON." });
1088
+ async execute() {
1089
+ try {
1090
+ if (!this.config.trim() || !this.entrypoints.trim()) throw new Error("Explicit configuration and snapshot paths are required.");
1091
+ const configPath = resolve(this.config);
1092
+ const snapshotPath = resolve(this.entrypoints);
1093
+ const [config, snapshot] = await Promise.all([readInput(configPath), readInput(snapshotPath)]);
1094
+ let rows;
1095
+ try {
1096
+ rows = JSON.parse(snapshot);
1097
+ } catch {
1098
+ throw new Error("Invalid entrypoint snapshot JSON.");
1099
+ }
1100
+ const plan = checkDeployment(parseDeploymentConfig(config, configPath), this.environment, rows);
1101
+ const provenance = {
1102
+ ...await gitProvenance(dirname(configPath)),
1103
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
1104
+ config: {
1105
+ path: configPath,
1106
+ sha256: digest(config)
1107
+ },
1108
+ entrypoints: {
1109
+ path: snapshotPath,
1110
+ sha256: digest(snapshot)
1111
+ }
1112
+ };
1113
+ const wrangler = {
1114
+ command: "pnpm",
1115
+ args: [
1116
+ "exec",
1117
+ "wrangler",
1118
+ "deploy",
1119
+ "--config",
1120
+ configPath,
1121
+ "--env",
1122
+ this.environment,
1123
+ "--dry-run"
1124
+ ]
1125
+ };
1126
+ const result = {
1127
+ ...plan,
1128
+ provenance,
1129
+ nextStep: wrangler
1130
+ };
1131
+ if (this.json) this.context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1132
+ else {
1133
+ this.context.stdout.write(`Deployment check: ${plan.status}\nWorker: ${plan.target.worker}\nEnvironment: ${plan.target.environment}\nConfig: ${configPath}\nCommit: ${provenance.commit ?? "unavailable"} (${provenance.dirty === null ? "cleanliness unknown" : provenance.dirty ? "dirty" : "clean"})\nConfig SHA-256: ${provenance.config.sha256}\nSnapshot SHA-256: ${provenance.entrypoints.sha256}\nBindings: ${plan.target.bindings.map((binding) => `${binding.name} (${binding.kind})`).join(", ") || "(none)"}\n`);
1134
+ for (const issue of plan.errors) this.context.stdout.write(`Error [${issue.code}]: ${issue.message}\n`);
1135
+ for (const issue of plan.warnings) this.context.stdout.write(`Warning [${issue.code}]: ${issue.message}\n`);
1136
+ this.context.stdout.write(`Next step (not executed): ${[wrangler.command, ...wrangler.args].map((arg) => `'${arg.replaceAll("'", "'\\''")}'`).join(" ")}\n`);
1137
+ }
1138
+ return plan.status === "passed" ? 0 : 1;
1139
+ } catch (error) {
1140
+ const message = error instanceof Error ? error.message : "Deployment check failed.";
1141
+ if (this.json) this.context.stdout.write(`${JSON.stringify({
1142
+ status: "failed",
1143
+ error: message
1144
+ })}\n`);
1145
+ else this.context.stderr.write(`${message}\n`);
1146
+ return 1;
1147
+ }
1148
+ }
1149
+ };
1150
+ //#endregion
710
1151
  //#region src/index.ts
711
1152
  const cli = new Cli({
712
1153
  binaryName: "vela",
@@ -724,8 +1165,10 @@ cli.register(OpenApiDumpCommand);
724
1165
  cli.register(McpServeCommand);
725
1166
  cli.register(StudioCommand);
726
1167
  cli.register(ClientGenerateCommand);
1168
+ cli.register(DoctorCommand);
1169
+ cli.register(DeployCheckCommand);
727
1170
  cli.runExit(process.argv.slice(2));
728
1171
  //#endregion
729
- export { ClientGenerateCommand, EntrypointListCommand, McpServeCommand, ModuleGraphCommand, NewCommand, OpenApiDumpCommand, RouteListCommand, SeedCommand, StudioCommand, collectEntrypoints, collectModules, collectRoutes, defineVelaConfig, formatSeedResults, generateClientContract, loadConfig, renderModuleTree, renderTable };
1172
+ export { ClientGenerateCommand, DeployCheckCommand, DoctorCommand, EntrypointListCommand, McpServeCommand, ModuleGraphCommand, NewCommand, OpenApiDumpCommand, RouteListCommand, SeedCommand, StudioCommand, collectEntrypoints, collectModules, collectRoutes, defineVelaConfig, formatSeedResults, generateClientContract, loadConfig, renderModuleTree, renderTable, resolveConfig };
730
1173
 
731
1174
  //# sourceMappingURL=index.js.map