@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-build.ts
CHANGED
|
@@ -103,24 +103,94 @@ export function dockerArgs(root: string, tag: string, buildId: string): readonly
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
|
|
106
|
+
* What `--platform` may name: Bun's own `--target` spellings, so the flag maps onto Bun's one to
|
|
107
|
+
* one and an agent who knows Bun's list knows this one. Four, not Bun's every target — these are
|
|
108
|
+
* the hosts the framework is tested on; a musl or baseline build is `bun build` by hand.
|
|
109
|
+
*/
|
|
110
|
+
export const BINARY_PLATFORMS = [
|
|
111
|
+
'bun-linux-x64',
|
|
112
|
+
'bun-linux-arm64',
|
|
113
|
+
'bun-windows-x64',
|
|
114
|
+
'bun-darwin-arm64',
|
|
115
|
+
] as const;
|
|
116
|
+
|
|
117
|
+
export type BinaryPlatform = (typeof BINARY_PLATFORMS)[number];
|
|
118
|
+
|
|
119
|
+
/** `--platform`, checked before the gate runs: an unknown value is a typo, never a guess. */
|
|
120
|
+
export function readPlatform(raw: string | undefined): BinaryPlatform | undefined {
|
|
121
|
+
if (raw === undefined) return undefined;
|
|
122
|
+
const known: readonly string[] = BINARY_PLATFORMS;
|
|
123
|
+
if (known.includes(raw)) return raw as BinaryPlatform;
|
|
124
|
+
throw new BadFlagError({
|
|
125
|
+
flag: 'platform',
|
|
126
|
+
command: 'build',
|
|
127
|
+
reason: `${quoteArg(raw)} is not a platform x build compiles for — one of ${BINARY_PLATFORMS.join(', ')}`,
|
|
128
|
+
fix: 'x build --target binary --platform bun-linux-x64',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The path the executable really lands at. Bun names a Windows executable `<out>.exe` — whether
|
|
134
|
+
* `--platform bun-windows-x64` asked for it or the host is Windows and no platform was named — so
|
|
135
|
+
* the `.exe` is appended HERE and passed to `--outfile`, and the path reported is the path written.
|
|
136
|
+
*/
|
|
137
|
+
export function binaryOutfile(
|
|
138
|
+
out: string,
|
|
139
|
+
platform: BinaryPlatform | undefined,
|
|
140
|
+
host: string = process.platform,
|
|
141
|
+
): string {
|
|
142
|
+
const windows = platform === undefined ? host === 'win32' : platform.startsWith('bun-windows-');
|
|
143
|
+
return windows && !out.toLowerCase().endsWith('.exe') ? `${out}.exe` : out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The define answers `frameworkVersion()` for framework code BUNDLED into an executable, where no
|
|
148
|
+
* `package.json` exists — the reason this target once compiled an artifact that could never boot,
|
|
149
|
+
* and why `docker/Dockerfile`'s self-contained `x` still needs it. Here nothing framework is
|
|
150
|
+
* bundled any more (`LAUNCHER_EXTERNALS`): `@ultimat3/core` loads from the app's `node_modules` and
|
|
151
|
+
* reads its own manifest, so the define reaches no module of this graph. It stays as a pin on that
|
|
152
|
+
* one version, this CLI's own — the packages release in lockstep and `x new` pins them together.
|
|
110
153
|
*
|
|
111
154
|
* `externalArgs()` is not optional either, and for the opposite reason: it names the one specifier
|
|
112
155
|
* this graph must NOT resolve. `apps/web/server.ts` reaches `serve.ts`, which reaches the island
|
|
113
156
|
* builder, which reaches `@babel/core` — whose `.cts`-config loader requires a package we
|
|
114
157
|
* deliberately do not install. Bun 1.3 fails the compile on it. See `compile-externals.ts`.
|
|
158
|
+
*
|
|
159
|
+
* The two autoload flags are what make the binary a LAUNCHER at all. It imports the app tree it is
|
|
160
|
+
* started in at run time — `app.config.ts`, then every module under `apps/*` — and a standalone
|
|
161
|
+
* executable reads neither `package.json` nor `tsconfig.json` at run time by default (Bun:
|
|
162
|
+
* `--compile-autoload-{package-json,tsconfig}`, both off). With no `package.json` the resolver
|
|
163
|
+
* ignores `exports`, so every `@ultimat3/*` (`exports` → `./src/index.ts`) was
|
|
164
|
+
* `Cannot find module '@ultimat3/core' from '<app>/app.config.ts'` and the binary never answered
|
|
165
|
+
* `/healthz` — on Windows, where CI first ran it, and on Linux alike. With no `tsconfig.json` the
|
|
166
|
+
* app's own `paths` aliases fail the same way. `e2e/binary-launch.e2e.test.ts` compiles and runs it.
|
|
115
167
|
*/
|
|
116
|
-
|
|
168
|
+
/**
|
|
169
|
+
* The framework is resolved from the app tree at run time, never bundled into the binary. The app's
|
|
170
|
+
* own modules import `@ultimat3/*` from its `node_modules` — they are loaded off disk, after the
|
|
171
|
+
* build — so a bundled copy was a SECOND instance of every package: the app's `configureAuthenticator`
|
|
172
|
+
* filled the disk copy's slot while the bundled server read its own, empty one (measured:
|
|
173
|
+
* `X_CONFIG_INVALID … no authenticator is configured` on a scaffold that installs one), and every
|
|
174
|
+
* registry split the same way. One instance is the one the app's imports reach.
|
|
175
|
+
*/
|
|
176
|
+
const LAUNCHER_EXTERNALS: readonly string[] = ['@ultimat3/*'];
|
|
177
|
+
|
|
178
|
+
export function binaryArgs(
|
|
179
|
+
root: string,
|
|
180
|
+
out: string,
|
|
181
|
+
platform?: BinaryPlatform,
|
|
182
|
+
): readonly string[] {
|
|
117
183
|
return [
|
|
118
184
|
'bun',
|
|
119
185
|
'build',
|
|
120
186
|
'--compile',
|
|
121
187
|
'--minify',
|
|
188
|
+
...(platform === undefined ? [] : ['--target', platform]),
|
|
122
189
|
'--define',
|
|
123
190
|
`${VERSION_DEFINE}=${JSON.stringify(frameworkVersion())}`,
|
|
191
|
+
'--compile-autoload-package-json',
|
|
192
|
+
'--compile-autoload-tsconfig',
|
|
193
|
+
...LAUNCHER_EXTERNALS.flatMap((specifier) => ['--external', specifier]),
|
|
124
194
|
...externalArgs(),
|
|
125
195
|
join(root, BUILD_ENTRY.binary),
|
|
126
196
|
'--outfile',
|
|
@@ -140,10 +210,12 @@ export function argsFor(
|
|
|
140
210
|
readonly out: string;
|
|
141
211
|
/** The docker target's `BUILD_ID` build arg — `appManifest(root)`'s own. */
|
|
142
212
|
readonly buildId?: string;
|
|
213
|
+
/** The binary target's `--platform`, as Bun's `--target`. */
|
|
214
|
+
readonly platform?: BinaryPlatform;
|
|
143
215
|
},
|
|
144
216
|
): readonly string[] {
|
|
145
217
|
if (target === 'docker') return dockerArgs(paths.root, paths.tag, paths.buildId ?? 'dev');
|
|
146
|
-
if (target === 'binary') return binaryArgs(paths.root, paths.out);
|
|
218
|
+
if (target === 'binary') return binaryArgs(paths.root, paths.out, paths.platform);
|
|
147
219
|
return staticArgs(paths.root, paths.out);
|
|
148
220
|
}
|
|
149
221
|
|
|
@@ -176,6 +248,8 @@ export function buildResult(input: {
|
|
|
176
248
|
readonly command: readonly string[];
|
|
177
249
|
readonly result: ExecResult;
|
|
178
250
|
readonly report?: StaticReport;
|
|
251
|
+
/** The binary target's `--platform`, when one was named. */
|
|
252
|
+
readonly platform?: BinaryPlatform;
|
|
179
253
|
}): CommandResult {
|
|
180
254
|
const { report, result, target } = input;
|
|
181
255
|
const output = result.ok ? '' : execOutput(result);
|
|
@@ -195,6 +269,7 @@ export function buildResult(input: {
|
|
|
195
269
|
],
|
|
196
270
|
data: {
|
|
197
271
|
target,
|
|
272
|
+
...(input.platform === undefined ? {} : { platform: input.platform }),
|
|
198
273
|
artifact: input.artifact,
|
|
199
274
|
durationMs: result.durationMs,
|
|
200
275
|
...(result.ok ? {} : { output }),
|
|
@@ -209,14 +284,14 @@ export function buildResult(input: {
|
|
|
209
284
|
}
|
|
210
285
|
|
|
211
286
|
/** The flags only some targets read. */
|
|
212
|
-
const TARGET_SCOPED_FLAGS = ['tag', 'out', 'preflight'] as const;
|
|
287
|
+
const TARGET_SCOPED_FLAGS = ['tag', 'out', 'platform', 'preflight'] as const;
|
|
213
288
|
|
|
214
289
|
type TargetScopedFlag = (typeof TARGET_SCOPED_FLAGS)[number];
|
|
215
290
|
|
|
216
291
|
/** The optional flags each target READS. One table, so a flag cannot be read by one and ignored. */
|
|
217
292
|
const TARGET_FLAGS: ReadonlyMap<BuildTarget, readonly TargetScopedFlag[]> = new Map([
|
|
218
293
|
['docker', ['tag', 'preflight']],
|
|
219
|
-
['binary', ['out', 'preflight']],
|
|
294
|
+
['binary', ['out', 'platform', 'preflight']],
|
|
220
295
|
['static', ['out', 'preflight']],
|
|
221
296
|
// The boot reads ONE place, and the image holds no devDependencies to run a gate with.
|
|
222
297
|
['prebuilt', []],
|
|
@@ -226,6 +301,7 @@ const TARGET_FLAGS: ReadonlyMap<BuildTarget, readonly TargetScopedFlag[]> = new
|
|
|
226
301
|
const UNREAD_BECAUSE: Readonly<Record<TargetScopedFlag, string>> = {
|
|
227
302
|
tag: 'only the docker target tags an image',
|
|
228
303
|
out: 'the docker target writes an image, not a path',
|
|
304
|
+
platform: 'only the binary target compiles an executable for a platform',
|
|
229
305
|
preflight: 'the prebuilt target runs no gate: `x verify` ran before `docker build` was called',
|
|
230
306
|
};
|
|
231
307
|
|
|
@@ -297,6 +373,7 @@ export const buildCommand: CliCommand = {
|
|
|
297
373
|
// an agent spends on the wrong question.
|
|
298
374
|
requireEntry(root, target);
|
|
299
375
|
refuseUnreadFlags(ctx.args, target);
|
|
376
|
+
const platform = readPlatform(flagString(ctx.args, 'platform'));
|
|
300
377
|
if (target === 'prebuilt') return buildPrebuilt(root);
|
|
301
378
|
|
|
302
379
|
// Run static verify steps before building — unless the caller is a gate that runs the same
|
|
@@ -317,10 +394,11 @@ export const buildCommand: CliCommand = {
|
|
|
317
394
|
// A relative `--out` is a path the caller typed from where they stand, so it resolves against
|
|
318
395
|
// the cwd — against the root it landed somewhere else whenever `x build` ran from `apps/web`.
|
|
319
396
|
const outFlag = flagString(ctx.args, 'out');
|
|
320
|
-
const
|
|
397
|
+
const requested =
|
|
321
398
|
outFlag === undefined
|
|
322
399
|
? join(root, '.x', target === 'static' ? 'static' : 'app')
|
|
323
400
|
: resolve(ctx.cwd, outFlag);
|
|
401
|
+
const out = target === 'binary' ? binaryOutfile(requested, platform) : requested;
|
|
324
402
|
const tag = flagString(ctx.args, 'tag') ?? 'ultimate-app:dev';
|
|
325
403
|
// The docker target stamps the manifest's build id into the image, so no role derives one at
|
|
326
404
|
// boot. The island chunks and stylesheets are the image build's own (`--target prebuilt`).
|
|
@@ -331,6 +409,7 @@ export const buildCommand: CliCommand = {
|
|
|
331
409
|
tag,
|
|
332
410
|
out,
|
|
333
411
|
...(buildId === undefined ? {} : { buildId }),
|
|
412
|
+
...(platform === undefined ? {} : { platform }),
|
|
334
413
|
});
|
|
335
414
|
// Removed BEFORE the builder runs, so a build that writes no inventory can never be reported
|
|
336
415
|
// with the last one's: a stale emitted list is worse than none, because it reads as this run's.
|
|
@@ -346,6 +425,7 @@ export const buildCommand: CliCommand = {
|
|
|
346
425
|
command,
|
|
347
426
|
result,
|
|
348
427
|
...(report === undefined ? {} : { report }),
|
|
428
|
+
...(platform === undefined ? {} : { platform }),
|
|
349
429
|
});
|
|
350
430
|
},
|
|
351
431
|
};
|
package/src/cmd-db-backfill.ts
CHANGED
|
@@ -106,7 +106,7 @@ function readShape(args: ParsedArgs): { readonly shape: BackfillShape; readonly
|
|
|
106
106
|
return refuseShape(
|
|
107
107
|
second.startsWith('--') ? second.slice(2) : 'name',
|
|
108
108
|
`x db backfill was asked for ${asked.join(' and ')} — one shape per invocation: --list, --pending, <name> or --all`,
|
|
109
|
-
`x db backfill ${asked[0]} --json`,
|
|
109
|
+
`x db backfill ${quoteArg(asked[0] ?? '--pending')} --json`,
|
|
110
110
|
);
|
|
111
111
|
}
|
|
112
112
|
if (asked.length === 0) {
|
package/src/cmd-db-branch.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// The facts (what a branch is, per mode) are `db-branch.ts`; the client lifetime is here.
|
|
5
5
|
|
|
6
6
|
import { ERROR_DOCS_URL, nearestName } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
7
|
+
import { type DbClient, postgresClient } from '@ultimat3/db';
|
|
8
8
|
import type { CommandContext } from './command';
|
|
9
9
|
import type { BranchRow } from './db-branch';
|
|
10
10
|
import {
|
|
@@ -62,7 +62,7 @@ function branchRetry(word: string, name: string | undefined): string {
|
|
|
62
62
|
* database routinely outlives the 10s a `web` profile allows.
|
|
63
63
|
*/
|
|
64
64
|
async function withBranchClient<T>(url: string, fn: (client: DbClient) => Promise<T>): Promise<T> {
|
|
65
|
-
const client =
|
|
65
|
+
const client = postgresClient({ url, role: 'migrate', applicationName: 'x-db-branch' });
|
|
66
66
|
try {
|
|
67
67
|
return await fn(client);
|
|
68
68
|
} finally {
|
package/src/cmd-db.ts
CHANGED
|
@@ -255,8 +255,11 @@ async function runReset(ctx: CommandContext, root: string): Promise<CommandResul
|
|
|
255
255
|
const services = resolveServices(root, ctx.env);
|
|
256
256
|
if (services.db.mode === 'external') {
|
|
257
257
|
throw new NotImplementedError({
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
// The usual way here is an EXPORTED `DATABASE_URL` (an integration suite's Postgres) that
|
|
259
|
+
// the shell handed down, so the variable is named and the embedded reset is one command (#674).
|
|
260
|
+
cause:
|
|
261
|
+
'DATABASE_URL is set, so x db reset would target an external Postgres — dropping a database this process does not own is not implemented in this build',
|
|
262
|
+
fix: 'env -u DATABASE_URL x db reset # resets the embedded database instead — or drop and recreate the external one yourself, then run: x db migrate',
|
|
260
263
|
});
|
|
261
264
|
}
|
|
262
265
|
// A running `x dev` has this directory open: deleting it under a live embedded Postgres is data
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The compose half of `x deploy`'s rollout: which serving roles can start their new container
|
|
2
|
+
// BEFORE the old one stops, read off the app's compose file. The sequence that does it is
|
|
3
|
+
// `cmd-deploy-start-first.ts`.
|
|
4
|
+
// `docker compose up -d <role>` alone is stop-first — it recreates, so the role serves nothing
|
|
5
|
+
// between the old container's exit and the new one's healthcheck.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How one serving role is rolled. `start-first` is the zero-downtime path; `stop-first` is
|
|
9
|
+
* compose's own recreate, kept where two containers of the role cannot coexist, with the reason.
|
|
10
|
+
*/
|
|
11
|
+
export type ComposeStrategy =
|
|
12
|
+
| { readonly kind: 'start-first'; readonly replicas: number }
|
|
13
|
+
| { readonly kind: 'stop-first'; readonly why: string };
|
|
14
|
+
|
|
15
|
+
/** The roles that stay up serving — the only ones a rollout strategy applies to. */
|
|
16
|
+
export const SERVING_ROLES = ['web', 'sync', 'worker', 'scheduler'] as const;
|
|
17
|
+
|
|
18
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
19
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The host port a `ports:` entry pins, or `undefined` when Docker picks it. Short syntax is
|
|
23
|
+
* `[[ip:]host:]container[/proto]`: `3000` and `127.0.0.1::3000` leave the host side to Docker, so
|
|
24
|
+
* any number of replicas bind; `3000:3000` and `127.0.0.1:3001:3001` name one, which exactly one
|
|
25
|
+
* container can hold. Long syntax pins one when `published` is said.
|
|
26
|
+
*/
|
|
27
|
+
function pinnedHostPort(entry: unknown): string | undefined {
|
|
28
|
+
if (isRecord(entry)) {
|
|
29
|
+
const published = entry['published'];
|
|
30
|
+
return published === undefined || published === null || published === ''
|
|
31
|
+
? undefined
|
|
32
|
+
: String(published);
|
|
33
|
+
}
|
|
34
|
+
if (typeof entry !== 'string') return undefined;
|
|
35
|
+
const spec = entry.replace(/^\[[^\]]*\]:/, '').split('/')[0] ?? '';
|
|
36
|
+
const parts = spec.split(':');
|
|
37
|
+
const host = parts.length === 3 ? parts[1] : parts.length === 2 ? parts[0] : '';
|
|
38
|
+
return host === undefined || host === '' ? undefined : host;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One service's strategy: start-first unless something in its definition forbids a second copy. */
|
|
42
|
+
function strategyOf(service: unknown): ComposeStrategy {
|
|
43
|
+
if (!isRecord(service)) {
|
|
44
|
+
return { kind: 'stop-first', why: 'the compose file declares no such service' };
|
|
45
|
+
}
|
|
46
|
+
if (service['container_name'] !== undefined) {
|
|
47
|
+
return {
|
|
48
|
+
kind: 'stop-first',
|
|
49
|
+
why: 'container_name names exactly one container, so a second replica cannot be created',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (service['network_mode'] === 'host') {
|
|
53
|
+
return {
|
|
54
|
+
kind: 'stop-first',
|
|
55
|
+
why: 'network_mode: host binds the host ports directly, so two replicas collide',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const ports = Array.isArray(service['ports']) ? service['ports'] : [];
|
|
59
|
+
const pinned = ports.map(pinnedHostPort).find((port) => port !== undefined);
|
|
60
|
+
if (pinned !== undefined) {
|
|
61
|
+
return {
|
|
62
|
+
kind: 'stop-first',
|
|
63
|
+
why: `publishes host port ${pinned}, which one container can bind — remove ports: and route through a proxy on the compose network to roll it start-first`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const deploy = service['deploy'];
|
|
67
|
+
const declared = isRecord(deploy) ? deploy['replicas'] : undefined;
|
|
68
|
+
const replicas =
|
|
69
|
+
typeof declared === 'number' && Number.isSafeInteger(declared) && declared >= 0 ? declared : 1;
|
|
70
|
+
return { kind: 'start-first', replicas };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every serving role's strategy, from the compose file's text. A file that is absent or does not
|
|
75
|
+
* parse decides nothing: every role keeps stop-first, and compose reports the file's own error
|
|
76
|
+
* when the step runs — this reader is never a second validator of it.
|
|
77
|
+
*/
|
|
78
|
+
export function composeStrategies(text: string | undefined): ReadonlyMap<string, ComposeStrategy> {
|
|
79
|
+
let parsed: unknown;
|
|
80
|
+
try {
|
|
81
|
+
parsed = text === undefined ? undefined : Bun.YAML.parse(text);
|
|
82
|
+
} catch {
|
|
83
|
+
parsed = undefined;
|
|
84
|
+
}
|
|
85
|
+
const services = isRecord(parsed) ? parsed['services'] : undefined;
|
|
86
|
+
if (!isRecord(services)) return new Map();
|
|
87
|
+
return new Map(SERVING_ROLES.map((role) => [role, strategyOf(services[role])]));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The compose file at `path`, or `undefined` when there is none to read. */
|
|
91
|
+
export async function readComposeFile(path: string): Promise<string | undefined> {
|
|
92
|
+
const file = Bun.file(path);
|
|
93
|
+
return (await file.exists()) ? file.text() : undefined;
|
|
94
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// What a helm deploy tells the chart about the drain: `app.config.ts`'s `drain` section — the ONE
|
|
2
|
+
// drain budget since 25.0.0 deleted `http.drainTimeoutMs` — in the seconds `docker/helm` derives
|
|
3
|
+
// each role's terminationGracePeriodSeconds from. Passed on every upgrade, so a raised
|
|
4
|
+
// budget can never ship against a grace period sized for 25 s.
|
|
5
|
+
|
|
6
|
+
import type { DrainConfig } from '@ultimat3/core';
|
|
7
|
+
import { isUltimateError, READINESS_GRACE_DEFAULT_MS } from '@ultimat3/core';
|
|
8
|
+
import { loadDrainConfig } from './serve-drain';
|
|
9
|
+
|
|
10
|
+
const seconds = (ms: number): number => Math.ceil(ms / 1000);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The `--set` pairs, or none when the app declares no budget (the chart's defaults are core's).
|
|
14
|
+
* The chart's `drain.deadlineSeconds` is ONE budget for every role, and so is `drain.deadlineMs`:
|
|
15
|
+
* the web role drains on it too. The margins stay the chart's.
|
|
16
|
+
*
|
|
17
|
+
* The grace is budgeted at no less than core's production default: the value read here is resolved
|
|
18
|
+
* in THIS process's environment, and an `x deploy` run under `NODE_ENV=development` resolves the
|
|
19
|
+
* grace to 0 while the pod it deploys runs with 5 s. Over-budgeting costs a pod nothing — it exits
|
|
20
|
+
* when its drain does — and under-budgeting is a SIGKILL mid-drain.
|
|
21
|
+
*/
|
|
22
|
+
export function helmDrainOverrides(drain: Partial<DrainConfig> | undefined): readonly string[] {
|
|
23
|
+
const out: string[] = [];
|
|
24
|
+
if (drain?.deadlineMs !== undefined) {
|
|
25
|
+
out.push('--set', `drain.deadlineSeconds=${seconds(drain.deadlineMs)}`);
|
|
26
|
+
}
|
|
27
|
+
if (drain === undefined) return out;
|
|
28
|
+
const grace = Math.max(drain.readinessGraceMs ?? 0, READINESS_GRACE_DEFAULT_MS);
|
|
29
|
+
out.push('--set', `drain.readinessGraceSeconds=${seconds(grace)}`);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The overrides for the app at `root`, off the one config loader. An `app.config.ts` the loader
|
|
35
|
+
* refuses yields no drain section, and the chart's defaults stand: `X_CONFIG_INVALID`'s own fix for
|
|
36
|
+
* a helm deploy is `--release <name>`, which must stay a way out, and the pods this upgrade starts
|
|
37
|
+
* refuse the same config at boot — which `helm upgrade --wait` reports as the failed rollout it is.
|
|
38
|
+
*/
|
|
39
|
+
export async function readHelmDrainOverrides(root: string): Promise<readonly string[]> {
|
|
40
|
+
let drain: Partial<DrainConfig> | undefined;
|
|
41
|
+
try {
|
|
42
|
+
drain = await loadDrainConfig(root);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (!isUltimateError(error) || error.code !== 'X_CONFIG_INVALID') throw error;
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return helmDrainOverrides(drain);
|
|
48
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// One compose role rolled start-first: the new containers come up beside the old ones, are waited
|
|
2
|
+
// on until healthy, and only then are the old ones stopped and removed. Converges on the declared
|
|
3
|
+
// replica count, so a rerun after a half-finished deploy finishes it rather than adding replicas.
|
|
4
|
+
|
|
5
|
+
import type { ExecOptions, Runner } from './exec';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bounds `--wait`: a new container that never reports healthy fails the step rather than holding
|
|
9
|
+
* the deploy open. Five minutes is past any `start_period` the scaffold declares (30s) plus a cold
|
|
10
|
+
* boot, and `--wait` already returns at once when a container turns `unhealthy` or exits.
|
|
11
|
+
*/
|
|
12
|
+
export const COMPOSE_WAIT_TIMEOUT_SECONDS = 300;
|
|
13
|
+
|
|
14
|
+
/** The label compose stamps on each container: the hash of the service definition it was made from. */
|
|
15
|
+
const CONFIG_HASH = 'com.docker.compose.config-hash';
|
|
16
|
+
const INSPECT_FORMAT = `{{.Id}} {{.Created}} {{index .Config.Labels "${CONFIG_HASH}"}}`;
|
|
17
|
+
|
|
18
|
+
/** The `up` that starts the new replicas beside the running ones; `--no-recreate` keeps the old. */
|
|
19
|
+
export function startFirstUp(
|
|
20
|
+
base: readonly string[],
|
|
21
|
+
role: string,
|
|
22
|
+
scale: number | undefined,
|
|
23
|
+
): readonly string[] {
|
|
24
|
+
const wait = ['--wait', '--wait-timeout', String(COMPOSE_WAIT_TIMEOUT_SECONDS)];
|
|
25
|
+
return scale === undefined
|
|
26
|
+
? [...base, 'up', '-d', '--no-deps', ...wait, role]
|
|
27
|
+
: [
|
|
28
|
+
...base,
|
|
29
|
+
'up',
|
|
30
|
+
'-d',
|
|
31
|
+
'--no-deps',
|
|
32
|
+
'--no-recreate',
|
|
33
|
+
...wait,
|
|
34
|
+
'--scale',
|
|
35
|
+
`${role}=${scale}`,
|
|
36
|
+
role,
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Failed {
|
|
41
|
+
readonly code: number;
|
|
42
|
+
readonly command: readonly string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type StartFirstOutcome =
|
|
46
|
+
| { readonly ok: true }
|
|
47
|
+
| ({
|
|
48
|
+
readonly ok: false;
|
|
49
|
+
/**
|
|
50
|
+
* The cleanup of an unhealthy new replica that ALSO failed — its argv names the containers
|
|
51
|
+
* still running, which the operator now owns. Absent when the cleanup ran clean.
|
|
52
|
+
*/
|
|
53
|
+
readonly cleanup?: Failed | undefined;
|
|
54
|
+
} & Failed);
|
|
55
|
+
|
|
56
|
+
const linesOf = (stdout: string): readonly string[] =>
|
|
57
|
+
stdout
|
|
58
|
+
.split('\n')
|
|
59
|
+
.map((line) => line.trim())
|
|
60
|
+
.filter((line) => line.length > 0);
|
|
61
|
+
|
|
62
|
+
/** `docker compose config --hash <role>` prints `<role> <hash>`. */
|
|
63
|
+
const hashOf = (stdout: string): string | undefined => linesOf(stdout)[0]?.split(/\s+/)[1];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The running containers split by the definition they were made from: `current` already runs the
|
|
67
|
+
* one being deployed (newest first), `stale` anything else. A container `inspect` did not describe
|
|
68
|
+
* is stale — stopping one too many is a restart, keeping one too many is the old build serving.
|
|
69
|
+
*/
|
|
70
|
+
function partition(
|
|
71
|
+
running: readonly string[],
|
|
72
|
+
inspected: string,
|
|
73
|
+
want: string,
|
|
74
|
+
): { readonly current: readonly string[]; readonly stale: readonly string[] } {
|
|
75
|
+
const described = new Map(
|
|
76
|
+
linesOf(inspected).map((line) => {
|
|
77
|
+
const [id = '', created = '', hash = ''] = line.split(/\s+/);
|
|
78
|
+
return [id, { created, hash }] as const;
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
const facts = running.map((id) => {
|
|
82
|
+
const match = [...described].find(([full]) => full === id || full.startsWith(id));
|
|
83
|
+
return { id, created: match?.[1].created ?? '', hash: match?.[1].hash };
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
current: facts
|
|
87
|
+
.filter((fact) => fact.hash === want)
|
|
88
|
+
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
|
89
|
+
.map((fact) => fact.id),
|
|
90
|
+
stale: facts.filter((fact) => fact.hash !== want).map((fact) => fact.id),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Start-first for one role. The containers running now are listed and split by compose's config
|
|
96
|
+
* hash against the definition being deployed: those already on it are kept (up to `replicas`,
|
|
97
|
+
* newest first), and only the shortfall is created BESIDE the rest (`--scale running + shortfall
|
|
98
|
+
* --no-recreate`, so compose builds the extra containers from the new definition and leaves the
|
|
99
|
+
* others alone). Everything is waited on until its healthcheck — `/readyz` for web and sync, the
|
|
100
|
+
* scrape port for the others — answers. Only then are the stale containers and any surplus stopped
|
|
101
|
+
* (SIGTERM, then the `stop_grace_period` compose stamped on each as its stop timeout) and removed.
|
|
102
|
+
*
|
|
103
|
+
* Counting the shortfall rather than `running + replicas` is what makes a rerun safe: after a
|
|
104
|
+
* deploy that brought the new containers up and then failed to stop the old, running it again
|
|
105
|
+
* creates nothing and finishes the stop.
|
|
106
|
+
*
|
|
107
|
+
* A new replica that never turns healthy is stopped and removed, and the old ones keep serving;
|
|
108
|
+
* a cleanup that fails is reported beside the failure, naming the containers left running. The
|
|
109
|
+
* same sequence `docker-rollout` performs, because `deploy.update_config.order: start-first` is a
|
|
110
|
+
* Swarm field `docker compose` ignores.
|
|
111
|
+
*/
|
|
112
|
+
export async function runStartFirst(step: {
|
|
113
|
+
readonly role: string;
|
|
114
|
+
readonly base: readonly string[];
|
|
115
|
+
readonly replicas: number;
|
|
116
|
+
readonly runner: Runner;
|
|
117
|
+
readonly options: ExecOptions;
|
|
118
|
+
}): Promise<StartFirstOutcome> {
|
|
119
|
+
const { role, base, replicas, runner, options } = step;
|
|
120
|
+
const run = async (command: readonly string[]) => {
|
|
121
|
+
const result = await runner(command, options);
|
|
122
|
+
return { result, failed: { ok: false as const, code: result.code, command } };
|
|
123
|
+
};
|
|
124
|
+
const listing = await run([...base, 'ps', '-q', role]);
|
|
125
|
+
if (!listing.result.ok) return listing.failed;
|
|
126
|
+
const running = linesOf(listing.result.stdout);
|
|
127
|
+
if (running.length === 0) {
|
|
128
|
+
const fresh = await run(startFirstUp(base, role, undefined));
|
|
129
|
+
return fresh.result.ok ? { ok: true } : fresh.failed;
|
|
130
|
+
}
|
|
131
|
+
const config = await run([...base, 'config', '--hash', role]);
|
|
132
|
+
if (!config.result.ok) return config.failed;
|
|
133
|
+
const inspect = await run(['docker', 'inspect', '--format', INSPECT_FORMAT, ...running]);
|
|
134
|
+
if (!inspect.result.ok) return inspect.failed;
|
|
135
|
+
const { current, stale } = partition(
|
|
136
|
+
running,
|
|
137
|
+
inspect.result.stdout,
|
|
138
|
+
hashOf(config.result.stdout) ?? '',
|
|
139
|
+
);
|
|
140
|
+
const shortfall = Math.max(0, replicas - current.length);
|
|
141
|
+
const up = await run(startFirstUp(base, role, running.length + shortfall));
|
|
142
|
+
if (!up.result.ok) return { ...up.failed, ...(await removeCreated(step, running)) };
|
|
143
|
+
const retire = [...stale, ...current.slice(replicas)];
|
|
144
|
+
if (retire.length === 0) return { ok: true };
|
|
145
|
+
const stopped = await run(['docker', 'stop', ...retire]);
|
|
146
|
+
if (!stopped.result.ok) return stopped.failed;
|
|
147
|
+
const removed = await run(['docker', 'rm', ...retire]);
|
|
148
|
+
return removed.result.ok ? { ok: true } : removed.failed;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* After a failed `up`: stop and remove what it created, so the containers that were serving are
|
|
153
|
+
* the role again. A listing that fails leaves the new replicas to the operator; a stop or rm that
|
|
154
|
+
* fails is returned as `cleanup`, its argv naming the containers still there.
|
|
155
|
+
*/
|
|
156
|
+
async function removeCreated(
|
|
157
|
+
step: {
|
|
158
|
+
readonly role: string;
|
|
159
|
+
readonly base: readonly string[];
|
|
160
|
+
readonly runner: Runner;
|
|
161
|
+
readonly options: ExecOptions;
|
|
162
|
+
},
|
|
163
|
+
running: readonly string[],
|
|
164
|
+
): Promise<{ readonly cleanup?: Failed }> {
|
|
165
|
+
const { role, base, runner, options } = step;
|
|
166
|
+
const after = await runner([...base, 'ps', '-q', role], options);
|
|
167
|
+
if (!after.ok) return { cleanup: { code: after.code, command: [...base, 'ps', '-q', role] } };
|
|
168
|
+
const created = linesOf(after.stdout).filter((id) => !running.includes(id));
|
|
169
|
+
if (created.length === 0) return {};
|
|
170
|
+
for (const command of [
|
|
171
|
+
['docker', 'stop', ...created],
|
|
172
|
+
['docker', 'rm', ...created],
|
|
173
|
+
]) {
|
|
174
|
+
const result = await runner(command, options);
|
|
175
|
+
if (!result.ok) return { cleanup: { code: result.code, command } };
|
|
176
|
+
}
|
|
177
|
+
return {};
|
|
178
|
+
}
|