@ultimat3/cli 1.2.0 → 2.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 +724 -0
- package/README.md +41 -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 +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +83 -16
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- 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 +13 -7
- 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 +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- 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 +165 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -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 +84 -14
- 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 +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +4 -3
- 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 +170 -10
- 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 +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -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-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,26 @@ 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 { loopFacts, loopFinding, loopNotice } from './statement-loop';
|
|
35
46
|
|
|
36
47
|
const DEFAULT_PORT = 3000;
|
|
37
48
|
|
|
@@ -41,7 +52,11 @@ export interface DevServer {
|
|
|
41
52
|
readonly roles: readonly Role[];
|
|
42
53
|
/** The manifest as it stands now — a reload that registers a new route moves it. */
|
|
43
54
|
readonly buildId: string;
|
|
44
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Modules that would not import, primitives that would not register, reloads that would not
|
|
57
|
+
* build — and the statement loops this process has counted so far, which is what puts an N+1 in
|
|
58
|
+
* `x dev`'s own output and in `--json` without a channel of its own.
|
|
59
|
+
*/
|
|
45
60
|
readonly findings: readonly Finding[];
|
|
46
61
|
readonly running: RunningRoles;
|
|
47
62
|
readonly runtime: RunningServices;
|
|
@@ -55,6 +70,12 @@ interface DevState {
|
|
|
55
70
|
reloads: number;
|
|
56
71
|
/** A save that will not build. Replaced on every attempt, so a fixed file clears it. */
|
|
57
72
|
reloadFinding: Finding | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* The client entries, rebuilt on the same tick as the manifest. An island is the one module this
|
|
75
|
+
* process never imports, so a fresh `Bun.build` is the whole of its reload — no module cache to
|
|
76
|
+
* invalidate, which is exactly why editing one takes effect where editing a route does not.
|
|
77
|
+
*/
|
|
78
|
+
islands: IslandBundle;
|
|
58
79
|
}
|
|
59
80
|
|
|
60
81
|
/** Debounced: a save that touches five files is one reload, not five. */
|
|
@@ -105,11 +126,19 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
105
126
|
// what configures one, which is the whole reason `/_x/timeline` has anything to draw.
|
|
106
127
|
const traces = createTraceRecorder();
|
|
107
128
|
configureTelemetry({ exporter: traces.exporter });
|
|
129
|
+
// Installed at the same moment and for the same reason: an observer is the single switch that
|
|
130
|
+
// turns statement instrumentation on at all (`@ultimat3/db`'s `observe.ts`), so the timeline's
|
|
131
|
+
// SQL rows and the repeat counts arrive together rather than through two toggles. `serve.ts`
|
|
132
|
+
// installs neither — a production process pays the one `undefined` branch the seam costs
|
|
133
|
+
// uninstalled, and nothing more (axiom 6).
|
|
134
|
+
const statements = createStatementLedger();
|
|
135
|
+
setStatementObserver(statements.observer);
|
|
108
136
|
const app = await loadApp(options.root);
|
|
109
137
|
const state: DevState = {
|
|
110
138
|
manifest: (await appManifest(options.root)).manifest,
|
|
111
139
|
reloads: 0,
|
|
112
140
|
reloadFinding: undefined,
|
|
141
|
+
islands: await buildIslands(options.root),
|
|
113
142
|
};
|
|
114
143
|
// The manifest's build id is a content hash of every fact below it, so a dev document's
|
|
115
144
|
// `x-ultimate-build` header names the exact shape the client was served against. Pinned at
|
|
@@ -132,18 +161,25 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
132
161
|
reloads: state.reloads,
|
|
133
162
|
}),
|
|
134
163
|
traces,
|
|
164
|
+
statements,
|
|
135
165
|
...envOf(options.env),
|
|
136
166
|
};
|
|
137
167
|
const panels = devPanels(dashboard).map((panel) => panel.key);
|
|
138
168
|
|
|
139
169
|
const routes: readonly Route[] = [
|
|
140
170
|
...devDashboardRoutes(dashboard),
|
|
141
|
-
|
|
171
|
+
// The same API table the container serves: a read that answers here and 404s in production
|
|
172
|
+
// is exactly the drift one composition exists to prevent.
|
|
173
|
+
...apiRoutes(),
|
|
142
174
|
// The image pipeline's only HTTP surface: the icons the web manifest declares, and the
|
|
143
175
|
// variants every `srcset` promises. Mounted before the app's own routes so a page route can
|
|
144
176
|
// never shadow `/icons` or `/media`.
|
|
145
177
|
...assetRoutes({ root: options.root, storage: runtime.storage }),
|
|
146
|
-
...
|
|
178
|
+
...storageRoutes({ storage: runtime.storage }),
|
|
179
|
+
// The chunks the documents below name. Mounted before the app's routes for the reason
|
|
180
|
+
// `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
|
|
181
|
+
...islandRoutes(() => state.islands),
|
|
182
|
+
...appRoutes({ buildId, resolveIsland: (file) => state.islands.resolverFor(file) }),
|
|
147
183
|
];
|
|
148
184
|
|
|
149
185
|
const running = await startRoles({
|
|
@@ -153,13 +189,28 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
153
189
|
runtime,
|
|
154
190
|
routes,
|
|
155
191
|
env: options.env,
|
|
192
|
+
// Read from `app.config.ts` rather than threaded through `DevOptions`: it is the app's own
|
|
193
|
+
// declaration, and `x dev` and `serve.ts` must not be able to disagree about where the app's
|
|
194
|
+
// sign-in page is.
|
|
195
|
+
signInPath: await loadSignInPath(options.root),
|
|
196
|
+
// The one document this process serves that the app did not write; `startRoles` covers the
|
|
197
|
+
// app's own surfaces itself. `x dev` sends the policy report-only, so an uncovered `<style>`
|
|
198
|
+
// here is a console report rather than a blank page — which is how this reached production.
|
|
199
|
+
inlineStyles: [await devShellStyle()],
|
|
200
|
+
// The fourth surface, and the only one an author sees without leaving the page they broke:
|
|
201
|
+
// the overlay renders this request's own loops under the error it is already showing.
|
|
202
|
+
// `serve.ts` boots through the same `startRoles` and passes nothing, so production has no
|
|
203
|
+
// diagnostic to call.
|
|
204
|
+
devNotices: (ctx: RequestContext): readonly OverlayNotice[] =>
|
|
205
|
+
statements.repeatsFor(asCtx(ctx)).map(loopFacts).map(loopNotice),
|
|
156
206
|
});
|
|
157
207
|
|
|
158
208
|
const stopWatching = watchApp(options.root, (file) => {
|
|
159
209
|
const started = performance.now();
|
|
160
|
-
void appManifest(options.root)
|
|
161
|
-
.then(({ manifest }) => {
|
|
210
|
+
void Promise.all([appManifest(options.root), buildIslands(options.root)])
|
|
211
|
+
.then(([{ manifest }, islands]) => {
|
|
162
212
|
state.manifest = manifest;
|
|
213
|
+
state.islands = islands;
|
|
163
214
|
state.reloads += 1;
|
|
164
215
|
state.reloadFinding = undefined;
|
|
165
216
|
options.onReload?.(file, Math.round(performance.now() - started));
|
|
@@ -178,12 +229,15 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
178
229
|
get buildId(): string {
|
|
179
230
|
return state.manifest.buildId;
|
|
180
231
|
},
|
|
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.
|
|
232
|
+
// A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed and the
|
|
233
|
+
// loop the last request tripped, not the findings as they were when the route table was built.
|
|
234
|
+
// The loops come last and carry their request id, so a boot report reads as a boot report and
|
|
235
|
+
// a diagnostic that arrived a minute later reads as one too.
|
|
183
236
|
get findings(): readonly Finding[] {
|
|
237
|
+
const loops = statements.repeats().map(loopFacts).map(loopFinding);
|
|
184
238
|
return state.reloadFinding === undefined
|
|
185
|
-
? app.findings
|
|
186
|
-
: [...app.findings, state.reloadFinding];
|
|
239
|
+
? [...app.findings, ...loops]
|
|
240
|
+
: [...app.findings, state.reloadFinding, ...loops];
|
|
187
241
|
},
|
|
188
242
|
running,
|
|
189
243
|
runtime,
|
|
@@ -197,6 +251,12 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
197
251
|
// leaving it in place would keep every span of the next `startDev` in this process's buffer.
|
|
198
252
|
configureTelemetry({ exporter: noopExporter });
|
|
199
253
|
traces.reset();
|
|
254
|
+
// Released with the exporter, after the roles, for the same reason: a statement still in
|
|
255
|
+
// flight is observed by the ledger that counted the rest of its request. Leaving it
|
|
256
|
+
// installed would keep every statement of the next `startDev` in this process's counts —
|
|
257
|
+
// and, worse, keep instrumentation on in a process that is no longer a dev server.
|
|
258
|
+
setStatementObserver(undefined);
|
|
259
|
+
statements.reset();
|
|
200
260
|
},
|
|
201
261
|
};
|
|
202
262
|
return server;
|
|
@@ -220,7 +280,13 @@ export const devCommand: CliCommand = {
|
|
|
220
280
|
},
|
|
221
281
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
222
282
|
const root = requireAppRoot('dev', ctx.cwd).dir;
|
|
223
|
-
|
|
283
|
+
// Validated, not `parseInt`'d: `x dev --port abc` handed `NaN` to `Bun.serve`, which binds an
|
|
284
|
+
// arbitrary port — a dev server reachable at an address nothing printed.
|
|
285
|
+
const port = intFlagOr(
|
|
286
|
+
ctx.args,
|
|
287
|
+
{ name: 'port', command: 'dev', ...PORT_RANGE, example: `x dev --port ${DEFAULT_PORT}` },
|
|
288
|
+
DEFAULT_PORT,
|
|
289
|
+
);
|
|
224
290
|
const roles = selectRoles(flagString(ctx.args, 'role'));
|
|
225
291
|
const server = await startDev({
|
|
226
292
|
root,
|
|
@@ -253,9 +319,10 @@ export const devCommand: CliCommand = {
|
|
|
253
319
|
// at, and the one url here that must NOT be behind the ingress the app's own url is.
|
|
254
320
|
metrics: `${server.running.metricsUrl}${METRICS_PATH}`,
|
|
255
321
|
stateDir: server.services.stateDir,
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
322
|
+
// Redacted, for the reason the mail and cdn lines below already give and this line did
|
|
323
|
+
// not: `DATABASE_URL`, `NATS_URL` and `S3_ENDPOINT` all carry a password, and this object
|
|
324
|
+
// is printed, logged and scraped. `reportedUrls` is the one projection that may be shown.
|
|
325
|
+
...reportedUrls(server.services),
|
|
259
326
|
// The selecting env key, never the credential behind it: `SMTP_URL` carries a password
|
|
260
327
|
// and this line is printed, logged and scraped.
|
|
261
328
|
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,16 @@
|
|
|
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, PORT_RANGE } from './flag-number';
|
|
12
15
|
import { msg } from './messages';
|
|
13
16
|
import type { CommandResult, Finding } from './output';
|
|
14
|
-
import { flagString } from './parse';
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
|
|
@@ -26,11 +28,24 @@ export interface DoctorProbe {
|
|
|
26
28
|
readonly port: number;
|
|
27
29
|
/** True while cursors are signed with the key shipped in the published package. */
|
|
28
30
|
readonly devCursorSecret: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* True while the local disk WOULD sign upload grants with the key shipped in the published
|
|
33
|
+
* package. Same semantics as `devCursorSecret`, environment only — an app that passes an
|
|
34
|
+
* explicit `signingSecret` in `app.config.ts` never consults the env var, so this can read true
|
|
35
|
+
* for an app that is fine. The finding is worded as a condition to check, not a certainty.
|
|
36
|
+
*/
|
|
37
|
+
readonly devStorageSecret: boolean;
|
|
29
38
|
/** True when this process believes it is serving real clients. */
|
|
30
39
|
readonly production: boolean;
|
|
31
40
|
exists(relativePath: string): boolean;
|
|
32
41
|
portFree(port: number): Promise<boolean>;
|
|
33
42
|
drift(): Promise<readonly Finding[]>;
|
|
43
|
+
/**
|
|
44
|
+
* The other half of the migrations directory: a newest migration with no `.snapshot.json`, which
|
|
45
|
+
* is what `x db gen` refuses on. Separate from `drift()` because they are separate questions with
|
|
46
|
+
* separate remedies — one is "generate a migration", the other is "this migration is incomplete".
|
|
47
|
+
*/
|
|
48
|
+
snapshots(): Promise<readonly Finding[]>;
|
|
34
49
|
}
|
|
35
50
|
|
|
36
51
|
const docs = (code: string): string => `https://ultimate.dev/errors/${code}`;
|
|
@@ -42,6 +57,9 @@ const finding = (code: string, cause: string, fix: string, at?: string): Finding
|
|
|
42
57
|
|
|
43
58
|
export const OFFLINE_FALLBACK = 'apps/web/app/offline.tsx';
|
|
44
59
|
|
|
60
|
+
/** The port `x dev` binds by default, so the probe answers about the port the developer will use. */
|
|
61
|
+
const DEFAULT_DOCTOR_PORT = 3000;
|
|
62
|
+
|
|
45
63
|
/**
|
|
46
64
|
* Ordered cheapest-first so the first failure is usually the root cause: a wrong Bun explains
|
|
47
65
|
* every other symptom, and running outside an app explains the rest.
|
|
@@ -88,6 +106,21 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
88
106
|
),
|
|
89
107
|
);
|
|
90
108
|
}
|
|
109
|
+
// The storage twin of the cursor key above, and the more expensive one to get wrong: the
|
|
110
|
+
// published string mints a signed `PUT` for any key with any `maxBytes` and `contentType`, and
|
|
111
|
+
// `acceptSignedUpload` trusts the signed constraints over the app's own `uploadPolicy`.
|
|
112
|
+
// Production only, for the reason the cursor check gives — every dev environment signs with the
|
|
113
|
+
// shipped key on purpose. `@ultimat3/storage` refuses this at construction; `x doctor` is what
|
|
114
|
+
// reports it before a deploy reaches the refusal.
|
|
115
|
+
if (probe.production && probe.devStorageSecret) {
|
|
116
|
+
findings.push(
|
|
117
|
+
finding(
|
|
118
|
+
'X_STORAGE_SECRET_DEV',
|
|
119
|
+
`${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`,
|
|
120
|
+
'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
91
124
|
if (!(await probe.portFree(probe.port))) {
|
|
92
125
|
findings.push(
|
|
93
126
|
finding(
|
|
@@ -125,6 +158,10 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
125
158
|
);
|
|
126
159
|
}
|
|
127
160
|
findings.push(...(await probe.drift()));
|
|
161
|
+
// Last, and it is why `X_CLI_UNEXPECTED`'s `fix: x doctor --json` is not a dead end on the path an
|
|
162
|
+
// author reaches it from: `x db gen` throwing `X_MIGRATION_SNAPSHOT_MISSING` used to be a
|
|
163
|
+
// condition this diagnostic could not see at all, so the fix line ran clean over a broken app.
|
|
164
|
+
findings.push(...(await probe.snapshots()));
|
|
128
165
|
return findings;
|
|
129
166
|
}
|
|
130
167
|
|
|
@@ -145,12 +182,19 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
|
|
|
145
182
|
root,
|
|
146
183
|
port,
|
|
147
184
|
devCursorSecret: usesDevCursorSecret(),
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
|
|
185
|
+
devStorageSecret: usesDevStorageSecret(),
|
|
186
|
+
// `ULTIMATE_ENV`, through core — the one key that says which deploy this is, with `NODE_ENV`
|
|
187
|
+
// as its documented fallback. This read `X_ENV ?? NODE_ENV`, a spelling nothing else in the
|
|
188
|
+
// repo reads, so a deploy declaring production the framework's own way was told it was not
|
|
189
|
+
// production and skipped both secret findings; and the `??` short-circuited, so any non-empty
|
|
190
|
+
// `X_ENV` shadowed a real `NODE_ENV=production` too. The non-throwing variant because
|
|
191
|
+
// `ULTIMATE_ENV` is not in the env schema — nothing validates it at boot, and a diagnostic
|
|
192
|
+
// that crashes on a typo is the one thing worse than a diagnostic that misses.
|
|
193
|
+
production: tryResolveEnvironment() === 'production',
|
|
151
194
|
exists: (relativePath) => (root === undefined ? false : existsSync(join(root, relativePath))),
|
|
152
195
|
portFree,
|
|
153
|
-
drift: async () => (root === undefined ? [] :
|
|
196
|
+
drift: async () => (root === undefined ? [] : checkSourceDrift(root)),
|
|
197
|
+
snapshots: async () => (root === undefined ? [] : checkMigrationSnapshots(root)),
|
|
154
198
|
};
|
|
155
199
|
}
|
|
156
200
|
|
|
@@ -159,10 +203,21 @@ export const doctorCommand: CliCommand = {
|
|
|
159
203
|
name: 'doctor',
|
|
160
204
|
summary: 'environment, versions, drift, ports, PWA prerequisites — each with a fix command',
|
|
161
205
|
usage: 'x doctor [--port 3000] [--json]',
|
|
162
|
-
flags: [
|
|
206
|
+
flags: [
|
|
207
|
+
{
|
|
208
|
+
name: 'port',
|
|
209
|
+
type: 'string',
|
|
210
|
+
summary: 'port to test',
|
|
211
|
+
default: String(DEFAULT_DOCTOR_PORT),
|
|
212
|
+
},
|
|
213
|
+
],
|
|
163
214
|
},
|
|
164
215
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
165
|
-
const port =
|
|
216
|
+
const port = intFlagOr(
|
|
217
|
+
ctx.args,
|
|
218
|
+
{ name: 'port', command: 'doctor', ...PORT_RANGE, example: 'x doctor --port 3000' },
|
|
219
|
+
DEFAULT_DOCTOR_PORT,
|
|
220
|
+
);
|
|
166
221
|
const findings = await runDoctor(probeFor(ctx.cwd, ctx.bunVersion, port));
|
|
167
222
|
return {
|
|
168
223
|
ok: findings.length === 0,
|
package/src/cmd-env.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// `x env` — the two things a typed environment owes an agent: the committed `.env.example` that
|
|
2
|
+
// says which variables exist, and the answer to "does this process have them?". Both are
|
|
3
|
+
// projections of the one `defineEnv` declaration in `app.config.ts`; neither reads a second list.
|
|
4
|
+
|
|
5
|
+
// Bun ships no path-join primitive, and `.env.example` is written app-root-relative.
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { checkEnv, ENV_EXAMPLE_PATH, maskedEnvValues } from '@ultimat3/core';
|
|
8
|
+
import { ENV_SCHEMA_EXPORT, envExampleFor, loadEnvSchema } from './app-env';
|
|
9
|
+
import { APP_CONFIG_FILE, requireAppRoot } from './app-root';
|
|
10
|
+
import type { CliCommand, CommandContext } from './command';
|
|
11
|
+
import { EnvSchemaMissingError } from './errors';
|
|
12
|
+
import { msg } from './messages';
|
|
13
|
+
import type { CommandResult, Finding, JsonValue } from './output';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every subcommand needs the declaration, and an app without one is a usage error rather than an
|
|
17
|
+
* empty success: `x env example` writing a two-comment file would look like it worked.
|
|
18
|
+
*/
|
|
19
|
+
async function requireSchema(cwd: string, subcommand: string) {
|
|
20
|
+
const root = requireAppRoot(`env ${subcommand}`, cwd).dir;
|
|
21
|
+
const schema = await loadEnvSchema(root);
|
|
22
|
+
if (schema === undefined) throw new EnvSchemaMissingError({ subcommand });
|
|
23
|
+
return { root, schema };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function writeExample(ctx: CommandContext): Promise<CommandResult> {
|
|
27
|
+
const { root, schema } = await requireSchema(ctx.cwd, 'example');
|
|
28
|
+
const contents = envExampleFor(schema);
|
|
29
|
+
const path = join(root, ENV_EXAMPLE_PATH);
|
|
30
|
+
const file = Bun.file(path);
|
|
31
|
+
const fresh = (await file.exists()) && (await file.text()) === contents;
|
|
32
|
+
if (!fresh) await Bun.write(path, contents);
|
|
33
|
+
const count = Object.keys(schema).length;
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
command: 'env',
|
|
37
|
+
summary: fresh
|
|
38
|
+
? msg('cli.env.fresh', { path: ENV_EXAMPLE_PATH })
|
|
39
|
+
: msg('cli.env.wrote', { path: ENV_EXAMPLE_PATH, count }),
|
|
40
|
+
data: { path: ENV_EXAMPLE_PATH, variables: count, written: !fresh },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The values are read from the real process environment, and only ever printed through
|
|
46
|
+
* `maskedEnvValues` — `checkEnv().values` holds the actual secrets because `defineEnv()` has to
|
|
47
|
+
* return them, and a `--json` report is the last place a DSN should appear in full.
|
|
48
|
+
*/
|
|
49
|
+
async function checkProcessEnv(ctx: CommandContext): Promise<CommandResult> {
|
|
50
|
+
const { schema } = await requireSchema(ctx.cwd, 'check');
|
|
51
|
+
const report = checkEnv(schema);
|
|
52
|
+
const total = Object.keys(schema).length;
|
|
53
|
+
const findings: readonly Finding[] = report.issues.map((issue) => ({
|
|
54
|
+
code: 'X_ENV_MISSING',
|
|
55
|
+
cause: `${issue.key} is ${issue.reason} (expected ${issue.expected})`,
|
|
56
|
+
fix: issue.fix,
|
|
57
|
+
docs: 'https://ultimate.dev/errors/X_ENV_MISSING',
|
|
58
|
+
at: ENV_EXAMPLE_PATH,
|
|
59
|
+
}));
|
|
60
|
+
return {
|
|
61
|
+
ok: report.ok,
|
|
62
|
+
command: 'env',
|
|
63
|
+
summary: report.ok
|
|
64
|
+
? msg('cli.env.checked', { count: total })
|
|
65
|
+
: msg('cli.env.invalid', { count: report.issues.length, total }),
|
|
66
|
+
findings,
|
|
67
|
+
data: {
|
|
68
|
+
variables: total,
|
|
69
|
+
values: maskedEnvValues(schema, report.values) as JsonValue,
|
|
70
|
+
},
|
|
71
|
+
exitCode: report.ok ? 0 : 1,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const envCommand: CliCommand = {
|
|
76
|
+
spec: {
|
|
77
|
+
name: 'env',
|
|
78
|
+
summary: `the typed environment declared by ${ENV_SCHEMA_EXPORT} in ${APP_CONFIG_FILE}`,
|
|
79
|
+
usage: 'x env [check|example] [--json]',
|
|
80
|
+
requiresApp: true,
|
|
81
|
+
subcommands: ['check', 'example'],
|
|
82
|
+
// The bare `x env` answers the question the fix line on every `X_ENV_MISSING` in this
|
|
83
|
+
// framework already tells its reader to run.
|
|
84
|
+
defaultSubcommand: 'check',
|
|
85
|
+
flags: [],
|
|
86
|
+
},
|
|
87
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
88
|
+
// `subcommand`, never `positionals[0]`: the parser has already lifted a declared subcommand
|
|
89
|
+
// out of the positionals, so reading the array here matches nothing and every invocation
|
|
90
|
+
// silently ran the default.
|
|
91
|
+
return (ctx.args.subcommand ?? 'check') === 'example'
|
|
92
|
+
? writeExample(ctx)
|
|
93
|
+
: checkProcessEnv(ctx);
|
|
94
|
+
},
|
|
95
|
+
};
|