@ultimat3/cli 1.0.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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +60 -0
  4. package/src/app-agents-md.ts +27 -0
  5. package/src/app-boundaries.ts +206 -0
  6. package/src/app-evals.ts +74 -0
  7. package/src/app-load.ts +136 -0
  8. package/src/app-manifest.ts +137 -0
  9. package/src/app-openapi.ts +12 -0
  10. package/src/app-root.ts +57 -0
  11. package/src/bin.ts +17 -0
  12. package/src/boundary-cuts.ts +219 -0
  13. package/src/budgets.ts +92 -0
  14. package/src/cmd-build.ts +109 -0
  15. package/src/cmd-db.ts +187 -0
  16. package/src/cmd-deploy.ts +124 -0
  17. package/src/cmd-dev.ts +286 -0
  18. package/src/cmd-doctor.ts +178 -0
  19. package/src/cmd-errors.ts +99 -0
  20. package/src/cmd-fix.ts +126 -0
  21. package/src/cmd-generate.ts +434 -0
  22. package/src/cmd-help.ts +94 -0
  23. package/src/cmd-i18n.ts +212 -0
  24. package/src/cmd-jobs.ts +237 -0
  25. package/src/cmd-manifest.ts +97 -0
  26. package/src/cmd-mcp.ts +176 -0
  27. package/src/cmd-new.ts +133 -0
  28. package/src/cmd-planned.ts +119 -0
  29. package/src/cmd-policy.ts +136 -0
  30. package/src/cmd-registries.ts +195 -0
  31. package/src/cmd-routes.ts +73 -0
  32. package/src/cmd-tasks.ts +151 -0
  33. package/src/cmd-test.ts +109 -0
  34. package/src/cmd-verify.ts +265 -0
  35. package/src/command.ts +33 -0
  36. package/src/dev-assets.ts +177 -0
  37. package/src/dev-dashboard.ts +242 -0
  38. package/src/dev-hooks.ts +51 -0
  39. package/src/dev-policy.ts +82 -0
  40. package/src/dev-queue.ts +109 -0
  41. package/src/dev-render.ts +129 -0
  42. package/src/dev-replicator.ts +92 -0
  43. package/src/dev-roles.ts +246 -0
  44. package/src/dev-runtime.ts +203 -0
  45. package/src/dev-services.ts +75 -0
  46. package/src/dev-traces.ts +141 -0
  47. package/src/dispatch.ts +98 -0
  48. package/src/drift.ts +86 -0
  49. package/src/error-catalog.ts +156 -0
  50. package/src/error-contract.ts +212 -0
  51. package/src/errors.ts +367 -0
  52. package/src/exec.ts +70 -0
  53. package/src/hold.ts +48 -0
  54. package/src/i18n-audit.ts +183 -0
  55. package/src/index.ts +179 -0
  56. package/src/jobs-drain.ts +151 -0
  57. package/src/jobs-json.ts +134 -0
  58. package/src/jobs-report.ts +132 -0
  59. package/src/jobs-table.ts +34 -0
  60. package/src/json-merge.ts +40 -0
  61. package/src/mcp-db-target.ts +50 -0
  62. package/src/mcp-errors.ts +99 -0
  63. package/src/mcp-host.ts +282 -0
  64. package/src/mcp-test-output.ts +57 -0
  65. package/src/messages.ts +119 -0
  66. package/src/output.ts +174 -0
  67. package/src/parse.ts +243 -0
  68. package/src/policy-facts.ts +196 -0
  69. package/src/policy-fixture.ts +71 -0
  70. package/src/registry.ts +73 -0
  71. package/src/scaffold-fixture.ts +69 -0
  72. package/src/scaffold-typecheck.ts +240 -0
  73. package/src/source-files.ts +38 -0
  74. package/src/table.ts +19 -0
  75. package/src/tasks-facts.ts +113 -0
  76. package/src/templates/action.ts +193 -0
  77. package/src/templates/admin.ts +46 -0
  78. package/src/templates/catalog-json.ts +17 -0
  79. package/src/templates/entity.ts +157 -0
  80. package/src/templates/index.ts +23 -0
  81. package/src/templates/job.ts +148 -0
  82. package/src/templates/locales.ts +93 -0
  83. package/src/templates/naming.ts +97 -0
  84. package/src/templates/policy.ts +120 -0
  85. package/src/templates/query.ts +116 -0
  86. package/src/templates/resource.ts +199 -0
  87. package/src/templates/route.ts +138 -0
  88. package/src/templates/scaffold-app.ts +320 -0
  89. package/src/templates/scaffold-docs.ts +156 -0
  90. package/src/templates/scaffold-i18n.ts +149 -0
  91. package/src/templates/scaffold-icon.ts +54 -0
  92. package/src/templates/scaffold-package-shape.ts +49 -0
  93. package/src/templates/scaffold-repo.ts +427 -0
  94. package/src/test-select.ts +130 -0
  95. package/src/test-shards.ts +188 -0
  96. package/src/thrown-by.ts +24 -0
  97. package/src/ts-scan.ts +217 -0
  98. package/src/verify-step.ts +83 -0
  99. package/src/verify-tests.ts +166 -0
  100. package/src/version-loader.ts +16 -0
  101. package/src/workspace-checks.ts +288 -0
