@ultimat3/cli 1.2.0 → 3.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 +761 -0
- package/README.md +42 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +134 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +219 -0
- package/src/cmd-db.ts +458 -153
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +92 -18
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +74 -10
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +14 -8
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +29 -24
- package/src/cmd-verify.ts +197 -25
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +269 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +144 -0
- package/src/db-seed.ts +294 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +108 -23
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +167 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +247 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +37 -7
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +78 -10
- package/src/error-catalog.ts +8 -18
- package/src/error-codes.ts +192 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +67 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +92 -15
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +128 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +93 -2
- package/src/metrics-endpoint.ts +64 -16
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +185 -13
- package/src/shell-quote.ts +15 -0
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +20 -11
- package/src/test-workers.ts +50 -0
- package/src/ts-scan.ts +284 -15
- package/src/tsconfig-references.ts +103 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
package/src/cmd-deploy.ts
CHANGED
|
@@ -6,12 +6,35 @@ import { existsSync } from 'node:fs';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { requireAppRoot } from './app-root';
|
|
8
8
|
import type { CliCommand, CommandContext } from './command';
|
|
9
|
-
import { CliNotImplementedError } from './errors';
|
|
9
|
+
import { BadFlagError, CliNotImplementedError } from './errors';
|
|
10
10
|
import { msg } from './messages';
|
|
11
11
|
import type { CommandResult, JsonValue } from './output';
|
|
12
12
|
import { flagBool, flagString } from './parse';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Ordered, and the order is the design. `migrate` GATES — it runs to completion before anything
|
|
16
|
+
* serves, and a schema difference after it fails the deploy. `backfill` is last and TRIGGERS: a
|
|
17
|
+
* data sweep put inside a release gate holds the deploy open while a slow UPDATE runs against a
|
|
18
|
+
* database still serving the PREVIOUS release, so it runs after the new pods are up and the
|
|
19
|
+
* workers already draining the queue are what perform it. That is also why it is not wired into
|
|
20
|
+
* `runMigrations()` and never will be.
|
|
21
|
+
*
|
|
22
|
+
* `backfill` is a one-shot like `migrate`, so it takes the same `run --rm` shape; the compose
|
|
23
|
+
* service behind it runs `x db backfill --all --write --json` rather than a `ROLE`, because
|
|
24
|
+
* `@ultimat3/core`'s `ROLES` is a closed list of process shapes and a sweep trigger is a command.
|
|
25
|
+
*
|
|
26
|
+
* ORDER HERE IS NECESSARY AND NOT SUFFICIENT. `docker compose up -d` returns when a container has
|
|
27
|
+
* STARTED, not when the application inside it is serving, so this list alone puts the trigger after
|
|
28
|
+
* the serving roles were asked to start and not after they are ready. The barrier that makes
|
|
29
|
+
* "after" true is declarative and belongs to the compose file, not to this plan: the `backfill`
|
|
30
|
+
* service needs `depends_on: { web: { condition: service_healthy } }`, which `docker compose run`
|
|
31
|
+
* honours. Both compose definitions — `docker/docker-compose.prod.yml` and the one
|
|
32
|
+
* `templates/scaffold-container.ts` scaffolds — still owe that service and that condition.
|
|
33
|
+
*/
|
|
34
|
+
export const DEPLOY_ROLES = ['migrate', 'web', 'sync', 'worker', 'scheduler', 'backfill'] as const;
|
|
35
|
+
|
|
36
|
+
/** The roles that run to completion and exit, as against the ones that stay up serving. */
|
|
37
|
+
const ONE_SHOT_ROLES: readonly string[] = ['migrate', 'backfill'];
|
|
15
38
|
|
|
16
39
|
export interface DeployPlan {
|
|
17
40
|
readonly image: string;
|
|
@@ -19,8 +42,39 @@ export interface DeployPlan {
|
|
|
19
42
|
readonly steps: readonly { readonly role: string; readonly command: readonly string[] }[];
|
|
20
43
|
}
|
|
21
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The chart declares `image` as a MAP — `repository`, `tag`, `pullPolicy` — and `_helpers.tpl`
|
|
47
|
+
* renders `printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag)`.
|
|
48
|
+
* `--set image=<ref>` replaces that map with a string, so every workload template fails on
|
|
49
|
+
* `.repository` and the deploy that was asked to ship one image ships nothing. The reference is
|
|
50
|
+
* split into the two keys the chart actually reads; a reference with no tag sets only the
|
|
51
|
+
* repository, which leaves the chart's own `default .Chart.AppVersion` in force.
|
|
52
|
+
*
|
|
53
|
+
* The last `:` after the last `/`, because a registry may carry a port: `localhost:5000/app` is a
|
|
54
|
+
* repository with no tag and `localhost:5000/app:1.2.3` is the same repository with one.
|
|
55
|
+
*/
|
|
56
|
+
export function helmImageOverrides(image: string): readonly string[] {
|
|
57
|
+
const colon = image.lastIndexOf(':');
|
|
58
|
+
const tag = colon > image.lastIndexOf('/') ? image.slice(colon + 1) : '';
|
|
59
|
+
const repository = tag === '' ? image : image.slice(0, colon);
|
|
60
|
+
return tag === ''
|
|
61
|
+
? ['--set', `image.repository=${repository}`]
|
|
62
|
+
: ['--set', `image.repository=${repository}`, '--set', `image.tag=${tag}`];
|
|
63
|
+
}
|
|
64
|
+
|
|
22
65
|
export function planDeploy(image: string, method: 'compose' | 'helm', root: string): DeployPlan {
|
|
23
66
|
if (method === 'helm') {
|
|
67
|
+
// `repo@sha256:…` is a reference this chart cannot express: it renders `repository:tag` and
|
|
68
|
+
// has no digest branch, so passing one through would deploy `repo@sha256:…:<appVersion>` —
|
|
69
|
+
// a tag no registry has. Refused here rather than by a `helm upgrade` failing halfway.
|
|
70
|
+
if (image.lastIndexOf('@') > image.lastIndexOf('/')) {
|
|
71
|
+
throw new BadFlagError({
|
|
72
|
+
flag: 'image',
|
|
73
|
+
command: 'deploy',
|
|
74
|
+
reason: `"${image}" pins a digest, and docker/helm renders repository:tag with no digest branch`,
|
|
75
|
+
fix: `x deploy --method helm --image ${image.slice(0, image.lastIndexOf('@'))}:<tag> --json`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
24
78
|
return {
|
|
25
79
|
image,
|
|
26
80
|
steps: [
|
|
@@ -32,8 +86,7 @@ export function planDeploy(image: string, method: 'compose' | 'helm', root: stri
|
|
|
32
86
|
'--install',
|
|
33
87
|
'app',
|
|
34
88
|
join(root, 'docker', 'helm'),
|
|
35
|
-
|
|
36
|
-
`image=${image}`,
|
|
89
|
+
...helmImageOverrides(image),
|
|
37
90
|
],
|
|
38
91
|
},
|
|
39
92
|
],
|
|
@@ -48,8 +101,8 @@ export function planDeploy(image: string, method: 'compose' | 'helm', root: stri
|
|
|
48
101
|
'compose',
|
|
49
102
|
'-f',
|
|
50
103
|
join(root, 'docker', 'docker-compose.prod.yml'),
|
|
51
|
-
role
|
|
52
|
-
role
|
|
104
|
+
ONE_SHOT_ROLES.includes(role) ? 'run' : 'up',
|
|
105
|
+
ONE_SHOT_ROLES.includes(role) ? '--rm' : '-d',
|
|
53
106
|
role,
|
|
54
107
|
],
|
|
55
108
|
})),
|
package/src/cmd-dev.ts
CHANGED
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
import { watch } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import { devShellStyle } from '@ultimat3/admin/dev';
|
|
10
10
|
import type { Role } from '@ultimat3/core';
|
|
11
11
|
import { configureTelemetry, METRICS_PATH, noopExporter } from '@ultimat3/core';
|
|
12
|
-
import
|
|
12
|
+
import { setStatementObserver } from '@ultimat3/db';
|
|
13
|
+
import type { OverlayNotice, RequestContext, Route } from '@ultimat3/http';
|
|
14
|
+
import { asCtx } from '@ultimat3/http';
|
|
13
15
|
import type { Manifest } from '@ultimat3/manifest';
|
|
14
16
|
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
17
|
+
import { apiRoutes } from './api-routes';
|
|
18
|
+
import { loadSignInPath } from './app-auth';
|
|
15
19
|
import { loadApp } from './app-load';
|
|
16
20
|
import { appManifest } from './app-manifest';
|
|
17
21
|
import { requireAppRoot } from './app-root';
|
|
@@ -19,19 +23,27 @@ import type { CliCommand, CommandContext } from './command';
|
|
|
19
23
|
import { assetRoutes } from './dev-assets';
|
|
20
24
|
import type { DevDashboardInput, DevStatus } from './dev-dashboard';
|
|
21
25
|
import { devDashboardRoutes, devPanels } from './dev-dashboard';
|
|
26
|
+
import { createStatementLedger } from './dev-n-plus-one';
|
|
22
27
|
import { appRoutes } from './dev-render';
|
|
23
28
|
import type { RunningRoles } from './dev-roles';
|
|
24
29
|
import { DEV_ROLES, selectRoles, startRoles } from './dev-roles';
|
|
25
30
|
import type { RunningServices } from './dev-runtime';
|
|
26
31
|
import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './dev-runtime';
|
|
27
32
|
import type { DevServices } from './dev-services';
|
|
28
|
-
import { describeServices, resolveServices } from './dev-services';
|
|
33
|
+
import { describeServices, reportedUrls, resolveServices } from './dev-services';
|
|
34
|
+
import { storageRoutes } from './dev-storage';
|
|
29
35
|
import { createTraceRecorder } from './dev-traces';
|
|
36
|
+
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
30
37
|
import { holdUntilShutdown } from './hold';
|
|
38
|
+
import type { IslandBundle } from './island-bundle';
|
|
39
|
+
import { buildIslands } from './island-bundle';
|
|
40
|
+
import { islandRoutes } from './island-routes';
|
|
31
41
|
import { msg } from './messages';
|
|
32
42
|
import type { CommandResult, Finding } from './output';
|
|
33
43
|
import { findingFrom } from './output';
|
|
34
44
|
import { flagString } from './parse';
|
|
45
|
+
import { metricsPortFor } from './serve';
|
|
46
|
+
import { loopFacts, loopFinding, loopNotice } from './statement-loop';
|
|
35
47
|
|
|
36
48
|
const DEFAULT_PORT = 3000;
|
|
37
49
|
|
|
@@ -41,7 +53,11 @@ export interface DevServer {
|
|
|
41
53
|
readonly roles: readonly Role[];
|
|
42
54
|
/** The manifest as it stands now — a reload that registers a new route moves it. */
|
|
43
55
|
readonly buildId: string;
|
|
44
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Modules that would not import, primitives that would not register, reloads that would not
|
|
58
|
+
* build — and the statement loops this process has counted so far, which is what puts an N+1 in
|
|
59
|
+
* `x dev`'s own output and in `--json` without a channel of its own.
|
|
60
|
+
*/
|
|
45
61
|
readonly findings: readonly Finding[];
|
|
46
62
|
readonly running: RunningRoles;
|
|
47
63
|
readonly runtime: RunningServices;
|
|
@@ -55,6 +71,12 @@ interface DevState {
|
|
|
55
71
|
reloads: number;
|
|
56
72
|
/** A save that will not build. Replaced on every attempt, so a fixed file clears it. */
|
|
57
73
|
reloadFinding: Finding | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* The client entries, rebuilt on the same tick as the manifest. An island is the one module this
|
|
76
|
+
* process never imports, so a fresh `Bun.build` is the whole of its reload — no module cache to
|
|
77
|
+
* invalidate, which is exactly why editing one takes effect where editing a route does not.
|
|
78
|
+
*/
|
|
79
|
+
islands: IslandBundle;
|
|
58
80
|
}
|
|
59
81
|
|
|
60
82
|
/** Debounced: a save that touches five files is one reload, not five. */
|
|
@@ -105,11 +127,19 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
105
127
|
// what configures one, which is the whole reason `/_x/timeline` has anything to draw.
|
|
106
128
|
const traces = createTraceRecorder();
|
|
107
129
|
configureTelemetry({ exporter: traces.exporter });
|
|
130
|
+
// Installed at the same moment and for the same reason: an observer is the single switch that
|
|
131
|
+
// turns statement instrumentation on at all (`@ultimat3/db`'s `observe.ts`), so the timeline's
|
|
132
|
+
// SQL rows and the repeat counts arrive together rather than through two toggles. `serve.ts`
|
|
133
|
+
// installs neither — a production process pays the one `undefined` branch the seam costs
|
|
134
|
+
// uninstalled, and nothing more (axiom 6).
|
|
135
|
+
const statements = createStatementLedger();
|
|
136
|
+
setStatementObserver(statements.observer);
|
|
108
137
|
const app = await loadApp(options.root);
|
|
109
138
|
const state: DevState = {
|
|
110
139
|
manifest: (await appManifest(options.root)).manifest,
|
|
111
140
|
reloads: 0,
|
|
112
141
|
reloadFinding: undefined,
|
|
142
|
+
islands: await buildIslands(options.root),
|
|
113
143
|
};
|
|
114
144
|
// The manifest's build id is a content hash of every fact below it, so a dev document's
|
|
115
145
|
// `x-ultimate-build` header names the exact shape the client was served against. Pinned at
|
|
@@ -132,34 +162,60 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
132
162
|
reloads: state.reloads,
|
|
133
163
|
}),
|
|
134
164
|
traces,
|
|
165
|
+
statements,
|
|
135
166
|
...envOf(options.env),
|
|
136
167
|
};
|
|
137
168
|
const panels = devPanels(dashboard).map((panel) => panel.key);
|
|
138
169
|
|
|
139
170
|
const routes: readonly Route[] = [
|
|
140
171
|
...devDashboardRoutes(dashboard),
|
|
141
|
-
|
|
172
|
+
// The same API table the container serves: a read that answers here and 404s in production
|
|
173
|
+
// is exactly the drift one composition exists to prevent.
|
|
174
|
+
...apiRoutes(),
|
|
142
175
|
// The image pipeline's only HTTP surface: the icons the web manifest declares, and the
|
|
143
176
|
// variants every `srcset` promises. Mounted before the app's own routes so a page route can
|
|
144
177
|
// never shadow `/icons` or `/media`.
|
|
145
178
|
...assetRoutes({ root: options.root, storage: runtime.storage }),
|
|
146
|
-
...
|
|
179
|
+
...storageRoutes({ storage: runtime.storage }),
|
|
180
|
+
// The chunks the documents below name. Mounted before the app's routes for the reason
|
|
181
|
+
// `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
|
|
182
|
+
...islandRoutes(() => state.islands),
|
|
183
|
+
...appRoutes({ buildId, resolveIsland: (file) => state.islands.resolverFor(file) }),
|
|
147
184
|
];
|
|
148
185
|
|
|
149
186
|
const running = await startRoles({
|
|
150
187
|
roles: options.roles ?? DEV_ROLES,
|
|
151
188
|
port: options.port,
|
|
189
|
+
// `serve.ts`'s expression, called rather than restated: `METRICS_PORT` was read in the
|
|
190
|
+
// container and ignored here, so the scrape port an operator moved was the one port `x dev`
|
|
191
|
+
// could not move — and the second `x dev` on a box died binding the hardcoded 9090.
|
|
192
|
+
metricsPort: metricsPortFor(options.env, options.port),
|
|
152
193
|
buildId,
|
|
153
194
|
runtime,
|
|
154
195
|
routes,
|
|
155
196
|
env: options.env,
|
|
197
|
+
// Read from `app.config.ts` rather than threaded through `DevOptions`: it is the app's own
|
|
198
|
+
// declaration, and `x dev` and `serve.ts` must not be able to disagree about where the app's
|
|
199
|
+
// sign-in page is.
|
|
200
|
+
signInPath: await loadSignInPath(options.root),
|
|
201
|
+
// The one document this process serves that the app did not write; `startRoles` covers the
|
|
202
|
+
// app's own surfaces itself. `x dev` sends the policy report-only, so an uncovered `<style>`
|
|
203
|
+
// here is a console report rather than a blank page — which is how this reached production.
|
|
204
|
+
inlineStyles: [await devShellStyle()],
|
|
205
|
+
// The fourth surface, and the only one an author sees without leaving the page they broke:
|
|
206
|
+
// the overlay renders this request's own loops under the error it is already showing.
|
|
207
|
+
// `serve.ts` boots through the same `startRoles` and passes nothing, so production has no
|
|
208
|
+
// diagnostic to call.
|
|
209
|
+
devNotices: (ctx: RequestContext): readonly OverlayNotice[] =>
|
|
210
|
+
statements.repeatsFor(asCtx(ctx)).map(loopFacts).map(loopNotice),
|
|
156
211
|
});
|
|
157
212
|
|
|
158
213
|
const stopWatching = watchApp(options.root, (file) => {
|
|
159
214
|
const started = performance.now();
|
|
160
|
-
void appManifest(options.root)
|
|
161
|
-
.then(({ manifest }) => {
|
|
215
|
+
void Promise.all([appManifest(options.root), buildIslands(options.root)])
|
|
216
|
+
.then(([{ manifest }, islands]) => {
|
|
162
217
|
state.manifest = manifest;
|
|
218
|
+
state.islands = islands;
|
|
163
219
|
state.reloads += 1;
|
|
164
220
|
state.reloadFinding = undefined;
|
|
165
221
|
options.onReload?.(file, Math.round(performance.now() - started));
|
|
@@ -178,12 +234,15 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
178
234
|
get buildId(): string {
|
|
179
235
|
return state.manifest.buildId;
|
|
180
236
|
},
|
|
181
|
-
// A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed
|
|
182
|
-
// findings as they were when the route table was built.
|
|
237
|
+
// A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed and the
|
|
238
|
+
// loop the last request tripped, not the findings as they were when the route table was built.
|
|
239
|
+
// The loops come last and carry their request id, so a boot report reads as a boot report and
|
|
240
|
+
// a diagnostic that arrived a minute later reads as one too.
|
|
183
241
|
get findings(): readonly Finding[] {
|
|
242
|
+
const loops = statements.repeats().map(loopFacts).map(loopFinding);
|
|
184
243
|
return state.reloadFinding === undefined
|
|
185
|
-
? app.findings
|
|
186
|
-
: [...app.findings, state.reloadFinding];
|
|
244
|
+
? [...app.findings, ...loops]
|
|
245
|
+
: [...app.findings, state.reloadFinding, ...loops];
|
|
187
246
|
},
|
|
188
247
|
running,
|
|
189
248
|
runtime,
|
|
@@ -197,6 +256,12 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
197
256
|
// leaving it in place would keep every span of the next `startDev` in this process's buffer.
|
|
198
257
|
configureTelemetry({ exporter: noopExporter });
|
|
199
258
|
traces.reset();
|
|
259
|
+
// Released with the exporter, after the roles, for the same reason: a statement still in
|
|
260
|
+
// flight is observed by the ledger that counted the rest of its request. Leaving it
|
|
261
|
+
// installed would keep every statement of the next `startDev` in this process's counts —
|
|
262
|
+
// and, worse, keep instrumentation on in a process that is no longer a dev server.
|
|
263
|
+
setStatementObserver(undefined);
|
|
264
|
+
statements.reset();
|
|
200
265
|
},
|
|
201
266
|
};
|
|
202
267
|
return server;
|
|
@@ -206,21 +271,29 @@ export const devCommand: CliCommand = {
|
|
|
206
271
|
spec: {
|
|
207
272
|
name: 'dev',
|
|
208
273
|
summary: 'all roles in one process: embedded services, sub-second reload, /_x mounted',
|
|
209
|
-
usage: 'x dev [--port 3000] [--role web,worker] [--json]',
|
|
274
|
+
usage: 'x dev [--port 3000] [--role web,worker] [--once] [--json]',
|
|
210
275
|
requiresApp: true,
|
|
211
276
|
flags: [
|
|
212
277
|
{ name: 'port', type: 'string', summary: 'HTTP port', default: String(DEFAULT_PORT) },
|
|
213
278
|
{
|
|
214
279
|
name: 'role',
|
|
215
280
|
type: 'string',
|
|
216
|
-
|
|
281
|
+
// `replicator` is named because it is selectable and NOT default — it takes a replication
|
|
282
|
+
// slot on a shared database, which is not something every `x dev` should do by starting.
|
|
283
|
+
summary: `roles to run (default: all of ${DEV_ROLES.join(',')}; replicator is opt-in)`,
|
|
217
284
|
},
|
|
218
285
|
{ name: 'once', type: 'boolean', summary: 'boot, report, exit — for smoke tests and CI' },
|
|
219
286
|
],
|
|
220
287
|
},
|
|
221
288
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
222
289
|
const root = requireAppRoot('dev', ctx.cwd).dir;
|
|
223
|
-
|
|
290
|
+
// Validated, not `parseInt`'d: `x dev --port abc` handed `NaN` to `Bun.serve`, which binds an
|
|
291
|
+
// arbitrary port — a dev server reachable at an address nothing printed.
|
|
292
|
+
const port = intFlagOr(
|
|
293
|
+
ctx.args,
|
|
294
|
+
{ name: 'port', command: 'dev', ...PORT_RANGE, example: `x dev --port ${DEFAULT_PORT}` },
|
|
295
|
+
DEFAULT_PORT,
|
|
296
|
+
);
|
|
224
297
|
const roles = selectRoles(flagString(ctx.args, 'role'));
|
|
225
298
|
const server = await startDev({
|
|
226
299
|
root,
|
|
@@ -253,9 +326,10 @@ export const devCommand: CliCommand = {
|
|
|
253
326
|
// at, and the one url here that must NOT be behind the ingress the app's own url is.
|
|
254
327
|
metrics: `${server.running.metricsUrl}${METRICS_PATH}`,
|
|
255
328
|
stateDir: server.services.stateDir,
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
329
|
+
// Redacted, for the reason the mail and cdn lines below already give and this line did
|
|
330
|
+
// not: `DATABASE_URL`, `NATS_URL` and `S3_ENDPOINT` all carry a password, and this object
|
|
331
|
+
// is printed, logged and scraped. `reportedUrls` is the one projection that may be shown.
|
|
332
|
+
...reportedUrls(server.services),
|
|
259
333
|
// The selecting env key, never the credential behind it: `SMTP_URL` carries a password
|
|
260
334
|
// and this line is printed, logged and scraped.
|
|
261
335
|
mail: describeMail(server.runtime),
|
package/src/cmd-docs.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// `x docs <question>` — the framework's documentation, answered offline from what is installed.
|
|
2
|
+
//
|
|
3
|
+
// One step, no filename known in advance, no network. An agent that has a question and no path
|
|
4
|
+
// should not have to guess which of 29 packages holds the answer, and it must never be handed a
|
|
5
|
+
// URL: `node_modules` already contains every doc, because the published artifact IS the source.
|
|
6
|
+
|
|
7
|
+
import type { DocEntry, DocHit } from '@ultimat3/manifest';
|
|
8
|
+
import { nearestTopics, scanInstalledDocs, searchDocs } from '@ultimat3/manifest';
|
|
9
|
+
import type { CliCommand, CommandContext } from './command';
|
|
10
|
+
import { MissingPositionalError } from './errors';
|
|
11
|
+
import { frameworkScopeDir } from './framework-scope';
|
|
12
|
+
import { msg } from './messages';
|
|
13
|
+
import type { CommandResult, Finding, JsonValue } from './output';
|
|
14
|
+
|
|
15
|
+
/** Matches printed by default. Enough to choose between, few enough to read all of. */
|
|
16
|
+
const DEFAULT_LIMIT = 5;
|
|
17
|
+
|
|
18
|
+
/** An install where the CLI cannot see its own dependency is broken, not merely undocumented. */
|
|
19
|
+
const unresolvedFinding = (): Finding => ({
|
|
20
|
+
code: 'X_CLI_UNEXPECTED',
|
|
21
|
+
cause: '@ultimat3/core does not resolve from the installed CLI, so no docs could be read',
|
|
22
|
+
fix: 'bun install && x doctor --json',
|
|
23
|
+
docs: 'https://ultimate.dev/errors/X_CLI_UNEXPECTED',
|
|
24
|
+
at: import.meta.dir,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const asJson = (hit: DocHit): JsonValue => ({
|
|
28
|
+
topic: hit.entry.topic,
|
|
29
|
+
package: hit.entry.package,
|
|
30
|
+
version: hit.entry.version,
|
|
31
|
+
kind: hit.entry.kind,
|
|
32
|
+
title: hit.entry.title,
|
|
33
|
+
text: hit.entry.text,
|
|
34
|
+
symbols: [...hit.entry.symbols],
|
|
35
|
+
source: hit.entry.source,
|
|
36
|
+
matched: [...hit.matched],
|
|
37
|
+
score: hit.score,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** The exact path an agent opens next — package-relative is ambiguous across 29 packages. */
|
|
41
|
+
const locate = (entry: DocEntry): string => `${entry.package}/${entry.source}`;
|
|
42
|
+
|
|
43
|
+
function humanLines(hits: readonly DocHit[]): readonly string[] {
|
|
44
|
+
const lines: string[] = [];
|
|
45
|
+
for (const hit of hits) {
|
|
46
|
+
lines.push(` ${hit.entry.topic} ${locate(hit.entry)}`);
|
|
47
|
+
// The title is the package's own header comment, quoted verbatim — source text, not this
|
|
48
|
+
// command's prose, so it never goes through the catalog.
|
|
49
|
+
if (hit.entry.title !== '') lines.push(` ${hit.entry.title}`);
|
|
50
|
+
if (hit.entry.symbols.length > 0) {
|
|
51
|
+
lines.push(
|
|
52
|
+
` ${msg('cli.docs.exports', { list: hit.entry.symbols.slice(0, 12).join(', ') })}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return lines;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The two commands that answer what `x docs` does not. The invocation stays inline and only its
|
|
61
|
+
* explanation is translated: a `fix:`-style command is copied and run verbatim, and a translated
|
|
62
|
+
* `x errors list --json` is a broken command (the same reason `Finding.fix` is exempt).
|
|
63
|
+
*/
|
|
64
|
+
const alsoTry = (): readonly string[] => [
|
|
65
|
+
` x errors list --json # ${msg('cli.docs.tryErrors')}`,
|
|
66
|
+
` x actions list --json # ${msg('cli.docs.tryActions')}`,
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* An `X_*` code is not a documentation question — `x errors explain` already answers it offline,
|
|
71
|
+
* with a runnable fix, and refuses a code nobody registered. Pointing at it costs the agent one
|
|
72
|
+
* step and beats ranking a code against prose that merely mentions it (axiom 1: one way).
|
|
73
|
+
*/
|
|
74
|
+
const CODE_QUERY = /\bX_[A-Z0-9_]{3,}\b/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Answered before anything is scanned, because the contract above is only true if nothing else
|
|
78
|
+
* runs. Ranking a code against prose returned five files for `X_DB_DRIFT` that merely contain
|
|
79
|
+
* "db" and "drift", with the redirect buried under them — and a code that matched nothing at all
|
|
80
|
+
* fell into `missResult`, which never carried the redirect. Returning here closes both, and skips
|
|
81
|
+
* a scan of every installed package for a question that was never about documentation.
|
|
82
|
+
*/
|
|
83
|
+
function codeResult(code: string, query: string): CommandResult {
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
command: 'docs',
|
|
87
|
+
summary: msg('cli.docs.code', { code }),
|
|
88
|
+
lines: [` x errors explain ${code} --json`],
|
|
89
|
+
data: { matches: [], suggestions: [], redirect: code, query },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A miss is still an instruction (axiom 4). Topics that half-matched come first; when nothing
|
|
95
|
+
* related at all, the honest fallback is the list of packages actually installed — the universe
|
|
96
|
+
* the question could have been about — rather than five topics picked for sharing letters.
|
|
97
|
+
*/
|
|
98
|
+
function missResult(query: string, entries: readonly DocEntry[]): CommandResult {
|
|
99
|
+
const suggestions = nearestTopics(entries, query);
|
|
100
|
+
const packages = [...new Set(entries.map((entry) => entry.package))].sort();
|
|
101
|
+
const next =
|
|
102
|
+
suggestions.length > 0
|
|
103
|
+
? suggestions.map((topic) => ` x docs ${topic} --json`)
|
|
104
|
+
: [` ${msg('cli.docs.installed', { list: packages.join(' ') })}`];
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
command: 'docs',
|
|
108
|
+
summary: msg('cli.docs.none', { query }),
|
|
109
|
+
lines: [...next, ...alsoTry()],
|
|
110
|
+
data: { matches: [], suggestions: [...suggestions], packages, query },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const docsCommand: CliCommand = {
|
|
115
|
+
spec: {
|
|
116
|
+
name: 'docs',
|
|
117
|
+
summary: 'the framework docs, answered offline from the installed packages',
|
|
118
|
+
usage: 'x docs "<question|topic|symbol>" [--limit <n>] [--json]',
|
|
119
|
+
flags: [
|
|
120
|
+
{ name: 'limit', type: 'string', summary: `matches to return (default: ${DEFAULT_LIMIT})` },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
// `async` is load-bearing: a synchronous throw would escape every caller that awaits the
|
|
124
|
+
// promise this signature promises, including the dispatcher's own error path.
|
|
125
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
126
|
+
// Joined, not `[0]`: an unquoted question arrives as many positionals, and answering only the
|
|
127
|
+
// first word is the failure an agent cannot see — it gets a plausible answer to "how".
|
|
128
|
+
const query = ctx.args.positionals.join(' ').trim();
|
|
129
|
+
if (query === '') {
|
|
130
|
+
throw new MissingPositionalError({
|
|
131
|
+
command: 'docs',
|
|
132
|
+
positional: 'question',
|
|
133
|
+
example: 'x docs "how does job() retry" --json',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const code = CODE_QUERY.exec(query)?.[0];
|
|
138
|
+
if (code !== undefined) return codeResult(code, query);
|
|
139
|
+
|
|
140
|
+
const scope = frameworkScopeDir();
|
|
141
|
+
if (scope === undefined) {
|
|
142
|
+
const finding = unresolvedFinding();
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
command: 'docs',
|
|
146
|
+
summary: msg('cli.docs.unresolved'),
|
|
147
|
+
findings: [finding],
|
|
148
|
+
data: { matches: [], suggestions: [], query },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const entries = await scanInstalledDocs(scope);
|
|
153
|
+
const rawLimit = ctx.args.flags.get('limit');
|
|
154
|
+
const parsed = typeof rawLimit === 'string' ? Number.parseInt(rawLimit, 10) : Number.NaN;
|
|
155
|
+
const limit = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIMIT;
|
|
156
|
+
const hits = searchDocs(entries, query, limit);
|
|
157
|
+
if (hits.length === 0) return missResult(query, entries);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
command: 'docs',
|
|
162
|
+
summary: msg('cli.docs.found', { count: hits.length, query }),
|
|
163
|
+
lines: [...humanLines(hits)],
|
|
164
|
+
data: { matches: hits.map(asJson), suggestions: [], query },
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
};
|
package/src/cmd-doctor.ts
CHANGED
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { usesDevCursorSecret } from '@ultimat3/core';
|
|
7
|
+
import { tryResolveEnvironment, usesDevCursorSecret } from '@ultimat3/core';
|
|
8
|
+
import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
|
|
8
9
|
import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
|
|
9
10
|
import type { CliCommand, CommandContext } from './command';
|
|
11
|
+
import { checkMigrationSnapshots } from './db-snapshot';
|
|
10
12
|
import { ICON_SOURCE } from './dev-assets';
|
|
11
|
-
import {
|
|
13
|
+
import { checkSourceDrift } from './drift';
|
|
14
|
+
import { intFlagOr, neighbouringPort, PORT_RANGE } from './flag-number';
|
|
12
15
|
import { msg } from './messages';
|
|
13
16
|
import type { CommandResult, Finding } from './output';
|
|
14
|
-
import {
|
|
17
|
+
import type { ParsedArgs } from './parse';
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
20
|
* The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
|
|
@@ -26,11 +29,24 @@ export interface DoctorProbe {
|
|
|
26
29
|
readonly port: number;
|
|
27
30
|
/** True while cursors are signed with the key shipped in the published package. */
|
|
28
31
|
readonly devCursorSecret: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* True while the local disk WOULD sign upload grants with the key shipped in the published
|
|
34
|
+
* package. Same semantics as `devCursorSecret`, environment only — an app that passes an
|
|
35
|
+
* explicit `signingSecret` in `app.config.ts` never consults the env var, so this can read true
|
|
36
|
+
* for an app that is fine. The finding is worded as a condition to check, not a certainty.
|
|
37
|
+
*/
|
|
38
|
+
readonly devStorageSecret: boolean;
|
|
29
39
|
/** True when this process believes it is serving real clients. */
|
|
30
40
|
readonly production: boolean;
|
|
31
41
|
exists(relativePath: string): boolean;
|
|
32
42
|
portFree(port: number): Promise<boolean>;
|
|
33
43
|
drift(): Promise<readonly Finding[]>;
|
|
44
|
+
/**
|
|
45
|
+
* The other half of the migrations directory: a newest migration with no `.snapshot.json`, which
|
|
46
|
+
* is what `x db gen` refuses on. Separate from `drift()` because they are separate questions with
|
|
47
|
+
* separate remedies — one is "generate a migration", the other is "this migration is incomplete".
|
|
48
|
+
*/
|
|
49
|
+
snapshots(): Promise<readonly Finding[]>;
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
const docs = (code: string): string => `https://ultimate.dev/errors/${code}`;
|
|
@@ -42,6 +58,9 @@ const finding = (code: string, cause: string, fix: string, at?: string): Finding
|
|
|
42
58
|
|
|
43
59
|
export const OFFLINE_FALLBACK = 'apps/web/app/offline.tsx';
|
|
44
60
|
|
|
61
|
+
/** The port `x dev` binds by default, so the probe answers about the port the developer will use. */
|
|
62
|
+
const DEFAULT_DOCTOR_PORT = 3000;
|
|
63
|
+
|
|
45
64
|
/**
|
|
46
65
|
* Ordered cheapest-first so the first failure is usually the root cause: a wrong Bun explains
|
|
47
66
|
* every other symptom, and running outside an app explains the rest.
|
|
@@ -88,12 +107,27 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
88
107
|
),
|
|
89
108
|
);
|
|
90
109
|
}
|
|
110
|
+
// The storage twin of the cursor key above, and the more expensive one to get wrong: the
|
|
111
|
+
// published string mints a signed `PUT` for any key with any `maxBytes` and `contentType`, and
|
|
112
|
+
// `acceptSignedUpload` trusts the signed constraints over the app's own `uploadPolicy`.
|
|
113
|
+
// Production only, for the reason the cursor check gives — every dev environment signs with the
|
|
114
|
+
// shipped key on purpose. `@ultimat3/storage` refuses this at construction; `x doctor` is what
|
|
115
|
+
// reports it before a deploy reaches the refusal.
|
|
116
|
+
if (probe.production && probe.devStorageSecret) {
|
|
117
|
+
findings.push(
|
|
118
|
+
finding(
|
|
119
|
+
'X_STORAGE_SECRET_DEV',
|
|
120
|
+
`${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`,
|
|
121
|
+
'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
91
125
|
if (!(await probe.portFree(probe.port))) {
|
|
92
126
|
findings.push(
|
|
93
127
|
finding(
|
|
94
128
|
'X_PORT_IN_USE',
|
|
95
129
|
`port ${probe.port} is already listening`,
|
|
96
|
-
`x dev --port ${probe.port
|
|
130
|
+
`x dev --port ${neighbouringPort(probe.port)}`,
|
|
97
131
|
),
|
|
98
132
|
);
|
|
99
133
|
}
|
|
@@ -125,9 +159,25 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
125
159
|
);
|
|
126
160
|
}
|
|
127
161
|
findings.push(...(await probe.drift()));
|
|
162
|
+
// Last, and it is why `X_CLI_UNEXPECTED`'s `fix: x doctor --json` is not a dead end on the path an
|
|
163
|
+
// author reaches it from: `x db gen` throwing `X_MIGRATION_SNAPSHOT_MISSING` used to be a
|
|
164
|
+
// condition this diagnostic could not see at all, so the fix line ran clean over a broken app.
|
|
165
|
+
findings.push(...(await probe.snapshots()));
|
|
128
166
|
return findings;
|
|
129
167
|
}
|
|
130
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The port to TEST, and the one place `x doctor` reads it. `PORT_RANGE.min` is 0 because `x dev
|
|
171
|
+
* --port 0` means "let the kernel pick"; here 0 means nothing, and `Bun.serve({ port: 0 })` always
|
|
172
|
+
* succeeds — so the port check could not fail, which is worse than not running it.
|
|
173
|
+
*/
|
|
174
|
+
export const doctorPort = (args: ParsedArgs): number =>
|
|
175
|
+
intFlagOr(
|
|
176
|
+
args,
|
|
177
|
+
{ name: 'port', command: 'doctor', ...PORT_RANGE, min: 1, example: 'x doctor --port 3000' },
|
|
178
|
+
DEFAULT_DOCTOR_PORT,
|
|
179
|
+
);
|
|
180
|
+
|
|
131
181
|
const portFree = async (port: number): Promise<boolean> => {
|
|
132
182
|
try {
|
|
133
183
|
const server = Bun.serve({ port, fetch: () => new Response('') });
|
|
@@ -145,12 +195,19 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
|
|
|
145
195
|
root,
|
|
146
196
|
port,
|
|
147
197
|
devCursorSecret: usesDevCursorSecret(),
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
|
|
198
|
+
devStorageSecret: usesDevStorageSecret(),
|
|
199
|
+
// `ULTIMATE_ENV`, through core — the one key that says which deploy this is, with `NODE_ENV`
|
|
200
|
+
// as its documented fallback. This read `X_ENV ?? NODE_ENV`, a spelling nothing else in the
|
|
201
|
+
// repo reads, so a deploy declaring production the framework's own way was told it was not
|
|
202
|
+
// production and skipped both secret findings; and the `??` short-circuited, so any non-empty
|
|
203
|
+
// `X_ENV` shadowed a real `NODE_ENV=production` too. The non-throwing variant because
|
|
204
|
+
// `ULTIMATE_ENV` is not in the env schema — nothing validates it at boot, and a diagnostic
|
|
205
|
+
// that crashes on a typo is the one thing worse than a diagnostic that misses.
|
|
206
|
+
production: tryResolveEnvironment() === 'production',
|
|
151
207
|
exists: (relativePath) => (root === undefined ? false : existsSync(join(root, relativePath))),
|
|
152
208
|
portFree,
|
|
153
|
-
drift: async () => (root === undefined ? [] :
|
|
209
|
+
drift: async () => (root === undefined ? [] : checkSourceDrift(root)),
|
|
210
|
+
snapshots: async () => (root === undefined ? [] : checkMigrationSnapshots(root)),
|
|
154
211
|
};
|
|
155
212
|
}
|
|
156
213
|
|
|
@@ -159,10 +216,17 @@ export const doctorCommand: CliCommand = {
|
|
|
159
216
|
name: 'doctor',
|
|
160
217
|
summary: 'environment, versions, drift, ports, PWA prerequisites — each with a fix command',
|
|
161
218
|
usage: 'x doctor [--port 3000] [--json]',
|
|
162
|
-
flags: [
|
|
219
|
+
flags: [
|
|
220
|
+
{
|
|
221
|
+
name: 'port',
|
|
222
|
+
type: 'string',
|
|
223
|
+
summary: 'port to test',
|
|
224
|
+
default: String(DEFAULT_DOCTOR_PORT),
|
|
225
|
+
},
|
|
226
|
+
],
|
|
163
227
|
},
|
|
164
228
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
165
|
-
const port =
|
|
229
|
+
const port = doctorPort(ctx.args);
|
|
166
230
|
const findings = await runDoctor(probeFor(ctx.cwd, ctx.bunVersion, port));
|
|
167
231
|
return {
|
|
168
232
|
ok: findings.length === 0,
|