@ultimat3/cli 9.0.0 → 11.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 +71 -0
- package/package.json +28 -26
- package/src/affected.ts +0 -3
- package/src/app-boundaries.ts +4 -5
- package/src/app-env.ts +7 -2
- package/src/browser-launcher.ts +0 -2
- package/src/budgets.ts +10 -3
- package/src/cmd-build.ts +2 -2
- package/src/cmd-db-backfill.ts +14 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-deploy.ts +14 -3
- package/src/cmd-dev.ts +3 -0
- package/src/cmd-docs.ts +2 -1
- package/src/cmd-doctor.ts +3 -5
- package/src/cmd-env.ts +2 -2
- package/src/cmd-fix.ts +2 -4
- package/src/cmd-new.ts +36 -6
- package/src/cmd-shot.ts +22 -2
- package/src/command.ts +12 -0
- package/src/db-finding.ts +2 -2
- package/src/db-seed.ts +0 -3
- package/src/dev-assets.ts +6 -0
- package/src/dev-cache.ts +12 -5
- package/src/dev-hooks.ts +8 -0
- package/src/dev-lock.ts +8 -7
- package/src/dev-purge.ts +8 -2
- package/src/dev-render.ts +5 -2
- package/src/dev-roles.ts +26 -2
- package/src/dev-runtime.ts +11 -3
- package/src/dev-storage.ts +8 -2
- package/src/dev-sync.ts +37 -2
- package/src/dispatch.ts +8 -0
- package/src/document-styles.ts +2 -1
- package/src/drift.ts +3 -2
- package/src/error-catalog.ts +11 -1
- package/src/error-codes.ts +19 -2
- package/src/error-contract.ts +30 -7
- package/src/error-pages.ts +79 -0
- package/src/errors.ts +26 -29
- package/src/favicon.ts +113 -0
- package/src/fix-path.ts +104 -0
- package/src/flag-reads.ts +2 -2
- package/src/generate-write.ts +3 -2
- package/src/guards.ts +4 -4
- package/src/hold.ts +73 -7
- package/src/index.ts +16 -6
- package/src/island-bundle.ts +32 -4
- package/src/island-routes.ts +7 -1
- package/src/live-routes.ts +181 -0
- package/src/mcp-errors.ts +7 -2
- package/src/messages.ts +6 -4
- package/src/metrics-endpoint.ts +0 -2
- package/src/output.ts +2 -2
- package/src/prerender.ts +64 -22
- package/src/script-csp.ts +17 -0
- package/src/serve.ts +12 -1
- package/src/static-report.ts +41 -3
- package/src/templates/admin-page.ts +11 -7
- package/src/templates/imports.ts +26 -0
- package/src/templates/route.ts +2 -2
- package/src/templates/scaffold-app.ts +18 -6
- package/src/templates/scaffold-auth.ts +151 -0
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-docs.ts +1 -1
- package/src/templates/scaffold-domain-package.ts +3 -1
- package/src/templates/scaffold-mcp-package.ts +6 -3
- package/src/templates/scaffold-repo.ts +17 -8
- package/src/templates/slice-foundation.ts +3 -5
- package/src/test-shards.ts +2 -2
- package/src/tsconfig-references.ts +2 -2
- package/src/verify-checks.ts +10 -3
- package/src/verify-floor.ts +8 -6
- package/src/verify-run.ts +2 -2
- package/src/verify-step.ts +2 -1
- package/src/verify-test-run.ts +2 -2
- package/src/workspace-checks.ts +10 -12
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +7 -1
package/src/cmd-new.ts
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { chmod } from 'node:fs/promises';
|
|
7
7
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
8
|
-
import { renderThrowable } from '@ultimat3/core';
|
|
8
|
+
import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
|
|
9
9
|
import { dedupe } from './cmd-generate';
|
|
10
10
|
import type { CliCommand, CommandContext } from './command';
|
|
11
|
-
import {
|
|
11
|
+
import { invocationOf } from './command';
|
|
12
|
+
import { AppNameIsPathError, MissingPositionalError } from './errors';
|
|
12
13
|
import type { Runner } from './exec';
|
|
13
14
|
import { msg } from './messages';
|
|
14
15
|
import type { CommandResult } from './output';
|
|
@@ -116,7 +117,8 @@ export interface WrittenApp {
|
|
|
116
117
|
* a migration whose snapshot never existed is what made the app's first two database commands
|
|
117
118
|
* refuse each other — `x db migrate` naming `x db gen`, and `x db gen` refusing a sidecar version
|
|
118
119
|
* control never had. The consequence is deliberate: `x verify`'s `drift` step is red on a pristine
|
|
119
|
-
* scaffold until `x db gen "initial"` runs
|
|
120
|
+
* scaffold until `x db gen "initial"` runs — which `bin/setup` does, and which is what
|
|
121
|
+
* `cli.new.done` tells the author to run.
|
|
120
122
|
*/
|
|
121
123
|
export async function writeNewApp(target: string, options: NewAppOptions): Promise<WrittenApp> {
|
|
122
124
|
const files = planNewApp(options);
|
|
@@ -130,6 +132,24 @@ function parentDir(cwd: string, dirFlag: string | undefined): string {
|
|
|
130
132
|
return isAbsolute(dirFlag) ? dirFlag : join(cwd, dirFlag);
|
|
131
133
|
}
|
|
132
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The `--dir`/name split of a positional that is really a path, or `undefined` when it is a name.
|
|
137
|
+
*
|
|
138
|
+
* Separator-agnostic: a Windows path pasted into a shell here is the same mistake, and `\` is not
|
|
139
|
+
* a character any app name may hold either.
|
|
140
|
+
*/
|
|
141
|
+
export function appNamePath(raw: string): { parent: string; base: string } | undefined {
|
|
142
|
+
if (!/[\\/]/.test(raw)) return undefined;
|
|
143
|
+
const trimmed = raw.replace(/[\\/]+$/, '');
|
|
144
|
+
const segments = trimmed.split(/[\\/]+/).filter((segment) => segment.length > 0);
|
|
145
|
+
const base = segments.at(-1) ?? 'myapp';
|
|
146
|
+
const parent = trimmed.slice(0, trimmed.lastIndexOf(base)).replace(/[\\/]+$/, '');
|
|
147
|
+
// `x new ./shop` and `x new shop/` both name the directory the caller is already in; `/shop`
|
|
148
|
+
// names the root, which is a directory and not "here".
|
|
149
|
+
if (parent !== '' && parent !== '.') return { parent, base };
|
|
150
|
+
return { parent: /^[\\/]/.test(trimmed) ? '/' : '.', base };
|
|
151
|
+
}
|
|
152
|
+
|
|
133
153
|
export const newCommand: CliCommand = {
|
|
134
154
|
spec: {
|
|
135
155
|
name: 'new',
|
|
@@ -143,7 +163,7 @@ export const newCommand: CliCommand = {
|
|
|
143
163
|
{
|
|
144
164
|
// The summary carries the default and the negation because the page has to answer "which
|
|
145
165
|
// one do I get if I type neither": the usage line offered `--no-example`, this table said
|
|
146
|
-
// `--example`, and `default: true` is a field only `--json` renders.
|
|
166
|
+
// `--example`, and `default: true` is a field only `--json` renders. 136 files against 109.
|
|
147
167
|
name: 'example',
|
|
148
168
|
type: 'boolean',
|
|
149
169
|
summary: 'include the example feature slice (default: on; --no-example for an empty app/)',
|
|
@@ -168,9 +188,19 @@ export const newCommand: CliCommand = {
|
|
|
168
188
|
throw new MissingPositionalError({
|
|
169
189
|
command: 'new',
|
|
170
190
|
positional: 'name',
|
|
171
|
-
|
|
191
|
+
// Never the literal `x new`: this command is also the whole of `bunx create-ultimate`,
|
|
192
|
+
// which runs BEFORE `x` exists — so the one instruction the reader was given named a
|
|
193
|
+
// binary they had not installed yet.
|
|
194
|
+
example: `${invocationOf(ctx, 'new')} myapp`,
|
|
172
195
|
});
|
|
173
196
|
}
|
|
197
|
+
// Before `names()`, which is what makes this reachable at all: it slugifies a path into one
|
|
198
|
+
// kebab-case directory name, so `/srv/apps/shop` becomes `srv-apps-shop` inside the cwd and
|
|
199
|
+
// the scaffold lands somewhere nobody asked for. `--dir` is the flag that takes a path.
|
|
200
|
+
const path = appNamePath(raw);
|
|
201
|
+
if (path !== undefined) {
|
|
202
|
+
throw new AppNameIsPathError({ name: raw, invocation: invocationOf(ctx, 'new'), ...path });
|
|
203
|
+
}
|
|
174
204
|
const app = names(raw);
|
|
175
205
|
const target = resolve(parentDir(ctx.cwd, flagString(ctx.args, 'dir')), app.kebab);
|
|
176
206
|
const options: NewAppOptions = { name: raw, example: ctx.args.flags.get('example') !== false };
|
|
@@ -195,7 +225,7 @@ export const newCommand: CliCommand = {
|
|
|
195
225
|
code: 'X_GENERATE_CONFLICT',
|
|
196
226
|
cause: `${target} already exists`,
|
|
197
227
|
fix: `x new ${app.kebab} --force, or choose another name`,
|
|
198
|
-
docs:
|
|
228
|
+
docs: ERROR_DOCS_URL,
|
|
199
229
|
at: target,
|
|
200
230
|
},
|
|
201
231
|
],
|
package/src/cmd-shot.ts
CHANGED
|
@@ -164,6 +164,17 @@ const intFlag = (
|
|
|
164
164
|
fallback,
|
|
165
165
|
);
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* How `devServerFor` starts a scratch server. A parameter with a default rather than a direct
|
|
169
|
+
* call, for the reason every `Runner` in this package is one: the failure path below — a boot that
|
|
170
|
+
* throws, and the lock it has to hand back — is otherwise only reachable by breaking a real app.
|
|
171
|
+
*/
|
|
172
|
+
export type BootDevServer = (input: {
|
|
173
|
+
readonly root: string;
|
|
174
|
+
readonly port: number;
|
|
175
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
176
|
+
}) => Promise<{ readonly url: string; stop(): Promise<void> }>;
|
|
177
|
+
|
|
167
178
|
export interface ShotServer {
|
|
168
179
|
readonly url: string;
|
|
169
180
|
/** Which server the picture is of. Reported, because the two have different failure modes. */
|
|
@@ -272,6 +283,7 @@ export async function devServerFor(
|
|
|
272
283
|
root: string,
|
|
273
284
|
env: Readonly<Record<string, string | undefined>>,
|
|
274
285
|
port: number,
|
|
286
|
+
boot: BootDevServer = (input) => startDev(input),
|
|
275
287
|
): Promise<ShotServer> {
|
|
276
288
|
const services = resolveServices(root, env);
|
|
277
289
|
const file = Bun.file(lockPath(services.stateDir));
|
|
@@ -281,13 +293,21 @@ export async function devServerFor(
|
|
|
281
293
|
return { url: lock.url, origin: 'reused', stop: () => Promise.resolve() };
|
|
282
294
|
}
|
|
283
295
|
}
|
|
284
|
-
await preflight({
|
|
296
|
+
const { release } = await preflight({
|
|
285
297
|
stateDir: services.stateDir,
|
|
286
298
|
port,
|
|
287
299
|
hostname: DEV_BINDING.hostname,
|
|
288
300
|
embeddedDb: services.db.mode === 'embedded',
|
|
289
301
|
});
|
|
290
|
-
|
|
302
|
+
// The directory is CLAIMED from here down — `preflight` returns holding it, never having merely
|
|
303
|
+
// looked — so a boot that throws has to give it back. `cmd-dev.ts` states the same rule at the
|
|
304
|
+
// same seam. Without it one failed `x shot` refused every later `x dev` and `x shot` on this
|
|
305
|
+
// checkout, naming a pid that had already exited. The original error is re-thrown untouched: a
|
|
306
|
+
// teardown must never replace the failure it is cleaning up after.
|
|
307
|
+
const dev = await boot({ root, port, env }).catch((error: unknown) => {
|
|
308
|
+
release();
|
|
309
|
+
throw error;
|
|
310
|
+
});
|
|
291
311
|
await writeLock(services.stateDir, {
|
|
292
312
|
pid: process.pid,
|
|
293
313
|
port,
|
package/src/command.ts
CHANGED
|
@@ -13,8 +13,20 @@ export interface CommandContext {
|
|
|
13
13
|
readonly runner: Runner;
|
|
14
14
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
15
15
|
readonly bunVersion: string;
|
|
16
|
+
/**
|
|
17
|
+
* How this process was invoked, up to and including the subcommand — `x new`, or
|
|
18
|
+
* `bunx create-ultimate` when `create-ultimate` is the entry point. Read by a `fix:` line, which
|
|
19
|
+
* is a command the reader is meant to RUN: `create-ultimate`'s whole reason to exist is running
|
|
20
|
+
* before `x` is installed, so `x new myapp` was an instruction nobody in that process could
|
|
21
|
+
* follow. Absent means the default below — a caller that builds a context by hand owes nothing.
|
|
22
|
+
*/
|
|
23
|
+
readonly invocation?: string;
|
|
16
24
|
}
|
|
17
25
|
|
|
26
|
+
/** What `ctx.invocation` means when nobody said: the binary, then the subcommand they typed. */
|
|
27
|
+
export const invocationOf = (ctx: CommandContext, command: string): string =>
|
|
28
|
+
ctx.invocation ?? `x ${command}`;
|
|
29
|
+
|
|
18
30
|
export interface CliCommand {
|
|
19
31
|
readonly spec: CommandSpec;
|
|
20
32
|
run(ctx: CommandContext): Promise<CommandResult>;
|
package/src/db-finding.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// step that failed. Its own module because `cmd-db.ts` and `cmd-db-branch.ts` both need it and
|
|
4
4
|
// neither may import the other.
|
|
5
5
|
|
|
6
|
-
import { renderThrowable } from '@ultimat3/core';
|
|
6
|
+
import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
|
|
7
7
|
import type { Finding } from './output';
|
|
8
8
|
import { findingFrom, isUltimateErrorShape } from './output';
|
|
9
9
|
|
|
@@ -24,5 +24,5 @@ export const stepFinding = (error: unknown, code: string): Finding =>
|
|
|
24
24
|
// a TypeError raised while reporting it.
|
|
25
25
|
cause: renderThrowable(error),
|
|
26
26
|
fix: 'x doctor --json',
|
|
27
|
-
docs:
|
|
27
|
+
docs: ERROR_DOCS_URL,
|
|
28
28
|
};
|
package/src/db-seed.ts
CHANGED
|
@@ -12,7 +12,6 @@ import type { Environment } from '@ultimat3/core';
|
|
|
12
12
|
import { UltimateError } from '@ultimat3/core';
|
|
13
13
|
import type { Driver, Seed, SeedTier } from '@ultimat3/entity';
|
|
14
14
|
import { isSeed, SEED_TIERS, seedTiersFor } from '@ultimat3/entity';
|
|
15
|
-
import { docsFor } from './error-codes';
|
|
16
15
|
import { BadFlagError } from './errors';
|
|
17
16
|
import type { Finding, JsonValue } from './output';
|
|
18
17
|
import { findingFrom } from './output';
|
|
@@ -49,7 +48,6 @@ export class SeedUnknownError extends UltimateError {
|
|
|
49
48
|
// A dry run, never a bare `x db seed`: the command that answers "which seeds are there" must
|
|
50
49
|
// not be the command that writes them.
|
|
51
50
|
fix: 'x db seed --dry-run --json',
|
|
52
|
-
docs: docsFor('X_DECLARATION_UNKNOWN'),
|
|
53
51
|
});
|
|
54
52
|
}
|
|
55
53
|
}
|
|
@@ -75,7 +73,6 @@ export class SeedEnvironmentError extends UltimateError {
|
|
|
75
73
|
code: 'X_SEED_ENVIRONMENT',
|
|
76
74
|
cause: `seed "${input.seed}" is tier ${input.tier} and ULTIMATE_ENV resolved ${input.environment}, where x db seed runs ${input.tiers.join(', ')} — ULTIMATE_SEED_TIER=${input.tier} says this deploy takes it anyway`,
|
|
77
75
|
fix: `x db seed ${input.seed} --tier ${input.tier} --json`,
|
|
78
|
-
docs: docsFor('X_SEED_ENVIRONMENT'),
|
|
79
76
|
});
|
|
80
77
|
}
|
|
81
78
|
}
|
package/src/dev-assets.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
authorizeStorageRead,
|
|
24
24
|
STORAGE_READ_PERMISSION,
|
|
25
25
|
} from './dev-storage';
|
|
26
|
+
import { faviconRoute } from './favicon';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The one source image every generated icon derives from. `x new` scaffolds it, `x doctor` checks
|
|
@@ -254,6 +255,11 @@ export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
|
|
|
254
255
|
handler: async (request: UltimateRequest, ctx: RequestContext): Promise<Response> =>
|
|
255
256
|
mediaResponse(request, ctx, options.storage, options.images),
|
|
256
257
|
});
|
|
258
|
+
// Mounted here rather than in `serve.ts` and `cmd-dev.ts` separately: this is the one route set
|
|
259
|
+
// both served surfaces already compose, and a favicon that answers on a laptop and 404s in the
|
|
260
|
+
// container is the dev/prod difference this package's own rule forbids. `favicon.ts` owns what
|
|
261
|
+
// the answer IS — this file only says the app's asset surface is where it hangs.
|
|
262
|
+
routes.push(faviconRoute(options.root));
|
|
257
263
|
|
|
258
264
|
return routes;
|
|
259
265
|
}
|
package/src/dev-cache.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
resetTiers,
|
|
21
21
|
} from '@ultimat3/cache';
|
|
22
22
|
import type { CacheTierName } from '@ultimat3/core';
|
|
23
|
-
import { CACHE_TIERS, defineConfig, logger } from '@ultimat3/core';
|
|
23
|
+
import { CACHE_TIERS, defineConfig, logger, renderThrowable } from '@ultimat3/core';
|
|
24
24
|
import type { Transport, TransportSubscription } from '@ultimat3/realtime/server';
|
|
25
25
|
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
26
26
|
import { APP_CONFIG_FILE } from './app-root';
|
|
@@ -187,7 +187,7 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
|
|
|
187
187
|
void applyBroadcast(payload);
|
|
188
188
|
})
|
|
189
189
|
.catch((error: unknown) => {
|
|
190
|
-
logger.warn('cache.broadcast.subscribe-failed', { error:
|
|
190
|
+
logger.warn('cache.broadcast.subscribe-failed', { error: broadcastErrorText(error) });
|
|
191
191
|
return undefined;
|
|
192
192
|
});
|
|
193
193
|
|
|
@@ -199,8 +199,15 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
|
|
|
199
199
|
};
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
/**
|
|
203
|
+
* `renderThrowable`, never `instanceof Error` + `.message`. Both run on a value this process did
|
|
204
|
+
* not build — a `Proxy` traps `getPrototypeOf` and a `message` getter can raise — and a throw here
|
|
205
|
+
* is inside the handler whose whole job is to keep the subscriber loop alive: losing it ends
|
|
206
|
+
* cross-instance cache invalidation for the process, quietly, which is the failure the loop's own
|
|
207
|
+
* `try` exists to prevent. The old form also answered `'unknown error'` for every non-`Error`
|
|
208
|
+
* throw, so a driver rejecting with a string reported nothing at all.
|
|
209
|
+
*/
|
|
210
|
+
export const broadcastErrorText = (error: unknown): string => renderThrowable(error);
|
|
204
211
|
|
|
205
212
|
/**
|
|
206
213
|
* A peer's wire tags, applied here. Never throws: a malformed frame or an undeclared tag must not
|
|
@@ -214,6 +221,6 @@ async function applyBroadcast(payload: string): Promise<void> {
|
|
|
214
221
|
const wire = parsed.filter((value): value is string => typeof value === 'string');
|
|
215
222
|
if (wire.length > 0) await receiveInvalidationBroadcast(wire);
|
|
216
223
|
} catch (error) {
|
|
217
|
-
logger.warn('cache.broadcast.apply-failed', { error:
|
|
224
|
+
logger.warn('cache.broadcast.apply-failed', { error: broadcastErrorText(error) });
|
|
218
225
|
}
|
|
219
226
|
}
|
package/src/dev-hooks.ts
CHANGED
|
@@ -41,14 +41,22 @@ export interface DevHookOptions {
|
|
|
41
41
|
* that starts a web role — `serve.ts` included — carry a diagnostic only one of them installs.
|
|
42
42
|
*/
|
|
43
43
|
readonly devNotices?: ServerHooks['devNotices'];
|
|
44
|
+
/**
|
|
45
|
+
* The app's own error page for a status, read off its disk. Passed for `devNotices`' reason: a
|
|
46
|
+
* hook that reached for the app root itself would make every host that starts a web role carry a
|
|
47
|
+
* path only the one that knows the root can supply.
|
|
48
|
+
*/
|
|
49
|
+
readonly errorPage?: ServerHooks['errorPage'];
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
export function devHooks(options: DevHookOptions = {}): ServerHooks {
|
|
47
53
|
const authenticate = configuredAuthenticator();
|
|
48
54
|
const devNotices = options.devNotices;
|
|
55
|
+
const errorPage = options.errorPage;
|
|
49
56
|
return {
|
|
50
57
|
...(authenticate === undefined ? {} : { authenticate }),
|
|
51
58
|
...(devNotices === undefined ? {} : { devNotices }),
|
|
59
|
+
...(errorPage === undefined ? {} : { errorPage }),
|
|
52
60
|
authorize: (route, _request, ctx): AuthzDecision => {
|
|
53
61
|
// An action route never arrives here: it carries `enforcedBy: 'handler'`, so the pipeline
|
|
54
62
|
// never asks. `invoke` is its one evaluation, and the only one holding the row a row-level
|
package/src/dev-lock.ts
CHANGED
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { closeSync, mkdirSync, openSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
-
import { UltimateError } from '@ultimat3/core';
|
|
20
|
-
import { docsFor } from './error-codes';
|
|
19
|
+
import { stringField, UltimateError } from '@ultimat3/core';
|
|
21
20
|
import { exec, type Runner } from './exec';
|
|
22
21
|
import { quoteArg } from './shell-quote';
|
|
23
22
|
|
|
@@ -68,7 +67,11 @@ export const isProcessAlive = (pid: number): boolean => {
|
|
|
68
67
|
return true;
|
|
69
68
|
} catch (error) {
|
|
70
69
|
// EPERM means it exists and belongs to another user. Alive, and not ours to signal.
|
|
71
|
-
|
|
70
|
+
// `stringField`, never a cast plus a property read: the rule `metrics-endpoint.ts` states and
|
|
71
|
+
// `caught-value-reads.test.ts` enforces — a getter that throws would take this path down one
|
|
72
|
+
// line before the guard meant to make it safe, and this guard decides whether a second `x dev`
|
|
73
|
+
// is allowed to open a single-writer data directory.
|
|
74
|
+
return stringField(error, 'code') === 'EPERM';
|
|
72
75
|
}
|
|
73
76
|
};
|
|
74
77
|
|
|
@@ -96,7 +99,6 @@ export class DevAlreadyRunningError extends UltimateError {
|
|
|
96
99
|
code: 'X_DEV_ALREADY_RUNNING',
|
|
97
100
|
cause: `pid ${input.lock.pid} is already running x dev on ${input.lock.url} and holds ${input.stateDir}${single}`,
|
|
98
101
|
fix: `use the one already running at ${input.lock.url}, or stop it: kill ${input.lock.pid}`,
|
|
99
|
-
docs: docsFor('X_DEV_ALREADY_RUNNING'),
|
|
100
102
|
meta: { pid: input.lock.pid, port: input.lock.port, stateDir: input.stateDir },
|
|
101
103
|
});
|
|
102
104
|
}
|
|
@@ -118,7 +120,6 @@ export class DevLockUnreadableError extends UltimateError {
|
|
|
118
120
|
code: 'X_DEV_LOCK_UNREADABLE',
|
|
119
121
|
cause: `${input.path} could not be parsed as a dev lock and could not be removed, so x dev cannot tell whether another process owns ${input.stateDir}`,
|
|
120
122
|
fix: `rm ${quoteArg(input.path)} # then re-run x dev`,
|
|
121
|
-
docs: docsFor('X_DEV_LOCK_UNREADABLE'),
|
|
122
123
|
meta: { path: input.path, stateDir: input.stateDir },
|
|
123
124
|
});
|
|
124
125
|
}
|
|
@@ -198,7 +199,6 @@ export class DevPortInUseError extends UltimateError {
|
|
|
198
199
|
holder.pid === undefined
|
|
199
200
|
? `x dev --port ${input.suggestion}`
|
|
200
201
|
: `x dev --port ${input.suggestion} # or free it, if that pid is yours: kill ${holder.pid}`,
|
|
201
|
-
docs: docsFor('X_PORT_IN_USE'),
|
|
202
202
|
meta: { port: input.port, ...holder },
|
|
203
203
|
});
|
|
204
204
|
}
|
|
@@ -247,7 +247,8 @@ function claimExclusive(path: string, lock: DevLock): boolean {
|
|
|
247
247
|
try {
|
|
248
248
|
fd = openSync(path, 'wx');
|
|
249
249
|
} catch (error) {
|
|
250
|
-
|
|
250
|
+
// Same rule as `isProcessAlive` above: read the field, never cast and dereference.
|
|
251
|
+
if (stringField(error, 'code') === 'EEXIST') return false;
|
|
251
252
|
throw error;
|
|
252
253
|
}
|
|
253
254
|
try {
|
package/src/dev-purge.ts
CHANGED
|
@@ -112,9 +112,15 @@ function declareSweep(): void {
|
|
|
112
112
|
* background work at all, and this is one more thing it does not do.
|
|
113
113
|
*/
|
|
114
114
|
export function installRetentionSweep(stores: RetentionStores): () => void {
|
|
115
|
-
|
|
115
|
+
const mine = retentionTargets(stores);
|
|
116
|
+
installed = mine;
|
|
116
117
|
declareSweep();
|
|
117
118
|
return () => {
|
|
118
|
-
|
|
119
|
+
// Only if the slot is still OURS. Two runtimes can share one process — a test harness, or
|
|
120
|
+
// `x shot`'s scratch server beside the one it photographs — and a boot that installed after
|
|
121
|
+
// this one owns the slot now: emptying it there would leave the LIVE boot's hourly sweep
|
|
122
|
+
// deleting nothing, forever, with no error anywhere. Same rule, and the same comment, as
|
|
123
|
+
// `installedRevalidator` in `@ultimat3/render`'s ISR controller.
|
|
124
|
+
if (installed === mine) installed = [];
|
|
119
125
|
};
|
|
120
126
|
}
|
package/src/dev-render.ts
CHANGED
|
@@ -183,11 +183,14 @@ async function resultFor(
|
|
|
183
183
|
return { status: 200, headers: staticHeaders(contentHash(body), options.buildId), body };
|
|
184
184
|
}
|
|
185
185
|
case 'isr': {
|
|
186
|
-
// `isrKey(url)`, never `url.pathname`: the query is part of what was rendered — this
|
|
186
|
+
// `isrKey(url, locale)`, never `url.pathname`: the query is part of what was rendered — this
|
|
187
187
|
// route's own `meta` reads `data.url` — so two URLs differing only in their query are two
|
|
188
188
|
// documents. Keyed on the pathname alone, the first render answered every later query
|
|
189
189
|
// string (#171). Render owns the derivation so no second caller can invent another.
|
|
190
|
-
|
|
190
|
+
// The locale is the second dimension and it is `ctx.locale`, the answer the `locale` stage
|
|
191
|
+
// already negotiated for THIS request — never `currentLocale()`, which would read the same
|
|
192
|
+
// value through an ambient store the key does not need.
|
|
193
|
+
const served = await isr.serve(isrKey(url, ctx.locale), () =>
|
|
191
194
|
documentFrom(entry, request, data, options),
|
|
192
195
|
);
|
|
193
196
|
return served.result;
|
package/src/dev-roles.ts
CHANGED
|
@@ -26,9 +26,11 @@ import { startReplicator } from './dev-replicator';
|
|
|
26
26
|
import type { RunningServices } from './dev-runtime';
|
|
27
27
|
import type { Env } from './dev-services';
|
|
28
28
|
import { startSync } from './dev-sync';
|
|
29
|
+
import { errorPageHook } from './error-pages';
|
|
29
30
|
import { BadFlagError, PortInvalidError, RuntimeDriverSplitError } from './errors';
|
|
30
31
|
import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
|
|
31
32
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
33
|
+
import { inlineScriptSources } from './script-csp';
|
|
32
34
|
import { inlineStyleSources } from './style-csp';
|
|
33
35
|
|
|
34
36
|
/** The roles `x dev` starts when `--role` names none, in boot order. */
|
|
@@ -63,6 +65,16 @@ export interface StartRolesOptions {
|
|
|
63
65
|
* `startRoles` takes plain values — a test starts a web role with no `app.config.ts` at all.
|
|
64
66
|
*/
|
|
65
67
|
readonly signInPath?: string | null;
|
|
68
|
+
/**
|
|
69
|
+
* The app root, for the one seam that is a FILE and not a value: `apps/web/site/errors/404.html`
|
|
70
|
+
* and its siblings. Bound HERE rather than passed by each caller, because `x dev` and `serve.ts`
|
|
71
|
+
* both boot through this function and an override wired at one of them alone is a page that
|
|
72
|
+
* appears in dev and not in production — `/favicon.ico`'s rule, one seam over.
|
|
73
|
+
*
|
|
74
|
+
* Optional for the reason `signInPath` is: `startRoles` takes plain values, and a test starts a
|
|
75
|
+
* web role with no app on disk at all. Absent, every error page is the framework's.
|
|
76
|
+
*/
|
|
77
|
+
readonly root?: string;
|
|
66
78
|
/**
|
|
67
79
|
* Inline `<style>` bodies this process serves that the app's own surfaces do not account for —
|
|
68
80
|
* `/_x`'s shell. The surfaces themselves are read from the stylesheet registry here rather than
|
|
@@ -235,7 +247,10 @@ function startWeb(options: StartRolesOptions): ServerHandle {
|
|
|
235
247
|
return createServer({
|
|
236
248
|
routes: options.routes,
|
|
237
249
|
role: 'web',
|
|
238
|
-
hooks: devHooks(
|
|
250
|
+
hooks: devHooks({
|
|
251
|
+
...(options.devNotices === undefined ? {} : { devNotices: options.devNotices }),
|
|
252
|
+
...(options.root === undefined ? {} : { errorPage: errorPageHook(options.root) }),
|
|
253
|
+
}),
|
|
239
254
|
// Both seams `createServer` already had and `startRoles` passed neither of, so an app's own
|
|
240
255
|
// middleware could not reach the pipeline any process the framework boots actually runs.
|
|
241
256
|
...(options.overrides?.middleware === undefined
|
|
@@ -260,8 +275,17 @@ function startWeb(options: StartRolesOptions): ServerHandle {
|
|
|
260
275
|
// Hashes, never `'unsafe-inline'`: a `render: 'static'` page is a file on disk, so
|
|
261
276
|
// nothing can stamp a per-response nonce into it, but its body is fixed and a hash is a
|
|
262
277
|
// function of that body. Read after `loadApp` — importing the app IS what registered them.
|
|
278
|
+
// BOTH directives, and the script half is the one that was missing: the hydration runtime
|
|
279
|
+
// is emitted inline in every document that carries an island, so `script-src 'self'` meant
|
|
280
|
+
// no island booted anywhere the policy is enforced — which is every container, and never
|
|
281
|
+
// `x dev`, where it is report-only.
|
|
263
282
|
security: {
|
|
264
|
-
csp: {
|
|
283
|
+
csp: {
|
|
284
|
+
extend: {
|
|
285
|
+
'style-src': inlineStyleSources(options.inlineStyles ?? []),
|
|
286
|
+
'script-src': inlineScriptSources(),
|
|
287
|
+
},
|
|
288
|
+
},
|
|
265
289
|
},
|
|
266
290
|
}),
|
|
267
291
|
}).start();
|
package/src/dev-runtime.ts
CHANGED
|
@@ -186,8 +186,14 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
|
|
|
186
186
|
// missing" from a helper the operator never configured. Not an outright ban on the local disk in
|
|
187
187
|
// production: a single-node Compose deploy on a mounted volume WITH a real secret is a rung on
|
|
188
188
|
// the scale ladder, and refusing it would be a deploy-shape decision, not a security fix.
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
// `{ env }` on ALL THREE, never the ambient `process.env`: this function is HANDED the boot's
|
|
190
|
+
// environment and reads `S3_BUCKET` off it one branch above, so a guard asking a second source
|
|
191
|
+
// could answer `development` for a process booting as `production` — or, with
|
|
192
|
+
// `usesDevStorageSecret({ env })` left bare, refuse a boot whose own env carries a real
|
|
193
|
+
// `STORAGE_SIGNING_SECRET` because the PROCESS does not. Which environment, whether a secret
|
|
194
|
+
// exists, and the name the message prints are one question about one table.
|
|
195
|
+
if (!isLocal({ env }) && usesDevStorageSecret({ env })) {
|
|
196
|
+
throw new LocalDiskUnsafeError({ environment: resolveEnvironment({ env }), root });
|
|
191
197
|
}
|
|
192
198
|
try {
|
|
193
199
|
mkdirSync(root, { recursive: true });
|
|
@@ -198,7 +204,9 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
|
|
|
198
204
|
`mount a writable volume at ${root}, or set S3_ENDPOINT and S3_BUCKET to use object storage instead`,
|
|
199
205
|
);
|
|
200
206
|
}
|
|
201
|
-
|
|
207
|
+
// The guard three lines up reads `env`; so must the disk it guards. Otherwise the boot's
|
|
208
|
+
// environment decides whether signing is allowed and the process's decides what key is used.
|
|
209
|
+
return defineStorage({ disks: { local: localDriver({ root, env }) }, default: 'local' });
|
|
202
210
|
}
|
|
203
211
|
|
|
204
212
|
/**
|
package/src/dev-storage.ts
CHANGED
|
@@ -151,8 +151,14 @@ export function parseByteRange(
|
|
|
151
151
|
if (from === '' && to === '') return undefined;
|
|
152
152
|
if (from === '') {
|
|
153
153
|
const wanted = Number(to);
|
|
154
|
-
// A suffix longer than the object is the whole object, not a refusal
|
|
155
|
-
|
|
154
|
+
// A suffix longer than the object is the whole object, not a refusal — but there is no whole
|
|
155
|
+
// object to fall back to at `size === 0`, and the arithmetic below answers `{ start: 0, end:
|
|
156
|
+
// -1 }`, which the route renders as `content-range: bytes 0--1/0` with status 206. RFC 9110
|
|
157
|
+
// requires 416. The non-suffix branch already gets this right through `start >= size`; this
|
|
158
|
+
// one had no equivalent test.
|
|
159
|
+
return size === 0 || wanted === 0
|
|
160
|
+
? UNSATISFIABLE
|
|
161
|
+
: { start: Math.max(size - wanted, 0), end: size - 1 };
|
|
156
162
|
}
|
|
157
163
|
const start = Number(from);
|
|
158
164
|
const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
|
package/src/dev-sync.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Split from `dev-roles.ts` because it is the one role with an authenticator, a presence registry
|
|
3
3
|
// and a listener of its own — and because that file is the boot's index, not its detail.
|
|
4
4
|
|
|
5
|
-
import { createContext, logger } from '@ultimat3/core';
|
|
5
|
+
import { createContext, logger, UltimateError } from '@ultimat3/core';
|
|
6
6
|
import { listQueries } from '@ultimat3/query';
|
|
7
7
|
import {
|
|
8
8
|
ChannelHub,
|
|
@@ -15,8 +15,43 @@ import {
|
|
|
15
15
|
SocketRegistry,
|
|
16
16
|
} from '@ultimat3/realtime/server';
|
|
17
17
|
import type { StartRolesOptions } from './dev-roles';
|
|
18
|
+
import { neighbouringPort, PORT_RANGE } from './flag-number';
|
|
18
19
|
import { syncAuthenticator } from './sync-authenticator';
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Beside its one thrower rather than in `errors.ts`, which is at 461 of the 500-line ceiling —
|
|
23
|
+
* the arrangement `db-seed.ts` and `metrics-endpoint.ts` already take. The code is
|
|
24
|
+
* `X_PORT_INVALID`, this package's own: "the port asked for is not one" is what it already means,
|
|
25
|
+
* and a second code for the same fact is the synonym the registry exists to prevent.
|
|
26
|
+
*/
|
|
27
|
+
class SyncPortUnavailableError extends UltimateError {
|
|
28
|
+
constructor(input: { port: number }) {
|
|
29
|
+
super({
|
|
30
|
+
code: 'X_PORT_INVALID',
|
|
31
|
+
cause: `the sync role binds PORT + 1, and PORT=${input.port} is the top of the range — it would ask for ${input.port + 1}, which is not a TCP port`,
|
|
32
|
+
fix: `x dev --port ${neighbouringPort(input.port)} # leaves ${PORT_RANGE.max} free for the sync node`,
|
|
33
|
+
meta: { port: input.port },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The port the sync node listens on. `PORT + 1`, and `0` stays `0` — the kernel picks, and adding
|
|
40
|
+
* one to it would pick a specific port instead.
|
|
41
|
+
*
|
|
42
|
+
* REFUSED at the top of the range, never clamped. `PORT_RANGE.max` is 65535 and `portValue`
|
|
43
|
+
* accepts it, so `x dev --port 65535` handed `Bun.serve` 65536 and the bare `RangeError` reached
|
|
44
|
+
* the terminal as `X_CLI_UNEXPECTED` with `fix: x doctor --json`. Clamping to 65534 would be worse
|
|
45
|
+
* than refusing: `PORT + 1` is the rule `docker/docker-compose.prod.yml` publishes `3001:3001`
|
|
46
|
+
* from and `docker/helm` derives `PORT = .port - 1` from, so a node quietly on `PORT - 1` is a
|
|
47
|
+
* socket nothing else in the deployment computes.
|
|
48
|
+
*/
|
|
49
|
+
export function syncPortFor(port: number): number {
|
|
50
|
+
if (port === 0) return 0;
|
|
51
|
+
if (port >= PORT_RANGE.max) throw new SyncPortUnavailableError({ port });
|
|
52
|
+
return port + 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
20
55
|
/** What `startRoles` holds on to: where the node listens, and how to take it down. */
|
|
21
56
|
export interface RunningSync {
|
|
22
57
|
readonly url: string;
|
|
@@ -97,7 +132,7 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
97
132
|
});
|
|
98
133
|
await node.start();
|
|
99
134
|
try {
|
|
100
|
-
const listener = listenSyncNode(node, { port: options.port
|
|
135
|
+
const listener = listenSyncNode(node, { port: syncPortFor(options.port) });
|
|
101
136
|
return {
|
|
102
137
|
url: listener.url,
|
|
103
138
|
stop: async () => {
|
package/src/dispatch.ts
CHANGED
|
@@ -31,6 +31,13 @@ export interface DispatchOptions {
|
|
|
31
31
|
* it cannot produce. `bin.ts` passes the real fd 2.
|
|
32
32
|
*/
|
|
33
33
|
readonly writeError?: (line: string) => void;
|
|
34
|
+
/**
|
|
35
|
+
* How the caller invoked this process, up to and including the subcommand — passed straight to
|
|
36
|
+
* `CommandContext.invocation` and read by the `fix:` lines that quote a whole command back.
|
|
37
|
+
* `create-ultimate` is the one caller that supplies it, because it is the one entry point whose
|
|
38
|
+
* name is not `x`.
|
|
39
|
+
*/
|
|
40
|
+
readonly invocation?: string;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
/**
|
|
@@ -122,6 +129,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
122
129
|
runner: options.runner ?? exec,
|
|
123
130
|
env: options.env,
|
|
124
131
|
bunVersion: options.bunVersion,
|
|
132
|
+
...(options.invocation === undefined ? {} : { invocation: options.invocation }),
|
|
125
133
|
};
|
|
126
134
|
|
|
127
135
|
try {
|
package/src/document-styles.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// browser drops every one of those declarations from, byte-for-byte identical to a working page
|
|
5
5
|
// apart from the styling nobody sees missing. A silent failure is exactly what axiom 3 exists for.
|
|
6
6
|
|
|
7
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
8
|
import type { Surface } from '@ultimat3/render';
|
|
8
9
|
import { routeEntries } from '@ultimat3/render';
|
|
9
10
|
import { stylesFor } from '@ultimat3/render/server';
|
|
@@ -49,7 +50,7 @@ export function checkDocumentStyles(documents: readonly SurfaceDocument[]): read
|
|
|
49
50
|
code: 'X_STYLES_GLOBAL_MISSING',
|
|
50
51
|
cause: `a ${document.surface}/ document carries ${document.css.length} characters of CSS and defines no :root custom properties, so every var(--color-*) and var(--space-*) in it resolves to nothing`,
|
|
51
52
|
fix: `add apps/web/${APP_GLOBAL_STYLESHEET} containing \`@use '@ultimat3/ui/global.scss';\` and apps/web/${APP_GLOBAL_MODULE} containing \`import './global.scss';\``,
|
|
52
|
-
docs:
|
|
53
|
+
docs: ERROR_DOCS_URL,
|
|
53
54
|
at: `apps/web/${APP_GLOBAL_STYLESHEET}`,
|
|
54
55
|
}));
|
|
55
56
|
}
|
package/src/drift.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
14
15
|
import { describeEntities } from '@ultimat3/entity';
|
|
15
16
|
import { countDeclaredEntities } from './app-entities';
|
|
16
17
|
import { loadApp } from './app-load';
|
|
@@ -181,7 +182,7 @@ export async function checkSourceDrift(
|
|
|
181
182
|
code: 'X_DB_DRIFT',
|
|
182
183
|
cause: 'packages/db has a schema but no migration recorded it',
|
|
183
184
|
fix: 'x db gen "initial"',
|
|
184
|
-
docs:
|
|
185
|
+
docs: ERROR_DOCS_URL,
|
|
185
186
|
at: MIGRATIONS_DIR,
|
|
186
187
|
},
|
|
187
188
|
];
|
|
@@ -192,7 +193,7 @@ export async function checkSourceDrift(
|
|
|
192
193
|
code: 'X_DB_DRIFT',
|
|
193
194
|
cause: `schema hashes to ${current}, newest migration ${latest.file} recorded ${latest.hash}`,
|
|
194
195
|
fix: 'x db gen "describe the change"',
|
|
195
|
-
docs:
|
|
196
|
+
docs: ERROR_DOCS_URL,
|
|
196
197
|
at: `${DB_PACKAGE}/src`,
|
|
197
198
|
},
|
|
198
199
|
];
|
package/src/error-catalog.ts
CHANGED
|
@@ -44,12 +44,22 @@ export const CATALOG_PACKAGES = [
|
|
|
44
44
|
'@ultimat3/ui',
|
|
45
45
|
] as const;
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The two packages the catalog may import WITHOUT `@ultimat3/cli` declaring them: they reach for a
|
|
49
|
+
* JSX runtime an app has and a bare CLI process does not, so a hard dependency would make the CLI
|
|
50
|
+
* uninstallable where the codes are merely absent today. Every other entry above is a real runtime
|
|
51
|
+
* import and must be a declared dependency — `error-catalog.test.ts` holds the list to exactly that,
|
|
52
|
+
* because an undeclared one resolves through workspace symlinks here and through nothing in an
|
|
53
|
+
* installed app, where `x errors explain X_FLAG_EXPIRED` then refuses a code the wiki promises.
|
|
54
|
+
*/
|
|
55
|
+
export const CATALOG_OPTIONAL_HOSTS: readonly string[] = ['@ultimat3/admin', '@ultimat3/ui'];
|
|
56
|
+
|
|
47
57
|
export interface ErrorCatalog {
|
|
48
58
|
/** Packages whose codes are now registered. */
|
|
49
59
|
readonly loaded: readonly string[];
|
|
50
60
|
/**
|
|
51
61
|
* Packages this process could not *resolve*, so their codes are absent from the answer. The one
|
|
52
|
-
* tolerated case is the optional host:
|
|
62
|
+
* tolerated case is the optional host: `CATALOG_OPTIONAL_HOSTS` reach for a JSX
|
|
53
63
|
* runtime an app has and a bare CLI process does not, and a list silently missing their codes is
|
|
54
64
|
* worse than one that says which packages are missing. A package that resolved and then threw is
|
|
55
65
|
* a defect, not a host gap, and goes to `failed`.
|