@ultimat3/cli 23.0.0 → 24.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 +15 -7
- package/README.md +3 -3
- package/package.json +28 -28
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-manifest.ts +2 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +7 -17
- package/src/budgets.ts +4 -35
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build.ts +64 -15
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +6 -5
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +41 -14
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +79 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs.ts +18 -6
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +12 -2
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +4 -4
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +23 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +33 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/i18n-audit.ts +4 -1
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +3 -15
- package/src/jobs-drain.ts +82 -15
- package/src/jobs-report.ts +4 -4
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +12 -3
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +2 -0
- package/src/metrics-endpoint.ts +25 -1
- package/src/output.ts +6 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +9 -4
- package/src/role-sync.ts +55 -20
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +31 -10
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +62 -35
- package/src/runtime-services.ts +20 -17
- package/src/runtime-storage.ts +10 -5
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +34 -6
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-web.ts +12 -7
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +3 -0
- package/src/templates/naming.ts +28 -0
- package/src/templates/route.ts +3 -6
- package/src/templates/scaffold-app.ts +6 -2
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +4 -2
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-repo.ts +9 -1
- package/src/templates/scaffold-shell.ts +39 -36
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/trusted-hops-error.ts +17 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +17 -46
- package/src/verify-deadline.ts +130 -33
- package/src/verify-merge.ts +60 -27
- package/src/verify-part-step.ts +90 -0
- package/src/verify-run.ts +29 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-tests.ts +5 -2
- package/src/build-errors.ts +0 -54
- package/src/verify-errors.ts +0 -58
package/src/cmd-mcp.ts
CHANGED
|
@@ -3,17 +3,24 @@
|
|
|
3
3
|
// supplies only the app, the caller and the socket. A tool answered here would be a second answer
|
|
4
4
|
// to a question the framework already answers.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
markListening,
|
|
8
|
+
nanoid,
|
|
9
|
+
renderFixShellArg,
|
|
10
|
+
timingSafeEqual,
|
|
11
|
+
UltimateError,
|
|
12
|
+
} from '@ultimat3/core';
|
|
7
13
|
import { mcpHttpRoute, serveStdio } from '@ultimat3/mcp';
|
|
8
14
|
import { requireAppRoot } from './app-root';
|
|
9
15
|
import { DEFAULT_PORT, mcpSpec } from './cmd-mcp-spec';
|
|
10
16
|
import type { CliCommand, CommandContext } from './command';
|
|
11
17
|
import { BadFlagError } from './errors';
|
|
12
|
-
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
18
|
+
import { intFlagOr, neighbouringPort, PORT_RANGE } from './flag-number';
|
|
13
19
|
import { holdUntilShutdown } from './hold';
|
|
14
20
|
import type { CliMcpServer } from './mcp-host';
|
|
15
21
|
import { createDevMcpServer, DEV_TOOL_SCOPES } from './mcp-host';
|
|
16
22
|
import { msg } from './messages';
|
|
23
|
+
import { isAddressInUse } from './metrics-endpoint';
|
|
17
24
|
import type { CommandResult } from './output';
|
|
18
25
|
import { flagString } from './parse';
|
|
19
26
|
|
|
@@ -56,6 +63,21 @@ const notFound = (path: string): Response =>
|
|
|
56
63
|
{ status: 404, headers: { 'content-type': 'application/json' } },
|
|
57
64
|
);
|
|
58
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The MCP port is bound already — by another `x mcp serve`, or by the inspector, whose default
|
|
68
|
+
* 9229 this one shares. `metrics-endpoint.ts`'s pattern: an `EADDRINUSE` throw becomes the
|
|
69
|
+
* framework's `X_PORT_IN_USE`, with the invocation one port over.
|
|
70
|
+
*/
|
|
71
|
+
export class McpPortInUseError extends UltimateError {
|
|
72
|
+
constructor(input: { port: number }) {
|
|
73
|
+
super({
|
|
74
|
+
code: 'X_PORT_IN_USE',
|
|
75
|
+
cause: `port ${String(input.port)} is already bound, so x mcp serve could not open its HTTP transport`,
|
|
76
|
+
fix: `x mcp serve --transport http --port ${renderFixShellArg(String(neighbouringPort(input.port)), '9230')}`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
59
81
|
/** A running HTTP transport. `stop()` releases the socket AND the host's lazily booted services. */
|
|
60
82
|
export interface McpHttpServer {
|
|
61
83
|
readonly result: CommandResult;
|
|
@@ -70,7 +92,7 @@ export interface McpHttpServer {
|
|
|
70
92
|
* directory outlive `run()` otherwise, and nothing — a test, an embedding caller, or a signal —
|
|
71
93
|
* could ever release them.
|
|
72
94
|
*/
|
|
73
|
-
export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
|
|
95
|
+
export async function startMcpHttp(host: CliMcpServer, port: number): Promise<McpHttpServer> {
|
|
74
96
|
const token = nanoid(32);
|
|
75
97
|
const route = mcpHttpRoute({
|
|
76
98
|
server: host.server,
|
|
@@ -83,17 +105,26 @@ export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
|
|
|
83
105
|
? { actor: host.caller.actor, scopes: DEV_TOOL_SCOPES }
|
|
84
106
|
: null,
|
|
85
107
|
});
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
let handle: ReturnType<typeof Bun.serve>;
|
|
109
|
+
try {
|
|
110
|
+
handle = Bun.serve({
|
|
111
|
+
port,
|
|
112
|
+
hostname: 'localhost',
|
|
113
|
+
fetch: (request: Request, server): Response | Promise<Response> => {
|
|
114
|
+
const url = new URL(request.url);
|
|
115
|
+
if (request.method !== route.method || url.pathname !== route.path) {
|
|
116
|
+
return notFound(route.path);
|
|
117
|
+
}
|
|
118
|
+
// The socket's peer: failed tokens are metered per address, and only the server knows it.
|
|
119
|
+
return route.handle(request, { address: server.requestIP(request)?.address });
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// The host booted before the bind; a refusal that left it open would leak its database.
|
|
124
|
+
await host.close();
|
|
125
|
+
if (isAddressInUse(error)) throw new McpPortInUseError({ port });
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
97
128
|
// Announces the socket as this process's own, so a caller on it is never mistaken for egress.
|
|
98
129
|
const stopListening = markListening(handle.url.origin);
|
|
99
130
|
const url = `${handle.url.origin}${route.path}`;
|
|
@@ -179,7 +210,7 @@ export const mcpCommand: CliCommand = {
|
|
|
179
210
|
// starts is what releases the socket and the database. Registering a shutdown hook and
|
|
180
211
|
// returning was the older shape — nothing installed a signal handler, so the hook was never
|
|
181
212
|
// reached and the exit code closed the socket the line above had just announced.
|
|
182
|
-
const served = startMcpHttp(host, port);
|
|
213
|
+
const served = await startMcpHttp(host, port);
|
|
183
214
|
return { ...served.result, hold: holdUntilShutdown('mcp', () => served.stop()) };
|
|
184
215
|
},
|
|
185
216
|
};
|
package/src/cmd-planned.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// does not exist", which is false and sends an agent looking for a typo. X_NOT_IMPLEMENTED plus a
|
|
4
4
|
// fix naming the closest shipped command says the true thing, and `x help` lists them honestly.
|
|
5
5
|
|
|
6
|
+
import { NotImplementedError } from '@ultimat3/core';
|
|
6
7
|
import type { CliCommand } from './command';
|
|
7
|
-
import { CliNotImplementedError } from './errors';
|
|
8
8
|
import type { CommandResult } from './output';
|
|
9
9
|
import type { CommandSpec } from './parse';
|
|
10
10
|
|
|
@@ -134,18 +134,28 @@ export const PLANNED_SUBCOMMANDS: readonly PlannedSubcommand[] = [
|
|
|
134
134
|
* the error rather than throwing it, because a `run` that throws synchronously escapes the
|
|
135
135
|
* promise its signature promises — the caller does `throw plannedSubcommand(...)`.
|
|
136
136
|
*/
|
|
137
|
-
export function plannedSubcommand(command: string, subcommand: string):
|
|
137
|
+
export function plannedSubcommand(command: string, subcommand: string): NotImplementedError {
|
|
138
138
|
const planned = PLANNED_SUBCOMMANDS.find(
|
|
139
139
|
(entry) => entry.command === command && entry.subcommand === subcommand,
|
|
140
140
|
);
|
|
141
|
-
return new
|
|
142
|
-
|
|
141
|
+
return new NotImplementedError({
|
|
142
|
+
cause: `x ${command} ${subcommand} is not implemented in this build`,
|
|
143
143
|
// `x help <command>`, not `x <command> --help`: for a command that declares subcommands and no
|
|
144
144
|
// default, the latter is refused by the parser before help is ever rendered.
|
|
145
145
|
fix: planned?.fix ?? `x help ${command}`,
|
|
146
146
|
});
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* What every invocation of a planned command answers — its `run`, and the dispatcher when the
|
|
151
|
+
* parser refused a flag before any `run` was reached. One builder, so the two cannot drift.
|
|
152
|
+
*/
|
|
153
|
+
export const plannedCommandError = (planned: PlannedCommand): NotImplementedError =>
|
|
154
|
+
new NotImplementedError({
|
|
155
|
+
cause: `x ${planned.name} is not implemented in this build`,
|
|
156
|
+
fix: planned.fix,
|
|
157
|
+
});
|
|
158
|
+
|
|
149
159
|
const specFor = (planned: PlannedCommand): CommandSpec => ({
|
|
150
160
|
name: planned.name,
|
|
151
161
|
summary: `${planned.summary} (planned)`,
|
|
@@ -167,10 +177,7 @@ const toCommand = (planned: PlannedCommand): CliCommand => ({
|
|
|
167
177
|
// `async` is load-bearing: a synchronous throw would escape every caller that awaits the
|
|
168
178
|
// promise this signature promises, including the dispatcher's own error path.
|
|
169
179
|
async run(): Promise<CommandResult> {
|
|
170
|
-
throw
|
|
171
|
-
feature: `x ${planned.name}`,
|
|
172
|
-
fix: planned.fix,
|
|
173
|
-
});
|
|
180
|
+
throw plannedCommandError(planned);
|
|
174
181
|
},
|
|
175
182
|
});
|
|
176
183
|
|
package/src/cmd-routes.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { loadApp } from './app-load';
|
|
|
10
10
|
import { requireAppRoot } from './app-root';
|
|
11
11
|
import { routesSpec } from './cmd-routes-spec';
|
|
12
12
|
import type { CliCommand, CommandContext } from './command';
|
|
13
|
-
import { BadFlagError } from './errors';
|
|
13
|
+
import { BadFlagError, UnknownCommandError } from './errors';
|
|
14
14
|
import { msg } from './messages';
|
|
15
15
|
import type { CommandResult, JsonValue } from './output';
|
|
16
16
|
import { flagString } from './parse';
|
|
@@ -52,7 +52,7 @@ const routeJson = (routes: readonly RouteDescriptor[]): JsonValue =>
|
|
|
52
52
|
render: route.mode,
|
|
53
53
|
hydrate: route.hydrate,
|
|
54
54
|
offline: route.offline,
|
|
55
|
-
budget: { js: route.budgetJs
|
|
55
|
+
budget: { js: route.budgetJs },
|
|
56
56
|
// Absent on a file route, never `null`: only a mounted route has the fact.
|
|
57
57
|
...(route.mount === undefined
|
|
58
58
|
? {}
|
|
@@ -83,6 +83,16 @@ export const routesCommand: CliCommand = {
|
|
|
83
83
|
spec: routesSpec,
|
|
84
84
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
85
85
|
const root = requireAppRoot('routes', ctx.cwd).dir;
|
|
86
|
+
// No positional: `x routes list --json` ran only because a stray word was ignored, which is
|
|
87
|
+
// how a retired spelling stayed in the route-miss fix lines. Refused before the app loads.
|
|
88
|
+
const stray = ctx.args.positionals[0];
|
|
89
|
+
if (stray !== undefined) {
|
|
90
|
+
throw new UnknownCommandError({
|
|
91
|
+
path: `routes ${stray}`,
|
|
92
|
+
known: ['routes'],
|
|
93
|
+
suggestion: 'routes --json',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
86
96
|
// Read before the app is loaded: a typo must not cost a boot to report, the rule `x mcp`'s
|
|
87
97
|
// `--transport` already follows.
|
|
88
98
|
const surface = readSurfaceFilter(flagString(ctx.args, 'surface'));
|
package/src/cmd-secrets.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
SecretsPlaintextInvalidError,
|
|
26
26
|
secretsFileExists,
|
|
27
27
|
serializeSecretValues,
|
|
28
|
+
UltimateError,
|
|
28
29
|
writeMasterKeyFile,
|
|
29
30
|
writeSecretsFile,
|
|
30
31
|
} from '@ultimat3/core';
|
|
@@ -33,12 +34,7 @@ import { requireAppRoot } from './app-root';
|
|
|
33
34
|
import { appSecrets, dropRetired, retire, retiredKeyIds } from './cmd-secrets-ring';
|
|
34
35
|
import { secretsSpec } from './cmd-secrets-spec';
|
|
35
36
|
import type { CliCommand, CommandContext } from './command';
|
|
36
|
-
import {
|
|
37
|
-
MissingPositionalError,
|
|
38
|
-
SecretsEditFailedError,
|
|
39
|
-
SecretsEditorMissingError,
|
|
40
|
-
SecretsExistsError,
|
|
41
|
-
} from './errors';
|
|
37
|
+
import { MissingPositionalError } from './errors';
|
|
42
38
|
import { msg } from './messages';
|
|
43
39
|
import type { CommandResult, JsonValue } from './output';
|
|
44
40
|
import { flagString } from './parse';
|
|
@@ -46,6 +42,51 @@ import { recoverRotation, rotateMasterKey } from './secrets-rotation';
|
|
|
46
42
|
import { shredOnSignal } from './signal-shred';
|
|
47
43
|
import { renderTable } from './table';
|
|
48
44
|
|
|
45
|
+
/**
|
|
46
|
+
* `x secrets edit` decrypts into a buffer and hands it to `$EDITOR`. There is no fallback editor:
|
|
47
|
+
* guessing one and opening a decrypted file in it is the last place a surprise belongs.
|
|
48
|
+
*/
|
|
49
|
+
export class SecretsEditorMissingError extends UltimateError {
|
|
50
|
+
constructor(input: { vars: readonly string[] }) {
|
|
51
|
+
super({
|
|
52
|
+
code: 'X_SECRETS_EDITOR_MISSING',
|
|
53
|
+
cause: `x secrets edit opens the decrypted secrets in an editor and none of ${input.vars.join(', ')} is set`,
|
|
54
|
+
fix: 'EDITOR=nano x secrets edit',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The editor exited non-zero — a crash, or a deliberate abort. The buffer is discarded either way
|
|
61
|
+
* and the committed file is left exactly as it was: resealing a buffer whose editor failed would
|
|
62
|
+
* commit whatever half-written state the crash left behind.
|
|
63
|
+
*/
|
|
64
|
+
export class SecretsEditFailedError extends UltimateError {
|
|
65
|
+
constructor(input: { editor: string; code: number }) {
|
|
66
|
+
super({
|
|
67
|
+
code: 'X_SECRETS_EDIT_FAILED',
|
|
68
|
+
cause: `"${input.editor}" exited ${input.code}, so the decrypted buffer was discarded and the committed secrets file was not rewritten`,
|
|
69
|
+
fix: 'x secrets edit',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `x secrets init` would overwrite a file that already exists. `X_GENERATE_CONFLICT` is this
|
|
76
|
+
* package's own code for exactly that, and a second name for "a generator would clobber something"
|
|
77
|
+
* is the duplication the code registry exists to prevent. Losing a master key is unrecoverable —
|
|
78
|
+
* the committed file it opens is then ciphertext nobody can read again.
|
|
79
|
+
*/
|
|
80
|
+
export class SecretsExistsError extends UltimateError {
|
|
81
|
+
constructor(input: { path: string; fix: string }) {
|
|
82
|
+
super({
|
|
83
|
+
code: 'X_GENERATE_CONFLICT',
|
|
84
|
+
cause: `${input.path} already exists, and x secrets init would replace it`,
|
|
85
|
+
fix: input.fix,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
49
90
|
export { SECRETS_SUBCOMMANDS } from './cmd-secrets-spec';
|
|
50
91
|
|
|
51
92
|
/** In order of precedence. `VISUAL` outranks `EDITOR` by POSIX convention on an interactive tty. */
|
package/src/cmd-shot-matrix.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
// why: no Bun native joins a path.
|
|
7
7
|
import { join, relative } from 'node:path';
|
|
8
|
+
import { escapeHtml } from '@ultimat3/core';
|
|
8
9
|
import { routeEntries } from '@ultimat3/render';
|
|
9
10
|
import { loadApp } from './app-load';
|
|
10
11
|
import type { ShotRun } from './cmd-shot';
|
|
@@ -94,9 +95,6 @@ export interface MatrixShot {
|
|
|
94
95
|
readonly image: string;
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
const escapeHtml = (text: string): string =>
|
|
98
|
-
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
99
|
-
|
|
100
98
|
/**
|
|
101
99
|
* The contact sheet. System colours only (`Canvas`, `CanvasText`, `Mark`): a generated review page
|
|
102
100
|
* has no design tokens to read, and a literal colour would be the one this repo refuses.
|
package/src/cmd-shot.ts
CHANGED
|
@@ -339,6 +339,14 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
|
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Where the artifacts land. A relative `--out` is a path the caller typed from where they stand,
|
|
344
|
+
* so it resolves against the cwd — `x build --out`'s rule. Against the app root it landed
|
|
345
|
+
* somewhere else whenever `x shot` ran from `apps/web`.
|
|
346
|
+
*/
|
|
347
|
+
export const shotOutDir = (cwd: string, out: string | undefined, fallback: string): string =>
|
|
348
|
+
out === undefined ? fallback : resolve(cwd, out);
|
|
349
|
+
|
|
342
350
|
export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
|
|
343
351
|
ok: artifacts.verdict.ok,
|
|
344
352
|
command: 'shot',
|
|
@@ -426,7 +434,8 @@ export const shotCommand: CliCommand = {
|
|
|
426
434
|
const boot = (): Promise<ShotServer> => devServerFor(root, ctx.env, port);
|
|
427
435
|
const shared = {
|
|
428
436
|
root,
|
|
429
|
-
|
|
437
|
+
// Absolute here, so the island sweep's own `resolve(root, out)` keeps the caller's path.
|
|
438
|
+
...(out === undefined ? {} : { out: shotOutDir(ctx.cwd, out, root) }),
|
|
430
439
|
settleMs,
|
|
431
440
|
timeoutMs,
|
|
432
441
|
...(executablePath === undefined ? {} : { executablePath }),
|
|
@@ -465,7 +474,7 @@ export const shotCommand: CliCommand = {
|
|
|
465
474
|
defaultLocale: app.defaultLocale,
|
|
466
475
|
...(theme === undefined ? {} : { themes: [theme] }),
|
|
467
476
|
});
|
|
468
|
-
const outDir = out
|
|
477
|
+
const outDir = shotOutDir(ctx.cwd, out, join(root, MATRIX_DIR));
|
|
469
478
|
return runShotMatrix({ cells, outDir, boot, shoot: runShot, base });
|
|
470
479
|
}
|
|
471
480
|
const shown = locale ?? app.defaultLocale;
|
|
@@ -474,7 +483,7 @@ export const shotCommand: CliCommand = {
|
|
|
474
483
|
await runShot({
|
|
475
484
|
...base,
|
|
476
485
|
route: path,
|
|
477
|
-
outDir: out
|
|
486
|
+
outDir: shotOutDir(ctx.cwd, out, join(root, SHOT_DIR, shotSlug(path))),
|
|
478
487
|
boot,
|
|
479
488
|
// Pinned whether or not `--locale` was given: the default locale, never the box's own.
|
|
480
489
|
acceptLanguage: shown,
|
package/src/cmd-tasks.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { nearestName, systemClock } from '@ultimat3/core';
|
|
8
8
|
import type { JobDriver, TaskFire, TaskHandle } from '@ultimat3/jobs';
|
|
9
9
|
import type { CronPhrases } from '@ultimat3/time';
|
|
10
|
+
import { fromEpochMs, isoInZone } from '@ultimat3/time';
|
|
10
11
|
import { loadApp } from './app-load';
|
|
11
12
|
import { requireAppRoot } from './app-root';
|
|
12
13
|
import { tasksSpec } from './cmd-tasks-spec';
|
|
@@ -19,7 +20,6 @@ import { flagString } from './parse';
|
|
|
19
20
|
import { renderTable } from './table';
|
|
20
21
|
import {
|
|
21
22
|
findTaskHandle,
|
|
22
|
-
isoInZone,
|
|
23
23
|
knownTaskNames,
|
|
24
24
|
listTaskFacts,
|
|
25
25
|
parseCountFlag,
|
|
@@ -58,7 +58,7 @@ const lastFireOf = (fires: ReadonlyMap<string, TaskFire>, name: string, tz: stri
|
|
|
58
58
|
if (fire === undefined) return NEVER_FIRED;
|
|
59
59
|
return {
|
|
60
60
|
lastMs: fire.occurrenceMs,
|
|
61
|
-
last: isoInZone(fire.occurrenceMs, tz),
|
|
61
|
+
last: isoInZone(fromEpochMs(fire.occurrenceMs), tz),
|
|
62
62
|
lastFiredAtMs: fire.firedAt,
|
|
63
63
|
};
|
|
64
64
|
};
|
package/src/cmd-test-spec.ts
CHANGED
|
@@ -10,7 +10,7 @@ export const testSpec: CommandSpec = {
|
|
|
10
10
|
name: 'test',
|
|
11
11
|
summary:
|
|
12
12
|
'run one test type — or the whole suite — across N workers, one isolated database per worker',
|
|
13
|
-
usage: `x test [${TEST_TYPES.join('|')}] [--filter path[,path…]] [--allow-empty] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--
|
|
13
|
+
usage: `x test [${TEST_TYPES.join('|')}] [--filter path[,path…]] [--allow-empty] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--isolate] [--json] [-- <bun test flags>]`,
|
|
14
14
|
positionalChoices: TEST_TYPES,
|
|
15
15
|
// The one command that hands a tail to another tool — `bun test` — and the reason
|
|
16
16
|
// `CommandSpec.passthrough` exists: `x test unit -- --coverage --bail` parsed both flags and
|
|
@@ -28,12 +28,6 @@ export const testSpec: CommandSpec = {
|
|
|
28
28
|
summary:
|
|
29
29
|
'a fresh global per test file (bun test --isolate) — off by default since 22.7; x.verify.json "isolate": true makes it the repo default',
|
|
30
30
|
},
|
|
31
|
-
{
|
|
32
|
-
name: 'worker',
|
|
33
|
-
type: 'string',
|
|
34
|
-
summary:
|
|
35
|
-
'run only shard I of an N-way split of the selection, serially — one CI job\u2019s share',
|
|
36
|
-
},
|
|
37
31
|
{
|
|
38
32
|
name: 'filter',
|
|
39
33
|
type: 'string',
|
package/src/cmd-test.ts
CHANGED
|
@@ -40,22 +40,20 @@ import type { TestType } from './verify-tests';
|
|
|
40
40
|
import { TEST_TYPES } from './verify-tests';
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* `--workers
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* `--workers`. `Number.parseInt` alone accepted `4abc` and `4.9` as four, while `cmd-verify.ts`'s
|
|
44
|
+
* own comment claimed `x test --workers` refused the same values `x verify` does — so the two
|
|
45
|
+
* commands disagreed about the same flag. One reader now answers for both.
|
|
46
46
|
*/
|
|
47
|
-
const
|
|
47
|
+
const readWorkers = (args: ParsedArgs): number | undefined =>
|
|
48
48
|
readIntFlag(args, {
|
|
49
|
-
name,
|
|
49
|
+
name: 'workers',
|
|
50
50
|
command: 'test',
|
|
51
|
-
min,
|
|
51
|
+
min: 1,
|
|
52
52
|
// The ceiling the summary already claimed and the reader never enforced: `--workers 5000` was
|
|
53
53
|
// accepted and the run clamps only to the file count, which is one Bun worker per test FILE,
|
|
54
|
-
// each with the framework module graph and a cloned database.
|
|
55
|
-
// N-way split, so the same bound holds it (the exact upper index is `workers - 1`, refused a
|
|
56
|
-
// line below by the check that knows the real width).
|
|
54
|
+
// each with the framework module graph and a cloned database.
|
|
57
55
|
max: WORKER_CEILING,
|
|
58
|
-
example:
|
|
56
|
+
example: 'x test --workers 1',
|
|
59
57
|
});
|
|
60
58
|
|
|
61
59
|
/**
|
|
@@ -168,29 +166,17 @@ export const testCommand: CliCommand = {
|
|
|
168
166
|
});
|
|
169
167
|
}
|
|
170
168
|
const files = sample === undefined ? selected : sampleFiles(selected, sample);
|
|
171
|
-
const explicit =
|
|
169
|
+
const explicit = readWorkers(ctx.args);
|
|
172
170
|
const plan =
|
|
173
171
|
explicit === undefined ? workerPlan(availableCpus(), totalMemory(), ctx.env) : undefined;
|
|
174
172
|
const requested = explicit ?? (plan as WorkerPlan).workers;
|
|
175
173
|
const floor = await readVerifyFloor(ctx.cwd);
|
|
176
174
|
const isolate = flagBool(ctx.args, 'isolate') || floor?.isolate === true;
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
// the
|
|
180
|
-
// — true for `x test live`, false for the bare `x test --workers 8` that selects every type,
|
|
181
|
-
// which ran the same `live` and `e2e` files eight at a time. It stays because a `--worker` run
|
|
182
|
-
// is one process per CI JOB: sharding a serial type across N of them is the cluster-wide race
|
|
183
|
-
// in a second disguise, and refusing the index is where that is caught.
|
|
175
|
+
// A serial type is one worker whatever `--workers` says. `test-passes.ts` makes the same call
|
|
176
|
+
// off the FILES, which is what decides which files really run one at a time; this keeps the
|
|
177
|
+
// width the run reports and reproduces with the one it ran at.
|
|
184
178
|
const ceiling = type !== undefined && SERIAL_TYPES.includes(type) ? 1 : files.length;
|
|
185
179
|
const workers = Math.max(1, Math.min(requested, ceiling));
|
|
186
|
-
const only = readIndex(ctx.args, 'worker', 0);
|
|
187
|
-
if (only !== undefined && only >= workers) {
|
|
188
|
-
throw new BadFlagError({
|
|
189
|
-
flag: 'worker',
|
|
190
|
-
command: 'test',
|
|
191
|
-
reason: `shard ${only} does not exist in a ${workers}-worker split (0..${workers - 1})`,
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
180
|
const result = await runShards({
|
|
195
181
|
root: ctx.cwd,
|
|
196
182
|
runner: ctx.runner,
|
|
@@ -201,11 +187,9 @@ export const testCommand: CliCommand = {
|
|
|
201
187
|
// A default-width run leases its workers from the machine pool, so a second `x test` or
|
|
202
188
|
// gate on the same box runs narrower instead of doubling its memory.
|
|
203
189
|
...(plan === undefined ? {} : withLease(machineLease(plan.workers, ctx.env))),
|
|
204
|
-
...(only === undefined ? {} : { only }),
|
|
205
190
|
...(filter === undefined ? {} : { filter }),
|
|
206
191
|
...(type === undefined ? {} : { type }),
|
|
207
|
-
// `kept` is the corpus the
|
|
208
|
-
// `selected`, not `discovered`: with `--affected` the sample was taken from the narrowed
|
|
192
|
+
// `kept` is the corpus the run saw. `selected`, not `discovered`: with `--affected` the sample was taken from the narrowed
|
|
209
193
|
// set, and reporting the whole tree as its total would name a corpus no run ever had.
|
|
210
194
|
...(sample === undefined ? {} : { sample: { kept: files.length, total: selected.length } }),
|
|
211
195
|
// The fourth input to the split. Without it a failing shard's `fix:` re-splits the whole
|
package/src/cmd-verify.ts
CHANGED
|
@@ -20,14 +20,13 @@ import type { ParsedArgs } from './parse';
|
|
|
20
20
|
import { flagBool, flagString } from './parse';
|
|
21
21
|
import { WORKER_CEILING, WORKER_FLOOR } from './test-workers';
|
|
22
22
|
import { VERIFY_STEPS } from './verify-checks';
|
|
23
|
-
import { VerifyMergeInputError, VerifyShardInvalidError } from './verify-errors';
|
|
24
23
|
import { readVerifyFloor } from './verify-floor';
|
|
25
24
|
import type { VerifyPart } from './verify-merge';
|
|
26
|
-
import { coverageRiders, mergeParts, parsePart } from './verify-merge';
|
|
25
|
+
import { coverageRiders, mergeParts, parsePart, VerifyMergeInputError } from './verify-merge';
|
|
27
26
|
import { stepStream } from './verify-progress';
|
|
28
27
|
import { runVerify } from './verify-run';
|
|
29
28
|
import type { ShardSpec, Timings } from './verify-shard';
|
|
30
|
-
import { assertShardable, parseShard, readTimings } from './verify-shard';
|
|
29
|
+
import { assertShardable, parseShard, readTimings, VerifyShardInvalidError } from './verify-shard';
|
|
31
30
|
import type { VerifyStepName } from './verify-step';
|
|
32
31
|
import { VERIFY_STEP_NAMES } from './verify-step';
|
|
33
32
|
import { writeErrorLine } from './write-line';
|
|
@@ -150,8 +149,9 @@ export const readOnlySteps = (args: ParsedArgs): readonly VerifyStepName[] | und
|
|
|
150
149
|
if (unknown.length === 0) {
|
|
151
150
|
return VERIFY_STEP_NAMES.filter((name) => items.includes(name));
|
|
152
151
|
}
|
|
152
|
+
// An empty item corrects to nothing — never to whichever step is nearest to ''.
|
|
153
153
|
const corrected = items.map((item) =>
|
|
154
|
-
isStepName(item) ? item : nearestName(item, VERIFY_STEP_NAMES),
|
|
154
|
+
isStepName(item) ? item : item === '' ? undefined : nearestName(item, VERIFY_STEP_NAMES),
|
|
155
155
|
);
|
|
156
156
|
const fix = corrected.every((item) => item !== undefined)
|
|
157
157
|
? `x verify --only ${renderFixShellArg([...new Set(corrected)].join(','), '<step,step>')} --json`
|
package/src/db-subscribes.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// Single responsibility: which tables `x db gen` must grant `REPLICA IDENTITY FULL
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Single responsibility: which tables `x db gen` must grant `REPLICA IDENTITY FULL` — the live
|
|
2
|
+
// queries' DECLARED `subscribes:` plus the `records` tables of every channel with params — and the
|
|
3
|
+
// refusal for a declared name no entity's table matches. Only this tier holds all the registries.
|
|
4
4
|
|
|
5
5
|
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
import { paramsChannelTables } from '@ultimat3/realtime/server';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The two fields this reads, and nothing else. Structurally satisfied by `QueryDescriptor` (whose
|
|
@@ -63,12 +64,19 @@ export class QuerySubscribesUnknownError extends UltimateError {
|
|
|
63
64
|
* Every name is checked against `tables` BEFORE any of them is returned: a run that emitted the
|
|
64
65
|
* good half and refused afterwards would leave an author with a migration that is right for one
|
|
65
66
|
* table and silently absent for the other.
|
|
67
|
+
*
|
|
68
|
+
* `channelTables` defaults to what the declared channels need (`paramsChannelTables()`): a channel
|
|
69
|
+
* with params routes a DELETE to the topic the OLD row names, and under the default identity that
|
|
70
|
+
* image is the key alone — no topic, no `remove`, and members keep the deleted record. They are
|
|
71
|
+
* derived from entity projections rather than typed by an author, so one outside `tables` is
|
|
72
|
+
* dropped, not refused: there is no declaration to send anyone back to.
|
|
66
73
|
*/
|
|
67
74
|
export function replicaIdentityTables(
|
|
68
75
|
queries: readonly SubscribingQuery[],
|
|
69
76
|
tables: ReadonlySet<string>,
|
|
77
|
+
channelTables: readonly string[] = paramsChannelTables(),
|
|
70
78
|
): readonly string[] {
|
|
71
|
-
const wanted = new Set<string>();
|
|
79
|
+
const wanted = new Set<string>(channelTables.filter((table) => tables.has(table)));
|
|
72
80
|
for (const query of queries) {
|
|
73
81
|
for (const table of query.subscribes ?? []) {
|
|
74
82
|
if (!tables.has(table)) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The doors in front of `/_x` that a browser can knock on. `x dev` binds loopback, but a page the
|
|
2
|
+
// developer has open can still reach it: by DNS rebinding (a hostile name that resolves to
|
|
3
|
+
// 127.0.0.1, read back same-origin) and by a cross-site request to the SQL panel.
|
|
4
|
+
|
|
5
|
+
import { proveSameOrigin, renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
6
|
+
import type { UltimateRequest } from '@ultimat3/http';
|
|
7
|
+
import { csrfBlocked, HttpError, json as jsonResponse, selfOrigin } from '@ultimat3/http';
|
|
8
|
+
import { t } from '@ultimat3/schema';
|
|
9
|
+
import { isLoopbackHostname } from './loopback-host';
|
|
10
|
+
|
|
11
|
+
/** A `/_x` request named a Host that is not this machine. Never echoes the Host it was sent. */
|
|
12
|
+
export class DevHostRefusedError extends UltimateError {
|
|
13
|
+
constructor(input: { devUrl: string }) {
|
|
14
|
+
super({
|
|
15
|
+
code: 'X_DEV_HOST_REFUSED',
|
|
16
|
+
cause:
|
|
17
|
+
'a /_x request named a Host that is not localhost, a *.localhost name or a loopback address — the shape of a DNS-rebinding page reading the dev dashboard',
|
|
18
|
+
fix: `curl -sS -H 'accept: application/json' ${renderFixShellArg(`${input.devUrl}/_x`, 'http://localhost:3000/_x')}`,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The Host as sent, else the one the URL was built from — Bun builds it from the header. */
|
|
24
|
+
const hostnameOf = (request: UltimateRequest): string | undefined => {
|
|
25
|
+
const header = request.header('host') ?? request.url.host;
|
|
26
|
+
return URL.parse(`http://${header}`)?.hostname;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `421 Misdirected Request` for a Host that is not this machine, on every `/_x` route. The status
|
|
31
|
+
* says "not this server", which is the truth: a rebound name was never meant to reach it.
|
|
32
|
+
*/
|
|
33
|
+
export function hostRefusal(request: UltimateRequest, devUrl: string): Response | undefined {
|
|
34
|
+
const hostname = hostnameOf(request);
|
|
35
|
+
if (hostname !== undefined && isLoopbackHostname(hostname)) return undefined;
|
|
36
|
+
const error = new DevHostRefusedError({ devUrl });
|
|
37
|
+
return jsonResponse(
|
|
38
|
+
{ ok: false, error: { code: error.code, cause: error.cause, fix: error.fix } },
|
|
39
|
+
{ status: 421 },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A statement runs on a POST that came from this origin — core's `proveSameOrigin`, the rule the
|
|
45
|
+
* app's own writes and the sync upgrade answer to, asked HERE rather than left to the pipeline's
|
|
46
|
+
* CSRF stage: an app with `csrf: { mode: 'off' }` must not switch the dev database's door off with
|
|
47
|
+
* it. Neither header present is `curl`, as `checkCsrf` reads it: no page can drive that client.
|
|
48
|
+
*/
|
|
49
|
+
export function assertSameOrigin(request: UltimateRequest): void {
|
|
50
|
+
const origin = request.header('origin');
|
|
51
|
+
const secFetchSite = request.header('sec-fetch-site');
|
|
52
|
+
if (origin === null && secFetchSite === null) return;
|
|
53
|
+
const verdict = proveSameOrigin({
|
|
54
|
+
selfOrigins: [selfOrigin(request.url, request.ctx.https)],
|
|
55
|
+
origin,
|
|
56
|
+
secFetchSite,
|
|
57
|
+
listed: () => false,
|
|
58
|
+
listName: 'no list — /_x admits its own origin only',
|
|
59
|
+
});
|
|
60
|
+
if (!verdict.ok) throw csrfBlocked(request.pathname, verdict.reason, request.ctx.ip);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The SQL panel's one input, from a form or a JSON body — `request.body` parses either. */
|
|
64
|
+
export const SQL_BODY = t.object({ sql: t.string });
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A statement on the query string is refused rather than run: a GET is what `<img src>` and a
|
|
68
|
+
* prefetch send cross-site with nobody's consent. The fix is the POST that does run it.
|
|
69
|
+
*/
|
|
70
|
+
export const sqlOnGet = (devUrl: string): HttpError =>
|
|
71
|
+
new HttpError({
|
|
72
|
+
code: 'X_METHOD_NOT_ALLOWED',
|
|
73
|
+
cause:
|
|
74
|
+
'/_x/db runs a statement on a same-origin POST only — a GET carrying ?sql= is what a cross-site page can send',
|
|
75
|
+
fix: `curl -sS -H 'accept: application/json' --data-urlencode 'sql=select 1' ${renderFixShellArg(`${devUrl}/_x/db`, 'http://localhost:3000/_x/db')}`,
|
|
76
|
+
});
|