@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/README.md +69 -12
- package/dist/config.d.ts +18 -9
- package/dist/config.js +48 -17
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +29 -8
- package/dist/index.js +515 -72
- package/dist/index.js.map +1 -1
- package/package.json +14 -12
- package/templates/worker/README.md +15 -0
- package/templates/worker/src/app.controller.ts +8 -2
- package/templates/worker/vela.config.mjs +9 -0
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.
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
265
|
-
|
|
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(
|
|
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
|
-
}
|
|
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
|
-
*
|
|
358
|
-
*
|
|
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
|
-
|
|
381
|
-
const
|
|
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
|
-
}
|
|
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: [
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
this.
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
524
|
-
if (Number.
|
|
525
|
-
this.context.stderr.write(`vela studio: --port must be a
|
|
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
|
|
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
|
|
614
|
-
|
|
615
|
-
const document = createOpenApiDocument(
|
|
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
|
-
}
|
|
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
|