package/src/cmd-db.ts ADDED
@@ -0,0 +1,187 @@
1
+ // `x db gen|migrate|reset|studio|branch` — everything that touches the database, including the
2
+ // branch DB that makes destructive work safe. An agent that can clone the database in a second
3
+ // can migrate, seed and break things without a human deciding whether to let it.
4
+
5
+ import { existsSync, readdirSync } from 'node:fs';
6
+ import { rm } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { branchPglite } from '@ultimat3/db';
9
+ import { requireAppRoot } from './app-root';
10
+ import type { CliCommand, CommandContext } from './command';
11
+ import { resolveServices } from './dev-services';
12
+ import { checkDrift, writeSchemaHash } from './drift';
13
+ import { CliNotImplementedError } from './errors';
14
+ import type { ExecResult } from './exec';
15
+ import { execOutput } from './exec';
16
+ import { msg } from './messages';
17
+ import type { CommandResult, Finding } from './output';
18
+ import { findingFrom } from './output';
19
+ import { flagString } from './parse';
20
+
21
+ export const DB_SUBCOMMANDS = ['gen', 'migrate', 'reset', 'studio', 'branch'] as const;
22
+
23
+ const failure = (result: ExecResult, code: string, fix: string): Finding => ({
24
+ code,
25
+ cause: `${result.command.join(' ')} exited ${result.code}: ${execOutput(result).slice(0, 400)}`,
26
+ fix,
27
+ docs: `https://ultimate.dev/errors/${code}`,
28
+ });
29
+
30
+ /** `x db branch <name>` on a real Postgres: copy-on-write clone, cheap and disposable. */
31
+ export function branchSql(source: string, branch: string): string {
32
+ return `CREATE DATABASE "${branch}" TEMPLATE "${source}"`;
33
+ }
34
+
35
+ export function branchDatabaseName(source: string, branch: string): string {
36
+ return `${source}_branch_${branch.replace(/[^a-zA-Z0-9_]/g, '_')}`;
37
+ }
38
+
39
+ export const previewUrl = (branch: string, port: number): string =>
40
+ `http://${branch}.localhost:${port}`;
41
+
42
+ async function runBranch(
43
+ ctx: CommandContext,
44
+ root: string,
45
+ branch: string,
46
+ ): Promise<CommandResult> {
47
+ const services = resolveServices(root, ctx.env);
48
+ const port = Number.parseInt(ctx.env['PORT'] ?? '3000', 10);
49
+ const url = previewUrl(branch, port);
50
+ if (services.db.mode === 'embedded') {
51
+ // @ultimat3/db owns embedded branching, name validation and the on-disk layout. Shelling out
52
+ // to `cp` here was a second implementation of all three — and `--reflink` is a GNU-only flag.
53
+ try {
54
+ const info = await branchPglite(branch, { from: services.db.url });
55
+ return {
56
+ ok: true,
57
+ command: 'db',
58
+ summary: msg('cli.db.branch.ready', { name: branch }),
59
+ data: { branch, database: info.dataDir, preview: url, mode: 'embedded' },
60
+ };
61
+ } catch (error) {
62
+ return {
63
+ ok: false,
64
+ command: 'db',
65
+ summary: msg('cli.usage'),
66
+ findings: [findingFrom(error)],
67
+ };
68
+ }
69
+ }
70
+ const source = services.db.url.split('/').at(-1) ?? 'postgres';
71
+ const database = branchDatabaseName(source, branch);
72
+ const psql = await ctx.runner(['psql', services.db.url, '-c', branchSql(source, database)], {
73
+ cwd: root,
74
+ });
75
+ if (!psql.ok) {
76
+ return {
77
+ ok: false,
78
+ command: 'db',
79
+ summary: msg('cli.usage'),
80
+ findings: [
81
+ failure(
82
+ psql,
83
+ 'X_DB_BRANCH_FAILED',
84
+ `close open connections to "${source}" (a TEMPLATE clone needs none), then retry`,
85
+ ),
86
+ ],
87
+ };
88
+ }
89
+ return {
90
+ ok: true,
91
+ command: 'db',
92
+ summary: msg('cli.db.branch.ready', { name: branch }),
93
+ data: { branch, database, preview: url, mode: 'external' },
94
+ };
95
+ }
96
+
97
+ export const dbCommand: CliCommand = {
98
+ spec: {
99
+ name: 'db',
100
+ summary: 'gen, migrate, reset, studio, branch',
101
+ usage: 'x db gen "add publish_at" | migrate | reset | studio | branch <name>',
102
+ requiresApp: true,
103
+ subcommands: DB_SUBCOMMANDS,
104
+ flags: [{ name: 'name', type: 'string', summary: 'migration or branch name' }],
105
+ },
106
+ async run(ctx: CommandContext): Promise<CommandResult> {
107
+ const root = requireAppRoot('db', ctx.cwd).dir;
108
+ const sub = ctx.args.subcommand ?? 'migrate';
109
+ const argument = ctx.args.positionals[0] ?? flagString(ctx.args, 'name');
110
+
111
+ if (sub === 'gen') {
112
+ const name = (argument ?? 'change').replace(/[^a-zA-Z0-9]+/g, '_').toLowerCase();
113
+ const result = await ctx.runner(['bunx', 'drizzle-kit', 'generate', '--name', name], {
114
+ cwd: root,
115
+ });
116
+ if (!result.ok) {
117
+ return {
118
+ ok: false,
119
+ command: 'db',
120
+ summary: msg('cli.usage'),
121
+ findings: [failure(result, 'X_DB_GEN_FAILED', 'x doctor --json')],
122
+ };
123
+ }
124
+ const hash = await writeSchemaHash(root, latestMigration(root, name));
125
+ return {
126
+ ok: true,
127
+ command: 'db',
128
+ summary: `migration ${name} generated`,
129
+ data: { migration: name, schemaHash: hash },
130
+ };
131
+ }
132
+
133
+ if (sub === 'migrate') {
134
+ const result = await ctx.runner(['bunx', 'drizzle-kit', 'migrate'], { cwd: root });
135
+ const drift = await checkDrift(root);
136
+ return {
137
+ ok: result.ok && drift.length === 0,
138
+ command: 'db',
139
+ summary: result.ok ? 'migrations applied' : 'migration failed',
140
+ findings: result.ok ? drift : [failure(result, 'X_DB_MIGRATE_FAILED', 'x db reset')],
141
+ data: { applied: result.ok },
142
+ };
143
+ }
144
+
145
+ if (sub === 'reset') {
146
+ const services = resolveServices(root, ctx.env);
147
+ if (services.db.mode === 'external') {
148
+ throw new CliNotImplementedError({
149
+ feature: 'x db reset against an external Postgres',
150
+ fix: 'drop and recreate the database yourself, then run: x db migrate',
151
+ });
152
+ }
153
+ await rm(join(services.stateDir, 'pgdata'), { recursive: true, force: true });
154
+ const migrate = await ctx.runner(['bunx', 'drizzle-kit', 'migrate'], { cwd: root });
155
+ return {
156
+ ok: migrate.ok,
157
+ command: 'db',
158
+ summary: migrate.ok ? 'database reset and migrated' : 'reset failed',
159
+ findings: migrate.ok ? [] : [failure(migrate, 'X_DB_MIGRATE_FAILED', 'x doctor --json')],
160
+ data: { stateDir: services.stateDir },
161
+ };
162
+ }
163
+
164
+ if (sub === 'studio') {
165
+ const result = await ctx.runner(['bunx', 'drizzle-kit', 'studio'], { cwd: root });
166
+ return {
167
+ ok: result.ok,
168
+ command: 'db',
169
+ summary: result.ok ? 'studio exited' : 'studio failed to start',
170
+ findings: result.ok ? [] : [failure(result, 'X_DB_STUDIO_FAILED', 'x doctor --json')],
171
+ };
172
+ }
173
+
174
+ return runBranch(ctx, root, argument ?? 'preview');
175
+ },
176
+ };
177
+
178
+ /** drizzle-kit names files `<index>_<name>.sql`; the hash sidecar has to match that base name. */
179
+ function latestMigration(root: string, name: string): string {
180
+ const dir = join(root, 'packages', 'db', 'migrations');
181
+ if (!existsSync(dir)) return `0000_${name}`;
182
+ const matches = readdirSync(dir)
183
+ .filter((file) => file.endsWith(`_${name}.sql`))
184
+ .sort();
185
+ const newest = matches.at(-1);
186
+ return newest === undefined ? `0000_${name}` : newest.replace(/\.sql$/, '');
187
+ }
@@ -0,0 +1,124 @@
1
+ // `x deploy` — containers only. The framework knows about images, roles and a registry; it does
2
+ // not know the name of a cloud, a KV store or an edge runtime (axiom 7). What it emits is a plan
3
+ // anything that runs containers can execute.
4
+
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { requireAppRoot } from './app-root';
8
+ import type { CliCommand, CommandContext } from './command';
9
+ import { CliNotImplementedError } from './errors';
10
+ import { msg } from './messages';
11
+ import type { CommandResult, JsonValue } from './output';
12
+ import { flagBool, flagString } from './parse';
13
+
14
+ export const DEPLOY_ROLES = ['migrate', 'web', 'sync', 'worker', 'scheduler'] as const;
15
+
16
+ export interface DeployPlan {
17
+ readonly image: string;
18
+ /** Ordered: migrate runs to completion before any role that serves traffic starts. */
19
+ readonly steps: readonly { readonly role: string; readonly command: readonly string[] }[];
20
+ }
21
+
22
+ export function planDeploy(image: string, method: 'compose' | 'helm', root: string): DeployPlan {
23
+ if (method === 'helm') {
24
+ return {
25
+ image,
26
+ steps: [
27
+ {
28
+ role: 'all',
29
+ command: [
30
+ 'helm',
31
+ 'upgrade',
32
+ '--install',
33
+ 'app',
34
+ join(root, 'docker', 'helm'),
35
+ '--set',
36
+ `image=${image}`,
37
+ ],
38
+ },
39
+ ],
40
+ };
41
+ }
42
+ return {
43
+ image,
44
+ steps: DEPLOY_ROLES.map((role) => ({
45
+ role,
46
+ command: [
47
+ 'docker',
48
+ 'compose',
49
+ '-f',
50
+ join(root, 'docker', 'docker-compose.prod.yml'),
51
+ role === 'migrate' ? 'run' : 'up',
52
+ role === 'migrate' ? '--rm' : '-d',
53
+ role,
54
+ ],
55
+ })),
56
+ };
57
+ }
58
+
59
+ export const deployCommand: CliCommand = {
60
+ spec: {
61
+ name: 'deploy',
62
+ summary: 'run the container deploy plan: migrate first, then the serving roles',
63
+ usage: 'x deploy --image repo/app:tag [--method compose|helm] [--dry-run] [--json]',
64
+ requiresApp: true,
65
+ flags: [
66
+ { name: 'image', type: 'string', summary: 'image reference to deploy' },
67
+ { name: 'method', type: 'string', summary: 'compose | helm', default: 'compose' },
68
+ { name: 'dry-run', type: 'boolean', summary: 'print the plan, run nothing' },
69
+ { name: 'critical', type: 'boolean', summary: 'security deploy: forces clients to reload' },
70
+ ],
71
+ },
72
+ async run(ctx: CommandContext): Promise<CommandResult> {
73
+ const root = requireAppRoot('deploy', ctx.cwd).dir;
74
+ const image = flagString(ctx.args, 'image') ?? 'ultimate-app:dev';
75
+ const method = flagString(ctx.args, 'method') === 'helm' ? 'helm' : 'compose';
76
+ if (method === 'helm' && !existsSync(join(root, 'docker', 'helm'))) {
77
+ throw new CliNotImplementedError({
78
+ feature: 'helm deploy without docker/helm in the app',
79
+ fix: 'copy docker/helm from the framework repo, or use: x deploy --method compose',
80
+ });
81
+ }
82
+ const plan = planDeploy(image, method, root);
83
+ const planJson: JsonValue = {
84
+ image: plan.image,
85
+ method,
86
+ critical: flagBool(ctx.args, 'critical'),
87
+ steps: plan.steps.map((step) => ({ role: step.role, command: step.command.join(' ') })),
88
+ };
89
+ if (flagBool(ctx.args, 'dry-run')) {
90
+ return {
91
+ ok: true,
92
+ command: 'deploy',
93
+ summary: msg('cli.deploy.plan', { images: 1, roles: DEPLOY_ROLES.join(',') }),
94
+ data: planJson,
95
+ lines: plan.steps.map((step) => ` ${step.role.padEnd(10)} ${step.command.join(' ')}`),
96
+ };
97
+ }
98
+ for (const step of plan.steps) {
99
+ const result = await ctx.runner(step.command, { cwd: root });
100
+ if (!result.ok) {
101
+ return {
102
+ ok: false,
103
+ command: 'deploy',
104
+ summary: msg('cli.deploy.plan', { images: 1, roles: step.role }),
105
+ findings: [
106
+ {
107
+ code: 'X_DEPLOY_FAILED',
108
+ cause: `role "${step.role}" step exited ${result.code}`,
109
+ fix: `${step.command.join(' ')} # run it directly to see the full output`,
110
+ docs: 'https://ultimate.dev/errors/X_DEPLOY_FAILED',
111
+ },
112
+ ],
113
+ data: planJson,
114
+ };
115
+ }
116
+ }
117
+ return {
118
+ ok: true,
119
+ command: 'deploy',
120
+ summary: msg('cli.deploy.plan', { images: 1, roles: DEPLOY_ROLES.join(',') }),
121
+ data: planJson,
122
+ };
123
+ },
124
+ };
package/src/cmd-dev.ts ADDED
@@ -0,0 +1,286 @@
1
+ // `x dev` — the app, booted. Every role in one Bun process, embedded Postgres/events/storage
2
+ // started for real, the app's own modules loaded into the framework's registries, and the route
3
+ // table those registries hold served over HTTP. `@ultimat3/admin`'s `/_x` dashboard is mounted
4
+ // alongside it — mounted, never re-implemented — so an agent can introspect the running app.
5
+ // No Docker, no env setup: an unset variable means the embedded default.
6
+
7
+ import { watch } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { listActions, toRoute } from '@ultimat3/action';
10
+ import type { Role } from '@ultimat3/core';
11
+ import { configureTelemetry, noopExporter } from '@ultimat3/core';
12
+ import type { Route } from '@ultimat3/http';
13
+ import type { Manifest } from '@ultimat3/manifest';
14
+ import { MANIFEST_FILENAME } from '@ultimat3/manifest';
15
+ import { loadApp } from './app-load';
16
+ import { appManifest } from './app-manifest';
17
+ import { requireAppRoot } from './app-root';
18
+ import type { CliCommand, CommandContext } from './command';
19
+ import { assetRoutes } from './dev-assets';
20
+ import type { DevDashboardInput, DevStatus } from './dev-dashboard';
21
+ import { devDashboardRoutes, devPanels } from './dev-dashboard';
22
+ import { appRoutes } from './dev-render';
23
+ import type { RunningRoles } from './dev-roles';
24
+ import { DEV_ROLES, selectRoles, startRoles } from './dev-roles';
25
+ import type { RunningServices } from './dev-runtime';
26
+ import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './dev-runtime';
27
+ import type { DevServices } from './dev-services';
28
+ import { describeServices, resolveServices } from './dev-services';
29
+ import { createTraceRecorder } from './dev-traces';
30
+ import { holdUntilShutdown } from './hold';
31
+ import { msg } from './messages';
32
+ import type { CommandResult, Finding } from './output';
33
+ import { findingFrom } from './output';
34
+ import { flagString } from './parse';
35
+
36
+ const DEFAULT_PORT = 3000;
37
+
38
+ export interface DevServer {
39
+ readonly url: string;
40
+ readonly services: DevServices;
41
+ readonly roles: readonly Role[];
42
+ /** The manifest as it stands now — a reload that registers a new route moves it. */
43
+ readonly buildId: string;
44
+ /** Modules that would not import, primitives that would not register, reloads that would not build. */
45
+ readonly findings: readonly Finding[];
46
+ readonly running: RunningRoles;
47
+ readonly runtime: RunningServices;
48
+ /** Panel keys `/_x` mounted, in tab order. Reported so `--json` names what is reachable. */
49
+ readonly panels: readonly string[];
50
+ stop(): Promise<void>;
51
+ }
52
+
53
+ interface DevState {
54
+ manifest: Manifest;
55
+ reloads: number;
56
+ /** A save that will not build. Replaced on every attempt, so a fixed file clears it. */
57
+ reloadFinding: Finding | undefined;
58
+ }
59
+
60
+ /** Debounced: a save that touches five files is one reload, not five. */
61
+ function watchApp(root: string, onChange: (file: string) => void): () => void {
62
+ let timer: ReturnType<typeof setTimeout> | undefined;
63
+ let last = '';
64
+ const watcher = watch(root, { recursive: true }, (_event, filename) => {
65
+ if (filename === null || filename.includes('.x/') || filename.includes('node_modules')) return;
66
+ last = filename;
67
+ if (timer !== undefined) clearTimeout(timer);
68
+ timer = setTimeout(() => onChange(last), 30);
69
+ });
70
+ return () => {
71
+ if (timer !== undefined) clearTimeout(timer);
72
+ watcher.close();
73
+ };
74
+ }
75
+
76
+ export interface StartDevOptions {
77
+ readonly root: string;
78
+ readonly port: number;
79
+ readonly env: Readonly<Record<string, string | undefined>>;
80
+ readonly roles?: readonly Role[];
81
+ readonly onReload?: (file: string, durationMs: number) => void;
82
+ }
83
+
84
+ /**
85
+ * The environment `devDashboard` refuses to mount in. Spread conditionally rather than passed as
86
+ * `undefined`: `exactOptionalPropertyTypes` makes "absent" and "explicitly undefined" different
87
+ * answers, and only the absent one lets the dashboard read the process environment itself.
88
+ */
89
+ const envOf = (env: StartDevOptions['env']): { env?: string } => {
90
+ const value = env['NODE_ENV'] ?? env['X_ENV'];
91
+ return value === undefined ? {} : { env: value };
92
+ };
93
+
94
+ /**
95
+ * Boot order is the production order: services first, then the app's modules (importing them IS
96
+ * the registration), then the roles that serve what those modules registered. A module that
97
+ * fails to import becomes a finding rather than a dead process — the point of the dev loop is to
98
+ * still be reachable while something is broken.
99
+ */
100
+ export async function startDev(options: StartDevOptions): Promise<DevServer> {
101
+ const services = resolveServices(options.root, options.env);
102
+ const runtime: RunningServices = await startServices(services, options.env);
103
+ // Installed before the app loads, so a span opened during registration is already recorded.
104
+ // Tracing is always on in the framework and free until an exporter is configured; `x dev` is
105
+ // what configures one, which is the whole reason `/_x/timeline` has anything to draw.
106
+ const traces = createTraceRecorder();
107
+ configureTelemetry({ exporter: traces.exporter });
108
+ const app = await loadApp(options.root);
109
+ const state: DevState = {
110
+ manifest: (await appManifest(options.root)).manifest,
111
+ reloads: 0,
112
+ reloadFinding: undefined,
113
+ };
114
+ // The manifest's build id is a content hash of every fact below it, so a dev document's
115
+ // `x-ultimate-build` header names the exact shape the client was served against. Pinned at
116
+ // boot on purpose: the header is handed to the HTTP config and the render modes once, and a
117
+ // reload cannot re-pin it — `state.manifest.buildId` is what `/_x` and `--json` report, so a
118
+ // divergence between the two is visible rather than silent, and a restart closes it.
119
+ const buildId = state.manifest.buildId;
120
+
121
+ let server: DevServer;
122
+ // Read at request time, never captured at boot: `/_x/services` must report the reload counter
123
+ // and the findings as they are now, not as they were when the route table was built.
124
+ const dashboard: DevDashboardInput = {
125
+ root: options.root,
126
+ runtime,
127
+ status: (): DevStatus => ({
128
+ url: server.url,
129
+ services: server.services,
130
+ roles: server.roles,
131
+ findings: server.findings,
132
+ reloads: state.reloads,
133
+ }),
134
+ traces,
135
+ ...envOf(options.env),
136
+ };
137
+ const panels = devPanels(dashboard).map((panel) => panel.key);
138
+
139
+ const routes: readonly Route[] = [
140
+ ...devDashboardRoutes(dashboard),
141
+ ...listActions().map(toRoute),
142
+ // The image pipeline's only HTTP surface: the icons the web manifest declares, and the
143
+ // variants every `srcset` promises. Mounted before the app's own routes so a page route can
144
+ // never shadow `/icons` or `/media`.
145
+ ...assetRoutes({ root: options.root, storage: runtime.storage }),
146
+ ...appRoutes({ buildId }),
147
+ ];
148
+
149
+ const running = await startRoles({
150
+ roles: options.roles ?? DEV_ROLES,
151
+ port: options.port,
152
+ buildId,
153
+ runtime,
154
+ routes,
155
+ env: options.env,
156
+ });
157
+
158
+ const stopWatching = watchApp(options.root, (file) => {
159
+ const started = performance.now();
160
+ void appManifest(options.root)
161
+ .then(({ manifest }) => {
162
+ state.manifest = manifest;
163
+ state.reloads += 1;
164
+ state.reloadFinding = undefined;
165
+ options.onReload?.(file, Math.round(performance.now() - started));
166
+ })
167
+ // Same rule as a module that will not import: a save the manifest cannot be rebuilt from is
168
+ // a finding on `/_x`, never an unhandled rejection that takes the dev server down.
169
+ .catch((error: unknown) => {
170
+ state.reloadFinding = { ...findingFrom(error), at: file };
171
+ });
172
+ });
173
+
174
+ server = {
175
+ url: running.url ?? `http://localhost:${options.port}`,
176
+ services,
177
+ roles: running.roles,
178
+ get buildId(): string {
179
+ return state.manifest.buildId;
180
+ },
181
+ // A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed, not the
182
+ // findings as they were when the route table was built.
183
+ get findings(): readonly Finding[] {
184
+ return state.reloadFinding === undefined
185
+ ? app.findings
186
+ : [...app.findings, state.reloadFinding];
187
+ },
188
+ running,
189
+ runtime,
190
+ panels,
191
+ async stop() {
192
+ stopWatching();
193
+ await running.stop();
194
+ await runtime.stop();
195
+ // Released after the roles: a span opened by an in-flight request still has an exporter to
196
+ // end into. `configureTelemetry` merges, so handing back the noop is how it is uninstalled —
197
+ // leaving it in place would keep every span of the next `startDev` in this process's buffer.
198
+ configureTelemetry({ exporter: noopExporter });
199
+ traces.reset();
200
+ },
201
+ };
202
+ return server;
203
+ }
204
+
205
+ export const devCommand: CliCommand = {
206
+ spec: {
207
+ name: 'dev',
208
+ summary: 'all roles in one process: embedded services, sub-second reload, /_x mounted',
209
+ usage: 'x dev [--port 3000] [--role web,worker] [--json]',
210
+ requiresApp: true,
211
+ flags: [
212
+ { name: 'port', type: 'string', summary: 'HTTP port', default: String(DEFAULT_PORT) },
213
+ {
214
+ name: 'role',
215
+ type: 'string',
216
+ summary: `roles to run (default: all of ${DEV_ROLES.join(',')})`,
217
+ },
218
+ { name: 'once', type: 'boolean', summary: 'boot, report, exit — for smoke tests and CI' },
219
+ ],
220
+ },
221
+ async run(ctx: CommandContext): Promise<CommandResult> {
222
+ const root = requireAppRoot('dev', ctx.cwd).dir;
223
+ const port = Number.parseInt(flagString(ctx.args, 'port') ?? String(DEFAULT_PORT), 10);
224
+ const roles = selectRoles(flagString(ctx.args, 'role'));
225
+ const server = await startDev({
226
+ root,
227
+ port,
228
+ roles,
229
+ env: ctx.env,
230
+ onReload: (file, durationMs) => {
231
+ if (!ctx.args.json)
232
+ process.stdout.write(`${msg('cli.dev.hmr', { file, ms: durationMs })}\n`);
233
+ },
234
+ });
235
+ const result: CommandResult = {
236
+ ok: server.findings.length === 0,
237
+ command: 'dev',
238
+ summary: msg('cli.dev.ready', {
239
+ url: server.url,
240
+ panels: server.panels.length,
241
+ // Rendered text, so the mail and CDN halves come from the catalog; `data` below carries the
242
+ // status values a script parses, which is why the two are different calls and not one.
243
+ services: `${describeServices(server.services)} ${mailLabel(server.runtime)} ${cdnLabel(server.runtime)}`,
244
+ }),
245
+ findings: server.findings,
246
+ // Every fact `lines` prints is a fact `--json` carries, `manifest` included — or the two
247
+ // renderers have drifted and only one of them can be scripted against.
248
+ data: {
249
+ url: server.url,
250
+ roles: [...server.roles],
251
+ sync: server.running.syncUrl,
252
+ stateDir: server.services.stateDir,
253
+ db: server.services.db.url,
254
+ events: server.services.events.url,
255
+ storage: server.services.storage.url,
256
+ // The selecting env key, never the credential behind it: `SMTP_URL` carries a password
257
+ // and this line is printed, logged and scraped.
258
+ mail: describeMail(server.runtime),
259
+ cdn: describeCdn(server.runtime),
260
+ // The slot this process holds, or null when the replicator was not selected. Two of these
261
+ // on one database is the one topology mistake that cannot be seen from the outside, so the
262
+ // slot is a scriptable fact rather than a line in a log.
263
+ replicationSlot: server.running.replicator?.slot ?? null,
264
+ buildId: server.buildId,
265
+ manifest: join(root, MANIFEST_FILENAME),
266
+ introspect: `${server.url}/_x`,
267
+ panels: [...server.panels],
268
+ },
269
+ lines: [
270
+ msg('cli.dev.roles', { roles: server.roles.join(', ') }),
271
+ msg('cli.dev.panels', { panels: server.panels.join(', ') }),
272
+ msg('cli.dev.manifest', { path: join(root, MANIFEST_FILENAME) }),
273
+ msg('cli.dev.introspect', { url: `${server.url}/_x` }),
274
+ ],
275
+ };
276
+ if (ctx.args.flags.get('once') === true) {
277
+ await server.stop();
278
+ return result;
279
+ }
280
+ // Long-running: `dispatch` awaits this instead of exiting, so the watcher keeps reloading and
281
+ // `/_x` stays reachable. Ctrl-C drains the web role through core's phases first and releases
282
+ // the embedded Postgres, the worker and the watcher after — a hard kill leaves the PGlite
283
+ // directory locked by a process that no longer exists.
284
+ return { ...result, hold: holdUntilShutdown('dev', () => server.stop()) };
285
+ },
286
+ };