@ultimat3/cli 22.3.5 → 22.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "22.3.5",
3
+ "version": "22.4.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.3.5",
42
- "@ultimat3/admin": "22.3.5",
43
- "@ultimat3/ai": "22.3.5",
44
- "@ultimat3/auth": "22.3.5",
45
- "@ultimat3/cache": "22.3.5",
46
- "@ultimat3/core": "22.3.5",
47
- "@ultimat3/db": "22.3.5",
48
- "@ultimat3/entity": "22.3.5",
49
- "@ultimat3/flags": "22.3.5",
50
- "@ultimat3/http": "22.3.5",
51
- "@ultimat3/i18n": "22.3.5",
52
- "@ultimat3/jobs": "22.3.5",
53
- "@ultimat3/mail": "22.3.5",
54
- "@ultimat3/manifest": "22.3.5",
55
- "@ultimat3/mcp": "22.3.5",
56
- "@ultimat3/money": "22.3.5",
57
- "@ultimat3/notify": "22.3.5",
58
- "@ultimat3/policy": "22.3.5",
59
- "@ultimat3/pwa": "22.3.5",
60
- "@ultimat3/query": "22.3.5",
61
- "@ultimat3/realtime": "22.3.5",
62
- "@ultimat3/render": "22.3.5",
63
- "@ultimat3/schema": "22.3.5",
64
- "@ultimat3/seo": "22.3.5",
65
- "@ultimat3/storage": "22.3.5",
66
- "@ultimat3/testing": "22.3.5",
67
- "@ultimat3/time": "22.3.5",
41
+ "@ultimat3/action": "22.4.0",
42
+ "@ultimat3/admin": "22.4.0",
43
+ "@ultimat3/ai": "22.4.0",
44
+ "@ultimat3/auth": "22.4.0",
45
+ "@ultimat3/cache": "22.4.0",
46
+ "@ultimat3/core": "22.4.0",
47
+ "@ultimat3/db": "22.4.0",
48
+ "@ultimat3/entity": "22.4.0",
49
+ "@ultimat3/flags": "22.4.0",
50
+ "@ultimat3/http": "22.4.0",
51
+ "@ultimat3/i18n": "22.4.0",
52
+ "@ultimat3/jobs": "22.4.0",
53
+ "@ultimat3/mail": "22.4.0",
54
+ "@ultimat3/manifest": "22.4.0",
55
+ "@ultimat3/mcp": "22.4.0",
56
+ "@ultimat3/money": "22.4.0",
57
+ "@ultimat3/notify": "22.4.0",
58
+ "@ultimat3/policy": "22.4.0",
59
+ "@ultimat3/pwa": "22.4.0",
60
+ "@ultimat3/query": "22.4.0",
61
+ "@ultimat3/realtime": "22.4.0",
62
+ "@ultimat3/render": "22.4.0",
63
+ "@ultimat3/schema": "22.4.0",
64
+ "@ultimat3/seo": "22.4.0",
65
+ "@ultimat3/storage": "22.4.0",
66
+ "@ultimat3/testing": "22.4.0",
67
+ "@ultimat3/time": "22.4.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
@@ -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
- 'apps/web/runtime.ts': `const stamp = async (request, ctx, next) => {
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
- export const runtime = { middleware: [stamp] };
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
- const runtime: RunningServices = await startServices(resolved, env);
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,
@@ -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);
@@ -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[],
@@ -27,7 +27,10 @@
27
27
  // in the gate would notice had gone stale.
28
28
  //
29
29
  // TWO THINGS THE OLD SPLIT OWNED AND BUN NOW OWNS. `--parallel` implies `--isolate`, so the
30
- // per-FILE module registry that made an arbitrary partition safe at all is unchanged. And the
30
+ // per-FILE module registry that made an arbitrary partition safe at all is unchanged. Isolation is
31
+ // not per-file MEMORY, though: on Bun 1.4.0 a finished file's global object is never freed while
32
+ // any `Bun.plugin` handler is registered, so every child gets `ULTIMATE_TEST_ISOLATED=1` and
33
+ // `@ultimat3/testing`'s preload clears the plugins after each file (`isolated-plugins.ts`). And the
31
34
  // per-WORKER database survives untouched: `@ultimat3/testing`'s `workerId` already read
32
35
  // `BUN_TEST_WORKER_ID` as its second key, which is exactly what Bun sets, 1..N, one per real
33
36
  // process (probed on 1.4.0). `ULTIMATE_TEST_WORKER` stays the first key and is what `--worker`
@@ -41,6 +44,10 @@ import { msg } from './messages';
41
44
  import type { CommandResult, Finding, JsonValue, StepResult } from './output';
42
45
  import { quoteArg } from './shell-quote';
43
46
  import { testEnvOverrides } from './test-dotenv';
47
+
48
+ /** `@ultimat3/testing`'s `ISOLATED_ENV`, restated: `cli → testing` is a runtime edge, kept to fixtures. */
49
+ export const ISOLATED_TEST_ENV = 'ULTIMATE_TEST_ISOLATED';
50
+
44
51
  import { testPasses } from './test-passes';
45
52
  import type { TestFile } from './test-select';
46
53
  import type { TestType } from './verify-tests';
@@ -231,6 +238,8 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
231
238
  // same parent env, so the leaked-key set cannot differ pass to pass.
232
239
  const envOverrides: Record<string, string | undefined> = {
233
240
  ...testEnvOverrides(options.root, options.env ?? Bun.env),
241
+ // Every run this spawns is isolated (`--parallel` or `--isolate`) unless the caller opted out.
242
+ ...((options.passthrough ?? []).includes('--no-isolate') ? {} : { [ISOLATED_TEST_ENV]: '1' }),
234
243
  };
235
244
  const passes = testPasses({
236
245
  files: options.files,