@ultimat3/cli 25.2.0 → 26.1.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/README.md +3 -3
- package/package.json +28 -28
- package/src/app-env.ts +23 -8
- package/src/budgets.ts +2 -1
- package/src/cmd-db-branch.ts +15 -11
- package/src/cmd-db-spec.ts +2 -2
- package/src/cmd-deploy-drain.ts +6 -1
- package/src/cmd-docs.ts +1 -1
- package/src/cmd-env.ts +4 -4
- package/src/cmd-jobs-spec.ts +11 -7
- package/src/cmd-jobs.ts +10 -10
- package/src/cmd-planned.ts +3 -3
- package/src/cmd-registries-spec.ts +6 -6
- package/src/cmd-registries.ts +6 -6
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-vapid-spec.ts +17 -0
- package/src/cmd-vapid.ts +135 -0
- package/src/db-branch.ts +6 -6
- package/src/db-finding.ts +1 -1
- package/src/dev-restart-retire.ts +3 -1
- package/src/dev-route-table.ts +4 -0
- package/src/document-styles.ts +2 -4
- package/src/error-contract.ts +1 -1
- package/src/errors.ts +4 -4
- package/src/fix-command.ts +1 -1
- package/src/framework-env.ts +92 -15
- package/src/framework-schema.ts +12 -0
- package/src/jobs-report.ts +3 -3
- package/src/jobs-table.ts +1 -1
- package/src/live-routes.ts +0 -1
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +2 -2
- package/src/messages.ts +11 -3
- package/src/page-navigation.ts +6 -1
- package/src/parse.ts +23 -2
- package/src/pwa-artifacts.ts +2 -2
- package/src/registry.ts +2 -0
- package/src/role-replicator.ts +1 -1
- package/src/runtime-jobs.ts +1 -1
- package/src/runtime-push.ts +80 -0
- package/src/runtime-services.ts +7 -0
- package/src/serve-boot.ts +8 -2
- package/src/serve-drain.ts +5 -1
- package/src/serve-retire.ts +7 -3
- package/src/serve-web.ts +4 -0
- package/src/site-seo.ts +1 -4
- package/src/sw-artifacts.ts +30 -13
- package/src/sync-url.ts +9 -0
- package/src/templates/scaffold-claude-agents.ts +4 -4
- package/src/templates/scaffold-claude-commands.ts +2 -2
- package/src/templates/scaffold-claude.ts +1 -1
- package/src/templates/scaffold-helm-templates.ts +6 -2
- package/src/templates/scaffold-helm.ts +4 -1
- package/src/worker-bundle.ts +21 -8
package/src/cmd-vapid.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// `x vapid` — the Web Push key pair (`pwa.push`). `create` mints a P-256 pair on WebCrypto and
|
|
2
|
+
// seals BOTH halves into `secrets.enc.json` in one write, so the two environment variables the
|
|
3
|
+
// boot reads can never come from different pairs; `show` names which pair this app signs with.
|
|
4
|
+
// The private key is never printed, in either renderer — `x secrets`' rule.
|
|
5
|
+
|
|
6
|
+
import type { MasterKeyRef, SecretValues } from '@ultimat3/core';
|
|
7
|
+
import {
|
|
8
|
+
isUltimateError,
|
|
9
|
+
readSecretsFile,
|
|
10
|
+
requireMasterKey,
|
|
11
|
+
SECRETS_FILE,
|
|
12
|
+
writeSecretsFile,
|
|
13
|
+
} from '@ultimat3/core';
|
|
14
|
+
import {
|
|
15
|
+
DEV_VAPID_KEYS,
|
|
16
|
+
generateVapidKeys,
|
|
17
|
+
VAPID_PRIVATE_KEY_ENV,
|
|
18
|
+
VAPID_PUBLIC_KEY_ENV,
|
|
19
|
+
} from '@ultimat3/pwa';
|
|
20
|
+
import { requireAppRoot } from './app-root';
|
|
21
|
+
import { SecretsExistsError } from './cmd-secrets';
|
|
22
|
+
import { vapidSpec } from './cmd-vapid-spec';
|
|
23
|
+
import type { CliCommand, CommandContext } from './command';
|
|
24
|
+
import { msg } from './messages';
|
|
25
|
+
import type { CommandResult } from './output';
|
|
26
|
+
import { recoverRotation } from './secrets-rotation';
|
|
27
|
+
|
|
28
|
+
/** The app's master key, as `x secrets` opens it — after finishing a rotation a crash interrupted. */
|
|
29
|
+
const secretsKeyFor = (root: string, env: CommandContext['env']): Promise<MasterKeyRef> =>
|
|
30
|
+
recoverRotation(root, requireMasterKey(root, env));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Both halves into the sealed file, or neither. A pair already there is refused: rotating a VAPID
|
|
34
|
+
* key unsubscribes every browser that subscribed with the old one, so it is a decision taken in
|
|
35
|
+
* `x secrets edit` (delete both lines, then create), never a side effect of re-running a command.
|
|
36
|
+
*/
|
|
37
|
+
async function create(ctx: CommandContext): Promise<CommandResult> {
|
|
38
|
+
const root = requireAppRoot('vapid create', ctx.cwd).dir;
|
|
39
|
+
const key = await secretsKeyFor(root, ctx.env);
|
|
40
|
+
const before: SecretValues = await readSecretsFile(root, key);
|
|
41
|
+
if (before[VAPID_PUBLIC_KEY_ENV] !== undefined || before[VAPID_PRIVATE_KEY_ENV] !== undefined) {
|
|
42
|
+
throw new SecretsExistsError({
|
|
43
|
+
path: `${SECRETS_FILE} (${VAPID_PUBLIC_KEY_ENV}, ${VAPID_PRIVATE_KEY_ENV})`,
|
|
44
|
+
fix: `x secrets edit # delete both ${VAPID_PUBLIC_KEY_ENV} and ${VAPID_PRIVATE_KEY_ENV} first — every subscriber subscribes again after a new pair`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const pair = await generateVapidKeys();
|
|
48
|
+
await writeSecretsFile(
|
|
49
|
+
root,
|
|
50
|
+
{
|
|
51
|
+
...before,
|
|
52
|
+
[VAPID_PUBLIC_KEY_ENV]: pair.publicKey,
|
|
53
|
+
[VAPID_PRIVATE_KEY_ENV]: pair.privateKey,
|
|
54
|
+
},
|
|
55
|
+
key,
|
|
56
|
+
);
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
command: 'vapid',
|
|
60
|
+
summary: msg('cli.vapid.created', { path: SECRETS_FILE }),
|
|
61
|
+
lines: [msg('cli.vapid.public', { key: pair.publicKey })],
|
|
62
|
+
data: {
|
|
63
|
+
path: SECRETS_FILE,
|
|
64
|
+
publicKey: pair.publicKey,
|
|
65
|
+
sealed: [VAPID_PUBLIC_KEY_ENV, VAPID_PRIVATE_KEY_ENV],
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Source = 'environment' | 'sealed' | 'development';
|
|
71
|
+
|
|
72
|
+
/** One literal key per source, so the catalog's every key is a string a reader can grep for. */
|
|
73
|
+
const SOURCE_SUMMARY = {
|
|
74
|
+
environment: 'cli.vapid.source.environment',
|
|
75
|
+
sealed: 'cli.vapid.source.sealed',
|
|
76
|
+
development: 'cli.vapid.source.development',
|
|
77
|
+
} as const satisfies Record<Source, string>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The pair the boot would resolve, in its order: the environment (a deploy's, or `installSecrets()`
|
|
81
|
+
* having copied the sealed file into it), then the sealed file read directly, then the published
|
|
82
|
+
* development pair a local process falls back to.
|
|
83
|
+
*/
|
|
84
|
+
async function show(ctx: CommandContext): Promise<CommandResult> {
|
|
85
|
+
const root = requireAppRoot('vapid show', ctx.cwd).dir;
|
|
86
|
+
const fromEnv = ctx.env[VAPID_PUBLIC_KEY_ENV]?.trim();
|
|
87
|
+
let source: Source = 'development';
|
|
88
|
+
let publicKey = DEV_VAPID_KEYS.publicKey;
|
|
89
|
+
let privateSet = false;
|
|
90
|
+
if (fromEnv !== undefined && fromEnv !== '') {
|
|
91
|
+
source = 'environment';
|
|
92
|
+
publicKey = fromEnv;
|
|
93
|
+
privateSet = (ctx.env[VAPID_PRIVATE_KEY_ENV] ?? '').trim() !== '';
|
|
94
|
+
} else {
|
|
95
|
+
const sealed = await readSealed(root, ctx);
|
|
96
|
+
const sealedKey = sealed?.[VAPID_PUBLIC_KEY_ENV];
|
|
97
|
+
if (sealed !== undefined && sealedKey !== undefined) {
|
|
98
|
+
source = 'sealed';
|
|
99
|
+
publicKey = sealedKey;
|
|
100
|
+
privateSet = sealed[VAPID_PRIVATE_KEY_ENV] !== undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
command: 'vapid',
|
|
106
|
+
summary: msg(SOURCE_SUMMARY[source], { path: SECRETS_FILE }),
|
|
107
|
+
lines: [msg('cli.vapid.public', { key: publicKey })],
|
|
108
|
+
data: { source, publicKey, privateKey: privateSet || source === 'development' },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Absent, not broken: no secrets file, or no key to open one — the development pair is in force. */
|
|
113
|
+
const ABSENT: ReadonlySet<string> = new Set(['X_SECRETS_FILE_MISSING', 'X_SECRETS_KEY_MISSING']);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The sealed values, or `undefined` when this checkout has no secrets file or no key to open it.
|
|
117
|
+
* Every other refusal — a tampered file, a key for another file, a file that is not one, a rotation
|
|
118
|
+
* that cannot be finished — is thrown with its own fix: a sealed pair is THERE but unreadable, and
|
|
119
|
+
* reporting `development` would send the reader to `x vapid create`, which refuses the pair.
|
|
120
|
+
*/
|
|
121
|
+
async function readSealed(root: string, ctx: CommandContext): Promise<SecretValues | undefined> {
|
|
122
|
+
try {
|
|
123
|
+
return await readSecretsFile(root, await secretsKeyFor(root, ctx.env));
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (isUltimateError(error) && ABSENT.has(error.code)) return undefined;
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const vapidCommand: CliCommand = {
|
|
131
|
+
spec: vapidSpec,
|
|
132
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
133
|
+
return (ctx.args.subcommand ?? 'show') === 'create' ? create(ctx) : show(ctx);
|
|
134
|
+
},
|
|
135
|
+
};
|
package/src/db-branch.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
* `reap` is deliberately absent: a nightly sweep is a `task` (`reapBranches` from `@ultimat3/db`),
|
|
28
28
|
* and a CLI verb for it would be a second path to one job with a max-age nobody can default.
|
|
29
29
|
*/
|
|
30
|
-
export const BRANCH_SUBCOMMANDS = ['
|
|
30
|
+
export const BRANCH_SUBCOMMANDS = ['list', 'create', 'delete'] as const;
|
|
31
31
|
|
|
32
32
|
export type BranchSubcommand = (typeof BRANCH_SUBCOMMANDS)[number];
|
|
33
33
|
|
|
@@ -91,7 +91,7 @@ export function branchDatabaseName(source: string, branch: string): string {
|
|
|
91
91
|
* `db.migrate` is aimed at a private database from a URL alone, with no connection to ask
|
|
92
92
|
* `current_database()` — so "a branch of somebody" is the only question it can pose, and the
|
|
93
93
|
* answer it wants for `analytics_branch_feat` is still "not the shared database". Anything holding
|
|
94
|
-
* a client asks `branchNameIn` instead, which is the question `
|
|
94
|
+
* a client asks `branchNameIn` instead, which is the question `list` and `delete` need.
|
|
95
95
|
*/
|
|
96
96
|
export const branchNameOf = (database: string): string | null =>
|
|
97
97
|
/_branch_(.+)$/.exec(database)?.[1] ?? null;
|
|
@@ -112,7 +112,7 @@ export function branchNameIn(source: string, database: string): string | null {
|
|
|
112
112
|
/**
|
|
113
113
|
* The embedded peer: `branchPglite` copies `<dir>` to `<dir>-<name>`, so the branch name is the
|
|
114
114
|
* suffix. `pgliteBranchDir` is the forward rule and this is its inverse — written once, because
|
|
115
|
-
* `x db branch
|
|
115
|
+
* `x db branch list` and the MCP host's branch check must agree about what a branch directory is.
|
|
116
116
|
*/
|
|
117
117
|
export function pgliteBranchName(dir: string, source: string): string | null {
|
|
118
118
|
return dir.startsWith(`${source}-`) ? dir.slice(source.length + 1) : null;
|
|
@@ -213,7 +213,7 @@ async function branchesOf(client: DbClient, source: string): Promise<readonly Br
|
|
|
213
213
|
/**
|
|
214
214
|
* Only databases carrying `createBranch`'s own marker comment, and only branches of the database
|
|
215
215
|
* this session is connected to. A database this listing does not name is one `drop` may not touch,
|
|
216
|
-
* which is what makes "you may only
|
|
216
|
+
* which is what makes "you may only delete what `list` shows" a guard rather than a courtesy — and a
|
|
217
217
|
* row belonging to another app on the same server made that guard answer for a database it had
|
|
218
218
|
* never seen.
|
|
219
219
|
*/
|
|
@@ -224,7 +224,7 @@ export async function listExternalBranches(client: DbClient): Promise<readonly B
|
|
|
224
224
|
/**
|
|
225
225
|
* Through `createBranch`, never a hand-written `CREATE DATABASE`: it validates the name, refuses a
|
|
226
226
|
* database that already exists with `X_BRANCH_EXISTS`, and writes the marker comment that makes
|
|
227
|
-
* the clone visible to `
|
|
227
|
+
* the clone visible to `list`. The CLI shelled out to `psql` until now and wrote no marker at all,
|
|
228
228
|
* so every branch it made was invisible to the only lister the framework has.
|
|
229
229
|
*/
|
|
230
230
|
export async function createExternalBranch(client: DbClient, branch: string): Promise<BranchRow> {
|
|
@@ -232,7 +232,7 @@ export async function createExternalBranch(client: DbClient, branch: string): Pr
|
|
|
232
232
|
const database = branchDatabaseName(source, branch);
|
|
233
233
|
const info = await createBranch(database, { client, base: source });
|
|
234
234
|
return {
|
|
235
|
-
// The name `
|
|
235
|
+
// The name `list` will show for it, derived the way `list` derives one — a create that reported a
|
|
236
236
|
// name the listing then spells differently is a `drop` the caller has to guess at.
|
|
237
237
|
name: branchNameIn(source, database) ?? database,
|
|
238
238
|
location: database,
|
package/src/db-finding.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { findingFrom, isUltimateErrorShape } from './output';
|
|
|
10
10
|
/**
|
|
11
11
|
* The engine names its own failures — `X_MIGRATION_CONFLICT` carries the ledger row that disagrees,
|
|
12
12
|
* `X_MIGRATION_IRREVERSIBLE` carries the exact `--allow-destructive` line to rerun, and
|
|
13
|
-
* `X_BRANCH_EXISTS` carries the `x db branch
|
|
13
|
+
* `X_BRANCH_EXISTS` carries the `x db branch delete` that clears it — so those reach the caller
|
|
14
14
|
* verbatim. `X_DB_GEN_FAILED` / `X_DB_MIGRATE_FAILED` / `X_DB_BRANCH_FAILED` are what is left: the
|
|
15
15
|
* step failed for a reason no framework error claimed, and the raw message is all there is.
|
|
16
16
|
*/
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// login) ran twice. The worker is retired first — `stop()` claims nothing and aborts nothing — and
|
|
5
5
|
// only then is the drain asked to release the port, the lock and the embedded database.
|
|
6
6
|
|
|
7
|
-
import { drain, logger, renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import { drain, logger, markRetiring, renderThrowable } from '@ultimat3/core';
|
|
8
8
|
import type { Worker } from '@ultimat3/jobs';
|
|
9
9
|
import { msg } from './messages';
|
|
10
10
|
import { writeErrorLine } from './write-line';
|
|
@@ -32,6 +32,8 @@ export async function retireThenDrain(
|
|
|
32
32
|
worker: RetiringWorker | null,
|
|
33
33
|
deps: RetireDeps = DEFAULT_DEPS,
|
|
34
34
|
): Promise<void> {
|
|
35
|
+
// The same retire the production SIGUSR2 is (`serve-retire.ts`), so the app reads it the same way.
|
|
36
|
+
markRetiring();
|
|
35
37
|
try {
|
|
36
38
|
if (worker !== null) {
|
|
37
39
|
const { inFlight } = await worker.stats();
|
package/src/dev-route-table.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { RealtimeConfig } from '@ultimat3/core';
|
|
6
6
|
import type { RateLimitStore, Route } from '@ultimat3/http';
|
|
7
7
|
import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
8
|
+
import { installedVapid } from '@ultimat3/pwa';
|
|
8
9
|
import { describePages } from '@ultimat3/render';
|
|
9
10
|
import type { IsrController } from '@ultimat3/render/server';
|
|
10
11
|
import type { ImageTransformDriver } from '@ultimat3/seo';
|
|
@@ -122,6 +123,9 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
122
123
|
...sync.scripts,
|
|
123
124
|
...(navigation.script === undefined ? [] : [navigation.script]),
|
|
124
125
|
],
|
|
126
|
+
// The runtime `startServices` installed when `pwa.push` is on: the worker's push
|
|
127
|
+
// handler and the page's `x-push-key` come from the pair this process signs with.
|
|
128
|
+
vapid: installedVapid(),
|
|
125
129
|
});
|
|
126
130
|
|
|
127
131
|
// The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
|
package/src/document-styles.ts
CHANGED
|
@@ -30,14 +30,12 @@ export interface SurfaceDocument {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Every surface that renders a document, with the CSS one would carry
|
|
34
|
-
*
|
|
35
|
-
* tokens. Read from render's own registry, filled when the CLI loaded the app: a second walk of
|
|
33
|
+
* Every surface that renders a document, with the CSS one would carry — a route lives on `site/`
|
|
34
|
+
* or `app/` only, so `api/` (no document) never appears. Read from render's own registry, filled when the CLI loaded the app: a second walk of
|
|
36
35
|
* the app's stylesheets here would be a second answer to "what does this document contain".
|
|
37
36
|
*/
|
|
38
37
|
export function documentSurfaces(): readonly SurfaceDocument[] {
|
|
39
38
|
const surfaces = new Set(routeEntries().map((entry) => entry.surface));
|
|
40
|
-
surfaces.delete('api');
|
|
41
39
|
return [...surfaces]
|
|
42
40
|
.sort()
|
|
43
41
|
.map((surface) => ({ surface, css: stylesFor(surface) }) satisfies SurfaceDocument);
|
package/src/error-contract.ts
CHANGED
|
@@ -32,7 +32,7 @@ export const BANNED_PHRASES: readonly RegExp[] = [
|
|
|
32
32
|
/**
|
|
33
33
|
* What makes a fix actionable as written: the `x` CLI, a tool the machine already has, a call the
|
|
34
34
|
* reader can paste, or a file they can open. A banned phrase is only a failure without one of
|
|
35
|
-
* these — "check the gateway, then: x actions
|
|
35
|
+
* these — "check the gateway, then: x actions show posts.publish --json" names the observation
|
|
36
36
|
* *and* the command, which is the shape the contract wants.
|
|
37
37
|
*/
|
|
38
38
|
export const COMMAND_TOKENS: readonly RegExp[] = [
|
package/src/errors.ts
CHANGED
|
@@ -248,7 +248,7 @@ export class ErrorCodeUnknownError extends UltimateError {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
/**
|
|
251
|
-
* `x actions|queries|entities
|
|
251
|
+
* `x actions|queries|entities|tasks show <name>` named a declaration the registries do not hold —
|
|
252
252
|
* a typo, or a module that never imported. `known` is the count, not the list: a 200-action app
|
|
253
253
|
* would bury the fix line under names nobody asked for, and `list` is one command away.
|
|
254
254
|
*/
|
|
@@ -259,7 +259,7 @@ export class DeclarationUnknownError extends UltimateError {
|
|
|
259
259
|
name: string;
|
|
260
260
|
known: readonly string[];
|
|
261
261
|
suggestion?: string;
|
|
262
|
-
/** The subcommand that takes one name
|
|
262
|
+
/** The subcommand that takes one name: `show` (#709), or `explain` for `x policy` — the "why". */
|
|
263
263
|
verb?: string;
|
|
264
264
|
}) {
|
|
265
265
|
super({
|
|
@@ -268,7 +268,7 @@ export class DeclarationUnknownError extends UltimateError {
|
|
|
268
268
|
fix:
|
|
269
269
|
input.suggestion === undefined
|
|
270
270
|
? `x ${input.kind} list --json`
|
|
271
|
-
: `x ${input.kind} ${input.verb ?? '
|
|
271
|
+
: `x ${input.kind} ${input.verb ?? 'show'} ${input.suggestion}`,
|
|
272
272
|
});
|
|
273
273
|
}
|
|
274
274
|
}
|
|
@@ -279,7 +279,7 @@ export class JobUnknownError extends UltimateError {
|
|
|
279
279
|
super({
|
|
280
280
|
code: 'X_JOB_UNKNOWN',
|
|
281
281
|
cause: `the "${input.driver}" queue holds no job with id "${input.id}"`,
|
|
282
|
-
fix: 'x jobs
|
|
282
|
+
fix: 'x jobs list --json',
|
|
283
283
|
});
|
|
284
284
|
}
|
|
285
285
|
}
|
package/src/fix-command.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { GLOBAL_FLAGS } from './parse';
|
|
|
27
27
|
// slot where the reader has nothing to substitute, so `x db branch <name>` — two shipped fix lines
|
|
28
28
|
// in `@ultimat3/mcp` — is `X_CLI_UNKNOWN_COMMAND` when run and resolved clean while a placeholder
|
|
29
29
|
// was invisible to the reader. Second and fourth slots are open positionals (`x new my-app`,
|
|
30
|
-
// `x db branch
|
|
30
|
+
// `x db branch delete <name>`), where a placeholder is exactly right.
|
|
31
31
|
// A `:` is part of a word only when a letter follows it, which is what separates the shipped
|
|
32
32
|
// positional `admin:page` from prose that ends a citation with a colon (`x verify: the gate`).
|
|
33
33
|
// Read without it, `x g admin:page` cites `x g admin` — a positional the CLI does not ship —
|
package/src/framework-env.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
tryResolveEnvironment,
|
|
15
15
|
usesDevCursorSecret,
|
|
16
16
|
} from '@ultimat3/core';
|
|
17
|
+
import { usesDevVapidKeys, VAPID_PRIVATE_KEY_ENV, VAPID_PUBLIC_KEY_ENV } from '@ultimat3/pwa';
|
|
17
18
|
import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
|
|
18
19
|
import type { Finding } from './output';
|
|
19
20
|
import { storageIsExternal } from './runtime-bindings';
|
|
@@ -42,11 +43,24 @@ export const namedDeployed = (env: EnvTable): boolean => {
|
|
|
42
43
|
return environment === 'staging' || environment === 'production';
|
|
43
44
|
};
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* What the app's `app.config.ts` decides about which framework keys it owes — read by the caller,
|
|
48
|
+
* which has the root (`appSecretFacts`). `x doctor` asks of no config and passes `NO_APP_FACTS`.
|
|
49
|
+
*/
|
|
50
|
+
export interface AppSecretFacts {
|
|
51
|
+
/** `pwa.enabled && pwa.push`: the boot resolves a VAPID pair and refuses without one. */
|
|
52
|
+
readonly push: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const NO_APP_FACTS: AppSecretFacts = Object.freeze({ push: false });
|
|
56
|
+
|
|
45
57
|
/** One framework secret: what refuses without it, when it is needed, and the command that mints one. */
|
|
46
58
|
export interface FrameworkSecret {
|
|
47
59
|
readonly key: string;
|
|
48
60
|
/** The diagnostic code `x doctor` and `x env check` report it under. */
|
|
49
|
-
readonly code: 'X_CURSOR_SECRET_DEV' | 'X_STORAGE_SECRET_DEV';
|
|
61
|
+
readonly code: 'X_CURSOR_SECRET_DEV' | 'X_STORAGE_SECRET_DEV' | 'X_PWA_VAPID_KEY_MISSING';
|
|
62
|
+
/** False for the one key here that is public (the VAPID public key). Default true. */
|
|
63
|
+
readonly secret?: boolean;
|
|
50
64
|
/** Why the framework needs it, one line, rendered into `.env.example`. */
|
|
51
65
|
readonly why: string;
|
|
52
66
|
/** When the key is needed at all, if not always — rendered beside `why`. */
|
|
@@ -54,8 +68,14 @@ export interface FrameworkSecret {
|
|
|
54
68
|
readonly cause: string;
|
|
55
69
|
/** Pasted into a shell, so it is a literal command and never a paraphrase. */
|
|
56
70
|
readonly fix: string;
|
|
57
|
-
/**
|
|
58
|
-
|
|
71
|
+
/**
|
|
72
|
+
* What `app.config.ts` must say for the key to be owed AT ALL — the VAPID pair, only with
|
|
73
|
+
* `pwa.push`. Absent: always. The ONE rule the example, the drift gate and `x env check` read
|
|
74
|
+
* (`configOwes`), so a second config-gated key is one line here, never two filters elsewhere.
|
|
75
|
+
*/
|
|
76
|
+
readonly owedWhen?: (app: AppSecretFacts) => boolean;
|
|
77
|
+
/** False when this deploy's shape never reads the key (object storage configured, push off). */
|
|
78
|
+
needed(env: EnvTable, app: AppSecretFacts): boolean;
|
|
59
79
|
/** True while the table would leave the process signing with the shipped development key. */
|
|
60
80
|
unsafe(env: EnvTable): boolean;
|
|
61
81
|
}
|
|
@@ -87,17 +107,50 @@ export const FRAMEWORK_SECRETS: readonly FrameworkSecret[] = [
|
|
|
87
107
|
needed: (env) => !storageIsExternal(env),
|
|
88
108
|
unsafe: (env) => usesDevStorageSecret({ env }),
|
|
89
109
|
},
|
|
110
|
+
// The VAPID pair, owed only once `pwa.push` is on. Both halves are env (never config) and are
|
|
111
|
+
// sealed together by `x vapid create`, so the fix is that one command for either half.
|
|
112
|
+
{
|
|
113
|
+
key: VAPID_PUBLIC_KEY_ENV,
|
|
114
|
+
code: 'X_PWA_VAPID_KEY_MISSING',
|
|
115
|
+
secret: false,
|
|
116
|
+
why: 'Web Push public key, written into every page as <meta name="x-push-key">. Generate the pair with `x vapid create`.',
|
|
117
|
+
condition: 'Read only when pwa.push is true.',
|
|
118
|
+
cause: `${VAPID_PUBLIC_KEY_ENV} is unset, so a push-enabled deploy refuses to boot (X_PWA_VAPID_KEY_MISSING)`,
|
|
119
|
+
fix: 'x vapid create',
|
|
120
|
+
owedWhen: (app) => app.push,
|
|
121
|
+
needed: (_env, app) => app.push,
|
|
122
|
+
unsafe: (env) => (env[VAPID_PUBLIC_KEY_ENV] ?? '').trim() === '',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
key: VAPID_PRIVATE_KEY_ENV,
|
|
126
|
+
code: 'X_PWA_VAPID_KEY_MISSING',
|
|
127
|
+
why: 'Signs every Web Push request (RFC 8292). Generate the pair with `x vapid create`.',
|
|
128
|
+
condition: 'Read only when pwa.push is true.',
|
|
129
|
+
cause: `${VAPID_PRIVATE_KEY_ENV} is unset or holds the published development key, so a push-enabled deploy refuses to boot`,
|
|
130
|
+
fix: 'x vapid create',
|
|
131
|
+
owedWhen: (app) => app.push,
|
|
132
|
+
needed: (_env, app) => app.push,
|
|
133
|
+
unsafe: (env) => usesDevVapidKeys(env),
|
|
134
|
+
},
|
|
90
135
|
];
|
|
91
136
|
|
|
137
|
+
/** Whether this app's config owes `secret` at all — `owedWhen`, else always. */
|
|
138
|
+
export const configOwes = (secret: FrameworkSecret, app: AppSecretFacts): boolean =>
|
|
139
|
+
secret.owedWhen?.(app) ?? true;
|
|
140
|
+
|
|
92
141
|
/** The doctor/check finding for one secret — one wording per condition, wherever it is reported. */
|
|
93
142
|
export function frameworkSecretFinding(secret: FrameworkSecret): Finding {
|
|
94
143
|
return { code: secret.code, cause: secret.cause, fix: secret.fix, docs: ERROR_DOCS_URL };
|
|
95
144
|
}
|
|
96
145
|
|
|
97
146
|
/** Every secret this table's deploy shape needs and lacks, ungated. */
|
|
98
|
-
const owedFindings = (
|
|
147
|
+
const owedFindings = (
|
|
148
|
+
env: EnvTable,
|
|
149
|
+
skip: ReadonlySet<string>,
|
|
150
|
+
app: AppSecretFacts,
|
|
151
|
+
): readonly Finding[] =>
|
|
99
152
|
FRAMEWORK_SECRETS.filter(
|
|
100
|
-
(secret) => !skip.has(secret.key) && secret.needed(env) && secret.unsafe(env),
|
|
153
|
+
(secret) => !skip.has(secret.key) && secret.needed(env, app) && secret.unsafe(env),
|
|
101
154
|
).map((secret) => ({ ...frameworkSecretFinding(secret), at: ENV_EXAMPLE_PATH }));
|
|
102
155
|
|
|
103
156
|
/**
|
|
@@ -109,13 +162,17 @@ const owedFindings = (env: EnvTable, skip: ReadonlySet<string>): readonly Findin
|
|
|
109
162
|
export function frameworkSecretFindings(
|
|
110
163
|
env: EnvTable,
|
|
111
164
|
skip: ReadonlySet<string> = new Set(),
|
|
165
|
+
app: AppSecretFacts = NO_APP_FACTS,
|
|
112
166
|
): readonly Finding[] {
|
|
113
|
-
return deployed(env) ? owedFindings(env, skip) : [];
|
|
167
|
+
return deployed(env) ? owedFindings(env, skip, app) : [];
|
|
114
168
|
}
|
|
115
169
|
|
|
116
170
|
/** `x doctor`'s: the same list and wording, gated by `namedDeployed` — this machine, as named. */
|
|
117
|
-
export function machineSecretFindings(
|
|
118
|
-
|
|
171
|
+
export function machineSecretFindings(
|
|
172
|
+
env: EnvTable,
|
|
173
|
+
app: AppSecretFacts = NO_APP_FACTS,
|
|
174
|
+
): readonly Finding[] {
|
|
175
|
+
return namedDeployed(env) ? owedFindings(env, new Set(), app) : [];
|
|
119
176
|
}
|
|
120
177
|
|
|
121
178
|
/**
|
|
@@ -123,19 +180,39 @@ export function machineSecretFindings(env: EnvTable): readonly Finding[] {
|
|
|
123
180
|
* declares is skipped: one key, one line, and the app's declaration is the one it validates.
|
|
124
181
|
* Blank values only — these are secrets, and this file is committed.
|
|
125
182
|
*/
|
|
126
|
-
function frameworkSection(schema: EnvSchema): string {
|
|
127
|
-
|
|
183
|
+
function frameworkSection(schema: EnvSchema, app: AppSecretFacts): string {
|
|
184
|
+
// `needed` against an empty table: the example names what a deploy of THIS app owes, so the
|
|
185
|
+
// config decides (push on or off) and no environment does — object storage is a deploy's choice,
|
|
186
|
+
// and the disk secret stays listed with its condition.
|
|
187
|
+
const owed = FRAMEWORK_SECRETS.filter(
|
|
188
|
+
(secret) => !Object.hasOwn(schema, secret.key) && configOwes(secret, app),
|
|
189
|
+
);
|
|
128
190
|
if (owed.length === 0) return '';
|
|
191
|
+
// Each key names its own generator: `openssl` mints a hex secret, and a VAPID pair minted that
|
|
192
|
+
// way is one the boot refuses — only `x vapid create` makes a P-256 pair. `x doctor` asks of no
|
|
193
|
+
// config, so it never reports the pair; `x env check` reports every one.
|
|
194
|
+
const pair = owed.some((secret) => secret.owedWhen !== undefined);
|
|
129
195
|
const lines = [
|
|
130
196
|
'',
|
|
131
197
|
'# --- Framework ---------------------------------------------------------------',
|
|
132
|
-
|
|
133
|
-
|
|
198
|
+
...(pair
|
|
199
|
+
? [
|
|
200
|
+
'# Required outside development/test, whatever envSchema declares. Generate the hex',
|
|
201
|
+
'# secrets with `openssl rand -hex 32` and the VAPID pair with `x vapid create`;',
|
|
202
|
+
'# `x env check` reports one that is missing.',
|
|
203
|
+
]
|
|
204
|
+
: [
|
|
205
|
+
'# Required outside development/test, whatever envSchema declares. Generate each with',
|
|
206
|
+
'# `openssl rand -hex 32`; `x env check` and `x doctor` report one that is missing.',
|
|
207
|
+
]),
|
|
134
208
|
];
|
|
135
209
|
for (const secret of owed) {
|
|
136
210
|
lines.push('', `# ${secret.why}`);
|
|
137
211
|
if (secret.condition !== undefined) lines.push(`# ${secret.condition}`);
|
|
138
|
-
lines.push(
|
|
212
|
+
lines.push(
|
|
213
|
+
`# required when deployed · string${secret.secret === false ? '' : ' · secret'}`,
|
|
214
|
+
`${secret.key}=`,
|
|
215
|
+
);
|
|
139
216
|
}
|
|
140
217
|
return `${lines.join('\n')}\n`;
|
|
141
218
|
}
|
|
@@ -144,5 +221,5 @@ function frameworkSection(schema: EnvSchema): string {
|
|
|
144
221
|
* The bytes `.env.example` must hold: the app's declaration, then the framework's keys. The ONE
|
|
145
222
|
* renderer `x env example`, the drift gate and `x new` share, so the three cannot disagree.
|
|
146
223
|
*/
|
|
147
|
-
export const appEnvExample = (schema: EnvSchema): string =>
|
|
148
|
-
`${renderEnvExample(schema)}${frameworkSection(schema)}`;
|
|
224
|
+
export const appEnvExample = (schema: EnvSchema, app: AppSecretFacts = NO_APP_FACTS): string =>
|
|
225
|
+
`${renderEnvExample(schema)}${frameworkSection(schema, app)}`;
|
package/src/framework-schema.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
SQL_NOTIFY_DIGESTS_TABLE,
|
|
18
18
|
SQL_NOTIFY_INBOX_TABLE,
|
|
19
19
|
} from '@ultimat3/notify';
|
|
20
|
+
import { PUSH_SUBSCRIPTIONS_TABLE, SQL_PUSH_SUBSCRIPTIONS_TABLE } from '@ultimat3/pwa/schema';
|
|
20
21
|
import { FrameworkSchemaFailedError } from './schema-errors';
|
|
21
22
|
|
|
22
23
|
export interface FrameworkSchema {
|
|
@@ -146,6 +147,17 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
|
|
|
146
147
|
tables: Object.freeze([MCP_CONFIRMATIONS_TABLE]),
|
|
147
148
|
ddl: Object.freeze([SQL_MCP_CONFIRMATIONS_TABLE]),
|
|
148
149
|
}),
|
|
150
|
+
/**
|
|
151
|
+
* Web Push subscriptions — what `postgresPushSubscriptionStore()` reads and the boot installs
|
|
152
|
+
* once `pwa.push` is on. Applied whether or not it is, for every row's reason: turning push on
|
|
153
|
+
* must not be the deploy that discovers the table was never created. Through
|
|
154
|
+
* `@ultimat3/pwa/schema`, a leaf, for the admin and mcp rows' reason.
|
|
155
|
+
*/
|
|
156
|
+
Object.freeze({
|
|
157
|
+
pkg: '@ultimat3/pwa',
|
|
158
|
+
tables: Object.freeze([PUSH_SUBSCRIPTIONS_TABLE]),
|
|
159
|
+
ddl: Object.freeze([SQL_PUSH_SUBSCRIPTIONS_TABLE]),
|
|
160
|
+
}),
|
|
149
161
|
]);
|
|
150
162
|
|
|
151
163
|
/** Every relation this boot creates, flattened. */
|
package/src/jobs-report.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Pure, driver-injected job operations behind `x jobs`: flag parsing, plus
|
|
1
|
+
// Pure, driver-injected job operations behind `x jobs`: flag parsing, plus list / show / retry. No
|
|
2
2
|
// CLI parsing, no process I/O, no rendering — a test drives every path with `memoryJobDriver()`
|
|
3
3
|
// alone. The `--json` shapes are `jobs-json.ts`, the table `jobs-table.ts`.
|
|
4
4
|
|
|
@@ -61,7 +61,7 @@ export function parseLimitFlag(value: string | undefined, command = 'jobs'): num
|
|
|
61
61
|
return limit;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
// ──
|
|
64
|
+
// ── list ──────────────────────────────────────────────────────────────────
|
|
65
65
|
|
|
66
66
|
export interface JobsListFilter {
|
|
67
67
|
readonly queue?: string | undefined;
|
|
@@ -105,7 +105,7 @@ async function jobPage(driver: JobDriver, filter: JobFilter): Promise<Page<JobRe
|
|
|
105
105
|
* `--state ready` filter (or the default 100-row cap) pushes out of view is the exact failure
|
|
106
106
|
* mode this command exists to prevent.
|
|
107
107
|
*
|
|
108
|
-
* Backfills are read `running` only. `x jobs
|
|
108
|
+
* Backfills are read `running` only. `x jobs list` is a LIVE view of the queue — a pass that
|
|
109
109
|
* finished last week is history, and `x db backfill --list` is where that question is asked and
|
|
110
110
|
* answered with the whole ledger. A driver with no ledger answers `[]` rather than throwing, so
|
|
111
111
|
* the queue view never fails over a fact nobody asked about.
|
package/src/jobs-table.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The `x jobs
|
|
1
|
+
// The `x jobs list` terminal table, and nothing else. Split out of `jobs-report.ts` so that
|
|
2
2
|
// deciding how a value LOOKS lives apart from deciding what the queue DOES: a rendering rule
|
|
3
3
|
// (column order, padding, the run-at unit) is reviewed here, once, with no queue logic around it.
|
|
4
4
|
|
package/src/live-routes.ts
CHANGED
|
@@ -183,7 +183,6 @@ export async function liveRouteGaps(
|
|
|
183
183
|
const reported = new Set<string>();
|
|
184
184
|
const gaps: LiveRouteGap[] = [];
|
|
185
185
|
for (const entry of entries) {
|
|
186
|
-
if (entry.surface === 'api') continue;
|
|
187
186
|
const never = entry.config.hydrate === 'never';
|
|
188
187
|
for (const read of await browserOnlyReads(root, entry.file)) {
|
|
189
188
|
if (reported.has(read.at) || (!never && inBrowser.has(read.at))) continue;
|
package/src/mcp-db-target.ts
CHANGED
|
@@ -56,7 +56,7 @@ const isProduction = (env: Env): boolean => {
|
|
|
56
56
|
/**
|
|
57
57
|
* `x db branch create <name>` names an external clone `<source>_branch_<name>`. Both readings come
|
|
58
58
|
* from `db-branch.ts` — the module that also WRITES those names — because a target that disagrees
|
|
59
|
-
* with `x db branch
|
|
59
|
+
* with `x db branch list` about what a branch is would let `db.migrate` run against a shared
|
|
60
60
|
* database on the strength of a naming rule one of the two had drifted away from.
|
|
61
61
|
*/
|
|
62
62
|
function postgresBranch(url: string): string | null {
|
package/src/mcp-errors.ts
CHANGED
|
@@ -60,7 +60,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
60
60
|
X_APP_PACKAGE_INVALID: 'bun pm pkg set name=my-app version=0.1.0',
|
|
61
61
|
X_ERROR_CODE_UNKNOWN: 'x errors list --json',
|
|
62
62
|
X_DECLARATION_UNKNOWN: 'x actions list --json',
|
|
63
|
-
X_JOB_UNKNOWN: 'x jobs
|
|
63
|
+
X_JOB_UNKNOWN: 'x jobs list --json',
|
|
64
64
|
X_FIX_TARGET_UNKNOWN: 'x fix boundary apps/web/site/page.tsx --json',
|
|
65
65
|
X_ERROR_FIX_INVALID: 'x verify --json # the finding names the file, the line and the fix text',
|
|
66
66
|
X_ERROR_FIX_PATH_MISSING:
|
|
@@ -213,7 +213,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
213
213
|
X_QUERY_SUBSCRIBES_UNKNOWN:
|
|
214
214
|
'x db gen "retry after fixing subscribes" --json # first edit subscribes: on the query the cause names',
|
|
215
215
|
X_DB_MIGRATE_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
|
|
216
|
-
X_DB_BRANCH_FAILED: 'x db branch
|
|
216
|
+
X_DB_BRANCH_FAILED: 'x db branch list --json',
|
|
217
217
|
X_DB_STUDIO_FAILED: 'x doctor --json',
|
|
218
218
|
// Runnable first, the narrowing behind a `#`, exactly as X_CLI_UNKNOWN_COMMAND above: naming
|
|
219
219
|
// the tier IS the consent, and which seed to consent to is the one thing this table cannot
|
package/src/messages.ts
CHANGED
|
@@ -14,7 +14,7 @@ const CATALOG = {
|
|
|
14
14
|
// A build-output line, so it belongs here rather than inline beside the emitter: `x build` is a
|
|
15
15
|
// human surface and every other word it prints comes from this catalog.
|
|
16
16
|
'cli.build.pushUnwired':
|
|
17
|
-
'pwa.push is true and no
|
|
17
|
+
'pwa.push is true and this build has no Web Push runtime behind it (a static export serves no subscribe action), so the emitted sw.js carries no push handler',
|
|
18
18
|
// `describeCron`'s vocabulary. `@ultimat3/time` is tier 1 and reaches no i18n runtime, so the
|
|
19
19
|
// caller supplies the words — and the caller here is a rendered `x tasks show` line, which is
|
|
20
20
|
// exactly what this catalog holds. `msg()` leaves an un-supplied `{n}`/`{time}`/`{days}`/
|
|
@@ -46,7 +46,7 @@ const CATALOG = {
|
|
|
46
46
|
'cli.db.branch.failed': 'branch command failed',
|
|
47
47
|
'cli.db.branch.listed': '{count} branch(es) of this database',
|
|
48
48
|
'cli.db.branch.none': 'this database has no branch',
|
|
49
|
-
/** The empty cell in an `x db branch
|
|
49
|
+
/** The empty cell in an `x db branch list` column — a value, not a column key. */
|
|
50
50
|
'cli.db.branch.unknown': '-',
|
|
51
51
|
'cli.db.gen.failed': 'migration not generated',
|
|
52
52
|
'cli.db.gen.unchanged': 'entities and migrations agree — nothing to generate',
|
|
@@ -149,7 +149,7 @@ const CATALOG = {
|
|
|
149
149
|
'cli.jobs.depth':
|
|
150
150
|
'{ready} ready · {running} running · {delayed} delayed · {dead} dead across {queues} queue(s)',
|
|
151
151
|
'cli.jobs.listed': '{count} job(s)',
|
|
152
|
-
'cli.jobs.nextPage': 'more — next page: x jobs
|
|
152
|
+
'cli.jobs.nextPage': 'more — next page: x jobs list --after {cursor}',
|
|
153
153
|
'cli.jobs.noError': 'no error recorded',
|
|
154
154
|
'cli.jobs.paused': 'queue {queue} paused — no worker claims from it; enqueues still land',
|
|
155
155
|
'cli.jobs.pausedQueues': 'paused queue(s): {queues}',
|
|
@@ -355,6 +355,14 @@ const CATALOG = {
|
|
|
355
355
|
'cli.secrets.edited': '{path} resealed — {added} added, {updated} changed, {removed} removed',
|
|
356
356
|
'cli.secrets.unchanged': '{path} unchanged — nothing was written',
|
|
357
357
|
'cli.secrets.set': 'sealed {name} into {path} — {count} secret(s)',
|
|
358
|
+
'cli.vapid.created':
|
|
359
|
+
'sealed a new Web Push key pair into {path} — ULTIMATE_VAPID_PUBLIC_KEY and ULTIMATE_VAPID_PRIVATE_KEY',
|
|
360
|
+
'cli.vapid.public': ' public key (every page carries it as <meta name="x-push-key">): {key}',
|
|
361
|
+
'cli.vapid.source.environment':
|
|
362
|
+
'this process signs Web Push with the pair in its environment (ULTIMATE_VAPID_*)',
|
|
363
|
+
'cli.vapid.source.sealed': 'this app signs Web Push with the pair sealed in {path}',
|
|
364
|
+
'cli.vapid.source.development':
|
|
365
|
+
'no pair in the environment or {path}: a local process signs with the published development pair, which a deployed boot refuses — x vapid create',
|
|
358
366
|
'cli.secrets.rotated':
|
|
359
367
|
'rotated {path} from master key {from} to {to} — {count} secret(s) resealed',
|
|
360
368
|
'cli.secrets.retired':
|
package/src/page-navigation.ts
CHANGED
|
@@ -140,7 +140,12 @@ export function navigationTagsOf(
|
|
|
140
140
|
): readonly HeadTag[] {
|
|
141
141
|
if (entry.config.navigation === 'document') return [];
|
|
142
142
|
const tags = navigationHeadFor(head, entry.surface);
|
|
143
|
-
|
|
143
|
+
if (tags === undefined) return [];
|
|
144
|
+
// A modal says so in its own document: the router shows it over the page beneath, never decided
|
|
145
|
+
// from a link — a redirect's target and a pasted `#/…` address are answered alike.
|
|
146
|
+
return clientNavigationTags(
|
|
147
|
+
entry.config.navigation === 'modal' ? { ...tags, modal: true } : tags,
|
|
148
|
+
);
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
/** `{ navigation }` for `metaOf`'s spread, or nothing. */
|