@ultimat3/cli 22.3.6 → 22.5.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/package.json +28 -28
- package/src/cmd-dev-fixture.ts +9 -3
- package/src/cmd-dev.ts +11 -7
- package/src/generate-kinds.ts +24 -0
- package/src/pwa-artifacts.ts +39 -1
- package/src/serve-boot.ts +5 -1
- package/src/serve-drain.ts +20 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.5.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,33 +38,33 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@babel/core": "^8.0.1",
|
|
41
|
-
"@ultimat3/action": "22.
|
|
42
|
-
"@ultimat3/admin": "22.
|
|
43
|
-
"@ultimat3/ai": "22.
|
|
44
|
-
"@ultimat3/auth": "22.
|
|
45
|
-
"@ultimat3/cache": "22.
|
|
46
|
-
"@ultimat3/core": "22.
|
|
47
|
-
"@ultimat3/db": "22.
|
|
48
|
-
"@ultimat3/entity": "22.
|
|
49
|
-
"@ultimat3/flags": "22.
|
|
50
|
-
"@ultimat3/http": "22.
|
|
51
|
-
"@ultimat3/i18n": "22.
|
|
52
|
-
"@ultimat3/jobs": "22.
|
|
53
|
-
"@ultimat3/mail": "22.
|
|
54
|
-
"@ultimat3/manifest": "22.
|
|
55
|
-
"@ultimat3/mcp": "22.
|
|
56
|
-
"@ultimat3/money": "22.
|
|
57
|
-
"@ultimat3/notify": "22.
|
|
58
|
-
"@ultimat3/policy": "22.
|
|
59
|
-
"@ultimat3/pwa": "22.
|
|
60
|
-
"@ultimat3/query": "22.
|
|
61
|
-
"@ultimat3/realtime": "22.
|
|
62
|
-
"@ultimat3/render": "22.
|
|
63
|
-
"@ultimat3/schema": "22.
|
|
64
|
-
"@ultimat3/seo": "22.
|
|
65
|
-
"@ultimat3/storage": "22.
|
|
66
|
-
"@ultimat3/testing": "22.
|
|
67
|
-
"@ultimat3/time": "22.
|
|
41
|
+
"@ultimat3/action": "22.5.0",
|
|
42
|
+
"@ultimat3/admin": "22.5.0",
|
|
43
|
+
"@ultimat3/ai": "22.5.0",
|
|
44
|
+
"@ultimat3/auth": "22.5.0",
|
|
45
|
+
"@ultimat3/cache": "22.5.0",
|
|
46
|
+
"@ultimat3/core": "22.5.0",
|
|
47
|
+
"@ultimat3/db": "22.5.0",
|
|
48
|
+
"@ultimat3/entity": "22.5.0",
|
|
49
|
+
"@ultimat3/flags": "22.5.0",
|
|
50
|
+
"@ultimat3/http": "22.5.0",
|
|
51
|
+
"@ultimat3/i18n": "22.5.0",
|
|
52
|
+
"@ultimat3/jobs": "22.5.0",
|
|
53
|
+
"@ultimat3/mail": "22.5.0",
|
|
54
|
+
"@ultimat3/manifest": "22.5.0",
|
|
55
|
+
"@ultimat3/mcp": "22.5.0",
|
|
56
|
+
"@ultimat3/money": "22.5.0",
|
|
57
|
+
"@ultimat3/notify": "22.5.0",
|
|
58
|
+
"@ultimat3/policy": "22.5.0",
|
|
59
|
+
"@ultimat3/pwa": "22.5.0",
|
|
60
|
+
"@ultimat3/query": "22.5.0",
|
|
61
|
+
"@ultimat3/realtime": "22.5.0",
|
|
62
|
+
"@ultimat3/render": "22.5.0",
|
|
63
|
+
"@ultimat3/schema": "22.5.0",
|
|
64
|
+
"@ultimat3/seo": "22.5.0",
|
|
65
|
+
"@ultimat3/storage": "22.5.0",
|
|
66
|
+
"@ultimat3/testing": "22.5.0",
|
|
67
|
+
"@ultimat3/time": "22.5.0",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/cmd-dev-fixture.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// What the app declares, and what each declaration is here to prove:
|
|
7
7
|
// app.config.ts the root marker a real `x dev` cannot start without; `ai.mcp` by default
|
|
8
8
|
// apps/web/mcp.ts the app's own MCP endpoint, mounted by the web role
|
|
9
|
-
// apps/web/runtime.ts the app's middleware, reaching a development process
|
|
9
|
+
// apps/web/runtime.ts the app's middleware and disks, reaching a development process
|
|
10
10
|
// .git/HEAD the fixture is its own repository — see the entry below
|
|
11
11
|
// apps/web/app/hello/* an SSR page with a component, edited on disk while `x dev` runs
|
|
12
12
|
// apps/web/app/notes/* a memory-backed entity and a live query, fed by the in-process bridge
|
|
@@ -46,13 +46,19 @@ export const mcp = defineAppMcp({ include: 'exposed', resolveToken: () => null }
|
|
|
46
46
|
|
|
47
47
|
// The app's own middleware, in the contract `app-runtime.ts` reads: a header on every response
|
|
48
48
|
// is the cheapest proof that the chain `x dev` composed is the app's and not only the replica's.
|
|
49
|
-
|
|
49
|
+
// Its disks too: `x dev` built its own embedded disk and ignored this one until 22.4.
|
|
50
|
+
'apps/web/runtime.ts': `import { defineStorage, localDriver } from '@ultimat3/storage';
|
|
51
|
+
const stamp = async (request, ctx, next) => {
|
|
50
52
|
const response = await next(request, ctx);
|
|
51
53
|
const headers = new Headers(response.headers);
|
|
52
54
|
headers.set('x-dev-runtime', 'app');
|
|
53
55
|
return new Response(response.body, { status: response.status, headers });
|
|
54
56
|
};
|
|
55
|
-
|
|
57
|
+
const storage = defineStorage({
|
|
58
|
+
disks: { appdisk: localDriver({ root: new URL('../../.app-disk', import.meta.url).pathname }) },
|
|
59
|
+
default: 'appdisk',
|
|
60
|
+
});
|
|
61
|
+
export const runtime = { middleware: [stamp], storage };
|
|
56
62
|
`,
|
|
57
63
|
|
|
58
64
|
// A memory-backed entity, so the fixture needs no migration: the row observer sits on
|
package/src/cmd-dev.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
withRealtimeEvents,
|
|
48
48
|
} from './runtime-bindings';
|
|
49
49
|
import { liveFeedLabel } from './runtime-live-feed';
|
|
50
|
+
import type { RuntimeOverrides } from './runtime-overrides';
|
|
50
51
|
import { replicaOverrides } from './runtime-replica';
|
|
51
52
|
import type { RunningServices } from './runtime-services';
|
|
52
53
|
import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './runtime-services';
|
|
@@ -133,14 +134,20 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
133
134
|
? { ...options.env, ULTIMATE_ENV: 'development' }
|
|
134
135
|
: options.env;
|
|
135
136
|
const resolved = resolveServices(options.root, env);
|
|
136
|
-
|
|
137
|
+
// The app's `apps/<app>/runtime.ts`, read ONCE and handed to `startServices` exactly as
|
|
138
|
+
// `serve.ts`'s `withAppRuntime` does — so the disks (`storage`), the queue, the mail driver and
|
|
139
|
+
// every other override a deployment declares are the ones development runs too. Until 22.4 this
|
|
140
|
+
// boot passed nothing here and built its own embedded disk, so an app's `/_storage` routes and its
|
|
141
|
+
// actions wrote to two different places in `x dev` and to one in production.
|
|
142
|
+
const appRuntime = await loadAppRuntime(options.root);
|
|
143
|
+
const runtime: RunningServices = await startServices(resolved, env, appRuntime);
|
|
137
144
|
// The events binding the boot line reports is the bus the runtime chose, read off it.
|
|
138
145
|
const services = withRealtimeEvents(resolved, env, runtime.realtime);
|
|
139
146
|
// `serve.ts`'s `releaseBoot` shape: everything acquired from here on is released, newest first,
|
|
140
147
|
// if the boot throws — a failed `x dev` left PGlite holding `.x/pgdata` for the retry to meet.
|
|
141
148
|
const acquired: (() => void | Promise<void>)[] = [() => runtime.stop()];
|
|
142
149
|
try {
|
|
143
|
-
return await bootDev(options, services, runtime, acquired);
|
|
150
|
+
return await bootDev(options, services, runtime, acquired, appRuntime);
|
|
144
151
|
} catch (error) {
|
|
145
152
|
await releaseBoot(acquired);
|
|
146
153
|
throw error;
|
|
@@ -152,6 +159,7 @@ async function bootDev(
|
|
|
152
159
|
services: ReturnType<typeof resolveServices>,
|
|
153
160
|
runtime: RunningServices,
|
|
154
161
|
acquired: (() => void | Promise<void>)[],
|
|
162
|
+
appRuntime: RuntimeOverrides | undefined,
|
|
155
163
|
): Promise<DevServer> {
|
|
156
164
|
// Installed before the app loads, so a span opened during registration is already recorded.
|
|
157
165
|
// Tracing is always on in the framework and free until an exporter is configured; `x dev` is
|
|
@@ -229,11 +237,7 @@ async function bootDev(
|
|
|
229
237
|
// The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
|
|
230
238
|
// `runtime`: the replica scope in front, the app's own middleware behind it. Before this the
|
|
231
239
|
// first argument was `undefined` here and an app's middleware reached no development process.
|
|
232
|
-
const replicaOverride = replicaOverrides(
|
|
233
|
-
await loadAppRuntime(options.root),
|
|
234
|
-
services.db,
|
|
235
|
-
options.env,
|
|
236
|
-
);
|
|
240
|
+
const replicaOverride = replicaOverrides(appRuntime, services.db, options.env);
|
|
237
241
|
const running = await startRoles({
|
|
238
242
|
roles: options.roles ?? DEV_ROLES,
|
|
239
243
|
port: options.port,
|
package/src/generate-kinds.ts
CHANGED
|
@@ -114,11 +114,35 @@ export function readName(raw: string | undefined, kind: Generator): string {
|
|
|
114
114
|
example: exampleFor(kind),
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
|
+
// A route's name IS its URL: `x g route casos/[id]/notificar` writes the page under the
|
|
118
|
+
// directory that serves it. Every other generator's name is one directory segment.
|
|
119
|
+
if (kind === 'route') return readRoutePath(raw);
|
|
117
120
|
refusePath(raw, 'name', kind);
|
|
118
121
|
refuseBadIdentifier(raw, kind);
|
|
119
122
|
return raw;
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
/** A static segment, or `[param]` / `[...rest]` — the directory convention `x routes` reads. */
|
|
126
|
+
const ROUTE_SEGMENT = /^(?:[A-Za-z0-9][A-Za-z0-9_-]*|\[(?:\.\.\.)?[A-Za-z][A-Za-z0-9_-]*\])$/;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `x g route` refused any path until 22.4, so a nested or dynamic page (`casos/[id]/notificar`)
|
|
130
|
+
* had to be generated flat and moved by hand — and the move is where the generated test's URL and
|
|
131
|
+
* the directory stopped agreeing. Each segment is checked on its own, which is also the traversal
|
|
132
|
+
* guard: `..`, `.`, an empty segment and a backslash are not segments this pattern admits.
|
|
133
|
+
*/
|
|
134
|
+
function readRoutePath(raw: string): string {
|
|
135
|
+
const segments = raw.replace(/^\/+/, '').split('/');
|
|
136
|
+
const bad = segments.find((segment) => !ROUTE_SEGMENT.test(segment));
|
|
137
|
+
if (bad === undefined) return raw.replace(/^\/+/, '');
|
|
138
|
+
throw new BadFlagError({
|
|
139
|
+
flag: 'name',
|
|
140
|
+
command: 'g route',
|
|
141
|
+
reason: `"${raw}" has the segment "${bad}", and a route segment is a word (a-z, 0-9, -, _) or [param] / [...rest]`,
|
|
142
|
+
fix: 'x g route posts/[slug] --surface site',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
122
146
|
/** `--feature`, refused where it is a path: it is a DIRECTORY under the surface, one segment. */
|
|
123
147
|
export function readFeature(raw: string | undefined, kind: Generator): string | undefined {
|
|
124
148
|
if (raw !== undefined) refusePath(raw, 'feature', kind);
|
package/src/pwa-artifacts.ts
CHANGED
|
@@ -32,12 +32,15 @@ import {
|
|
|
32
32
|
renderThemeColorMeta,
|
|
33
33
|
serializeWebManifest,
|
|
34
34
|
} from '@ultimat3/pwa';
|
|
35
|
+
import type { AssetPath } from '@ultimat3/render';
|
|
36
|
+
import { assetPathProblem } from '@ultimat3/render';
|
|
35
37
|
import { escapeAttribute } from '@ultimat3/seo';
|
|
36
38
|
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
37
39
|
import { APP_CONFIG_FILE } from './app-root';
|
|
38
40
|
import { hasSourceIcon, iconPlan, iconRenderer } from './icon-assets';
|
|
39
41
|
import type { AppLocales } from './pwa-manifest-locales';
|
|
40
42
|
import { appLocales, localeMembers, spellIn } from './pwa-manifest-locales';
|
|
43
|
+
import { parseHashedAssetUrl, siteAssetTable } from './site-assets';
|
|
41
44
|
|
|
42
45
|
/** What a browser fetches from `<link rel="manifest">`. The spec's own extension, not `.json`. */
|
|
43
46
|
export const WEB_MANIFEST_PATH = '/manifest.webmanifest';
|
|
@@ -208,7 +211,7 @@ export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | und
|
|
|
208
211
|
name: app.name,
|
|
209
212
|
tokens: app.colors,
|
|
210
213
|
icons: icons?.manifestIcons ?? [],
|
|
211
|
-
...localeMembers(app.block, locale, app.locales),
|
|
214
|
+
...withHashedAssets(localeMembers(app.block, locale, app.locales), root),
|
|
212
215
|
});
|
|
213
216
|
const path = spellIn(WEB_MANIFEST_PATH, locale, app.locales);
|
|
214
217
|
return {
|
|
@@ -235,6 +238,41 @@ export async function loadPwaArtifacts(root: string): Promise<PwaArtifacts | und
|
|
|
235
238
|
};
|
|
236
239
|
}
|
|
237
240
|
|
|
241
|
+
/**
|
|
242
|
+
* A screenshot or shortcut icon written as the asset it is — `assets/pwa/wide.png` or
|
|
243
|
+
* `/assets/pwa/wide.png` — answers the content-hashed URL the site actually serves, exactly as
|
|
244
|
+
* `asset()` does in a page. The site serves an asset ONLY at its hashed URL, and the manifest took
|
|
245
|
+
* `src` verbatim, so an app had to hash its own screenshots to avoid shipping a 404 in the install
|
|
246
|
+
* sheet (notificado.co's `pwa-install.ts`). A missing file is `X_ASSET_MISSING` at boot, never a
|
|
247
|
+
* broken image in an install prompt; any other `src` (absolute, already hashed) passes untouched.
|
|
248
|
+
*/
|
|
249
|
+
function withHashedAssets<M extends ReturnType<typeof localeMembers>>(members: M, root: string): M {
|
|
250
|
+
const resolve = (src: string): string => {
|
|
251
|
+
const path = src.replace(/^\//, '');
|
|
252
|
+
if (!path.startsWith('assets/') || parseHashedAssetUrl(`/${path}`) !== undefined) return src;
|
|
253
|
+
if (assetPathProblem(path) !== undefined) return src;
|
|
254
|
+
return siteAssetTable(root).resolve(path as AssetPath).url;
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
...members,
|
|
258
|
+
...(members.screenshots === undefined
|
|
259
|
+
? {}
|
|
260
|
+
: { screenshots: members.screenshots.map((shot) => ({ ...shot, src: resolve(shot.src) })) }),
|
|
261
|
+
...(members.shortcuts === undefined
|
|
262
|
+
? {}
|
|
263
|
+
: {
|
|
264
|
+
shortcuts: members.shortcuts.map((shortcut) =>
|
|
265
|
+
shortcut.icons === undefined
|
|
266
|
+
? shortcut
|
|
267
|
+
: {
|
|
268
|
+
...shortcut,
|
|
269
|
+
icons: shortcut.icons.map((icon) => ({ ...icon, src: resolve(icon.src) })),
|
|
270
|
+
},
|
|
271
|
+
),
|
|
272
|
+
}),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
238
276
|
/** The manifest `locale` names, by URL segment (`es-CO` is `es-co`), if the app routes it. */
|
|
239
277
|
const manifestIn = (
|
|
240
278
|
manifests: readonly LocalizedManifest[],
|
package/src/serve-boot.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// — are this file's.
|
|
5
5
|
|
|
6
6
|
import type { Role } from '@ultimat3/core';
|
|
7
|
+
import { configureLifecycle } from '@ultimat3/core';
|
|
7
8
|
import type { Route } from '@ultimat3/http';
|
|
8
9
|
import { describeRoutes } from '@ultimat3/render';
|
|
9
10
|
import { createIsrController } from '@ultimat3/render/server';
|
|
@@ -26,7 +27,7 @@ import { replicaOverrides } from './runtime-replica';
|
|
|
26
27
|
import type { RunningServices } from './runtime-services';
|
|
27
28
|
import { servedStorage, storageRoutes } from './runtime-storage';
|
|
28
29
|
import { seoRoutes } from './seo-routes';
|
|
29
|
-
import { loadDrainConfig } from './serve-drain';
|
|
30
|
+
import { loadDrainConfig, loadHealthConfig } from './serve-drain';
|
|
30
31
|
import { configureReporting, containerBinding, metricsPortFor, portFromEnv } from './serve-env';
|
|
31
32
|
import type { ServedApp, ServeOptions } from './serve-types';
|
|
32
33
|
import { loadSiteSettings, publicOrigin } from './site-config';
|
|
@@ -77,6 +78,9 @@ export async function bootRoles(boot: {
|
|
|
77
78
|
// still wins — that is the deploy talking.
|
|
78
79
|
const metricsPort = metricsPortFor(options.env, port, options.metricsPort);
|
|
79
80
|
const drain = await loadDrainConfig(options.root);
|
|
81
|
+
// Process-wide, so every role's `/readyz` — web, sync, worker — answers in the declared mode.
|
|
82
|
+
const health = await loadHealthConfig(options.root);
|
|
83
|
+
if (health?.readiness !== undefined) configureLifecycle({ readiness: health.readiness });
|
|
80
84
|
const replicaOverride = replicaOverrides(options.runtime, runtime.services.db, options.env);
|
|
81
85
|
const running = await startRoles({
|
|
82
86
|
roles: [role],
|
package/src/serve-drain.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// `app.config.ts`'s `drain`
|
|
2
|
-
// config reader here makes (`theme-boot.ts`, `app-auth.ts`), because the config is a
|
|
1
|
+
// `app.config.ts`'s `drain` and `health` sections, read for the production boot — the same import
|
|
2
|
+
// every other config reader here makes (`theme-boot.ts`, `app-auth.ts`), because the config is a
|
|
3
|
+
// module. Both shape what `/readyz` tells the load balancer.
|
|
3
4
|
|
|
4
|
-
import type { DrainConfig } from '@ultimat3/core';
|
|
5
|
+
import type { DrainConfig, HealthConfig } from '@ultimat3/core';
|
|
5
6
|
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
6
7
|
import { APP_CONFIG_FILE } from './app-root';
|
|
7
8
|
|
|
@@ -22,3 +23,19 @@ export async function loadDrainConfig(root: string): Promise<Partial<DrainConfig
|
|
|
22
23
|
if (!isRecord(drain) || !Object.hasOwn(drain, 'readinessGraceMs')) return undefined;
|
|
23
24
|
return { readinessGraceMs: drain['readinessGraceMs'] as number };
|
|
24
25
|
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `health.readiness`, or `undefined` when the app declares none (core keeps `'dependencies'`). The
|
|
29
|
+
* value is handed to `configureLifecycle` as written, so a bad one is refused by core's own rule.
|
|
30
|
+
*/
|
|
31
|
+
export async function loadHealthConfig(root: string): Promise<Partial<HealthConfig> | undefined> {
|
|
32
|
+
const path = `${root}/${APP_CONFIG_FILE}`;
|
|
33
|
+
if (!(await Bun.file(path).exists())) return undefined;
|
|
34
|
+
const module = (await import(path)) as Record<string, unknown>;
|
|
35
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
36
|
+
const health = isRecord(config)
|
|
37
|
+
? (config['health'] as Partial<HealthConfig> | undefined)
|
|
38
|
+
: undefined;
|
|
39
|
+
if (!isRecord(health) || health.readiness === undefined) return undefined;
|
|
40
|
+
return { readiness: health.readiness };
|
|
41
|
+
}
|