@ultimat3/cli 17.0.0 → 18.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 +90 -7
- package/package.json +30 -30
- package/src/app-root.ts +13 -1
- package/src/cmd-dev.ts +16 -2
- package/src/cmd-doctor.ts +1 -1
- package/src/cmd-test.ts +20 -9
- package/src/db-accept-created.ts +207 -0
- package/src/db-generate.ts +18 -1
- package/src/db-subscribes.ts +81 -0
- package/src/db-ungeneratable.ts +14 -2
- package/src/dev-assets.ts +19 -54
- package/src/dev-notify-retention.ts +69 -0
- package/src/dev-purge.ts +47 -2
- package/src/dev-render.ts +20 -4
- package/src/dev-replicator.ts +19 -1
- package/src/dev-runtime.ts +12 -3
- package/src/dev-services.ts +8 -0
- package/src/error-codes.ts +9 -0
- package/src/icon-assets.ts +74 -0
- package/src/index.ts +19 -4
- package/src/island-harness-script.ts +8 -1
- package/src/island-shot.ts +37 -4
- package/src/island-verdict.ts +16 -4
- package/src/mcp-errors.ts +6 -0
- package/src/messages.ts +1 -1
- package/src/prerender.ts +16 -0
- package/src/pwa-artifacts.ts +188 -0
- package/src/serve.ts +13 -1
- package/src/templates/naming.ts +11 -0
- package/src/templates/scaffold-repo.ts +14 -1
- package/src/test-shards.ts +150 -116
- package/src/ts-scan.ts +6 -1
- package/src/verify-test-run.ts +31 -46
package/src/index.ts
CHANGED
|
@@ -72,6 +72,7 @@ export { testCommand } from './cmd-test';
|
|
|
72
72
|
export { runVerify, VERIFY_STEPS, verifyCommand, verifyStepNames } from './cmd-verify';
|
|
73
73
|
export type { CliCommand, CommandContext } from './command';
|
|
74
74
|
export { failed, ok } from './command';
|
|
75
|
+
export { acceptCreatedTables, createdTables } from './db-accept-created';
|
|
75
76
|
export type { BranchRow, BranchSubcommand } from './db-branch';
|
|
76
77
|
export {
|
|
77
78
|
BRANCH_SUBCOMMANDS,
|
|
@@ -83,11 +84,11 @@ export {
|
|
|
83
84
|
} from './db-branch';
|
|
84
85
|
export type { GeneratedFiles, GenerateMigrationOptions, GenerateOutcome } from './db-generate';
|
|
85
86
|
export { generateAppMigration, migrationSql } from './db-generate';
|
|
87
|
+
export type { SubscribingQuery } from './db-subscribes';
|
|
88
|
+
export { QuerySubscribesUnknownError, replicaIdentityTables } from './db-subscribes';
|
|
86
89
|
export type { AssetRoutesOptions } from './dev-assets';
|
|
87
90
|
export {
|
|
88
91
|
assetRoutes,
|
|
89
|
-
ICON_BASE_PATH,
|
|
90
|
-
ICON_SOURCE,
|
|
91
92
|
MEDIA_BASE_PATH,
|
|
92
93
|
} from './dev-assets';
|
|
93
94
|
export type { DevDashboardInput, DevStatus } from './dev-dashboard';
|
|
@@ -234,6 +235,13 @@ export {
|
|
|
234
235
|
} from './framework-schema';
|
|
235
236
|
export type { Guard } from './guards';
|
|
236
237
|
export { findingProblem, GUARD_DIR, guardFindings, guardPaths } from './guards';
|
|
238
|
+
export {
|
|
239
|
+
hasSourceIcon,
|
|
240
|
+
ICON_BASE_PATH,
|
|
241
|
+
ICON_SOURCE,
|
|
242
|
+
iconPlan,
|
|
243
|
+
iconRenderer,
|
|
244
|
+
} from './icon-assets';
|
|
237
245
|
// The island bundler, and only its entry point. An island is the one module Ultimate ships to a
|
|
238
246
|
// browser, so an app has to be able to build one to TEST one — `mountIsland` from
|
|
239
247
|
// `@ultimat3/testing` takes this function as its `build` parameter (issue #260). `discoverIslands`,
|
|
@@ -274,6 +282,13 @@ export type { CommandSpec, FlagSpec, ParsedArgs } from './parse';
|
|
|
274
282
|
export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from './parse';
|
|
275
283
|
export type { PrerenderedPage, PrerenderOptions, PrerenderReport } from './prerender';
|
|
276
284
|
export { DEFAULT_ORIGIN, isPrerenderable, prerenderSite } from './prerender';
|
|
285
|
+
export type { PwaArtifacts } from './pwa-artifacts';
|
|
286
|
+
export {
|
|
287
|
+
loadPwaArtifacts,
|
|
288
|
+
pwaManifestRoute,
|
|
289
|
+
WEB_MANIFEST_PATH,
|
|
290
|
+
writePwaIcons,
|
|
291
|
+
} from './pwa-artifacts';
|
|
277
292
|
export { COMMANDS, cliVersion, commandFor, SPECS } from './registry';
|
|
278
293
|
export type { SchemaDifference, SchemaDirection, SchemaPart } from './schema-diff';
|
|
279
294
|
export { diffDeclaredSchema } from './schema-diff';
|
|
@@ -323,8 +338,8 @@ export type { TestCounts } from './test-counts';
|
|
|
323
338
|
export { countsOf } from './test-counts';
|
|
324
339
|
export type { TestFile } from './test-select';
|
|
325
340
|
export { belongsToType, discoverTests, sampleFiles } from './test-select';
|
|
326
|
-
export type { ReproduceOptions, RunShardsOptions
|
|
327
|
-
export {
|
|
341
|
+
export type { ReproduceOptions, RunShardsOptions } from './test-shards';
|
|
342
|
+
export { filesIn, reproduceFor, runShards, testArgs } from './test-shards';
|
|
328
343
|
export { availableCpus, defaultWorkers, WORKER_CEILING } from './test-workers';
|
|
329
344
|
export type {
|
|
330
345
|
CodeFixSite,
|
|
@@ -144,4 +144,11 @@ export const readinessProbe = (selector: string): string =>
|
|
|
144
144
|
// Children OR text: a component that renders one text node has painted, and one that mounted
|
|
145
145
|
// and rendered nothing is the silence a non-zero box would otherwise read as success.
|
|
146
146
|
'filled:box?(box.children.length>0||(box.textContent||"").trim().length>0):false,' +
|
|
147
|
-
'box:{x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)}
|
|
147
|
+
'box:{x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)},' +
|
|
148
|
+
// The box is VIEWPORT coordinates — what `getBoundingClientRect()` answers — and a capture clip
|
|
149
|
+
// is PAGE coordinates. They agree only while the page is at the origin, which is the one case a
|
|
150
|
+
// harness happens to be in and is not a rule anything enforces: a state whose component sits
|
|
151
|
+
// below the fold scrolls, and a clip taken from the raw rect then crops the wrong band with
|
|
152
|
+
// nothing to report it. The offset is returned rather than added here so `box` keeps meaning
|
|
153
|
+
// exactly what the verdict already publishes.
|
|
154
|
+
'scroll:{x:Math.round(window.scrollX||0),y:Math.round(window.scrollY||0)}};})()';
|
package/src/island-shot.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// why: no Bun native joins a path; `Bun.write` and `Bun.file` both take one already joined.
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { finiteCount } from '@ultimat3/core';
|
|
9
|
-
import type { ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
|
|
9
|
+
import type { CaptureClip, ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
|
|
10
10
|
import { systemScrapeClock } from '@ultimat3/scraping';
|
|
11
11
|
import type { IslandShotTarget, IslandStatesManifest, IslandViewport } from '@ultimat3/testing';
|
|
12
12
|
import { islandShotTargets, islandStatesFile } from '@ultimat3/testing';
|
|
@@ -139,6 +139,21 @@ export function photographFault(
|
|
|
139
139
|
return undefined;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The capture rectangle for a readiness answer, in PAGE coordinates.
|
|
144
|
+
*
|
|
145
|
+
* `seen` is non-null and its box has area by the time this is reached — `photographFault` refuses
|
|
146
|
+
* both above, and it refuses them BEFORE the shutter for exactly this reason: a zero-area clip is
|
|
147
|
+
* `X_SCRAPE_CAPTURE_CLIP_EMPTY` from the port, which is a worse report of the same fault than
|
|
148
|
+
* "rendered nothing". The `?? 0` pair is the parser's floor and not a second opinion.
|
|
149
|
+
*/
|
|
150
|
+
const clipFor = (seen: IslandReadiness | null): CaptureClip => ({
|
|
151
|
+
x: (seen?.box.x ?? 0) + (seen?.scroll.x ?? 0),
|
|
152
|
+
y: (seen?.box.y ?? 0) + (seen?.scroll.y ?? 0),
|
|
153
|
+
width: seen?.box.width ?? 0,
|
|
154
|
+
height: seen?.box.height ?? 0,
|
|
155
|
+
});
|
|
156
|
+
|
|
142
157
|
const hostFix = (target: IslandShotTarget): string =>
|
|
143
158
|
`in ${islandStatesFile(target.island)} set island to a path that exports mount(el, props)`;
|
|
144
159
|
|
|
@@ -171,6 +186,17 @@ async function captureOne(
|
|
|
171
186
|
timeoutMs: options.timeoutMs,
|
|
172
187
|
});
|
|
173
188
|
const page = session.page;
|
|
189
|
+
// BEFORE the navigation, so the first paint already has it: `prefers-color-scheme` is a live
|
|
190
|
+
// media query, and the theme a component resolves on mount is the one it will keep.
|
|
191
|
+
//
|
|
192
|
+
// This is the INPUT and the harness's `data-theme` attribute is the OUTCOME, and both are set
|
|
193
|
+
// deliberately. The attribute is right for a component that READS a theme it does not own; the
|
|
194
|
+
// preference is the only thing that reaches one that RESOLVES its own. `examples/dummy`'s
|
|
195
|
+
// settings island is the second kind — its state's `theme` prop is `'system'`, so on mount it
|
|
196
|
+
// DELETES the attribute the harness set, both pictures fall through to `:root`, and the two
|
|
197
|
+
// came back byte-identical with the same md5 (issue #338). Re-setting the attribute after
|
|
198
|
+
// readiness is not the repair: it photographs a state the component would never reach.
|
|
199
|
+
await page.colorScheme(target.theme);
|
|
174
200
|
await page.goto(url, { timeout: options.timeoutMs });
|
|
175
201
|
const expression = readinessProbe(target.target ?? '[data-x-island]');
|
|
176
202
|
const probe = (): Promise<IslandReadiness | null> =>
|
|
@@ -202,9 +228,16 @@ async function captureOne(
|
|
|
202
228
|
...fault,
|
|
203
229
|
});
|
|
204
230
|
}
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
231
|
+
// The COMPONENT, not the viewport it happens to sit in — the crop this feature was designed
|
|
232
|
+
// around, and which nothing passed until 2026-08-26 (issue #338). The rectangle is the
|
|
233
|
+
// readiness probe's own box, which is the crop target the manifest declared, translated from
|
|
234
|
+
// the DOM's viewport coordinates into the page coordinates a capture clip is in.
|
|
235
|
+
//
|
|
236
|
+
// The clip ALONE. `fullPage: false` beside it is accepted — `assertCaptureFraming` refuses only
|
|
237
|
+
// `=== true`, and `cdp-target.ts` sends `{ clip }` and nothing else either way, so all four
|
|
238
|
+
// pictures really were written with the pair — but it is a field that says nothing: the two
|
|
239
|
+
// are exclusive, and spelling out the default of the one you did not ask for reads as a choice.
|
|
240
|
+
const bytes = await page.screenshot({ clip: clipFor(seen) });
|
|
208
241
|
if (bytes.byteLength < floor) {
|
|
209
242
|
throw new IslandUnphotographableError({
|
|
210
243
|
island: target.island,
|
package/src/island-verdict.ts
CHANGED
|
@@ -27,10 +27,13 @@ export const ISLAND_SHOT_MESSAGE_KEYS = [
|
|
|
27
27
|
* this tool cannot support — and both are properties of the port rather than of a run, which is
|
|
28
28
|
* why they are a constant and not a per-run list.
|
|
29
29
|
*
|
|
30
|
-
* The crop one is
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* The crop one is what a rectangle still cannot see: the picture IS the component's own box now
|
|
31
|
+
* (`clipFor`, `island-shot.ts`), so what a reader loses is the surroundings — a component that
|
|
32
|
+
* overflows its box, or one whose fault is the space around it, is outside the frame. It said the
|
|
33
|
+
* opposite until 2026-08-26 — "the browser port takes no clip rectangle" — which stopped being
|
|
34
|
+
* true when the port gained `CaptureClip`, and a blind spot that names a capability the tool has
|
|
35
|
+
* is the same lie as one that hides a gap. The locale one is the reach of a page-side clock patch:
|
|
36
|
+
* `date.toLocaleString()` resolves the zone inside the engine and never through the patched
|
|
34
37
|
* `Intl.DateTimeFormat`.
|
|
35
38
|
*/
|
|
36
39
|
export const ISLAND_BLIND_SPOTS = [
|
|
@@ -54,7 +57,15 @@ export interface IslandReadiness {
|
|
|
54
57
|
readonly mounted: boolean;
|
|
55
58
|
readonly failed: string | null;
|
|
56
59
|
readonly filled: boolean;
|
|
60
|
+
/** The crop target's rectangle in VIEWPORT coordinates, which is what the DOM answers. */
|
|
57
61
|
readonly box: IslandBox;
|
|
62
|
+
/**
|
|
63
|
+
* The page's scroll offset at the moment the box was measured. A capture clip is in PAGE
|
|
64
|
+
* coordinates, so this is what turns one into the other — and it is a separate field rather than
|
|
65
|
+
* an addition inside the probe because `box` is published in the verdict and means the DOM's own
|
|
66
|
+
* answer there.
|
|
67
|
+
*/
|
|
68
|
+
readonly scroll: { readonly x: number; readonly y: number };
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
const readinessSchema: StandardSchemaV1<unknown, IslandReadiness> = t.object({
|
|
@@ -66,6 +77,7 @@ const readinessSchema: StandardSchemaV1<unknown, IslandReadiness> = t.object({
|
|
|
66
77
|
failed: t.nullable(t.string),
|
|
67
78
|
filled: t.boolean,
|
|
68
79
|
box: t.object({ x: t.number, y: t.number, width: t.number, height: t.number }),
|
|
80
|
+
scroll: t.object({ x: t.number, y: t.number }),
|
|
69
81
|
}) as unknown as StandardSchemaV1<unknown, IslandReadiness>;
|
|
70
82
|
|
|
71
83
|
/**
|
package/src/mcp-errors.ts
CHANGED
|
@@ -168,6 +168,12 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
168
168
|
// reproduces the finding, and the finding names the file and the header line to add.
|
|
169
169
|
X_MIGRATION_UNGENERATABLE:
|
|
170
170
|
'x verify --only drift --json # then add the `-- ungeneratable: <n>` header line the finding names',
|
|
171
|
+
// The edit comes first and the command re-runs it, exactly as the two schema codes above do:
|
|
172
|
+
// the cause names the query and the name that matched nothing, and `subscribes:` is a field in
|
|
173
|
+
// that query's own file. Regenerating is safe here — this refusal happens BEFORE anything is
|
|
174
|
+
// written, so there is no half-generated migration to undo.
|
|
175
|
+
X_QUERY_SUBSCRIBES_UNKNOWN:
|
|
176
|
+
'x db gen "retry after fixing subscribes" --json # first edit subscribes: on the query the cause names',
|
|
171
177
|
X_DB_MIGRATE_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
|
|
172
178
|
X_DB_BRANCH_FAILED: 'x db branch ls --json',
|
|
173
179
|
X_DB_STUDIO_FAILED: 'x doctor --json',
|
package/src/messages.ts
CHANGED
|
@@ -212,7 +212,7 @@ const CATALOG = {
|
|
|
212
212
|
'cli.shot.island.picture': ' pictures {path}',
|
|
213
213
|
'cli.shot.island.verdict': ' verdict {path}',
|
|
214
214
|
'cli.shot.island.blind.crop':
|
|
215
|
-
'the picture is the
|
|
215
|
+
'the picture is the crop target and nothing around it — a component that overflows its own box, or whose fault is the space beside it, is outside the frame',
|
|
216
216
|
'cli.shot.island.blind.locale':
|
|
217
217
|
'toLocaleString() on a Date resolves its zone inside the engine — only an explicit timeZone is pinned by this harness',
|
|
218
218
|
'cli.ci.failed':
|
package/src/prerender.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { errorPageDocument, STATIC_ERROR_PAGE } from './error-pages';
|
|
|
17
17
|
import { FAVICON_PATH, faviconBytes } from './favicon';
|
|
18
18
|
import type { IslandBundle } from './island-bundle';
|
|
19
19
|
import { buildIslands, writeIslands } from './island-bundle';
|
|
20
|
+
import { loadPwaArtifacts, WEB_MANIFEST_PATH, writePwaIcons } from './pwa-artifacts';
|
|
20
21
|
import type { SkippedRoute, UnmeasuredRoute } from './static-report';
|
|
21
22
|
import { skippedRoute, skipReasonFor, writeStaticReport } from './static-report';
|
|
22
23
|
|
|
@@ -147,6 +148,20 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
147
148
|
join(options.out, STATIC_ERROR_PAGE),
|
|
148
149
|
await errorPageDocument(options.root, NOT_FOUND_STATUS),
|
|
149
150
|
);
|
|
151
|
+
// And the file every document above is about to name. A static export is served with no process
|
|
152
|
+
// behind it, so `<link rel="manifest">` resolves to a 404 unless the bytes are in the artifact —
|
|
153
|
+
// an installable app that is installable only under `x dev` is the dev/prod split this whole
|
|
154
|
+
// wiring exists to close. `undefined` when the app is not installable, and then no document
|
|
155
|
+
// names it either.
|
|
156
|
+
const pwa = await loadPwaArtifacts(options.root);
|
|
157
|
+
if (pwa !== undefined) {
|
|
158
|
+
await Bun.write(join(options.out, WEB_MANIFEST_PATH.slice(1)), pwa.body);
|
|
159
|
+
// And the icons that manifest NAMES. A static host runs no `assetRoutes()`, so every
|
|
160
|
+
// `/icons/*` entry would be a 404 in the install prompt — the manifest half of the same
|
|
161
|
+
// promise `favicon.ico` above keeps. Nothing when the app committed no source icon, which is
|
|
162
|
+
// also when the manifest names no icon.
|
|
163
|
+
await writePwaIcons(options.root, options.out);
|
|
164
|
+
}
|
|
150
165
|
|
|
151
166
|
// Every render below goes through `routeDocument`, which is the function a REQUEST reaches — and
|
|
152
167
|
// a request arrives inside `runWithContext`, installed by the HTTP pipeline (`dev-render.ts`).
|
|
@@ -161,6 +176,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
|
|
|
161
176
|
runWithContext(ctx, () =>
|
|
162
177
|
routeDocument(entry, data, {
|
|
163
178
|
resolveIsland: (file: string) => islands.resolverFor(file),
|
|
179
|
+
...(pwa === undefined ? {} : { pwaHead: pwa.head }),
|
|
164
180
|
}),
|
|
165
181
|
);
|
|
166
182
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// The web manifest an installable app promises, and that no build had ever produced.
|
|
2
|
+
//
|
|
3
|
+
// `pwa.enabled` was a switch with no reader anywhere in the tree (issue #362, and
|
|
4
|
+
// `scripts/lib/config-reader-pins.ts` pinned it as a `jobs.driver` candidate). `@ultimat3/pwa` has
|
|
5
|
+
// shipped `generateWebManifest`, `renderThemeColorMeta`, `planIcons` and `appleTouchLinks` since it
|
|
6
|
+
// existed and NOTHING called them, so every Ultimate app served a `<head>` with no
|
|
7
|
+
// `<link rel="manifest">`, no `theme-color` and no apple-touch icon — and no browser has ever
|
|
8
|
+
// offered to install one, however the config was written.
|
|
9
|
+
//
|
|
10
|
+
// WHY HERE. `dev-assets.ts`'s reason exactly: three packages declare what an installable app is and
|
|
11
|
+
// none of them can read a config file off disk. This one composes tier 0's `pwa` block with tier
|
|
12
|
+
// 4's generator and hands both served surfaces and the static export the same two strings.
|
|
13
|
+
//
|
|
14
|
+
// WHAT IT DOES NOT DO: emit a service worker. `offline`, `backgroundSync` and `push` still have no
|
|
15
|
+
// build behind them — `wiki/PWA-And-Offline.md` says so — and a bad `sw.js` is sticky in a way a
|
|
16
|
+
// manifest is not, so the worker lands behind a real browser check rather than beside this.
|
|
17
|
+
|
|
18
|
+
// why: Bun exposes no synchronous file-existence primitive, and this read is the same one
|
|
19
|
+
// `app-auth.ts` and `dev-cache.ts` each make before importing an app's config — a root with no
|
|
20
|
+
// `app.config.ts` is an ordinary answer here (a scratch root, `x build` outside an app).
|
|
21
|
+
import { existsSync } from 'node:fs';
|
|
22
|
+
// why: Bun exposes no path-join primitive, and `APP_CONFIG_FILE` is app-root-relative — the same
|
|
23
|
+
// necessity `favicon.ts` and `dev-assets.ts` each record for their own root-relative constant.
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import type { PwaColors } from '@ultimat3/core';
|
|
26
|
+
import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
|
|
27
|
+
import { applyCacheHeaders } from '@ultimat3/http';
|
|
28
|
+
import {
|
|
29
|
+
appleTouchLinks,
|
|
30
|
+
generateWebManifest,
|
|
31
|
+
renderThemeColorMeta,
|
|
32
|
+
serializeWebManifest,
|
|
33
|
+
} from '@ultimat3/pwa';
|
|
34
|
+
import { escapeAttribute } from '@ultimat3/seo';
|
|
35
|
+
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
36
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
37
|
+
import { hasSourceIcon, iconPlan, iconRenderer } from './icon-assets';
|
|
38
|
+
|
|
39
|
+
/** What a browser fetches from `<link rel="manifest">`. The spec's own extension, not `.json`. */
|
|
40
|
+
export const WEB_MANIFEST_PATH = '/manifest.webmanifest';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Short, never immutable. The path carries no content hash, so an app that changed its install
|
|
44
|
+
* title must be able to publish it — an hour is `favicon.ts`'s number, for the same asset class.
|
|
45
|
+
*/
|
|
46
|
+
const MANIFEST_CACHE: CacheHint = { mode: 'public', maxAgeSeconds: 3600 };
|
|
47
|
+
|
|
48
|
+
/** The two strings every surface needs: the file's bytes, and what `<head>` must carry to name it. */
|
|
49
|
+
export interface PwaArtifacts {
|
|
50
|
+
/** `manifest.webmanifest`, serialized. */
|
|
51
|
+
readonly body: string;
|
|
52
|
+
/**
|
|
53
|
+
* `<link rel="manifest">`, both `theme-color` metas, and every apple-touch icon link. One string
|
|
54
|
+
* because a document either carries all of it or none: a manifest link with no theme colour
|
|
55
|
+
* installs an app whose status bar flashes white on every launch, and an apple-touch link with
|
|
56
|
+
* no manifest is an iOS icon for an app iOS will not add.
|
|
57
|
+
*/
|
|
58
|
+
readonly head: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
62
|
+
typeof value === 'object' && value !== null;
|
|
63
|
+
|
|
64
|
+
const text = (value: unknown): string | undefined =>
|
|
65
|
+
typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `colors` read structurally, for `loadSignInPath`'s reason: `defineConfig` returns a plain object
|
|
69
|
+
* and a config resolved through an older core simply has no such key. `validate()` already refused
|
|
70
|
+
* a blank one an `await import` above this line, so anything this rejects is a hand-written config
|
|
71
|
+
* object — and the honest answer for one is no manifest at all, never a colour we invented.
|
|
72
|
+
*/
|
|
73
|
+
function colorsOf(value: unknown): PwaColors | undefined {
|
|
74
|
+
if (!isRecord(value)) return undefined;
|
|
75
|
+
const light = isRecord(value['light']) ? value['light'] : undefined;
|
|
76
|
+
const dark = isRecord(value['dark']) ? value['dark'] : undefined;
|
|
77
|
+
if (light === undefined || dark === undefined) return undefined;
|
|
78
|
+
const [lt, lb, dt, db] = [
|
|
79
|
+
text(light['themeColor']),
|
|
80
|
+
text(light['backgroundColor']),
|
|
81
|
+
text(dark['themeColor']),
|
|
82
|
+
text(dark['backgroundColor']),
|
|
83
|
+
];
|
|
84
|
+
if (lt === undefined || lb === undefined || dt === undefined || db === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
return {
|
|
87
|
+
light: { themeColor: lt, backgroundColor: lb },
|
|
88
|
+
dark: { themeColor: dt, backgroundColor: db },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The `pwa` block, as much of it as this file needs, or `undefined` when the app declares none. */
|
|
93
|
+
interface InstallableApp {
|
|
94
|
+
readonly name: string;
|
|
95
|
+
readonly colors: PwaColors;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function loadInstallable(root: string): Promise<InstallableApp | undefined> {
|
|
99
|
+
const configPath = join(root, APP_CONFIG_FILE);
|
|
100
|
+
if (!existsSync(configPath)) return undefined;
|
|
101
|
+
const module = (await import(configPath)) as Record<string, unknown>;
|
|
102
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
103
|
+
if (!isRecord(config)) return undefined;
|
|
104
|
+
const pwa = config['pwa'];
|
|
105
|
+
// `pwa.enabled`, read. Not a truthiness test: `enabled` is the key this whole file exists to
|
|
106
|
+
// give a reader, and `=== true` is what makes a hand-written `enabled: 'yes'` produce no
|
|
107
|
+
// manifest rather than one nobody asked for.
|
|
108
|
+
if (!isRecord(pwa) || pwa['enabled'] !== true) return undefined;
|
|
109
|
+
const name = text(pwa['name']);
|
|
110
|
+
const colors = colorsOf(pwa['colors']);
|
|
111
|
+
if (name === undefined || colors === undefined) return undefined;
|
|
112
|
+
return { name, colors };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolved ONCE at boot, like `loadSignInPath` and `loadCacheTiers` and unlike `faviconBytes`:
|
|
117
|
+
* `await import` caches the module, so re-reading per request would answer the same object at a
|
|
118
|
+
* per-request cost, and the head string has to be available synchronously while a document renders.
|
|
119
|
+
*/
|
|
120
|
+
export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | undefined> {
|
|
121
|
+
const app = await loadInstallable(root);
|
|
122
|
+
if (app === undefined) return undefined;
|
|
123
|
+
// The icons the manifest promises are exactly the ones `/icons/*` serves and `x build` writes —
|
|
124
|
+
// ONE plan, so no surface can name a size another will not produce.
|
|
125
|
+
//
|
|
126
|
+
// AND ONLY WHEN THE APP HAS A SOURCE FOR THEM. `planIcons` answers the same fourteen entries
|
|
127
|
+
// whether `apps/web/site/icon.png` exists or not, so a manifest built off it unconditionally
|
|
128
|
+
// promises twelve icons and three apple-touch links that are twelve 404s in an install prompt —
|
|
129
|
+
// the promise-nothing-keeps shape this whole module exists to close, one level down.
|
|
130
|
+
// `examples/dummy` is exactly that app: it declares `pwa.enabled: true` and commits no icon.
|
|
131
|
+
// A missing source is NOT reported here — `x doctor` already refuses it by name with
|
|
132
|
+
// `X_PWA_ICON_MISSING`, and a second reporter of one condition is the duplication this package's
|
|
133
|
+
// own rule forbids. Read at boot, like the rest of this function: adding the file takes effect
|
|
134
|
+
// on the next start, because the manifest is generated once and served as bytes.
|
|
135
|
+
const icons = (await hasSourceIcon(root)) ? iconPlan() : undefined;
|
|
136
|
+
const result = generateWebManifest({
|
|
137
|
+
name: app.name,
|
|
138
|
+
tokens: app.colors,
|
|
139
|
+
icons: icons?.manifestIcons ?? [],
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
body: serializeWebManifest(result.manifest),
|
|
143
|
+
head:
|
|
144
|
+
`<link rel="manifest" href="${escapeAttribute(WEB_MANIFEST_PATH)}">` +
|
|
145
|
+
renderThemeColorMeta(result.themeColorMeta) +
|
|
146
|
+
(icons === undefined ? '' : appleTouchLinks(icons)),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The icon bytes a STATIC export has to carry, written under `out`. Answers the paths it wrote.
|
|
152
|
+
*
|
|
153
|
+
* A static host runs no `assetRoutes()`, so every `/icons/*` entry the manifest names is a 404
|
|
154
|
+
* unless the bytes are in the artifact — the same rule `prerenderSite` already applies to
|
|
155
|
+
* `favicon.ico` and `404.html`, one asset class further along. Nothing when the app has no source
|
|
156
|
+
* icon, which is also when the manifest names none.
|
|
157
|
+
*/
|
|
158
|
+
export async function writePwaIcons(root: string, out: string): Promise<readonly string[]> {
|
|
159
|
+
if (!(await hasSourceIcon(root))) return [];
|
|
160
|
+
const plan = iconPlan();
|
|
161
|
+
const render = iconRenderer(root);
|
|
162
|
+
const written: string[] = [];
|
|
163
|
+
for (const entry of plan.entries) {
|
|
164
|
+
// `outputPath` is `/icons/<file>`; `out` is the export root, so the leading slash goes.
|
|
165
|
+
await Bun.write(join(out, entry.outputPath.slice(1)), await render(plan, entry.outputPath));
|
|
166
|
+
written.push(entry.outputPath);
|
|
167
|
+
}
|
|
168
|
+
return written;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Mounted through `assetRoutes`, so `x dev` and the container serve it from one place — a surface
|
|
173
|
+
* that answers in dev and not in the image is the failure that file's own header names.
|
|
174
|
+
* Public: a browser fetches a manifest before anyone has signed in, and an installable app that
|
|
175
|
+
* needs a session to describe itself is not installable.
|
|
176
|
+
*/
|
|
177
|
+
export const pwaManifestRoute = (artifacts: PwaArtifacts): Route => ({
|
|
178
|
+
method: 'GET',
|
|
179
|
+
path: WEB_MANIFEST_PATH,
|
|
180
|
+
meta: { name: 'assets.manifest', auth: 'public', cache: MANIFEST_CACHE, tags: ['assets'] },
|
|
181
|
+
handler: async (_request: UltimateRequest): Promise<Response> =>
|
|
182
|
+
applyCacheHeaders(
|
|
183
|
+
new Response(artifacts.body, {
|
|
184
|
+
headers: { 'content-type': 'application/manifest+json; charset=utf-8' },
|
|
185
|
+
}),
|
|
186
|
+
MANIFEST_CACHE,
|
|
187
|
+
),
|
|
188
|
+
});
|
package/src/serve.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { apiRoutes } from './api-routes';
|
|
|
25
25
|
import { loadSignInPath } from './app-auth';
|
|
26
26
|
import { loadApp } from './app-load';
|
|
27
27
|
import { appManifest } from './app-manifest';
|
|
28
|
+
import { acceptCreatedTables } from './db-accept-created';
|
|
28
29
|
import { assetRoutes } from './dev-assets';
|
|
29
30
|
import { startQueue } from './dev-queue';
|
|
30
31
|
import { appRoutes } from './dev-render';
|
|
@@ -43,6 +44,7 @@ import { islandRoutes } from './island-routes';
|
|
|
43
44
|
import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
|
|
44
45
|
import { readMigrations } from './migrations';
|
|
45
46
|
import { startOtlpExport } from './otlp-export';
|
|
47
|
+
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
46
48
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
47
49
|
|
|
48
50
|
export const DEFAULT_PORT = 3000;
|
|
@@ -200,7 +202,11 @@ export async function runMigrations(options: ServeOptions): Promise<MigratedApp>
|
|
|
200
202
|
available: migrations.length,
|
|
201
203
|
appVersion: report.appVersion,
|
|
202
204
|
});
|
|
203
|
-
|
|
205
|
+
// Every migration above has just been applied, so a `create table` in one of them is proof
|
|
206
|
+
// the app owns that relation — and a snapshot records only what ENTITIES declare, so without
|
|
207
|
+
// this a hand-written table is `unexpected-table` on this deploy and on every deploy after it
|
|
208
|
+
// (issue #345). Only that one difference, only for a name a migration's SQL creates.
|
|
209
|
+
const drift = acceptCreatedTables(await checkDrift({ migrations }), migrations);
|
|
204
210
|
// Logged with the first difference, not just a count: a release phase's log is the only place
|
|
205
211
|
// an operator sees this, and "3 differences" names nothing to act on.
|
|
206
212
|
if (!drift.ok) {
|
|
@@ -293,18 +299,24 @@ async function bootRoles(boot: {
|
|
|
293
299
|
// does from the same source — the alternative is a second bundler invocation in the image build
|
|
294
300
|
// whose output nothing compares against the one the dev loop proved.
|
|
295
301
|
const islands = await buildIslands(options.root);
|
|
302
|
+
// The same two strings `x dev` resolves, from the same reader: a `<link rel="manifest">` served
|
|
303
|
+
// on a laptop and absent in the image is exactly the dev/prod difference this file exists to
|
|
304
|
+
// prevent, and it is the one an operator cannot see without installing the app.
|
|
305
|
+
const pwa = await loadPwaArtifacts(options.root);
|
|
296
306
|
const routes: readonly Route[] = [
|
|
297
307
|
...apiRoutes(),
|
|
298
308
|
...assetRoutes({
|
|
299
309
|
root: options.root,
|
|
300
310
|
storage: runtime.storage,
|
|
301
311
|
...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
|
|
312
|
+
...(pwa === undefined ? {} : { pwa }),
|
|
302
313
|
}),
|
|
303
314
|
...storageRoutes({ storage: runtime.storage }),
|
|
304
315
|
...islandRoutes(() => islands),
|
|
305
316
|
...appRoutes({
|
|
306
317
|
buildId,
|
|
307
318
|
resolveIsland: (file) => islands.resolverFor(file),
|
|
319
|
+
...(pwa === undefined ? {} : { pwaHead: pwa.head }),
|
|
308
320
|
// Only when a store was supplied. `createIsrController` defaults to a per-process memory
|
|
309
321
|
// store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
|
|
310
322
|
// the fleet while the other eleven keep serving the page it just invalidated.
|
package/src/templates/naming.ts
CHANGED
|
@@ -79,6 +79,17 @@ export const plural = (input: string): string => {
|
|
|
79
79
|
|
|
80
80
|
export const titleKey = (input: string): string => `app.${kebab(input)}.title`;
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* A human title from a slug: `ledger-demo` -> `Ledger Demo`. Its one caller is the scaffolded
|
|
84
|
+
* `pwa.name`, which is what a browser shows a person in the install prompt — `app.name` is a slug
|
|
85
|
+
* by `NAME_RE` and `pascal` would offer to install `LedgerDemo`. Not a `NameSet` member: every
|
|
86
|
+
* other field there names a code identifier, and this one is prose.
|
|
87
|
+
*/
|
|
88
|
+
export const titleCase = (input: string): string =>
|
|
89
|
+
words(input)
|
|
90
|
+
.map((word) => `${word[0]?.toUpperCase() ?? ''}${word.slice(1)}`)
|
|
91
|
+
.join(' ');
|
|
92
|
+
|
|
82
93
|
export interface NameSet {
|
|
83
94
|
readonly raw: string;
|
|
84
95
|
readonly kebab: string;
|
|
@@ -8,6 +8,7 @@ import { ENV_EXAMPLE_PATH } from '@ultimat3/core';
|
|
|
8
8
|
import { VERIFY_FLOOR_FILE } from '../verify-floor';
|
|
9
9
|
import type { VerifyStepName } from '../verify-step';
|
|
10
10
|
import type { GeneratedFile, NameSet } from './naming';
|
|
11
|
+
import { titleCase } from './naming';
|
|
11
12
|
import { dbPackageFiles } from './scaffold-db-package';
|
|
12
13
|
import { docsFiles } from './scaffold-docs';
|
|
13
14
|
import { domainPackageFiles } from './scaffold-domain-package';
|
|
@@ -180,7 +181,19 @@ export const config = defineConfig({
|
|
|
180
181
|
jobs: { queues: ['${app.kebab}-default'], concurrency: 4 },
|
|
181
182
|
// In-process transport by default; set urlEnv and transport: 'nats' to scale past one node.
|
|
182
183
|
realtime: { enabled: true, transport: 'memory' },
|
|
183
|
-
|
|
184
|
+
// \`name\` and \`colors\` are what an install prompt shows and what a browser paints the splash
|
|
185
|
+
// with before any stylesheet has loaded — the four values the framework cannot derive, so
|
|
186
|
+
// \`defineConfig\` refuses \`pwa.enabled: true\` without them. Raw hex is legal here and nowhere
|
|
187
|
+
// else in an app.
|
|
188
|
+
pwa: {
|
|
189
|
+
enabled: true,
|
|
190
|
+
offline: 'runtime',
|
|
191
|
+
name: '${titleCase(app.raw)}',
|
|
192
|
+
colors: {
|
|
193
|
+
light: { themeColor: '#1b1f3b', backgroundColor: '#ffffff' },
|
|
194
|
+
dark: { themeColor: '#1b1f3b', backgroundColor: '#0b0d1a' },
|
|
195
|
+
},
|
|
196
|
+
},
|
|
184
197
|
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
185
198
|
});
|
|
186
199
|
`;
|