@ultimat3/cli 23.0.0 → 25.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.
- package/CLAUDE.md +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- 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 +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- 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 +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- 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 +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- 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-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- 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 +2 -6
- package/src/island-bundle.ts +57 -79
- 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 +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- 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 +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- 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-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- 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 +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- 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 -33
- 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-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -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 +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -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/naming.ts +28 -0
- 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 +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- 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-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- 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-client.ts +4 -2
- 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-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- 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 +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- 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/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// `x dev`'s restart, worker first (issue #677). A save that reaches a primitive-defining module
|
|
2
|
+
// restarts the child, and the drain that ends it used to be the FIRST thing it did: a job that ran
|
|
3
|
+
// past the drain budget was aborted `X_DRAINING` and requeued, so a non-idempotent one (a bank
|
|
4
|
+
// login) ran twice. The worker is retired first — `stop()` claims nothing and aborts nothing — and
|
|
5
|
+
// only then is the drain asked to release the port, the lock and the embedded database.
|
|
6
|
+
|
|
7
|
+
import { drain, logger, renderThrowable } from '@ultimat3/core';
|
|
8
|
+
import type { Worker } from '@ultimat3/jobs';
|
|
9
|
+
import { msg } from './messages';
|
|
10
|
+
import { writeErrorLine } from './write-line';
|
|
11
|
+
|
|
12
|
+
/** The two calls a retire makes, so a test hands in a worker without booting a queue. */
|
|
13
|
+
export type RetiringWorker = Pick<Worker, 'stats' | 'stop'>;
|
|
14
|
+
|
|
15
|
+
export interface RetireDeps {
|
|
16
|
+
/** The process drain the restart ends in. Default: core's `drain`. */
|
|
17
|
+
readonly drain: (reason: string) => Promise<void>;
|
|
18
|
+
/** Where the wait is said. Default: stderr — fd 1 may be `--json`'s one document. */
|
|
19
|
+
readonly say: (line: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_DEPS: RetireDeps = { drain, say: writeErrorLine };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Retire `worker`, then drain. Unbounded on purpose: in development a restart that waits for a
|
|
26
|
+
* long job is the cheaper failure than a side effect run twice, and the line it prints says how to
|
|
27
|
+
* stop waiting — Ctrl-C is a real drain, which binds the worker's teardown to the drain deadline
|
|
28
|
+
* and cuts off what is still running there. A retire that FAILS still drains: a restart left
|
|
29
|
+
* half-done is a child that stopped claiming and never exits.
|
|
30
|
+
*/
|
|
31
|
+
export async function retireThenDrain(
|
|
32
|
+
worker: RetiringWorker | null,
|
|
33
|
+
deps: RetireDeps = DEFAULT_DEPS,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
try {
|
|
36
|
+
if (worker !== null) {
|
|
37
|
+
const { inFlight } = await worker.stats();
|
|
38
|
+
if (inFlight > 0) deps.say(msg('cli.dev.restartWaiting', { count: inFlight }));
|
|
39
|
+
await worker.stop('x dev restart');
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
logger.warn('cli.dev.restart-retire-failed', { error: renderThrowable(error) });
|
|
43
|
+
} finally {
|
|
44
|
+
await deps.drain('restart');
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/dev-route-table.ts
CHANGED
|
@@ -4,21 +4,23 @@
|
|
|
4
4
|
|
|
5
5
|
import type { RealtimeConfig } from '@ultimat3/core';
|
|
6
6
|
import type { RateLimitStore, Route } from '@ultimat3/http';
|
|
7
|
-
import {
|
|
7
|
+
import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
8
|
+
import { describePages } from '@ultimat3/render';
|
|
8
9
|
import type { IsrController } from '@ultimat3/render/server';
|
|
10
|
+
import type { ImageTransformDriver } from '@ultimat3/seo';
|
|
9
11
|
import type { Storage } from '@ultimat3/storage';
|
|
10
12
|
import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
|
|
11
13
|
import { mountAppMcp } from './app-mcp';
|
|
12
14
|
import type { DevDashboardInput } from './dev-dashboard';
|
|
13
15
|
import { devDashboardRoutes } from './dev-dashboard';
|
|
14
|
-
import {
|
|
16
|
+
import { errorPageStyleBodies } from './error-page-csp';
|
|
15
17
|
import type { IslandBundle } from './island-bundle';
|
|
16
18
|
import { islandHarnessRoutes } from './island-harness-route';
|
|
17
19
|
import { islandRoutes } from './island-routes';
|
|
18
20
|
import { loadIslandStates } from './island-states-load';
|
|
19
21
|
import { loadNavigation, pageNavigation } from './page-navigation';
|
|
20
22
|
import type { PageSpeculation } from './page-speculation';
|
|
21
|
-
import { loadSpeculation, pageSpeculation } from './page-speculation';
|
|
23
|
+
import { loadSpeculation, otherLocaleSegments, pageSpeculation } from './page-speculation';
|
|
22
24
|
import { pageSync } from './page-sync';
|
|
23
25
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
24
26
|
import { adminMountRoutes } from './runtime-admin';
|
|
@@ -33,6 +35,7 @@ import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
|
33
35
|
import { serviceWorkerRoutes } from './sw-routes';
|
|
34
36
|
import type { ThemeBoot } from './theme-boot';
|
|
35
37
|
import { loadThemeMode, themeBoot } from './theme-boot';
|
|
38
|
+
import { loadThemeBrand } from './theme-brand';
|
|
36
39
|
|
|
37
40
|
export interface DevRouteTableInput {
|
|
38
41
|
readonly root: string;
|
|
@@ -53,6 +56,12 @@ export interface DevRouteTableInput {
|
|
|
53
56
|
* empty it: a stored render outlives the module that produced it.
|
|
54
57
|
*/
|
|
55
58
|
readonly isr?: IsrController | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* `apps/<app>/runtime.ts`'s `images` driver, handed to `/media` exactly as the container hands it
|
|
61
|
+
* (`serve-web.ts`): dropped here, dev answered every variant from the builtin pipeline and a
|
|
62
|
+
* CDN-backed transform was first exercised in production.
|
|
63
|
+
*/
|
|
64
|
+
readonly images?: ImageTransformDriver | undefined;
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
export interface DevRouteTable {
|
|
@@ -61,8 +70,11 @@ export interface DevRouteTable {
|
|
|
61
70
|
readonly theme: ThemeBoot;
|
|
62
71
|
/** The speculation rules, admitted the same way — `undefined` when no document carries any. */
|
|
63
72
|
readonly speculation: PageSpeculation | undefined;
|
|
64
|
-
/**
|
|
65
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Every inline `<style>` body these routes put in a document: the app's error pages' and its
|
|
75
|
+
* brand's (`theme-brand.ts`) — what `startRoles({ inlineStyles })` hashes into `style-src`.
|
|
76
|
+
*/
|
|
77
|
+
readonly inlineStyles: readonly string[];
|
|
66
78
|
/** Where the app's default MCP endpoint was mounted, or `null`. */
|
|
67
79
|
readonly mcpPath: string | null;
|
|
68
80
|
/** Every mounted MCP endpoint's path, default first — one per population the app serves. */
|
|
@@ -74,6 +86,8 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
74
86
|
// is mounted, and the 0kb baseline is not spent on a `<link>` to a file that does not exist.
|
|
75
87
|
const pwa = await loadPwaArtifacts(input.root);
|
|
76
88
|
const theme = themeBoot(await loadThemeMode(input.root));
|
|
89
|
+
// Read at boot, like the theme mode: a save to the theme module is applied on the next `x dev`.
|
|
90
|
+
const brand = await loadThemeBrand(input.root);
|
|
77
91
|
// `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
|
|
78
92
|
const site = await loadSiteSettings(input.root);
|
|
79
93
|
const origin = publicOrigin(input.env, site);
|
|
@@ -87,8 +101,12 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
87
101
|
const speculation = pageSpeculation({
|
|
88
102
|
config: await loadSpeculation(input.root),
|
|
89
103
|
client: declared.surfaces,
|
|
104
|
+
localeSegments: otherLocaleSegments(await appLocaleSet(input.root)),
|
|
90
105
|
});
|
|
91
|
-
const
|
|
106
|
+
const inlineStyles = [
|
|
107
|
+
...(await errorPageStyleBodies(input.root)),
|
|
108
|
+
...(brand === undefined ? [] : [brand.style]),
|
|
109
|
+
];
|
|
92
110
|
// Built once at boot and NOT rebuilt with the islands on a watcher tick: a service worker that
|
|
93
111
|
// changes under a page it controls is the update path, and one per keystroke exercises it per save.
|
|
94
112
|
const serviceWorker =
|
|
@@ -97,7 +115,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
97
115
|
: serviceWorkerArtifacts({
|
|
98
116
|
pwa,
|
|
99
117
|
buildId: input.buildId,
|
|
100
|
-
routes:
|
|
118
|
+
routes: describePages(),
|
|
101
119
|
islands: input.islands(),
|
|
102
120
|
styles: styleBundle(),
|
|
103
121
|
scripts: [
|
|
@@ -126,6 +144,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
126
144
|
...assetRoutes({
|
|
127
145
|
root: input.root,
|
|
128
146
|
storage: served,
|
|
147
|
+
...(input.images === undefined ? {} : { images: input.images }),
|
|
129
148
|
...(pwa === undefined ? {} : { pwa }),
|
|
130
149
|
}),
|
|
131
150
|
...storageRoutes({ storage: served }),
|
|
@@ -146,6 +165,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
146
165
|
...islandHarnessRoutes({
|
|
147
166
|
islands: () => input.islands(),
|
|
148
167
|
states: () => loadIslandStates(input.root),
|
|
168
|
+
devUrl: () => input.dashboard.status().url,
|
|
149
169
|
}),
|
|
150
170
|
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
151
171
|
...sync.routes,
|
|
@@ -157,6 +177,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
157
177
|
...adminMountRoutes({
|
|
158
178
|
buildId: input.buildId,
|
|
159
179
|
themeHead: theme.head,
|
|
180
|
+
...(brand === undefined ? {} : { brandHead: brand.head }),
|
|
160
181
|
...(origin === undefined ? {} : { origin }),
|
|
161
182
|
}),
|
|
162
183
|
...appRoutes({
|
|
@@ -167,6 +188,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
167
188
|
persisted: sync.persisted,
|
|
168
189
|
...(navigation.head === undefined ? {} : { navigation: navigation.head }),
|
|
169
190
|
themeHead: theme.head,
|
|
191
|
+
...(brand === undefined ? {} : { brandHead: brand.head }),
|
|
170
192
|
...(speculation === undefined ? {} : { speculationHead: speculation.head }),
|
|
171
193
|
...(origin === undefined ? {} : { origin }),
|
|
172
194
|
...(pwa === undefined
|
|
@@ -179,7 +201,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
179
201
|
routes,
|
|
180
202
|
theme,
|
|
181
203
|
speculation,
|
|
182
|
-
|
|
204
|
+
inlineStyles,
|
|
183
205
|
mcpPath: mcpMount.path,
|
|
184
206
|
mcpPaths: mcpMount.paths,
|
|
185
207
|
};
|
package/src/dev-supervisor.ts
CHANGED
|
@@ -5,17 +5,23 @@
|
|
|
5
5
|
// of logging "reloaded" over stale code, and this loop boots a fresh one on the same port. Every
|
|
6
6
|
// other save stays the in-process reload it was — a restart is paid only where nothing else works.
|
|
7
7
|
|
|
8
|
-
// why: Bun has no path API; the restart line names files relative to the app root.
|
|
9
|
-
import { relative } from 'node:path';
|
|
10
|
-
import { drain } from '@ultimat3/core';
|
|
11
8
|
import type { StalePin } from './app-reload-graph';
|
|
12
9
|
import { devSpec } from './cmd-dev-spec';
|
|
13
|
-
import type { DevChildGone } from './dev-child-watch';
|
|
14
|
-
import {
|
|
10
|
+
import type { DevChildGone, DevDrainMessage } from './dev-child-watch';
|
|
11
|
+
import {
|
|
12
|
+
DEV_SUPERVISOR_PID_ENV,
|
|
13
|
+
devHardExitMs,
|
|
14
|
+
startDevChildWatch,
|
|
15
|
+
supervisorPid,
|
|
16
|
+
} from './dev-child-watch';
|
|
15
17
|
import { devPortFor } from './dev-port';
|
|
18
|
+
import { type RetiringWorker, retireThenDrain } from './dev-restart-retire';
|
|
19
|
+
import { PORT_RANGE } from './flag-number';
|
|
16
20
|
import { msg } from './messages';
|
|
17
21
|
import type { Finding } from './output';
|
|
18
22
|
import { parseArgs } from './parse';
|
|
23
|
+
import { posixRelative } from './posix-path';
|
|
24
|
+
import { writeErrorLine } from './write-line';
|
|
19
25
|
|
|
20
26
|
/** Set on the child: it serves, and a pinned save makes it exit for a restart. */
|
|
21
27
|
export const DEV_CHILD_ENV = 'ULTIMATE_DEV_CHILD';
|
|
@@ -48,12 +54,43 @@ export function devSupervision(
|
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
/** A port
|
|
52
|
-
|
|
57
|
+
/** A port the OS hands out for `0`, released at once — the candidate `freeDevPort` checks. */
|
|
58
|
+
const kernelPort = (): number => {
|
|
53
59
|
const probe = Bun.serve({ port: 0, fetch: () => new Response() });
|
|
54
60
|
const port = probe.port ?? 0;
|
|
55
61
|
probe.stop(true);
|
|
56
62
|
return port;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Whether `port` can be bound right now: `Bun.serve` refusing it is the answer, not an error. */
|
|
66
|
+
const portBindable = (port: number): boolean => {
|
|
67
|
+
try {
|
|
68
|
+
Bun.serve({ port, fetch: () => new Response() }).stop(true);
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Kernel candidates tried for a free PAIR before the last one is handed back unchecked. */
|
|
76
|
+
const PAIR_ATTEMPTS = 16;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A port whose PAIR nothing holds right now — what `--port 0` is pinned to. `x dev` binds the web
|
|
80
|
+
* role on `port` and the sync role on `port + 1` (`syncPortFor`), so a port the kernel says is
|
|
81
|
+
* free proves nothing about its neighbour: asking for one alone failed the boot `X_PORT_IN_USE`
|
|
82
|
+
* on a busy box whenever the neighbour was taken (`cmd-dev-orphan.live.test.ts`, 2026-10-07).
|
|
83
|
+
*/
|
|
84
|
+
export function freeDevPort(
|
|
85
|
+
pick: () => number = kernelPort,
|
|
86
|
+
bindable: (port: number) => boolean = portBindable,
|
|
87
|
+
): number {
|
|
88
|
+
let port = 0;
|
|
89
|
+
for (let attempt = 0; attempt < PAIR_ATTEMPTS; attempt += 1) {
|
|
90
|
+
port = pick();
|
|
91
|
+
if (port > 0 && port < PORT_RANGE.max && bindable(port + 1)) return port;
|
|
92
|
+
}
|
|
93
|
+
return port;
|
|
57
94
|
}
|
|
58
95
|
|
|
59
96
|
/** `argv` with every `--port` removed and `--port <port>` appended. */
|
|
@@ -75,6 +112,35 @@ function withPort(argv: readonly string[], port: number): readonly string[] {
|
|
|
75
112
|
export interface DevChild {
|
|
76
113
|
readonly exited: Promise<number>;
|
|
77
114
|
kill(signal: 'SIGINT' | 'SIGTERM'): void;
|
|
115
|
+
/** The IPC channel; absent or closed, `stopChild` falls back to the signal. */
|
|
116
|
+
send?(message: DevDrainMessage): void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Asks the child to drain: a `drain` message over IPC, on every platform — on Windows
|
|
121
|
+
* `kill('SIGTERM')` is TerminateProcess, which skips the drain and leaves the embedded database
|
|
122
|
+
* unclean. The signal only when there is no channel to ask over (it closed, or a test's child) —
|
|
123
|
+
* or once `afterMs` has passed with the child still running: one still booting has no listener
|
|
124
|
+
* yet and drops the message, and a stop that never lands is a supervisor waiting forever.
|
|
125
|
+
*/
|
|
126
|
+
export function stopChild(
|
|
127
|
+
child: DevChild,
|
|
128
|
+
signal: 'SIGINT' | 'SIGTERM',
|
|
129
|
+
afterMs: number = devHardExitMs(),
|
|
130
|
+
): void {
|
|
131
|
+
if (child.send !== undefined) {
|
|
132
|
+
try {
|
|
133
|
+
child.send({ type: 'x-dev-drain', signal });
|
|
134
|
+
const fallback = setTimeout(() => child.kill(signal), afterMs);
|
|
135
|
+
// Never what keeps this process alive: awaiting the child's exit is.
|
|
136
|
+
fallback.unref();
|
|
137
|
+
void child.exited.then(() => clearTimeout(fallback));
|
|
138
|
+
return;
|
|
139
|
+
} catch {
|
|
140
|
+
// The channel closed under us: the child is exiting or gone, and the signal is all that's left.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
child.kill(signal);
|
|
78
144
|
}
|
|
79
145
|
|
|
80
146
|
export interface SuperviseDevInput {
|
|
@@ -91,16 +157,23 @@ const spawnInherited = (
|
|
|
91
157
|
command: readonly string[],
|
|
92
158
|
env: Record<string, string | undefined>,
|
|
93
159
|
): DevChild =>
|
|
94
|
-
Bun.spawn([...command], {
|
|
160
|
+
Bun.spawn([...command], {
|
|
161
|
+
stdin: 'inherit',
|
|
162
|
+
stdout: 'inherit',
|
|
163
|
+
stderr: 'inherit',
|
|
164
|
+
env,
|
|
165
|
+
// The channel `stopChild` drains over; the child sends nothing back.
|
|
166
|
+
ipc: () => undefined,
|
|
167
|
+
});
|
|
95
168
|
|
|
96
169
|
/**
|
|
97
170
|
* Runs children until one exits with anything but `DEV_RESTART_EXIT_CODE`, and answers that code.
|
|
98
|
-
* SIGINT and SIGTERM are forwarded — a terminal's Ctrl-C reaches
|
|
99
|
-
* drain is idempotent — and once one arrived no child is started
|
|
100
|
-
* is a stop, never a respawn.
|
|
171
|
+
* SIGINT and SIGTERM are forwarded as a `drain` message (`stopChild`) — a terminal's Ctrl-C reaches
|
|
172
|
+
* the child on its own too, and its drain is idempotent — and once one arrived no child is started
|
|
173
|
+
* again: a Ctrl-C during a restart is a stop, never a respawn.
|
|
101
174
|
*
|
|
102
175
|
* Every other way out stops the child too: an uncaught error or a `process.exit` here reaches the
|
|
103
|
-
* `exit` listener, which
|
|
176
|
+
* `exit` listener, which asks a child still running to drain. The one way no listener sees is a SIGKILL
|
|
104
177
|
* of this process — the child's own watch (`dev-child-watch.ts`) answers that, from its side, off
|
|
105
178
|
* the pid this loop hands it.
|
|
106
179
|
*/
|
|
@@ -111,12 +184,12 @@ export async function superviseDev(input: SuperviseDevInput): Promise<number> {
|
|
|
111
184
|
let stopping = false;
|
|
112
185
|
const forward = (signal: 'SIGINT' | 'SIGTERM') => (): void => {
|
|
113
186
|
stopping = true;
|
|
114
|
-
child
|
|
187
|
+
if (child !== undefined) stopChild(child, signal);
|
|
115
188
|
};
|
|
116
189
|
const onInt = forward('SIGINT');
|
|
117
190
|
const onTerm = forward('SIGTERM');
|
|
118
191
|
const onExit = (): void => {
|
|
119
|
-
if (running) child
|
|
192
|
+
if (running && child !== undefined) stopChild(child, 'SIGTERM');
|
|
120
193
|
};
|
|
121
194
|
process.on('SIGINT', onInt);
|
|
122
195
|
process.on('SIGTERM', onTerm);
|
|
@@ -145,8 +218,8 @@ export function restartReason(root: string, pins: readonly StalePin[]): string {
|
|
|
145
218
|
return pins
|
|
146
219
|
.map(({ changed, pinned }) =>
|
|
147
220
|
changed === pinned
|
|
148
|
-
?
|
|
149
|
-
: `${
|
|
221
|
+
? posixRelative(root, pinned)
|
|
222
|
+
: `${posixRelative(root, changed)} under ${posixRelative(root, pinned)}`,
|
|
150
223
|
)
|
|
151
224
|
.join(', ');
|
|
152
225
|
}
|
|
@@ -158,7 +231,7 @@ export function restartFinding(root: string, pins: readonly StalePin[]): Finding
|
|
|
158
231
|
code: 'X_DEV_RESTART_REQUIRED',
|
|
159
232
|
cause: `${restartReason(root, pins)} — a module that defines a primitive still holds the code before the save, and re-importing it would register a second definition`,
|
|
160
233
|
fix: 'restart x dev (a supervised `x dev` restarts itself; an embedded startDev() passes onRestart)',
|
|
161
|
-
at: first === undefined ? '' :
|
|
234
|
+
at: first === undefined ? '' : posixRelative(root, first.pinned),
|
|
162
235
|
};
|
|
163
236
|
}
|
|
164
237
|
|
|
@@ -166,12 +239,20 @@ export function restartFinding(root: string, pins: readonly StalePin[]): Finding
|
|
|
166
239
|
export interface ChildRestart {
|
|
167
240
|
readonly options: { readonly onRestart?: (pins: readonly StalePin[]) => void };
|
|
168
241
|
readonly exit: (code: number) => void;
|
|
242
|
+
/**
|
|
243
|
+
* The worker this child booted, handed over once `startDev` returns: a restart retires it before
|
|
244
|
+
* the drain, so a save never cancels a running job (`dev-restart-retire.ts`). `null` for a child
|
|
245
|
+
* running no worker role.
|
|
246
|
+
*/
|
|
247
|
+
readonly adopt: (worker: RetiringWorker | null) => void;
|
|
169
248
|
}
|
|
170
249
|
|
|
171
250
|
/**
|
|
172
251
|
* Only a supervised child restarts: it says why on stderr (fd 1 may be `--json`'s one document),
|
|
173
|
-
*
|
|
174
|
-
*
|
|
252
|
+
* retires its worker — every running job finishes, none is claimed — then starts core's drain (the
|
|
253
|
+
* hold then releases the lock, the port and the embedded database) and exits
|
|
254
|
+
* `DEV_RESTART_EXIT_CODE` once released. One restart per child however many saves land while it
|
|
255
|
+
* waits. Anything else keeps the finding on `/_x`.
|
|
175
256
|
*
|
|
176
257
|
* A child its supervisor named (`DEV_SUPERVISOR_PID_ENV`) also watches for that supervisor's death
|
|
177
258
|
* and its app root's, and stops on either — exit 1 for a root that is gone, which the supervisor,
|
|
@@ -181,9 +262,14 @@ export function childRestart(
|
|
|
181
262
|
root: string,
|
|
182
263
|
env: Readonly<Record<string, string | undefined>>,
|
|
183
264
|
watch: typeof startDevChildWatch = startDevChildWatch,
|
|
265
|
+
retire: (worker: RetiringWorker | null) => Promise<void> = retireThenDrain,
|
|
184
266
|
): ChildRestart {
|
|
185
|
-
if (env[DEV_CHILD_ENV] !== '1')
|
|
267
|
+
if (env[DEV_CHILD_ENV] !== '1') {
|
|
268
|
+
return { options: {}, exit: () => undefined, adopt: () => undefined };
|
|
269
|
+
}
|
|
186
270
|
let leaving: 'restart' | DevChildGone | undefined;
|
|
271
|
+
let worker: RetiringWorker | null = null;
|
|
272
|
+
let restarting = false;
|
|
187
273
|
const watched =
|
|
188
274
|
supervisorPid(env) === undefined
|
|
189
275
|
? undefined
|
|
@@ -193,12 +279,18 @@ export function childRestart(
|
|
|
193
279
|
return {
|
|
194
280
|
options: {
|
|
195
281
|
onRestart: (pins) => {
|
|
196
|
-
|
|
282
|
+
writeErrorLine(msg('cli.dev.restart', { reason: restartReason(root, pins) }));
|
|
197
283
|
leaving ??= 'restart';
|
|
198
284
|
watched?.stopping(DEV_RESTART_EXIT_CODE);
|
|
199
|
-
|
|
285
|
+
if (restarting) return;
|
|
286
|
+
restarting = true;
|
|
287
|
+
// Never rejects: a failed retire is logged and the drain still runs (`retireThenDrain`).
|
|
288
|
+
void retire(worker);
|
|
200
289
|
},
|
|
201
290
|
},
|
|
291
|
+
adopt: (booted) => {
|
|
292
|
+
worker = booted;
|
|
293
|
+
},
|
|
202
294
|
exit: () => {
|
|
203
295
|
if (leaving === 'restart') process.exit(DEV_RESTART_EXIT_CODE);
|
|
204
296
|
if (leaving === 'root') process.exit(1);
|
package/src/dev-traces.ts
CHANGED
|
@@ -127,49 +127,82 @@ function toTrace(root: ReadableSpan, spans: readonly ReadableSpan[]): RequestTra
|
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
129
|
* Spans end innermost-first, so a trace is only whole once its root arrives — which is also the
|
|
130
|
-
* moment the request finished.
|
|
131
|
-
*
|
|
130
|
+
* moment the request finished. Until then it is OPEN: unclassified, because the span that says
|
|
131
|
+
* "request" or "background" is the last one to end. A trace whose root arrives and is NOT a
|
|
132
|
+
* request's is forgotten the moment it ends; one whose root IS a request's is RETAINED.
|
|
133
|
+
*
|
|
134
|
+
* Two maps, two quotas, because only retained requests are what the limit promises to keep. One
|
|
135
|
+
* shared buffer let an open trace evict a retained request: at limit 1, a worker's `job.poll`
|
|
136
|
+
* ended its `db.update` first, that provisional trace took the only slot, and discarding
|
|
137
|
+
* `job.poll` a moment later could not bring the request back.
|
|
132
138
|
*/
|
|
133
139
|
export function createTraceRecorder(options: { limit?: number } = {}): TraceRecorder {
|
|
134
|
-
// `
|
|
140
|
+
// `buffer.size > NaN` is false on every pass, so an unchecked limit does not widen the buffer —
|
|
135
141
|
// it deletes the eviction loop, and a dev session then holds every span of every request it has
|
|
136
142
|
// ever seen. At least 1: a recorder that retains nothing is what `/_x/timeline` reads.
|
|
137
143
|
const limit = finiteCount('createTraceRecorder', 'limit', options.limit ?? DEFAULT_LIMIT, 1);
|
|
138
|
-
// Insertion-ordered: the oldest trace id is the first key, which is the one eviction drops.
|
|
139
|
-
const
|
|
144
|
+
// Insertion-ordered, both: the oldest trace id is the first key, which is the one eviction drops.
|
|
145
|
+
const retained = new Map<string, ReadableSpan[]>();
|
|
146
|
+
// Bounded too, by the same figure but separately: a root that never ends (a span leaked by app
|
|
147
|
+
// code) would otherwise hold its children forever. Overflowing it costs an in-flight trace its
|
|
148
|
+
// early spans, never a finished request.
|
|
149
|
+
const open = new Map<string, ReadableSpan[]>();
|
|
150
|
+
|
|
151
|
+
const admit = (buffer: Map<string, ReadableSpan[]>, traceId: string, spans: ReadableSpan[]) => {
|
|
152
|
+
buffer.set(traceId, spans);
|
|
153
|
+
// Bounded by TRACE, not by span: dropping half a request would leave a flame with holes.
|
|
154
|
+
// The cost is stated rather than capped — one trace's span array has no bound of its own, so
|
|
155
|
+
// a request issuing 50k statements holds 50k `ReadableSpan`s until it is evicted. That is a
|
|
156
|
+
// dev-only recorder (`serve.ts` installs none), and a per-trace cap would silently produce
|
|
157
|
+
// the holed flame this bound exists to prevent.
|
|
158
|
+
while (buffer.size > limit) {
|
|
159
|
+
const oldest = buffer.keys().next();
|
|
160
|
+
if (oldest.done === true) break;
|
|
161
|
+
buffer.delete(oldest.value);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
140
164
|
|
|
141
165
|
const record = (span: ReadableSpan): void => {
|
|
142
166
|
const traceId = span.context.traceId;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
167
|
+
// Late spans of a request already retained (work it handed off under its own trace id) join it.
|
|
168
|
+
const kept = retained.get(traceId);
|
|
169
|
+
if (kept !== undefined) {
|
|
170
|
+
kept.push(span);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// Taken out and put back last: an open trace still receiving spans is never the oldest key,
|
|
174
|
+
// so the open bound evicts the trace that went quiet, not the one mid-flight.
|
|
175
|
+
const spans = open.get(traceId) ?? [];
|
|
176
|
+
open.delete(traceId);
|
|
177
|
+
// A request's root: the trace is classified and complete, so it moves into the quota.
|
|
178
|
+
if (isHttpRoot(span)) {
|
|
179
|
+
spans.push(span);
|
|
180
|
+
admit(retained, traceId, spans);
|
|
156
181
|
return;
|
|
157
182
|
}
|
|
183
|
+
// A root that is not a request's — no parent, no HTTP facts — ends its trace, and the trace is
|
|
184
|
+
// a scheduler tick or a worker's job: not the panel's to show. Dropped here, not filtered at
|
|
185
|
+
// read time, because idle background roles minting a few per second would otherwise churn
|
|
186
|
+
// the buffers for nothing.
|
|
187
|
+
if (span.parentSpanId === undefined) return;
|
|
158
188
|
spans.push(span);
|
|
189
|
+
if (spans.length === 1) admit(open, traceId, spans);
|
|
190
|
+
else open.set(traceId, spans);
|
|
159
191
|
};
|
|
160
192
|
|
|
161
193
|
return {
|
|
162
194
|
exporter: { export: record },
|
|
163
195
|
traces(): readonly RequestTrace[] {
|
|
164
196
|
const traces: RequestTrace[] = [];
|
|
165
|
-
for (const spans of
|
|
197
|
+
for (const spans of retained.values()) {
|
|
166
198
|
const root = httpRootOf(spans);
|
|
167
199
|
if (root !== undefined) traces.push(toTrace(root, spans));
|
|
168
200
|
}
|
|
169
201
|
return traces.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
170
202
|
},
|
|
171
203
|
reset(): void {
|
|
172
|
-
|
|
204
|
+
retained.clear();
|
|
205
|
+
open.clear();
|
|
173
206
|
},
|
|
174
207
|
};
|
|
175
208
|
}
|
package/src/dispatch.ts
CHANGED
|
@@ -6,9 +6,9 @@ import { isAbsolute, resolve } from 'node:path';
|
|
|
6
6
|
import { setLogStream } from '@ultimat3/core';
|
|
7
7
|
import { requireAppRoot, requireBunVersion } from './app-root';
|
|
8
8
|
import { createHelpCommand } from './cmd-help';
|
|
9
|
-
import { plannedCommandFor } from './cmd-planned';
|
|
9
|
+
import { plannedCommandError, plannedCommandFor } from './cmd-planned';
|
|
10
10
|
import type { CommandContext } from './command';
|
|
11
|
-
import {
|
|
11
|
+
import { UnknownCommandError } from './errors';
|
|
12
12
|
import type { Runner } from './exec';
|
|
13
13
|
import { exec } from './exec';
|
|
14
14
|
import type { CommandResult } from './output';
|
|
@@ -16,10 +16,14 @@ import { exitCodeFor, findingFrom, render } from './output';
|
|
|
16
16
|
import type { ParsedArgs } from './parse';
|
|
17
17
|
import { parseArgs, wantsJson } from './parse';
|
|
18
18
|
import { commandFor, SPECS } from './registry';
|
|
19
|
-
import {
|
|
19
|
+
import { applyRootEnv, rootEnvChanges } from './root-env';
|
|
20
20
|
|
|
21
21
|
export interface DispatchOptions {
|
|
22
22
|
readonly argv: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* The directory the PROCESS started in (`bin.ts` passes `process.cwd()`): `--cwd` resolves
|
|
25
|
+
* against it, and it is the only directory whose `.env*` Bun loaded on its own.
|
|
26
|
+
*/
|
|
23
27
|
readonly cwd: string;
|
|
24
28
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
25
29
|
readonly bunVersion: string;
|
|
@@ -91,10 +95,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
91
95
|
// X_NOT_IMPLEMENTED with a runnable fix. Substituted here rather than in `parse.ts`, which is
|
|
92
96
|
// pure and knows nothing about what a command means; the precedent is the help swap below.
|
|
93
97
|
const planned = plannedCommandFor(commandFor(options.argv[0] ?? '')?.spec.name);
|
|
94
|
-
const failure =
|
|
95
|
-
planned === undefined
|
|
96
|
-
? error
|
|
97
|
-
: new CliNotImplementedError({ feature: `x ${planned.name}`, fix: planned.fix });
|
|
98
|
+
const failure = planned === undefined ? error : plannedCommandError(planned);
|
|
98
99
|
// `wantsJson`, not `includes('--json')`: a typo'd flag or a typo'd command is exactly the case
|
|
99
100
|
// an agent hits while always passing `-j`, and the short form rendered prose it then parsed.
|
|
100
101
|
const result = errorResult(planned?.name ?? 'x', failure);
|
|
@@ -125,16 +126,19 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
125
126
|
options.cwd,
|
|
126
127
|
typeof args.flags.get('cwd') === 'string' ? String(args.flags.get('cwd')) : undefined,
|
|
127
128
|
);
|
|
128
|
-
// The ROOT's `.env*`, not the cwd's: Bun loaded the cwd's at startup
|
|
129
|
-
// `apps/web` finds its root by walking up.
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
129
|
+
// The ROOT's `.env*`, not the cwd's: Bun loaded the PROCESS cwd's at startup (`options.cwd`,
|
|
130
|
+
// before `--cwd` moved it), and a command run from `apps/web` finds its root by walking up.
|
|
131
|
+
// Applied to the process env too when that IS the env passed in (`applyRootEnv`), so a package
|
|
132
|
+
// reading `Bun.env` directly sees what the command sees — another app's keys removed included.
|
|
133
|
+
const env = applyRootEnv(
|
|
134
|
+
options.env,
|
|
135
|
+
await rootEnvChanges({ cwd, processCwd: options.cwd, env: options.env }),
|
|
136
|
+
);
|
|
133
137
|
const ctx: CommandContext = {
|
|
134
138
|
args: helpArgs,
|
|
135
139
|
cwd,
|
|
136
140
|
runner: options.runner ?? exec,
|
|
137
|
-
env
|
|
141
|
+
env,
|
|
138
142
|
bunVersion: options.bunVersion,
|
|
139
143
|
...(options.invocation === undefined ? {} : { invocation: options.invocation }),
|
|
140
144
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// `x doctor`'s APP_URL rule, pure: a set APP_URL must be an origin a browser can match, and a
|
|
2
|
+
// loopback one must name the port `x dev` serves on — OAuth's redirect, absolute links and the sync
|
|
3
|
+
// node's admitted origin all read it, and a laptop origin on another port reaches another server.
|
|
4
|
+
|
|
5
|
+
import { ERROR_DOCS_URL, renderFixShellArg } from '@ultimat3/core';
|
|
6
|
+
import { isLoopbackHostname } from './loopback-host';
|
|
7
|
+
import type { Finding } from './output';
|
|
8
|
+
import { findingFrom } from './output';
|
|
9
|
+
import { syncOriginsFrom } from './sync-url';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Unset is no finding: `x dev` serves without one, and a production build warns on its own
|
|
13
|
+
* (`originWarning`). A public origin is not judged against the dev port — that is the deploy's
|
|
14
|
+
* address, not this machine's. The parse is the sync boot's own (`syncOriginsFrom`), so doctor
|
|
15
|
+
* refuses exactly the values the boot would.
|
|
16
|
+
*/
|
|
17
|
+
export function appUrlFindings(raw: string | undefined, port: number): readonly Finding[] {
|
|
18
|
+
let origins: readonly string[];
|
|
19
|
+
try {
|
|
20
|
+
origins = syncOriginsFrom({ APP_URL: raw });
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return [findingFrom(error)];
|
|
23
|
+
}
|
|
24
|
+
const origin = origins[0];
|
|
25
|
+
if (origin === undefined) return [];
|
|
26
|
+
const url = new URL(origin);
|
|
27
|
+
if (!isLoopbackHostname(url.hostname)) return [];
|
|
28
|
+
const declared = url.port === '' ? (url.protocol === 'https:' ? 443 : 80) : Number(url.port);
|
|
29
|
+
if (declared === port) return [];
|
|
30
|
+
const wanted = `${url.protocol}//${url.hostname}:${String(port)}`;
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
code: 'X_APP_URL_PORT_MISMATCH',
|
|
34
|
+
cause: `APP_URL is ${origin}, but x dev serves this app on port ${String(port)} — OAuth redirects, absolute links and the sync origin would point at another server`,
|
|
35
|
+
fix: `export APP_URL=${renderFixShellArg(wanted, 'http://localhost:3000')}`,
|
|
36
|
+
docs: ERROR_DOCS_URL,
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
}
|