@ultimat3/cli 24.0.0 → 25.1.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/CLAUDE.md +15 -8
- package/README.md +30 -5
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +3 -1
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +3 -3
- package/src/app-env.ts +21 -14
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +2 -2
- package/src/app-mcp.ts +25 -27
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +2 -8
- package/src/app-permissions-site.ts +31 -7
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +115 -121
- package/src/cdp-shot-driver.ts +17 -0
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth.ts +2 -2
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +89 -9
- package/src/cmd-db-backfill.ts +1 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +5 -2
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -357
- package/src/cmd-doctor.ts +16 -66
- package/src/cmd-env.ts +29 -14
- package/src/cmd-generate.ts +12 -2
- package/src/cmd-i18n.ts +11 -8
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +18 -137
- package/src/cmd-mcp.ts +25 -7
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +9 -0
- package/src/cmd-routes.ts +3 -3
- package/src/cmd-shot-matrix.ts +21 -4
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +35 -39
- package/src/cmd-test.ts +1 -1
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +19 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +1 -1
- package/src/dev-dashboard.ts +5 -1
- package/src/dev-lock.ts +96 -24
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +14 -7
- package/src/doctor-auth.ts +4 -4
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +2 -2
- package/src/drift.ts +29 -6
- package/src/error-codes.ts +6 -0
- package/src/error-page-csp.ts +14 -15
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-kinds.ts +3 -17
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +50 -1
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +21 -8
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +54 -64
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +32 -16
- package/src/local-cli-handoff.ts +90 -0
- package/src/mcp-errors.ts +8 -1
- package/src/mcp-host.ts +6 -5
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +27 -16
- package/src/metrics-endpoint.ts +3 -2
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +18 -0
- package/src/page-speculation.ts +14 -11
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +63 -98
- package/src/pwa-artifacts.ts +8 -1
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/retry-memo.ts +1 -1
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +110 -31
- package/src/role-start.ts +66 -87
- package/src/role-sync.ts +65 -6
- package/src/role-wake.ts +2 -3
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +15 -2
- package/src/runtime-bindings.ts +23 -3
- package/src/runtime-cache.ts +8 -8
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +2 -2
- package/src/runtime-jobs.ts +78 -18
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +2 -2
- package/src/runtime-overrides.ts +3 -3
- package/src/runtime-purge.ts +1 -1
- package/src/runtime-queue.ts +19 -30
- package/src/runtime-realtime.ts +5 -3
- package/src/runtime-render.ts +77 -82
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +36 -15
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +3 -3
- package/src/schema-diff.ts +1 -1
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +2 -2
- package/src/serve-boot.ts +22 -11
- package/src/serve-drain.ts +3 -1
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +19 -8
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -15
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-seo.ts +6 -4
- package/src/source-files.ts +13 -3
- package/src/static-report.ts +5 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +1 -1
- package/src/templates/action.ts +4 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +23 -8
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +2 -1
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container.ts +12 -6
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-templates.ts +74 -18
- package/src/templates/scaffold-helm.ts +31 -4
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +18 -15
- package/src/templates/scaffold-shell.ts +17 -2
- package/src/test-only-shipped.ts +193 -0
- package/src/test-select.ts +2 -1
- package/src/test-shards.ts +1 -4
- package/src/theme-boot.ts +2 -2
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/verify-checks.ts +12 -2
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +87 -10
- package/src/verify-merge.ts +48 -16
- package/src/verify-part-step.ts +10 -2
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +3 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +0 -1
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/jobs-drain.ts +0 -230
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
package/src/cmd-dev.ts
CHANGED
|
@@ -1,372 +1,32 @@
|
|
|
1
|
-
// `x dev` — the app, booted. Every role in one Bun process,
|
|
2
|
-
// started for real,
|
|
3
|
-
//
|
|
4
|
-
// alongside it — mounted, never re-implemented — so an agent can introspect the running app.
|
|
1
|
+
// `x dev` — the app, booted (`dev-boot.ts`), reported and held. Every role in one Bun process,
|
|
2
|
+
// embedded Postgres/events/storage started for real, and `@ultimat3/admin`'s `/_x` dashboard
|
|
3
|
+
// mounted alongside the app — mounted, never re-implemented — so an agent can introspect it.
|
|
5
4
|
// No Docker, no env setup: an unset variable means the embedded default.
|
|
6
5
|
|
|
7
6
|
import { join } from 'node:path';
|
|
8
|
-
import {
|
|
9
|
-
import type { Role } from '@ultimat3/core';
|
|
10
|
-
import { configureTelemetry, METRICS_PATH, noopExporter } from '@ultimat3/core';
|
|
11
|
-
import { setStatementObserver } from '@ultimat3/db';
|
|
12
|
-
import type { OverlayNotice, RequestContext } from '@ultimat3/http';
|
|
13
|
-
import { asCtx } from '@ultimat3/http';
|
|
14
|
-
import type { Manifest } from '@ultimat3/manifest';
|
|
7
|
+
import { METRICS_PATH } from '@ultimat3/core';
|
|
15
8
|
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
16
|
-
import { loadSignInPath } from './app-auth';
|
|
17
|
-
import { appManifest } from './app-manifest';
|
|
18
|
-
import type { StalePin } from './app-reload-graph';
|
|
19
|
-
import { enableReloadTracking, takeStalePins } from './app-reload-graph';
|
|
20
9
|
import { requireAppRoot } from './app-root';
|
|
21
|
-
import { loadAppRuntime } from './app-runtime';
|
|
22
10
|
import { devSpec } from './cmd-dev-spec';
|
|
23
11
|
import type { CliCommand, CommandContext } from './command';
|
|
24
|
-
import
|
|
25
|
-
import { devPanels } from './dev-dashboard';
|
|
26
|
-
import { declareDevEnvironment, needsDevEnvironmentDeclaration } from './dev-environment';
|
|
12
|
+
import { startDev } from './dev-boot';
|
|
27
13
|
import { clearLock, preflight, writeLock } from './dev-lock';
|
|
28
|
-
import { createStatementLedger } from './dev-n-plus-one';
|
|
29
14
|
import { devPortFor } from './dev-port';
|
|
30
|
-
import {
|
|
31
|
-
import { devRouteTable } from './dev-route-table';
|
|
32
|
-
import { childRestart, restartFinding } from './dev-supervisor';
|
|
33
|
-
import { createTraceRecorder } from './dev-traces';
|
|
34
|
-
import { watchTree } from './dev-watch-tree';
|
|
15
|
+
import { childRestart } from './dev-supervisor';
|
|
35
16
|
import { holdUntilShutdown } from './hold';
|
|
36
|
-
import type { IslandBundle } from './island-bundle';
|
|
37
|
-
import { buildIslands } from './island-bundle';
|
|
38
|
-
import { FRAME_STYLE } from './island-harness';
|
|
39
17
|
import { msg } from './messages';
|
|
40
|
-
import type { CommandResult
|
|
41
|
-
import { findingFrom } from './output';
|
|
18
|
+
import type { CommandResult } from './output';
|
|
42
19
|
import { flagString } from './parse';
|
|
43
|
-
import
|
|
44
|
-
import {
|
|
45
|
-
import type { DevServices } from './runtime-bindings';
|
|
46
|
-
import {
|
|
47
|
-
describeServices,
|
|
48
|
-
reportedUrls,
|
|
49
|
-
resolveServices,
|
|
50
|
-
withRealtimeEvents,
|
|
51
|
-
} from './runtime-bindings';
|
|
52
|
-
import { attachedIsr } from './runtime-isr';
|
|
20
|
+
import { DEV_BINDING, selectRoles } from './role-start';
|
|
21
|
+
import { describeServices, reportedUrls, resolveServices } from './runtime-bindings';
|
|
53
22
|
import { liveFeedLabel } from './runtime-live-feed';
|
|
54
|
-
import
|
|
55
|
-
import {
|
|
56
|
-
import type { RunningServices } from './runtime-services';
|
|
57
|
-
import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './runtime-services';
|
|
58
|
-
import { metricsPortFor, releaseBoot } from './serve';
|
|
59
|
-
import { loopFacts, loopFinding, loopNotice } from './statement-loop';
|
|
23
|
+
import { cdnLabel, describeCdn, describeMail, mailLabel } from './runtime-services';
|
|
24
|
+
import { writeLine } from './write-line';
|
|
60
25
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
export
|
|
64
|
-
|
|
65
|
-
readonly services: DevServices;
|
|
66
|
-
readonly roles: readonly Role[];
|
|
67
|
-
/**
|
|
68
|
-
* `BUILD_ID` when stamped — the id every response carries. Otherwise the manifest as it stands
|
|
69
|
-
* now, so a reload that registers a new route moves it.
|
|
70
|
-
*/
|
|
71
|
-
readonly buildId: string;
|
|
72
|
-
/**
|
|
73
|
-
* Modules that would not import, primitives that would not register, reloads that would not
|
|
74
|
-
* build — and the statement loops this process has counted so far, which is what puts an N+1 in
|
|
75
|
-
* `x dev`'s own output and in `--json` without a channel of its own.
|
|
76
|
-
*/
|
|
77
|
-
readonly findings: readonly Finding[];
|
|
78
|
-
readonly running: RunningRoles;
|
|
79
|
-
readonly runtime: RunningServices;
|
|
80
|
-
/** Panel keys `/_x` mounted, in tab order. Reported so `--json` names what is reachable. */
|
|
81
|
-
readonly panels: readonly string[];
|
|
82
|
-
/** `POST <path>` of the app's default MCP endpoint, or `null` when nothing was mounted. */
|
|
83
|
-
readonly mcp: string | null;
|
|
84
|
-
/** `POST <path>` of every mounted MCP endpoint, default first — `[]` when none was. */
|
|
85
|
-
readonly mcpPaths: readonly string[];
|
|
86
|
-
stop(): Promise<void>;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
interface DevState {
|
|
90
|
-
manifest: Manifest;
|
|
91
|
-
reloads: number;
|
|
92
|
-
/** A save that will not build. Replaced on every attempt, so a fixed file clears it. */
|
|
93
|
-
reloadFinding: Finding | undefined;
|
|
94
|
-
/**
|
|
95
|
-
* Modules that would not import and primitives that would not register, as of the LAST scan —
|
|
96
|
-
* the boot's, then each rebuild's. A page saved with a syntax error is a finding here until the
|
|
97
|
-
* save that fixes it, and the boot's list alone would never show it.
|
|
98
|
-
*/
|
|
99
|
-
appFindings: readonly Finding[];
|
|
100
|
-
/**
|
|
101
|
-
* The client entries, rebuilt on the same tick as the manifest. An island is the one module this
|
|
102
|
-
* process never imports, so a fresh `Bun.build` is the whole of its reload. The route module
|
|
103
|
-
* beside it is re-imported by that same scan (`app-load.ts`) — the two are one generation, or
|
|
104
|
-
* a save serves a new island under an old page, which is what it did until 2026-09-07.
|
|
105
|
-
*/
|
|
106
|
-
islands: IslandBundle;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export interface StartDevOptions {
|
|
110
|
-
readonly root: string;
|
|
111
|
-
readonly port: number;
|
|
112
|
-
readonly env: Readonly<Record<string, string | undefined>>;
|
|
113
|
-
readonly roles?: readonly Role[];
|
|
114
|
-
readonly onReload?: (file: string, durationMs: number) => void;
|
|
115
|
-
/**
|
|
116
|
-
* A save reached a module that defines a primitive, so this process cannot serve it
|
|
117
|
-
* (`takeStalePins`). Given, it is called INSTEAD of `onReload` — `x dev`'s child drains and exits
|
|
118
|
-
* for its supervisor to boot a fresh one. Absent, the save is `X_DEV_RESTART_REQUIRED` on `/_x`.
|
|
119
|
-
*/
|
|
120
|
-
readonly onRestart?: (pins: readonly StalePin[]) => void;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* The environment `devDashboard` refuses to mount in. Spread conditionally rather than passed as
|
|
125
|
-
* `undefined`: `exactOptionalPropertyTypes` makes "absent" and "explicitly undefined" different
|
|
126
|
-
* answers, and only the absent one lets the dashboard read the process environment itself.
|
|
127
|
-
*/
|
|
128
|
-
const envOf = (env: StartDevOptions['env']): { env?: string } => {
|
|
129
|
-
const value = env['NODE_ENV'] ?? env['X_ENV'];
|
|
130
|
-
return value === undefined ? {} : { env: value };
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Boot order is the production order: services first, then the app's modules (importing them IS
|
|
135
|
-
* the registration), then the roles that serve what those modules registered. A module that
|
|
136
|
-
* fails to import becomes a finding rather than a dead process — the point of the dev loop is to
|
|
137
|
-
* still be reachable while something is broken.
|
|
138
|
-
*/
|
|
139
|
-
export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
140
|
-
// EVERY boot: a scratch server (`x shot`, `ui.shot`) boots here too, and without this a
|
|
141
|
-
// fail-closed dev actor installs nothing — the picture is of a 401. Idempotent.
|
|
142
|
-
declareDevEnvironment(options.env);
|
|
143
|
-
// Before the first scan: the watcher's rescans evict a saved module AND everything importing it,
|
|
144
|
-
// and that needs the import graph recorded as each module is first loaded (`app-reload-graph.ts`).
|
|
145
|
-
enableReloadTracking(true);
|
|
146
|
-
// The declaration lands on `process.env`; the env this boot passes on must carry it too, or every
|
|
147
|
-
// service resolved from `options.env` still reads no `ULTIMATE_ENV`.
|
|
148
|
-
const env = needsDevEnvironmentDeclaration(options.env)
|
|
149
|
-
? { ...options.env, ULTIMATE_ENV: 'development' }
|
|
150
|
-
: options.env;
|
|
151
|
-
const resolved = resolveServices(options.root, env);
|
|
152
|
-
// The app's `apps/<app>/runtime.ts`, read ONCE and handed to `startServices` exactly as
|
|
153
|
-
// `serve.ts`'s `withAppRuntime` does — so the disks (`storage`), the queue, the mail driver and
|
|
154
|
-
// every other override a deployment declares are the ones development runs too. Until 22.4 this
|
|
155
|
-
// boot passed nothing here and built its own embedded disk, so an app's `/_storage` routes and its
|
|
156
|
-
// actions wrote to two different places in `x dev` and to one in production.
|
|
157
|
-
const appRuntime = await loadAppRuntime(options.root);
|
|
158
|
-
const runtime: RunningServices = await startServices(resolved, env, appRuntime);
|
|
159
|
-
// The events binding the boot line reports is the bus the runtime chose, read off it.
|
|
160
|
-
const services = withRealtimeEvents(resolved, env, runtime.realtime);
|
|
161
|
-
// `serve.ts`'s `releaseBoot` shape: everything acquired from here on is released, newest first,
|
|
162
|
-
// if the boot throws — a failed `x dev` left PGlite holding `.x/pgdata` for the retry to meet.
|
|
163
|
-
const acquired: (() => void | Promise<void>)[] = [() => runtime.stop()];
|
|
164
|
-
try {
|
|
165
|
-
return await bootDev(options, services, runtime, acquired, appRuntime);
|
|
166
|
-
} catch (error) {
|
|
167
|
-
await releaseBoot(acquired);
|
|
168
|
-
throw error;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async function bootDev(
|
|
173
|
-
options: StartDevOptions,
|
|
174
|
-
services: ReturnType<typeof resolveServices>,
|
|
175
|
-
runtime: RunningServices,
|
|
176
|
-
acquired: (() => void | Promise<void>)[],
|
|
177
|
-
appRuntime: RuntimeOverrides | undefined,
|
|
178
|
-
): Promise<DevServer> {
|
|
179
|
-
// Installed before the app loads, so a span opened during registration is already recorded.
|
|
180
|
-
// Tracing is always on in the framework and free until an exporter is configured; `x dev` is
|
|
181
|
-
// what configures one, which is the whole reason `/_x/timeline` has anything to draw.
|
|
182
|
-
const traces = createTraceRecorder();
|
|
183
|
-
configureTelemetry({ exporter: traces.exporter });
|
|
184
|
-
acquired.push(() => {
|
|
185
|
-
configureTelemetry({ exporter: noopExporter });
|
|
186
|
-
traces.reset();
|
|
187
|
-
});
|
|
188
|
-
// Installed at the same moment and for the same reason: an observer is the single switch that
|
|
189
|
-
// turns statement instrumentation on at all (`@ultimat3/db`'s `observe.ts`), so the timeline's
|
|
190
|
-
// SQL rows and the repeat counts arrive together rather than through two toggles. `serve.ts`
|
|
191
|
-
// installs neither — a production process pays the one `undefined` branch the seam costs
|
|
192
|
-
// uninstalled, and nothing more (axiom 6).
|
|
193
|
-
const statements = createStatementLedger();
|
|
194
|
-
setStatementObserver(statements.observer);
|
|
195
|
-
acquired.push(() => {
|
|
196
|
-
setStatementObserver(undefined);
|
|
197
|
-
statements.reset();
|
|
198
|
-
});
|
|
199
|
-
// ONE load at boot, the same call the rebuild below makes: the manifest and the findings are
|
|
200
|
-
// two projections of one scan. Until 2026-09-07 this was `loadApp` for the findings and then
|
|
201
|
-
// `appManifest` — which loads again — for the manifest, so a save landing between the two put
|
|
202
|
-
// `/_x`'s findings and its manifest on different registration states.
|
|
203
|
-
const app = await appManifest(options.root);
|
|
204
|
-
const state: DevState = {
|
|
205
|
-
manifest: app.manifest,
|
|
206
|
-
reloads: 0,
|
|
207
|
-
reloadFinding: undefined,
|
|
208
|
-
appFindings: app.findings,
|
|
209
|
-
islands: await buildIslands(options.root),
|
|
210
|
-
};
|
|
211
|
-
// The manifest's build id is a content hash of every fact below it, so a dev document's
|
|
212
|
-
// `x-ultimate-build` header names the exact shape the client was served against. Pinned at
|
|
213
|
-
// boot on purpose: the header is handed to the HTTP config and the render modes once, and a
|
|
214
|
-
// reload cannot re-pin it — `state.manifest.buildId` is what `/_x` and `--json` report, so a
|
|
215
|
-
// divergence between the two is visible rather than silent, and a restart closes it.
|
|
216
|
-
// `BUILD_ID` wins when set — `serve.ts`'s rule, so an e2e `deploy.newBuild()` can restart `x dev`
|
|
217
|
-
// as a new build with the same sources.
|
|
218
|
-
// Stamped, it is ALSO what `server.buildId` answers below: the process serves no other build.
|
|
219
|
-
const rawStamp = options.env['BUILD_ID'];
|
|
220
|
-
const stamped = rawStamp !== undefined && rawStamp !== '' ? rawStamp : undefined;
|
|
221
|
-
const buildId = stamped ?? state.manifest.buildId;
|
|
222
|
-
|
|
223
|
-
let server: DevServer;
|
|
224
|
-
// Read at request time, never captured at boot: `/_x/services` must report the reload counter
|
|
225
|
-
// and the findings as they are now, not as they were when the route table was built.
|
|
226
|
-
const dashboard: DevDashboardInput = {
|
|
227
|
-
root: options.root,
|
|
228
|
-
runtime,
|
|
229
|
-
status: (): DevStatus => ({
|
|
230
|
-
url: server.url,
|
|
231
|
-
services: server.services,
|
|
232
|
-
roles: server.roles,
|
|
233
|
-
findings: server.findings,
|
|
234
|
-
reloads: state.reloads,
|
|
235
|
-
}),
|
|
236
|
-
traces,
|
|
237
|
-
statements,
|
|
238
|
-
...envOf(options.env),
|
|
239
|
-
};
|
|
240
|
-
const panels = devPanels(dashboard).map((panel) => panel.key);
|
|
241
|
-
|
|
242
|
-
// `x dev`'s own, so a reload can empty it (a save otherwise kept a page's first render for its
|
|
243
|
-
// ttl); attached, so a tag bust reaches it too. Released with the roles.
|
|
244
|
-
const { isr, release: releaseIsr } = attachedIsr({ buildId });
|
|
245
|
-
acquired.push(releaseIsr);
|
|
246
|
-
const { routes, theme, speculation, errorStyles, mcpPath, mcpPaths } = await devRouteTable({
|
|
247
|
-
isr,
|
|
248
|
-
root: options.root,
|
|
249
|
-
env: options.env,
|
|
250
|
-
buildId,
|
|
251
|
-
storage: runtime.storage,
|
|
252
|
-
dashboard,
|
|
253
|
-
islands: () => state.islands,
|
|
254
|
-
realtime: runtime.realtime,
|
|
255
|
-
rateLimitStore: appRuntime?.rateLimitStore ?? runtime.rateLimitStore,
|
|
256
|
-
appRoutes: appRuntime?.routes,
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
// The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
|
|
260
|
-
// `runtime`: the replica scope in front, the app's own middleware behind it. Before this the
|
|
261
|
-
// first argument was `undefined` here and an app's middleware reached no development process.
|
|
262
|
-
const replicaOverride = replicaOverrides(appRuntime, services.db, options.env);
|
|
263
|
-
const running = await startRoles({
|
|
264
|
-
roles: options.roles ?? DEV_ROLES,
|
|
265
|
-
port: options.port,
|
|
266
|
-
// `serve.ts`'s expression, called rather than restated: `METRICS_PORT` was read in the
|
|
267
|
-
// container and ignored here, so the scrape port an operator moved was the one port `x dev`
|
|
268
|
-
// could not move — and the second `x dev` on a box died binding the hardcoded 9090.
|
|
269
|
-
metricsPort: metricsPortFor(options.env, options.port),
|
|
270
|
-
buildId,
|
|
271
|
-
runtime,
|
|
272
|
-
routes,
|
|
273
|
-
env: options.env,
|
|
274
|
-
// Read from `app.config.ts` rather than threaded through `DevOptions`: `x dev` and `serve.ts`
|
|
275
|
-
// must not be able to disagree about where the app's sign-in page is.
|
|
276
|
-
signInPath: await loadSignInPath(options.root),
|
|
277
|
-
// The same seam `serve.ts` passes: the app's own error page is a FILE under this root.
|
|
278
|
-
root: options.root,
|
|
279
|
-
// The `/_x` shell, the harness's frame, and the app's own error pages — the inline bodies this
|
|
280
|
-
// process serves; the app's surfaces are content-hashed files `'self'` admits.
|
|
281
|
-
inlineStyles: [await devShellStyle(), FRAME_STYLE, ...errorStyles],
|
|
282
|
-
inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
|
|
283
|
-
// The overlay renders this request's own loops under the error it is already showing.
|
|
284
|
-
// `serve.ts` boots through the same `startRoles` and passes nothing (axiom 6).
|
|
285
|
-
devNotices: (ctx: RequestContext): readonly OverlayNotice[] =>
|
|
286
|
-
statements.repeatsFor(asCtx(ctx)).map(loopFacts).map(loopNotice),
|
|
287
|
-
// The read-replica scope, opened per request. Absent for every app that names no
|
|
288
|
-
// `DATABASE_REPLICA_URL` — which is every embedded boot by construction, since PGlite has no
|
|
289
|
-
// standby — so this key does not exist on a homework app's boot at all.
|
|
290
|
-
...(replicaOverride === undefined ? {} : { overrides: replicaOverride }),
|
|
291
|
-
});
|
|
292
|
-
acquired.push(() => running.stop());
|
|
293
|
-
|
|
294
|
-
// One rebuild at a time, and the last save wins: a tick arriving mid-build coalesces into ONE
|
|
295
|
-
// trailing rebuild instead of racing the one in flight for `state.manifest` and `state.islands`.
|
|
296
|
-
const rebuild = coalesceReloads(
|
|
297
|
-
async (file) => {
|
|
298
|
-
const started = performance.now();
|
|
299
|
-
const [{ manifest, findings }, islands] = await Promise.all([
|
|
300
|
-
appManifest(options.root),
|
|
301
|
-
buildIslands(options.root),
|
|
302
|
-
]);
|
|
303
|
-
state.manifest = manifest;
|
|
304
|
-
state.appFindings = findings;
|
|
305
|
-
state.islands = islands;
|
|
306
|
-
for (const path of isr.store().paths()) isr.store().delete(path);
|
|
307
|
-
state.reloads += 1;
|
|
308
|
-
// Read on EVERY rebuild, so a pin one save reached is never reported against the next save.
|
|
309
|
-
const pins = takeStalePins();
|
|
310
|
-
state.reloadFinding =
|
|
311
|
-
pins.length > 0 && options.onRestart === undefined
|
|
312
|
-
? restartFinding(options.root, pins)
|
|
313
|
-
: undefined;
|
|
314
|
-
if (pins.length > 0 && options.onRestart !== undefined) options.onRestart(pins);
|
|
315
|
-
else options.onReload?.(file, Math.round(performance.now() - started));
|
|
316
|
-
},
|
|
317
|
-
// Same rule as a module that will not import: a save the manifest cannot be rebuilt from is
|
|
318
|
-
// a finding on `/_x`, never an unhandled rejection that takes the dev server down.
|
|
319
|
-
(error: unknown, file: string) => {
|
|
320
|
-
state.reloadFinding = { ...findingFrom(error), at: file };
|
|
321
|
-
},
|
|
322
|
-
);
|
|
323
|
-
// Watched one directory at a time, so an ignored one costs no descriptor at all — `dev-watch.ts`
|
|
324
|
-
// decides which, from the app's own `.gitignore`. A recursive watch on the root registered one
|
|
325
|
-
// inotify descriptor per directory in the tree, `.git/`, `node_modules/` and the `.x/` this
|
|
326
|
-
// process writes to included, and filtered the events afterwards.
|
|
327
|
-
const watcher = watchTree({ root: options.root, onChange: rebuild });
|
|
328
|
-
|
|
329
|
-
server = {
|
|
330
|
-
url: running.url ?? `http://localhost:${options.port}`,
|
|
331
|
-
services,
|
|
332
|
-
roles: running.roles,
|
|
333
|
-
mcp: mcpPath,
|
|
334
|
-
mcpPaths,
|
|
335
|
-
get buildId(): string {
|
|
336
|
-
return stamped ?? state.manifest.buildId;
|
|
337
|
-
},
|
|
338
|
-
// A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed and the
|
|
339
|
-
// loop the last request tripped, not the findings as they were when the route table was built.
|
|
340
|
-
// The loops come last and carry their request id, so a boot report reads as a boot report and
|
|
341
|
-
// a diagnostic that arrived a minute later reads as one too.
|
|
342
|
-
get findings(): readonly Finding[] {
|
|
343
|
-
const loops = statements.repeats().map(loopFacts).map(loopFinding);
|
|
344
|
-
return state.reloadFinding === undefined
|
|
345
|
-
? [...state.appFindings, ...loops]
|
|
346
|
-
: [...state.appFindings, state.reloadFinding, ...loops];
|
|
347
|
-
},
|
|
348
|
-
running,
|
|
349
|
-
runtime,
|
|
350
|
-
panels,
|
|
351
|
-
async stop() {
|
|
352
|
-
watcher.close();
|
|
353
|
-
await running.stop();
|
|
354
|
-
releaseIsr();
|
|
355
|
-
await runtime.stop();
|
|
356
|
-
// Released after the roles: a span opened by an in-flight request still has an exporter to
|
|
357
|
-
// end into. `configureTelemetry` merges, so handing back the noop is how it is uninstalled —
|
|
358
|
-
// leaving it in place would keep every span of the next `startDev` in this process's buffer.
|
|
359
|
-
configureTelemetry({ exporter: noopExporter });
|
|
360
|
-
traces.reset();
|
|
361
|
-
// With the exporter, for the same reason: an in-flight statement is observed by the ledger
|
|
362
|
-
// that counted its request. Left installed, the next `startDev`'s statements land in these
|
|
363
|
-
// counts — and instrumentation stays on in a process that is no longer a dev server.
|
|
364
|
-
setStatementObserver(undefined);
|
|
365
|
-
statements.reset();
|
|
366
|
-
},
|
|
367
|
-
};
|
|
368
|
-
return server;
|
|
369
|
-
}
|
|
26
|
+
// The boot moved to `dev-boot.ts`; its names stay importable from here, where every caller and the
|
|
27
|
+
// package barrel already reach them.
|
|
28
|
+
export type { DevServer, StartDevOptions } from './dev-boot';
|
|
29
|
+
export { startDev } from './dev-boot';
|
|
370
30
|
|
|
371
31
|
export const devCommand: CliCommand = {
|
|
372
32
|
spec: devSpec,
|
|
@@ -400,8 +60,7 @@ export const devCommand: CliCommand = {
|
|
|
400
60
|
roles,
|
|
401
61
|
env: ctx.env,
|
|
402
62
|
onReload: (file, durationMs) => {
|
|
403
|
-
if (!ctx.args.json)
|
|
404
|
-
process.stdout.write(`${msg('cli.dev.hmr', { file, ms: durationMs })}\n`);
|
|
63
|
+
if (!ctx.args.json) writeLine(msg('cli.dev.hmr', { file, ms: durationMs }));
|
|
405
64
|
},
|
|
406
65
|
// Supervised, a save this process cannot serve is a drain and an exit for a fresh child.
|
|
407
66
|
...restart.options,
|
|
@@ -409,6 +68,8 @@ export const devCommand: CliCommand = {
|
|
|
409
68
|
release();
|
|
410
69
|
throw error;
|
|
411
70
|
});
|
|
71
|
+
// A restart retires this worker before it drains, so a save never cancels a running job.
|
|
72
|
+
restart.adopt(server.running.worker);
|
|
412
73
|
const result: CommandResult = {
|
|
413
74
|
ok: server.findings.length === 0,
|
|
414
75
|
command: 'dev',
|
package/src/cmd-doctor.ts
CHANGED
|
@@ -4,20 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
ERROR_DOCS_URL,
|
|
10
|
-
tryResolveEnvironment,
|
|
11
|
-
usesDevCursorSecret,
|
|
12
|
-
} from '@ultimat3/core';
|
|
13
|
-
import {
|
|
14
|
-
checkDb,
|
|
15
|
-
createPostgresClient,
|
|
16
|
-
PGLITE_FIX,
|
|
17
|
-
PGLITE_MISSING,
|
|
18
|
-
PGLITE_PACKAGE,
|
|
19
|
-
} from '@ultimat3/db';
|
|
20
|
-
import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
|
|
7
|
+
import { ENV_EXAMPLE_PATH, ERROR_DOCS_URL } from '@ultimat3/core';
|
|
8
|
+
import { checkDb, PGLITE_FIX, PGLITE_MISSING, PGLITE_PACKAGE, postgresClient } from '@ultimat3/db';
|
|
21
9
|
import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
|
|
22
10
|
import { doctorSpec } from './cmd-doctor-spec';
|
|
23
11
|
import type { CliCommand, CommandContext } from './command';
|
|
@@ -31,6 +19,7 @@ import { offlineFallbackFinding, offlineFallbackProbe } from './doctor-offline';
|
|
|
31
19
|
import type { SealedKeysFact } from './doctor-sealed';
|
|
32
20
|
import { sealedKeyFindings, sealedKeysProbe } from './doctor-sealed';
|
|
33
21
|
import { PORT_RANGE, portPairAfter, readIntFlag } from './flag-number';
|
|
22
|
+
import { machineSecretFindings } from './framework-env';
|
|
34
23
|
import { ICON_SOURCE } from './icon-assets';
|
|
35
24
|
import { msg } from './messages';
|
|
36
25
|
import type { CommandResult, Finding } from './output';
|
|
@@ -40,6 +29,7 @@ import { portFree } from './port-probe';
|
|
|
40
29
|
import { syncPortFor } from './role-sync';
|
|
41
30
|
import { checkMigrationDrift } from './schema-drift';
|
|
42
31
|
import { portFromEnv } from './serve-env';
|
|
32
|
+
import { quoteArg } from './shell-quote';
|
|
43
33
|
|
|
44
34
|
/**
|
|
45
35
|
* The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
|
|
@@ -54,17 +44,13 @@ export interface DoctorProbe {
|
|
|
54
44
|
readonly port: number;
|
|
55
45
|
/** `APP_URL` as this environment declares it; `undefined` when unset. */
|
|
56
46
|
readonly appUrl: string | undefined;
|
|
57
|
-
/** True while cursors are signed with the key shipped in the published package. */
|
|
58
|
-
readonly devCursorSecret: boolean;
|
|
59
47
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
48
|
+
* The framework's own secrets this machine lacks — `framework-env.ts`'s
|
|
49
|
+
* `machineSecretFindings`, gated by `namedDeployed`: only an environment NAMED staging or
|
|
50
|
+
* production (an unnamed developer shell owes nothing; `x env check` is the fail-closed answer).
|
|
51
|
+
* The list's own `needed`/`unsafe` decide; doctor reports.
|
|
64
52
|
*/
|
|
65
|
-
readonly
|
|
66
|
-
/** True when this process believes it is serving real clients. */
|
|
67
|
-
readonly production: boolean;
|
|
53
|
+
readonly frameworkFindings: readonly Finding[];
|
|
68
54
|
exists(relativePath: string): boolean;
|
|
69
55
|
portFree(port: number): Promise<boolean>;
|
|
70
56
|
/**
|
|
@@ -191,7 +177,7 @@ async function portFindings(probe: DoctorProbe): Promise<readonly Finding[]> {
|
|
|
191
177
|
finding(
|
|
192
178
|
'X_PORT_IN_USE',
|
|
193
179
|
`port ${entry.port} is already listening, and \`x dev --port ${probe.port}\` binds it for the ${entry.role} role`,
|
|
194
|
-
`x dev --port ${portPairAfter(probe.port)}`,
|
|
180
|
+
`x dev --port ${quoteArg(String(portPairAfter(probe.port)))}`,
|
|
195
181
|
),
|
|
196
182
|
);
|
|
197
183
|
}
|
|
@@ -231,41 +217,14 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
231
217
|
// `.env.example` is the committed projection of `envSchema`, so the copy lands every
|
|
232
218
|
// declared key with its default and blank secrets; `x env example` writes it where an app
|
|
233
219
|
// has none, and `X_ENV_EXAMPLE_DRIFT` is what reports that.
|
|
234
|
-
`cp ${ENV_EXAMPLE_PATH} ${ENV_DEVELOPMENT}`,
|
|
220
|
+
`cp ${quoteArg(ENV_EXAMPLE_PATH)} ${quoteArg(ENV_DEVELOPMENT)}`,
|
|
235
221
|
ENV_DEVELOPMENT,
|
|
236
222
|
),
|
|
237
223
|
);
|
|
238
224
|
}
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
// reader to skim past the report. The key is a defect only where cursors reach real clients,
|
|
243
|
-
// who can read it out of the published package and forge a page position.
|
|
244
|
-
// Sits here because it costs two env reads and a comparison — cheaper than binding a port.
|
|
245
|
-
if (probe.production && probe.devCursorSecret) {
|
|
246
|
-
findings.push(
|
|
247
|
-
finding(
|
|
248
|
-
'X_CURSOR_SECRET_DEV',
|
|
249
|
-
'cursors are signed with the shipped development key, so a client can forge a page position',
|
|
250
|
-
'export ULTIMATE_CURSOR_SECRET="$(openssl rand -hex 32)"',
|
|
251
|
-
),
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
// The storage twin of the cursor key above, and the more expensive one to get wrong: the
|
|
255
|
-
// published string mints a signed `PUT` for any key with any `maxBytes` and `contentType`, and
|
|
256
|
-
// `acceptSignedUpload` trusts the signed constraints over the app's own `uploadPolicy`.
|
|
257
|
-
// Production only, for the reason the cursor check gives — every dev environment signs with the
|
|
258
|
-
// shipped key on purpose. `@ultimat3/storage` refuses this at construction; `x doctor` is what
|
|
259
|
-
// reports it before a deploy reaches the refusal.
|
|
260
|
-
if (probe.production && probe.devStorageSecret) {
|
|
261
|
-
findings.push(
|
|
262
|
-
finding(
|
|
263
|
-
'X_STORAGE_SECRET_DEV',
|
|
264
|
-
`${STORAGE_SIGNING_SECRET_KEY} is unset or holds the shipped development key, so a local-disk deploy would accept forged upload grants that override its own uploadPolicy`,
|
|
265
|
-
'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
|
|
266
|
-
),
|
|
267
|
-
);
|
|
268
|
-
}
|
|
225
|
+
// Silent in development and on a shell naming no environment; reported where this machine names
|
|
226
|
+
// a deploy. One list, one wording (`framework-env.ts`, #679).
|
|
227
|
+
findings.push(...probe.frameworkFindings);
|
|
269
228
|
findings.push(...(await portFindings(probe)));
|
|
270
229
|
findings.push(...appUrlFindings(probe.appUrl, probe.port));
|
|
271
230
|
// `@ultimat3/pwa`'s own codes, not CLI twins of them. `X_PWA_NO_ICON_SOURCE` and
|
|
@@ -376,7 +335,7 @@ const installedAbove = (dir: string, specifier: string): boolean => {
|
|
|
376
335
|
*/
|
|
377
336
|
async function probeDatabase(url: string | undefined): Promise<Finding | null> {
|
|
378
337
|
if (externalUrl(url) === undefined) return null;
|
|
379
|
-
const client =
|
|
338
|
+
const client = postgresClient({ url, applicationName: 'x-doctor' });
|
|
380
339
|
try {
|
|
381
340
|
const report = await checkDb(client);
|
|
382
341
|
if (report.ok) return null;
|
|
@@ -407,16 +366,7 @@ export function probeFor(
|
|
|
407
366
|
root,
|
|
408
367
|
port,
|
|
409
368
|
appUrl: env['APP_URL'],
|
|
410
|
-
|
|
411
|
-
devStorageSecret: usesDevStorageSecret(),
|
|
412
|
-
// `ULTIMATE_ENV`, through core — the one key that says which deploy this is, with `NODE_ENV`
|
|
413
|
-
// as its documented fallback. This read `X_ENV ?? NODE_ENV`, a spelling nothing else in the
|
|
414
|
-
// repo reads, so a deploy declaring production the framework's own way was told it was not
|
|
415
|
-
// production and skipped both secret findings; and the `??` short-circuited, so any non-empty
|
|
416
|
-
// `X_ENV` shadowed a real `NODE_ENV=production` too. The non-throwing variant because
|
|
417
|
-
// `ULTIMATE_ENV` is not in the env schema — nothing validates it at boot, and a diagnostic
|
|
418
|
-
// that crashes on a typo is the one thing worse than a diagnostic that misses.
|
|
419
|
-
production: tryResolveEnvironment() === 'production',
|
|
369
|
+
frameworkFindings: machineSecretFindings(env),
|
|
420
370
|
exists: (relativePath) => (root === undefined ? false : existsSync(join(root, relativePath))),
|
|
421
371
|
portFree,
|
|
422
372
|
database: () => probeDatabase(process.env['DATABASE_URL']),
|
package/src/cmd-env.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { envExampleFor, loadEnvSchema } from './app-env';
|
|
|
15
15
|
import { requireAppRoot } from './app-root';
|
|
16
16
|
import { envSpec } from './cmd-env-spec';
|
|
17
17
|
import type { CliCommand, CommandContext } from './command';
|
|
18
|
+
import { frameworkSecretFindings } from './framework-env';
|
|
18
19
|
import { msg } from './messages';
|
|
19
20
|
import type { CommandResult, Finding, JsonValue } from './output';
|
|
20
21
|
|
|
@@ -67,33 +68,47 @@ async function writeExample(ctx: CommandContext): Promise<CommandResult> {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
/**
|
|
70
|
-
* The values are read from the
|
|
71
|
-
* `
|
|
71
|
+
* The values are read from the environment the command was handed (`ctx.env`, the process's own
|
|
72
|
+
* in `bin.ts`) — one table for the app's keys and the framework's — and only ever printed through
|
|
73
|
+
* `maskedEnvValues`: `checkEnv().values` holds the actual secrets because `defineEnv()` has to
|
|
72
74
|
* return them, and a `--json` report is the last place a DSN should appear in full.
|
|
75
|
+
*
|
|
76
|
+
* The framework's deploy-required secrets (`framework-env.ts`) are asked exactly where the boot
|
|
77
|
+
* refuses them — outside development/test, and where no environment is named at all — so `ULTIMATE_ENV=production x env check` answers what
|
|
78
|
+
* the first deployed boot would, instead of each role crash-looping on one key at a time (#679).
|
|
73
79
|
*/
|
|
74
80
|
async function checkProcessEnv(ctx: CommandContext): Promise<CommandResult> {
|
|
75
81
|
const { schema } = await requireSchema(ctx.cwd, 'check');
|
|
76
|
-
const report = checkEnv(schema);
|
|
82
|
+
const report = checkEnv(schema, { env: ctx.env });
|
|
77
83
|
const total = Object.keys(schema).length;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
// A key the app's own declaration already reported is one finding, not two.
|
|
85
|
+
const reported = new Set(report.issues.map((issue) => issue.key));
|
|
86
|
+
// Gated by `deployed()` inside — the boot's own rule, so a table naming no environment is
|
|
87
|
+
// checked as the production process it boots as.
|
|
88
|
+
const framework = frameworkSecretFindings(ctx.env, reported);
|
|
89
|
+
const findings: readonly Finding[] = [
|
|
90
|
+
...report.issues.map((issue) => ({
|
|
91
|
+
code: 'X_ENV_MISSING',
|
|
92
|
+
cause: `${issue.key} is ${issue.reason} (expected ${issue.expected})`,
|
|
93
|
+
fix: issue.fix,
|
|
94
|
+
docs: ERROR_DOCS_URL,
|
|
95
|
+
at: ENV_EXAMPLE_PATH,
|
|
96
|
+
})),
|
|
97
|
+
...framework,
|
|
98
|
+
];
|
|
99
|
+
const ok = findings.length === 0;
|
|
85
100
|
return {
|
|
86
|
-
ok
|
|
101
|
+
ok,
|
|
87
102
|
command: 'env',
|
|
88
|
-
summary:
|
|
103
|
+
summary: ok
|
|
89
104
|
? msg('cli.env.checked', { count: total })
|
|
90
|
-
: msg('cli.env.invalid', { count:
|
|
105
|
+
: msg('cli.env.invalid', { count: findings.length, total }),
|
|
91
106
|
findings,
|
|
92
107
|
data: {
|
|
93
108
|
variables: total,
|
|
94
109
|
values: maskedEnvValues(schema, report.values) as JsonValue,
|
|
95
110
|
},
|
|
96
|
-
exitCode:
|
|
111
|
+
exitCode: ok ? 0 : 1,
|
|
97
112
|
};
|
|
98
113
|
}
|
|
99
114
|
|
package/src/cmd-generate.ts
CHANGED
|
@@ -12,13 +12,15 @@ import { requireAppRoot } from './app-root';
|
|
|
12
12
|
import { generateSpec } from './cmd-generate-spec';
|
|
13
13
|
import type { CliCommand, CommandContext } from './command';
|
|
14
14
|
import { invocationOf } from './command';
|
|
15
|
+
import { localiseCatalogs } from './generate-catalog-locales';
|
|
15
16
|
import { assertFeatureExists } from './generate-feature';
|
|
16
17
|
import { generate, sliceDir } from './generate-files';
|
|
17
18
|
import { ungrantedByGenerator } from './generate-grant-findings';
|
|
18
19
|
import { grantGeneratedPermissions } from './generate-grants';
|
|
19
20
|
import type { Generator } from './generate-kinds';
|
|
20
21
|
import { readFeature, readKind, readName, readPermission, readSurface } from './generate-kinds';
|
|
21
|
-
import {
|
|
22
|
+
import { refusePluralTable } from './generate-plural';
|
|
23
|
+
import { refuseShadowedTypes, refuseShadowedValues } from './generate-shadow';
|
|
22
24
|
import { containedPath, planWrites, writeFiles } from './generate-write';
|
|
23
25
|
import { declareGeneratedImports } from './generated-imports';
|
|
24
26
|
import { registerGeneratedEntities, resolveDbModule } from './handle-registration';
|
|
@@ -110,10 +112,18 @@ export const generateCommand: CliCommand = {
|
|
|
110
112
|
...(dbModule === undefined ? {} : { dbModule }),
|
|
111
113
|
shell,
|
|
112
114
|
});
|
|
113
|
-
|
|
115
|
+
// A non-default locale's strings arrive marked, and a locale with no catalog yet is refused
|
|
116
|
+
// with `x i18n add <locale>` — before a dry run answers, so it answers the same files.
|
|
117
|
+
const files = await localiseCatalogs(root, planFor(name));
|
|
114
118
|
// On the planned files, before a dry run answers or anything lands: a name whose type spelling
|
|
115
119
|
// the emitted code also uses as a global is a slice that does not compile.
|
|
116
120
|
refuseShadowedTypes(files, kind, name);
|
|
121
|
+
// And one whose binding a planned file both imports and declares (`x g job job`).
|
|
122
|
+
refuseShadowedValues(files, kind, name);
|
|
123
|
+
// And a plural slice name that an `entity.ts` this run writes would pluralise again.
|
|
124
|
+
refusePluralTable(files, kind, name, featureFlag, (path) =>
|
|
125
|
+
existsSync(containedPath(root, path)),
|
|
126
|
+
);
|
|
117
127
|
// The caller's own invocation, EVERY flag it set included: without `--feature` the fix wrote
|
|
118
128
|
// a second slice beside the one that conflicted.
|
|
119
129
|
const flags = reproducedFlags(generateCommand.spec, ctx.args);
|