@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.
Files changed (54) hide show
  1. package/README.md +3 -3
  2. package/package.json +28 -28
  3. package/src/app-env.ts +23 -8
  4. package/src/budgets.ts +2 -1
  5. package/src/cmd-db-branch.ts +15 -11
  6. package/src/cmd-db-spec.ts +2 -2
  7. package/src/cmd-deploy-drain.ts +6 -1
  8. package/src/cmd-docs.ts +1 -1
  9. package/src/cmd-env.ts +4 -4
  10. package/src/cmd-jobs-spec.ts +11 -7
  11. package/src/cmd-jobs.ts +10 -10
  12. package/src/cmd-planned.ts +3 -3
  13. package/src/cmd-registries-spec.ts +6 -6
  14. package/src/cmd-registries.ts +6 -6
  15. package/src/cmd-tasks.ts +2 -2
  16. package/src/cmd-vapid-spec.ts +17 -0
  17. package/src/cmd-vapid.ts +135 -0
  18. package/src/db-branch.ts +6 -6
  19. package/src/db-finding.ts +1 -1
  20. package/src/dev-restart-retire.ts +3 -1
  21. package/src/dev-route-table.ts +4 -0
  22. package/src/document-styles.ts +2 -4
  23. package/src/error-contract.ts +1 -1
  24. package/src/errors.ts +4 -4
  25. package/src/fix-command.ts +1 -1
  26. package/src/framework-env.ts +92 -15
  27. package/src/framework-schema.ts +12 -0
  28. package/src/jobs-report.ts +3 -3
  29. package/src/jobs-table.ts +1 -1
  30. package/src/live-routes.ts +0 -1
  31. package/src/mcp-db-target.ts +1 -1
  32. package/src/mcp-errors.ts +2 -2
  33. package/src/messages.ts +11 -3
  34. package/src/page-navigation.ts +6 -1
  35. package/src/parse.ts +23 -2
  36. package/src/pwa-artifacts.ts +2 -2
  37. package/src/registry.ts +2 -0
  38. package/src/role-replicator.ts +1 -1
  39. package/src/runtime-jobs.ts +1 -1
  40. package/src/runtime-push.ts +80 -0
  41. package/src/runtime-services.ts +7 -0
  42. package/src/serve-boot.ts +8 -2
  43. package/src/serve-drain.ts +5 -1
  44. package/src/serve-retire.ts +7 -3
  45. package/src/serve-web.ts +4 -0
  46. package/src/site-seo.ts +1 -4
  47. package/src/sw-artifacts.ts +30 -13
  48. package/src/sync-url.ts +9 -0
  49. package/src/templates/scaffold-claude-agents.ts +4 -4
  50. package/src/templates/scaffold-claude-commands.ts +2 -2
  51. package/src/templates/scaffold-claude.ts +1 -1
  52. package/src/templates/scaffold-helm-templates.ts +6 -2
  53. package/src/templates/scaffold-helm.ts +4 -1
  54. package/src/worker-bundle.ts +21 -8
@@ -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 = ['ls', 'create', 'drop'] as const;
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 `ls` and `drop` need.
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 ls` and the MCP host's branch check must agree about what a branch directory is.
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 drop what `ls` shows" a guard rather than a courtesy — and a
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 `ls`. The CLI shelled out to `psql` until now and wrote no marker at all,
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 `ls` will show for it, derived the way `ls` derives one — a create that reported a
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 drop` that clears it — so those reach the caller
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();
@@ -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
@@ -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. `api/` is excluded because
34
- * it emits no document at all — a surface with nothing to style is not a surface missing its
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);
@@ -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 describe posts.publish --json" names the observation
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 describe <name>` named a declaration the registries do not hold —
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. `describe` for the registries, `show` for `x tasks`. */
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 ?? 'describe'} ${input.suggestion}`,
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 ls --json',
282
+ fix: 'x jobs list --json',
283
283
  });
284
284
  }
285
285
  }
@@ -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 drop <name>`), where a placeholder is exactly right.
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 —
@@ -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
- /** False when this deploy's shape never reads the key (object storage configured). */
58
- needed(env: EnvTable): boolean;
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 = (env: EnvTable, skip: ReadonlySet<string>): readonly Finding[] =>
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(env: EnvTable): readonly Finding[] {
118
- return namedDeployed(env) ? owedFindings(env, new Set()) : [];
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
- const owed = FRAMEWORK_SECRETS.filter((secret) => !Object.hasOwn(schema, secret.key));
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
- '# Required outside development/test, whatever envSchema declares. Generate each with',
133
- '# `openssl rand -hex 32`; `x env check` and `x doctor` report one that is missing.',
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('# required when deployed · string · secret', `${secret.key}=`);
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)}`;
@@ -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. */
@@ -1,4 +1,4 @@
1
- // Pure, driver-injected job operations behind `x jobs`: flag parsing, plus ls / show / retry. No
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
- // ── ls ────────────────────────────────────────────────────────────────────
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 ls` is a LIVE view of the queue — a pass that
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 ls` terminal table, and nothing else. Split out of `jobs-report.ts` so that
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
 
@@ -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;
@@ -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 ls` about what a branch is would let `db.migrate` run against a shared
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 ls --json',
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 ls --json',
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 VAPID key is configured, so the emitted sw.js carries no push handler',
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 ls` column — a value, not a column key. */
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 ls --after {cursor}',
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':
@@ -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
- return tags === undefined ? [] : clientNavigationTags(tags);
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. */