@ultimat3/cli 22.15.0 → 24.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.
Files changed (267) hide show
  1. package/CLAUDE.md +36 -13
  2. package/README.md +31 -8
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/affected.ts +27 -35
  7. package/src/api-registration.ts +125 -11
  8. package/src/app-artifacts.ts +1 -1
  9. package/src/app-auth.ts +4 -27
  10. package/src/app-boundaries.ts +6 -20
  11. package/src/app-config-load.ts +51 -0
  12. package/src/app-env.ts +5 -11
  13. package/src/app-load.ts +55 -11
  14. package/src/app-manifest.ts +16 -8
  15. package/src/app-mcp.ts +10 -23
  16. package/src/app-permissions-borrowed.ts +129 -0
  17. package/src/app-permissions-site.ts +24 -0
  18. package/src/app-permissions.ts +0 -0
  19. package/src/app-reload-graph.ts +7 -30
  20. package/src/app-transport.ts +219 -0
  21. package/src/boundary-findings.ts +16 -4
  22. package/src/browser-transport.ts +160 -0
  23. package/src/budgets.ts +27 -34
  24. package/src/cdp-shot-driver.ts +3 -1
  25. package/src/cmd-auth-spec.ts +16 -0
  26. package/src/cmd-auth.ts +65 -0
  27. package/src/cmd-build-spec.ts +10 -3
  28. package/src/cmd-build.ts +107 -11
  29. package/src/cmd-db-backfill.ts +25 -12
  30. package/src/cmd-db.ts +66 -16
  31. package/src/cmd-deploy-helm.ts +7 -6
  32. package/src/cmd-deploy.ts +4 -4
  33. package/src/cmd-dev.ts +9 -8
  34. package/src/cmd-doctor-spec.ts +2 -9
  35. package/src/cmd-doctor.ts +75 -24
  36. package/src/cmd-env.ts +26 -2
  37. package/src/cmd-generate.ts +195 -47
  38. package/src/cmd-help.ts +19 -6
  39. package/src/cmd-i18n.ts +10 -9
  40. package/src/cmd-jobs-spec.ts +19 -3
  41. package/src/cmd-jobs.ts +136 -7
  42. package/src/cmd-manifest-spec.ts +1 -1
  43. package/src/cmd-manifest.ts +6 -25
  44. package/src/cmd-mcp.ts +46 -15
  45. package/src/cmd-new.ts +15 -1
  46. package/src/cmd-planned.ts +15 -8
  47. package/src/cmd-routes.ts +27 -3
  48. package/src/cmd-secrets-ring.ts +76 -0
  49. package/src/cmd-secrets-spec.ts +10 -2
  50. package/src/cmd-secrets.ts +95 -18
  51. package/src/cmd-shot-matrix.ts +1 -3
  52. package/src/cmd-shot.ts +12 -3
  53. package/src/cmd-tasks.ts +84 -15
  54. package/src/cmd-test-spec.ts +1 -7
  55. package/src/cmd-test.ts +13 -29
  56. package/src/cmd-verify.ts +13 -5
  57. package/src/comment-safe.ts +16 -0
  58. package/src/coverage-floor.ts +334 -0
  59. package/src/coverage-lcov.ts +193 -0
  60. package/src/coverage-source.ts +136 -0
  61. package/src/db-object-drift.ts +30 -0
  62. package/src/db-replay-engine.ts +115 -0
  63. package/src/db-schema-dump.ts +150 -0
  64. package/src/db-schema-refresh.ts +122 -0
  65. package/src/db-subscribes.ts +12 -4
  66. package/src/dev-dashboard-guard.ts +76 -0
  67. package/src/dev-dashboard.ts +51 -29
  68. package/src/dev-lock.ts +4 -7
  69. package/src/dev-route-table.ts +9 -0
  70. package/src/dispatch.ts +3 -6
  71. package/src/doctor-app-url.ts +39 -0
  72. package/src/doctor-auth.ts +95 -0
  73. package/src/doctor-coverage.ts +39 -0
  74. package/src/doctor-guards.ts +54 -0
  75. package/src/doctor-sealed.ts +132 -0
  76. package/src/document-graph.ts +82 -0
  77. package/src/drift-replica-identity.ts +42 -0
  78. package/src/error-catalog.ts +6 -9
  79. package/src/error-codes.ts +55 -3
  80. package/src/error-unthrown.ts +29 -11
  81. package/src/errors.ts +54 -83
  82. package/src/framework-schema-apply.ts +78 -0
  83. package/src/framework-schema-stamp.ts +96 -0
  84. package/src/framework-schema.ts +28 -2
  85. package/src/generate-files.ts +79 -12
  86. package/src/generate-grant-findings.ts +76 -0
  87. package/src/generate-grants.ts +77 -3
  88. package/src/generate-kinds.ts +52 -1
  89. package/src/generate-shadow.ts +46 -0
  90. package/src/generate-write.ts +62 -40
  91. package/src/generated-imports.ts +59 -0
  92. package/src/gh-target.ts +12 -7
  93. package/src/guard-sources.ts +104 -0
  94. package/src/guards.ts +23 -7
  95. package/src/handle-registration.ts +217 -0
  96. package/src/i18n-audit.ts +4 -1
  97. package/src/i18n-registration.ts +36 -0
  98. package/src/image-prepare.ts +51 -8
  99. package/src/import-closure.ts +165 -0
  100. package/src/index.ts +12 -5
  101. package/src/island-bundle.ts +3 -15
  102. package/src/island-store.ts +24 -16
  103. package/src/jobs-drain.ts +95 -21
  104. package/src/jobs-json.ts +50 -0
  105. package/src/jobs-report.ts +7 -4
  106. package/src/json-merge.ts +46 -0
  107. package/src/loopback-host.ts +13 -0
  108. package/src/manifest-staleness.ts +62 -0
  109. package/src/mcp-errors.ts +41 -4
  110. package/src/mcp-host.ts +16 -9
  111. package/src/mcp-test-run.ts +38 -0
  112. package/src/messages.ts +35 -0
  113. package/src/metrics-endpoint.ts +95 -24
  114. package/src/migration-extensions.ts +29 -0
  115. package/src/module-imports.ts +47 -0
  116. package/src/output.ts +24 -9
  117. package/src/page-navigation.ts +7 -23
  118. package/src/page-speculation.ts +5 -20
  119. package/src/permission-grants.ts +41 -9
  120. package/src/prerender-out.ts +34 -7
  121. package/src/prerender.ts +8 -5
  122. package/src/publish-closure.ts +101 -0
  123. package/src/pwa-artifacts.ts +26 -94
  124. package/src/registry.ts +2 -0
  125. package/src/revalidated-response.ts +4 -3
  126. package/src/role-load.ts +143 -0
  127. package/src/role-replicator.ts +16 -0
  128. package/src/role-start.ts +27 -5
  129. package/src/role-sync.ts +55 -20
  130. package/src/role-wake.ts +45 -0
  131. package/src/runtime-admin.ts +141 -0
  132. package/src/runtime-assets.ts +11 -7
  133. package/src/runtime-bindings.ts +4 -4
  134. package/src/runtime-cache.ts +38 -47
  135. package/src/runtime-hooks.ts +5 -2
  136. package/src/runtime-idempotency-scope.ts +23 -0
  137. package/src/runtime-isr.ts +36 -0
  138. package/src/runtime-jobs.ts +8 -33
  139. package/src/runtime-mfa-warning.ts +29 -0
  140. package/src/runtime-notify-retention.ts +10 -46
  141. package/src/runtime-overrides.ts +1 -1
  142. package/src/runtime-purge.ts +38 -16
  143. package/src/runtime-queue.ts +39 -11
  144. package/src/runtime-realtime.ts +9 -45
  145. package/src/runtime-render.ts +68 -35
  146. package/src/runtime-services.ts +22 -18
  147. package/src/runtime-storage.ts +10 -5
  148. package/src/scaffold-typecheck.ts +1 -0
  149. package/src/schema-diff.ts +16 -0
  150. package/src/schema-drift.ts +45 -15
  151. package/src/schema-dump-drift.ts +64 -0
  152. package/src/seo-routes.ts +80 -9
  153. package/src/serve-boot.ts +114 -158
  154. package/src/serve-drain.ts +9 -33
  155. package/src/serve-env.ts +9 -6
  156. package/src/serve-prebuilt-paths.ts +19 -0
  157. package/src/serve-prebuilt.ts +84 -0
  158. package/src/serve-web.ts +169 -0
  159. package/src/serve.ts +6 -9
  160. package/src/server-barrels.ts +54 -0
  161. package/src/shot-locale.ts +5 -23
  162. package/src/site-assets.ts +3 -5
  163. package/src/site-config.ts +9 -48
  164. package/src/site-seo.ts +1 -1
  165. package/src/sitemap-lastmod.ts +5 -0
  166. package/src/solid-loader.ts +12 -3
  167. package/src/source-edit.ts +49 -0
  168. package/src/source-files.ts +6 -0
  169. package/src/stored-object-headers.ts +43 -0
  170. package/src/sync-url.ts +2 -2
  171. package/src/tasks-facts.ts +3 -26
  172. package/src/templates/action.ts +147 -11
  173. package/src/templates/admin-catalog.ts +58 -0
  174. package/src/templates/admin-page.ts +36 -8
  175. package/src/templates/admin.ts +57 -17
  176. package/src/templates/backfill.ts +121 -87
  177. package/src/templates/entity.ts +140 -44
  178. package/src/templates/guard-animated-layout-property.ts +4 -11
  179. package/src/templates/guard-bare-error.ts +9 -13
  180. package/src/templates/guard-focus-visible.ts +4 -11
  181. package/src/templates/guard-image-dimensions.ts +6 -6
  182. package/src/templates/guard-island-without-states.ts +2 -4
  183. package/src/templates/guard-raw-breakpoint.ts +210 -0
  184. package/src/templates/guard-raw-colour.ts +4 -11
  185. package/src/templates/guard-raw-length.ts +375 -0
  186. package/src/templates/guard-raw-motion.ts +213 -0
  187. package/src/templates/guard-raw-shadow.ts +187 -0
  188. package/src/templates/guard-raw-z-index.ts +166 -0
  189. package/src/templates/guard-repo-raw-sql.ts +345 -0
  190. package/src/templates/guard-semantic-interactive.ts +6 -6
  191. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  192. package/src/templates/guard-undefined-style-class.ts +215 -0
  193. package/src/templates/guard-untranslated-string.ts +6 -6
  194. package/src/templates/guard-unzoned-date.ts +9 -13
  195. package/src/templates/index.ts +3 -2
  196. package/src/templates/island.ts +34 -51
  197. package/src/templates/job-body-test.ts +121 -0
  198. package/src/templates/job.ts +23 -8
  199. package/src/templates/naming.ts +28 -0
  200. package/src/templates/policy.ts +16 -9
  201. package/src/templates/query.ts +242 -26
  202. package/src/templates/resource-create.ts +62 -18
  203. package/src/templates/resource-form-island.ts +58 -80
  204. package/src/templates/resource-page.ts +269 -0
  205. package/src/templates/resource-service.ts +96 -0
  206. package/src/templates/resource.ts +86 -61
  207. package/src/templates/route.ts +41 -35
  208. package/src/templates/scaffold-api.ts +55 -1
  209. package/src/templates/scaffold-app.ts +177 -53
  210. package/src/templates/scaffold-auth.ts +83 -19
  211. package/src/templates/scaffold-browser-client.ts +90 -0
  212. package/src/templates/scaffold-container-compose.ts +10 -1
  213. package/src/templates/scaffold-container.ts +20 -2
  214. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  215. package/src/templates/scaffold-dashboard-example.ts +124 -31
  216. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  217. package/src/templates/scaffold-dashboard.ts +1 -2
  218. package/src/templates/scaffold-db-client.ts +144 -0
  219. package/src/templates/scaffold-db-package.ts +90 -8
  220. package/src/templates/scaffold-docs.ts +50 -5
  221. package/src/templates/scaffold-guards.ts +54 -11
  222. package/src/templates/scaffold-helm-fences.ts +76 -0
  223. package/src/templates/scaffold-helm-templates.ts +32 -7
  224. package/src/templates/scaffold-helm.ts +31 -1
  225. package/src/templates/scaffold-http.ts +9 -7
  226. package/src/templates/scaffold-repo.ts +61 -3
  227. package/src/templates/scaffold-roles.ts +56 -9
  228. package/src/templates/scaffold-shell.ts +74 -105
  229. package/src/templates/scaffold-site.ts +45 -19
  230. package/src/templates/scaffold-ui-package.ts +18 -0
  231. package/src/templates/slice-foundation.ts +46 -8
  232. package/src/test-counts.ts +14 -2
  233. package/src/test-ignores.ts +25 -0
  234. package/src/test-passes.ts +0 -15
  235. package/src/test-select.ts +5 -14
  236. package/src/test-shards.ts +41 -93
  237. package/src/test-workers.ts +26 -4
  238. package/src/theme-boot.ts +6 -31
  239. package/src/transport-calls.ts +105 -0
  240. package/src/trusted-hops-error.ts +17 -0
  241. package/src/unscanned-admin.ts +54 -0
  242. package/src/unthrown-codes.ts +26 -0
  243. package/src/verify-checks.ts +43 -53
  244. package/src/verify-coverage-run.ts +190 -0
  245. package/src/verify-deadline.ts +353 -0
  246. package/src/verify-floor.ts +36 -9
  247. package/src/verify-merge.ts +171 -45
  248. package/src/verify-part-step.ts +90 -0
  249. package/src/verify-progress.ts +25 -0
  250. package/src/verify-role-load-probe.ts +54 -0
  251. package/src/verify-role-load.ts +148 -0
  252. package/src/verify-run.ts +116 -37
  253. package/src/verify-shard.ts +33 -11
  254. package/src/verify-stalled.ts +152 -0
  255. package/src/verify-step.ts +42 -1
  256. package/src/verify-tests.ts +83 -8
  257. package/src/workspace-checks.ts +50 -8
  258. package/src/workspace-dep-edit.ts +127 -0
  259. package/src/build-errors.ts +0 -54
  260. package/src/cmd-dev-fixture.ts +0 -204
  261. package/src/dev-live-fixture.ts +0 -122
  262. package/src/policy-fixture.ts +0 -78
  263. package/src/realtime-browser-probe-fixture.ts +0 -9
  264. package/src/role-start-fixture.ts +0 -78
  265. package/src/scaffold-fixture.ts +0 -160
  266. package/src/templates/island-fixture.ts +0 -76
  267. package/src/verify-errors.ts +0 -49
@@ -73,6 +73,10 @@ interface CacheEntry {
73
73
  */
74
74
  const cache = new Map<string, CacheEntry>();
75
75
 
76
+ /** Transforms running now, and the `prepareStackTrace` the first of them found (see below). */
77
+ let inFlight = 0;
78
+ let foundStackTrace: typeof Error.prepareStackTrace;
79
+
76
80
  /** Test seam: the cache is process-global because the dev server it serves is too. */
77
81
  export function clearIslandTransformCache(): void {
78
82
  cache.clear();
@@ -92,8 +96,12 @@ export async function transformIslandTsx(source: string, path: string): Promise<
92
96
 
93
97
  // Babel installs its own `prepareStackTrace` on the first TRANSFORM — not on import, which is
94
98
  // where this guard was first put — and leaving it installed makes `Error.captureStackTrace`
95
- // strict for every unrelated module loaded later in the same process.
96
- const saved = Error.prepareStackTrace;
99
+ // strict for every unrelated module loaded later in the same process, and names every error in
100
+ // it `Error` (a `TypeError`'s stack then opens `Error: …`). Saved by the FIRST transform in
101
+ // flight and restored by the LAST: `Bun.build` loads a second `.tsx` while the first compiles,
102
+ // and a per-call save taken inside that window saved Babel's own rewriter and restored it last.
103
+ if (inFlight === 0) foundStackTrace = Error.prepareStackTrace;
104
+ inFlight += 1;
97
105
  let code: string | null | undefined;
98
106
  try {
99
107
  // `transformAsync` and never `transformFileAsync`: the latter is gated behind `@babel/core`'s
@@ -111,7 +119,8 @@ export async function transformIslandTsx(source: string, path: string): Promise<
111
119
  });
112
120
  code = result?.code;
113
121
  } finally {
114
- Error.prepareStackTrace = saved;
122
+ inFlight -= 1;
123
+ if (inFlight === 0) Error.prepareStackTrace = foundStackTrace;
115
124
  }
116
125
  // A parse error is NOT caught here: Babel's own message already names the file, the line and the
117
126
  // column ("a.island.tsx: Unexpected token (2:23)"), and `buildOne` wraps whatever escapes in
@@ -0,0 +1,49 @@
1
+ // The two text edits a generator makes to a file the app owns: one keyed line added to an object
2
+ // literal, and one import added where the formatter's sorted block would have it. Shared by the
3
+ // registrars (`handle-registration.ts`, `admin-registration.ts`) so "in key order, expanded" means
4
+ // one thing in both.
5
+
6
+ const IDENTIFIER = '[A-Za-z_$][\\w$]*';
7
+
8
+ const keyOf = (item: string): string => item.split(':')[0]?.trim() ?? '';
9
+
10
+ /** Whether the object body already holds `key` — as `key: value` or as a shorthand. */
11
+ export const holdsKey = (body: string, key: string): boolean =>
12
+ new RegExp(`(^|[\\s,{])${key}\\s*[:,]`).test(`${body},`);
13
+
14
+ /**
15
+ * An object literal's body with `line` on its own row, in key order. A body the author keeps on
16
+ * one line is expanded — an expanded literal is the shape a formatter leaves alone at any length.
17
+ * `indent` is what an entry of this literal is indented by.
18
+ */
19
+ export function withKeyedLine(body: string, key: string, line: string, indent: string): string {
20
+ if (!body.includes('\n')) {
21
+ const items = body
22
+ .split(',')
23
+ .map((item) => item.trim())
24
+ .filter((item) => item !== '');
25
+ const lines = [...items.map((item) => `${indent}${item},`), line].sort((a, b) =>
26
+ keyOf(a) < keyOf(b) ? -1 : 1,
27
+ );
28
+ return `\n${lines.join('\n')}\n${indent.slice(2)}`;
29
+ }
30
+ const lines = body.split('\n');
31
+ const isEntry = (text: string): boolean => new RegExp(`^\\s*${IDENTIFIER}\\s*[:,]`).test(text);
32
+ const after = lines.findIndex((text) => isEntry(text) && keyOf(text) > key);
33
+ // Past the last entry when none sorts later: the row before the closing brace's own indent.
34
+ lines.splice(after === -1 ? lines.length - 1 : after, 0, line);
35
+ return lines.join('\n');
36
+ }
37
+
38
+ /** `source` with `line` imported, placed where a sorted import block would have it. */
39
+ export function withSortedImport(source: string, line: string, specifier: string): string {
40
+ if (source.includes(`from '${specifier}'`)) return source;
41
+ const imports = [...source.matchAll(/^import\b[^;]*?from\s+'([^']+)';\n/gm)];
42
+ // Packages only: a relative import is a LATER group, so it is never what this line sorts against.
43
+ const packages = imports.filter((match) => !(match[1] ?? '').startsWith('.'));
44
+ const later = packages.find((match) => (match[1] ?? '') > specifier);
45
+ const last = packages.at(-1);
46
+ const first = imports[0]?.index ?? 0;
47
+ const at = later?.index ?? (last === undefined ? first : last.index + last[0].length);
48
+ return `${source.slice(0, at)}${line}\n${source.slice(at)}`;
49
+ }
@@ -16,6 +16,12 @@ export const SOURCE_GLOBS = [
16
16
  'api/**/*.{ts,tsx}',
17
17
  'shared/**/*.{ts,tsx}',
18
18
  'apps/*/{app,site,api,shared}/**/*.{ts,tsx}',
19
+ // An app's own entry files: authored, shipped, and outside every surface glob above — a 900-line
20
+ // `server.ts` or an unrunnable `fix:` in it was invisible to `filesize` and `errors`.
21
+ // And its guards: run on the gate, held to the error contract, and invisible to `filesize`.
22
+ 'guards/*.ts',
23
+ 'apps/*/server.ts',
24
+ 'apps/*/prerender.ts',
19
25
  ] as const;
20
26
 
21
27
  /**
@@ -0,0 +1,43 @@
1
+ // How a stored object may be presented when the app's own origin serves it (plan 101, s1-sec L6):
2
+ // inline only for a type a browser renders without running anything, a sandboxed download otherwise. Read by
3
+ // both surfaces that serve stored bytes — `/_storage` (`runtime-storage.ts`) and `/media`.
4
+
5
+ /**
6
+ * Raster images, audio and video: what an `<img>`, `<audio>` or `<video>` presents and nothing
7
+ * executes. NOT `image/svg+xml` (it carries `<script>`), `text/html`, `application/xml` or PDF —
8
+ * any of them opened from the app's origin runs with the app's cookies and its `'self'` sources.
9
+ */
10
+ const INLINE_TYPES: ReadonlySet<string> = new Set([
11
+ 'image/png',
12
+ 'image/jpeg',
13
+ 'image/gif',
14
+ 'image/webp',
15
+ 'image/avif',
16
+ 'audio/mpeg',
17
+ 'audio/ogg',
18
+ 'audio/wav',
19
+ 'audio/webm',
20
+ 'video/mp4',
21
+ 'video/webm',
22
+ 'video/ogg',
23
+ ]);
24
+
25
+ /** `image/PNG; charset=…` is `image/png`: parameters and case never widen the list. */
26
+ const essenceOf = (contentType: string): string =>
27
+ (contentType.split(';')[0] ?? '').trim().toLowerCase();
28
+
29
+ export const isInlineSafe = (contentType: string): boolean =>
30
+ INLINE_TYPES.has(essenceOf(contentType));
31
+
32
+ /**
33
+ * The headers a stored object is served with beyond its type. `attachment` makes a navigation to an
34
+ * uploaded `text/html` a download instead of a page on the app's origin; an embedding element
35
+ * (`<img>`, `<video>`) ignores it, so nothing that displays an object inline stops working. The
36
+ * `sandbox` policy is for the browser that renders it anyway: an opaque origin, no script, no
37
+ * cookies. `@ultimat3/http`'s security stage keeps it and adds the app's policy beside it.
38
+ */
39
+ export function storedObjectHeaders(contentType: string): Readonly<Record<string, string>> {
40
+ return isInlineSafe(contentType)
41
+ ? {}
42
+ : { 'content-disposition': 'attachment', 'content-security-policy': 'sandbox' };
43
+ }
package/src/sync-url.ts CHANGED
@@ -43,8 +43,8 @@ export function syncConnectSources(env: Readonly<Record<string, string | undefin
43
43
  }
44
44
 
45
45
  /**
46
- * The page origins a sync node admits besides its own host name: `APP_URL`'s, when set. Only a
47
- * deployment that serves the page on another host than the node needs it; an unparsable value is
46
+ * The page origins a sync node admits besides its own origin: `APP_URL`'s, when set. A deployment
47
+ * that serves the page on another port or host than the node sees needs it; an unparsable value is
48
48
  * refused rather than read as "no page origin", which would refuse every socket in silence.
49
49
  */
50
50
  export function syncOriginsFrom(env: Readonly<Record<string, string | undefined>>): string[] {
@@ -5,35 +5,12 @@
5
5
  import type { TaskDescriptor, TaskHandle } from '@ultimat3/jobs';
6
6
  import { getTask, registeredTasks } from '@ultimat3/jobs';
7
7
  import type { CronPhrases } from '@ultimat3/time';
8
- import {
9
- describeCron,
10
- fromEpochMs,
11
- nextCronOccurrenceMs,
12
- offsetLabel,
13
- toZoned,
14
- } from '@ultimat3/time';
8
+ import { describeCron, fromEpochMs, isoInZone, nextCronOccurrenceMs } from '@ultimat3/time';
15
9
  import { BadFlagError } from './errors';
16
10
 
17
11
  const DEFAULT_COUNT = 5;
18
12
  const MAX_COUNT = 50;
19
13
 
20
- const pad2 = (value: number): string => String(value).padStart(2, '0');
21
-
22
- /**
23
- * ISO-8601 rendered with the ZONE's OWN offset (`2026-03-08T03:00:00-04:00`), never collapsed to
24
- * UTC `Z` — an ambient zone is exactly the bug a task's `tz` exists to prevent. No single
25
- * "instant in a zone, as ISO" export exists in `@ultimat3/time` to call instead: `toIso` is
26
- * UTC-`Z` only, `isoDateInZone` is date-only, and `formatWithOffset` renders locale prose, not
27
- * ISO. So this composes the package's own instant→zoned-wall-clock conversion (`toZoned`) and
28
- * zone-label helper (`offsetLabel`) rather than a fresh `Intl.DateTimeFormat` call here.
29
- */
30
- function isoInZone(ms: number, zone: string): string {
31
- const zoned = toZoned(fromEpochMs(ms), zone);
32
- const date = `${String(zoned.year).padStart(4, '0')}-${pad2(zoned.month)}-${pad2(zoned.day)}`;
33
- const time = `${pad2(zoned.hour)}:${pad2(zoned.minute)}:${pad2(zoned.second)}`;
34
- return `${date}T${time}${offsetLabel(zoned.offsetMinutes)}`;
35
- }
36
-
37
14
  /** `x tasks list` row: the descriptor plus the next occurrence, ms and rendered alike. */
38
15
  export interface TaskFact extends TaskDescriptor {
39
16
  readonly nextMs: number;
@@ -43,7 +20,7 @@ export interface TaskFact extends TaskDescriptor {
43
20
  function toFact(handle: TaskHandle, nowMs: number): TaskFact {
44
21
  const descriptor = handle.describe();
45
22
  const nextMs = nextCronOccurrenceMs(descriptor.cron, descriptor.tz, nowMs);
46
- return { ...descriptor, nextMs, next: isoInZone(nextMs, descriptor.tz) };
23
+ return { ...descriptor, nextMs, next: isoInZone(fromEpochMs(nextMs), descriptor.tz) };
47
24
  }
48
25
 
49
26
  export function listTaskFacts(nowMs: number): readonly TaskFact[] {
@@ -107,7 +84,7 @@ export function taskShowFacts(
107
84
  let cursor = nowMs;
108
85
  for (let i = 0; i < count; i += 1) {
109
86
  cursor = nextCronOccurrenceMs(descriptor.cron, descriptor.tz, cursor);
110
- upcoming.push({ ms: cursor, at: isoInZone(cursor, descriptor.tz) });
87
+ upcoming.push({ ms: cursor, at: isoInZone(fromEpochMs(cursor), descriptor.tz) });
111
88
  }
112
89
  return { descriptor, describe: describeCron(descriptor.cron, 'en-US', phrases), upcoming };
113
90
  }
@@ -3,9 +3,11 @@
3
3
  // job handle, an MCP tool and these tests; the generator writes the declaration and the tests.
4
4
 
5
5
  import type { FeatureTarget } from './entity';
6
+ import { sortedImports } from './imports';
6
7
  import type { GeneratedFile, NameSet } from './naming';
7
8
  import { names } from './naming';
8
- import { sliceExports, sliceFoundation } from './slice-foundation';
9
+ import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
10
+ import { sliceExports, sliceFoundation, sliceTakesScaffoldRow } from './slice-foundation';
9
11
  import { wrapImport } from './wrap';
10
12
 
11
13
  /**
@@ -80,6 +82,9 @@ export const ${name.camel} = mutator({
80
82
  input: t.object({ id: t.uuid, orgId: t.uuid, title: t.string }),
81
83
  output: t.object({ id: t.uuid, title: t.string }),
82
84
  policy: can${feature.pascal}Write,
85
+ // Required on a mutator (X_MUTATOR_NOT_IDEMPOTENT): the client replays this write under one
86
+ // Idempotency-Key after a dropped response, and the replay answers the first result.
87
+ idempotent: true,
83
88
  // No \`mcp\` until a real description is written: see \`x g action\`.
84
89
  // tx.table(name) rather than tx.${feature.plural}: the typed accessor exists only once the app
85
90
  // augments LocalTables, and generated code cannot assume that has happened yet. The name is the
@@ -104,6 +109,10 @@ ${
104
109
  });
105
110
  `;
106
111
 
112
+ /** The code the slice's `errors.ts` declares for a missing row. */
113
+ const notFoundCode = (feature: NameSet): string =>
114
+ `X_${feature.kebab.toUpperCase().split('-').join('_')}_NOT_FOUND`;
115
+
107
116
  const ID = '00000000-0000-4000-8000-000000000001';
108
117
  const ORG = '00000000-0000-4000-8000-000000000002';
109
118
  const OTHER_ORG = '00000000-0000-4000-8000-000000000009';
@@ -160,20 +169,137 @@ const outsider = testActor('outsider', {
160
169
  : ''
161
170
  }`;
162
171
 
163
- const actionUnitTest = (
164
- name: NameSet,
165
- feature: NameSet,
166
- isMutator: boolean,
167
- ): string => `// ${name.camel}: its declared shape and the input it refuses. Both are answered by the declaration
168
- // alone, so they belong to the \`unit\` step — the contract projections are next door.
169
- ${preamble(name, feature, isMutator, 'expect, unitTest', false)}
170
- ${shapeTest(name, isMutator)}
172
+ /** What the unit test may do with the slice the declaration lives in. */
173
+ interface HandlerShape {
174
+ readonly isMutator: boolean;
175
+ /** The handler reads a row by id and throws `<Feature>NotFoundError` when there is none. */
176
+ readonly lookup: boolean;
177
+ /** The slice still takes the row `x g entity` scaffolds, so the test may store one. */
178
+ readonly storesRow: boolean;
179
+ readonly dbModule: string;
180
+ }
181
+
182
+ /**
183
+ * The optimistic half, run against the client store `@ultimat3/testing` ships. A twin is replayed
184
+ * on every rebase, so the two facts worth failing on are the row one application leaves and that a
185
+ * second leaves the same one — and that a row the store never held is not invented.
186
+ */
187
+ const localTwinTests = (): string => `
188
+ unitTest('the local twin marks the row pending, and a replay lands in the same place', () => {
189
+ const store = memoryLocalTx({ [TABLE]: { [id]: { id, title: 'before', pending: false } } });
190
+ target.local(store.tx, input);
191
+ const once = store.rows(TABLE);
192
+ expect(once).toEqual({ [id]: { id, title: 'a title', pending: true } });
193
+ // A rebase replays the queued write: twice through must equal once through.
194
+ target.local(store.tx, input);
195
+ expect(store.rows(TABLE)).toEqual(once);
196
+ });
197
+
198
+ unitTest('the local twin invents no row the store never held', () => {
199
+ const store = memoryLocalTx();
200
+ target.local(store.tx, input);
201
+ expect(store.rows(TABLE)).toEqual({});
202
+ });
203
+ `;
204
+
205
+ /** The authoritative half's cases, refusal first, in the shape the slice earns. */
206
+ const handlerTests = (feature: NameSet, shape: HandlerShape): string => {
207
+ const answer = shape.isMutator ? "{ id, title: 'a title' }" : '{ id }';
208
+ if (!shape.lookup) {
209
+ return `
210
+ unitTest('an actor without the grant is refused before the handler runs', async () => {
211
+ const stranger = testActor('stranger', { orgId }).actor;
212
+ const refused = await target.as(stranger, input).catch((error: unknown) => error);
213
+ expect(refused).toBeUltimateError('X_FORBIDDEN');
214
+ });
215
+
216
+ unitTest('the handler answers a writer in the org', async () => {
217
+ expect(await target.as(writer, input)).toEqual(${answer});
218
+ });
219
+ `;
220
+ }
221
+ const found = shape.isMutator
222
+ ? "{ id: row.id, title: 'a title' }"
223
+ : "{ id: row.id, title: 'kept' }";
224
+ const call = shape.isMutator
225
+ ? "{ id: row.id, orgId, title: 'a title' }"
226
+ : '{ id: row.id, orgId }';
227
+ return `
228
+ unitTest('the handler refuses an id nothing holds', async () => {
229
+ const refused = await target.as(writer, input).catch((error: unknown) => error);
230
+ expect(refused).toBeUltimateError('${notFoundCode(feature)}');
231
+ });
232
+ ${
233
+ shape.storesRow
234
+ ? `
235
+ unitTest('the handler answers the row it found', async () => {
236
+ const draft = { orgId, title: 'kept', price: { minor: 0, currency: 'USD' } };
237
+ const row = await runWithContext(createContext({ actor: writer }), () => repo.insert(draft));
238
+ const answer = await target.as(writer, ${call});
239
+ expect(answer).toEqual(${found});
240
+ });
241
+ `
242
+ : ''
243
+ }`;
244
+ };
245
+
246
+ /**
247
+ * The unit test: the declaration's shape, the input it refuses, and the HANDLER — run through
248
+ * `.as()`, the one execution path, against the in-memory driver. A mutator's local twin runs
249
+ * against `memoryLocalTx`. The contract projections are next door, in their own suite.
250
+ *
251
+ * A row is stored only in a slice whose row this generator can spell (`sliceTakesScaffoldRow`): a
252
+ * slice an author reshaped has its own columns, and a test that inserted `{ title, price }` into
253
+ * it would be a guess about someone else's table.
254
+ */
255
+ const actionUnitTest = (name: NameSet, feature: NameSet, shape: HandlerShape): string => {
256
+ const stores = shape.lookup && shape.storesRow;
257
+ const testing = [
258
+ ...(stores ? ['afterEach'] : []),
259
+ 'expect',
260
+ ...(shape.isMutator ? ['memoryLocalTx'] : []),
261
+ 'unitTest',
262
+ ].join(', ');
263
+ return `// ${name.camel}: its declared shape, the input it refuses, and what its handler answers — in
264
+ // process, so all three belong to the \`unit\` step. The contract projections are next door.
265
+ ${sortedImports([
266
+ ...(stores ? ["import { createContext, runWithContext } from '@ultimat3/core';"] : []),
267
+ "import { testActor } from '@ultimat3/policy';",
268
+ `import { ${testing} } from '@ultimat3/testing';`,
269
+ ...(stores ? [`import { driver } from '${shape.dbModule}';`] : []),
270
+ ])}${stores ? "\nimport * as repo from '../repo';" : ''}
271
+ import { ${name.camel} } from './${name.kebab}';
272
+
273
+ const id = '${ID}';
274
+ const orgId = '${ORG}';
275
+ const input = { id, orgId${shape.isMutator ? ", title: 'a title'" : ''} };
276
+ ${shape.isMutator ? `\n// The client store's table: the entity's own, so the twin and the server row share one key.\nconst TABLE = '${feature.table}';\n` : ''}
277
+ // Named here because every projection needs a stable name and this file does not boot the app.
278
+ // At boot \`registerActions(await import('./actions'))\` stamps the same name onto the same
279
+ // object, so \`${name.camel}.tool()\` works there with nothing to remember.
280
+ const target = ${name.camel}.named('${name.camel}');
281
+
282
+ // Holds the grant in the org the input names: past the policy, so what answers is the handler.
283
+ const grant = ['${feature.kebab}:write'];
284
+ const writer = testActor('writer', { orgId, permissions: grant }).actor;
285
+ ${
286
+ stores
287
+ ? `
288
+ // One store per process: without this, one test's rows are the next test's fixtures.
289
+ afterEach(() => {
290
+ driver.reset?.();
291
+ });
292
+ `
293
+ : ''
294
+ }
295
+ ${shapeTest(name, shape.isMutator)}
171
296
 
172
297
  unitTest('${name.camel} rejects input that is not a uuid', async () => {
173
298
  await expect(target.input).toRejectInput({ ...input, id: 'not-a-uuid' });
174
299
  await expect(target.input).toAcceptInput(input);
175
300
  });
176
- `;
301
+ ${shape.isMutator ? localTwinTests() : ''}${handlerTests(feature, shape)}`;
302
+ };
177
303
 
178
304
  const actionContractTest = (
179
305
  name: NameSet,
@@ -218,6 +344,8 @@ export interface ActionOptions extends FeatureTarget {
218
344
  * data. `x g resource` passes the entity it is writing in the same run.
219
345
  */
220
346
  readonly sliceEntity?: string;
347
+ /** The slice's `repo.ts` as it stands on disk, or absent alongside `sliceEntity`. */
348
+ readonly sliceRepo?: string;
221
349
  }
222
350
 
223
351
  export function actionFiles(rawName: string, target: ActionOptions): readonly GeneratedFile[] {
@@ -246,7 +374,15 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
246
374
  // suites: the input parse is a `unit` assertion and the three projections are `contract` ones.
247
375
  // Emitted as one file, the contract half ran under `unit` and `x test contract` answered
248
376
  // X_TEST_NO_FILES; renaming that one file would have put the `unitTest` in the same bind.
249
- { path: `${dir}/${name.kebab}.test.ts`, contents: actionUnitTest(name, feature, isMutator) },
377
+ {
378
+ path: `${dir}/${name.kebab}.test.ts`,
379
+ contents: actionUnitTest(name, feature, {
380
+ isMutator,
381
+ lookup,
382
+ storesRow: sliceTakesScaffoldRow(target, target.sliceEntity, target.sliceRepo),
383
+ dbModule: target.dbModule ?? PLACEHOLDER_DB_MODULE,
384
+ }),
385
+ },
250
386
  {
251
387
  path: `${dir}/${name.kebab}.contract.test.ts`,
252
388
  contents: actionContractTest(name, feature, isMutator),
@@ -0,0 +1,58 @@
1
+ // The catalog keys the admin reads for a generated entity. An entity in the app's handle IS an
2
+ // admin screen — `defineAdmin({ entities: adminEntitiesOf(db) })` — and that screen resolves
3
+ // `admin.<table>.title` and one `admin.<table>.field.<column>` per column. Nothing emitted them, so
4
+ // a generated list rendered `⟦admin.widgets.field.title⟧` in every header.
5
+
6
+ import { catalogJson } from './catalog-json';
7
+ import type { FeatureTarget } from './entity';
8
+ import { entityFiles } from './entity';
9
+ import { catalogPath, resolveLocales } from './locales';
10
+ import type { GeneratedFile } from './naming';
11
+ import { kebab, names } from './naming';
12
+
13
+ /** ` title: text({ max: 200 }),` — one column of the entity this same run writes. */
14
+ const COLUMN_LINE = /^ {4}([A-Za-z_$][\w$]*): [A-Za-z_$][\w$]*\(/gm;
15
+
16
+ /** `createdAt` → `Created at`, `orgId` → `Org ID`: a label a person reads, in sentence case. */
17
+ export const columnLabel = (property: string): string => {
18
+ const words = kebab(property)
19
+ .split('-')
20
+ .map((word) => (word === 'id' ? 'ID' : word));
21
+ const [first = '', ...rest] = words;
22
+ return [`${first[0]?.toUpperCase() ?? ''}${first.slice(1)}`, ...rest].join(' ');
23
+ };
24
+
25
+ /**
26
+ * The entries, keyed exactly as `@ultimat3/admin` derives them: both read the entity's own name
27
+ * (`$name`, the table) and its property names. The columns come off the entity this run writes —
28
+ * never a second list typed here — so a column added to the template is a label added with it.
29
+ */
30
+ export function adminCatalogEntries(
31
+ rawName: string,
32
+ target: FeatureTarget,
33
+ ): Readonly<Record<string, string>> {
34
+ const name = names(rawName);
35
+ const source = String(
36
+ entityFiles(rawName, target).find((file) => file.path.endsWith('/entity.ts'))?.contents ?? '',
37
+ );
38
+ const entries: Record<string, string> = {
39
+ [`admin.${name.table}.title`]: columnLabel(name.plural),
40
+ };
41
+ for (const match of source.matchAll(COLUMN_LINE)) {
42
+ const property = match[1] ?? '';
43
+ entries[`admin.${name.table}.field.${property}`] = columnLabel(property);
44
+ }
45
+ return entries;
46
+ }
47
+
48
+ /** One mergeable catalog file per locale — for `x g entity`, which has no other catalog entry. */
49
+ export const adminCatalogFiles = (
50
+ rawName: string,
51
+ target: FeatureTarget,
52
+ locales: readonly string[] | undefined,
53
+ ): readonly GeneratedFile[] =>
54
+ resolveLocales(locales).map((locale) => ({
55
+ path: catalogPath(locale),
56
+ contents: catalogJson(adminCatalogEntries(rawName, target)),
57
+ merge: 'json' as const,
58
+ }));
@@ -4,15 +4,27 @@
4
4
  // table and `guardedPage()` is the one thing that decides it. A scaffold that wrote a route
5
5
  // declaration here would hand back the unguarded second way in that seam exists to close.
6
6
 
7
+ import { ADMIN_FILE } from '../admin-registration';
7
8
  import { catalogJson } from './catalog-json';
8
9
  import { sortedImports } from './imports';
9
10
  import { catalogPath, resolveLocales } from './locales';
10
11
  import type { GeneratedFile } from './naming';
11
12
  import { camel, kebab, pascal } from './naming';
12
- import { LINE_WIDTH } from './wrap';
13
+ import { LINE_WIDTH, wrapImport } from './wrap';
13
14
 
14
- /** Where an admin lives when the caller does not say. `x new` scaffolds this layout. */
15
- export const DEFAULT_ADMIN_PAGE_DIR = 'apps/admin/src/pages';
15
+ /** The directory `defineAdmin` is declared in — the admin's one home, inside the app scan. */
16
+ const ADMIN_HOME = ADMIN_FILE.slice(0, ADMIN_FILE.lastIndexOf('/'));
17
+
18
+ /**
19
+ * Where a page lands when the caller does not say: beside the declaration `x new` writes, under the
20
+ * directory the app scan imports. `apps/admin/src/pages` was outside it, and an admin kept there was
21
+ * never mounted (`X_ADMIN_UNSCANNED`).
22
+ */
23
+ export const DEFAULT_ADMIN_PAGE_DIR = `${ADMIN_HOME}/pages`;
24
+
25
+ /** The import a `defineAdmin` in `ADMIN_FILE` writes for a page in `dir`, when one can be derived. */
26
+ const specifierFor = (dir: string, name: string): string =>
27
+ dir.startsWith(`${ADMIN_HOME}/`) ? `./${dir.slice(ADMIN_HOME.length + 1)}/${name}` : `./${name}`;
16
28
 
17
29
  export interface AdminPageOptions {
18
30
  /** The permission the page's own work needs. `admin:read` is composed in front of it. */
@@ -115,8 +127,8 @@ const pageSource = (
115
127
  // so a route declaration in this file would be a second, unguarded way in.
116
128
  //
117
129
  // Wire it in once, and add \`navGroup\` to link it in the sidebar — this file is ${dir}/${name}.tsx,
118
- // so the specifier is relative to wherever \`defineAdmin\` lives:
119
- // import { ${declaration}Page } from './${name}';
130
+ // so the specifier is relative to wherever \`defineAdmin\` lives (${ADMIN_FILE} in a scaffolded app):
131
+ // import { ${declaration}Page } from '${specifierFor(dir, name)}';
120
132
  // defineAdmin({ …, pages: […, ${declaration}Page] })
121
133
  //
122
134
  // The permission below is declared here AND has to be decided: an authz map built from a fixed
@@ -146,11 +158,27 @@ export const ${declaration}Page: AdminCustomPage = {
146
158
 
147
159
  const pageTest = (name: string, permission: string): string => {
148
160
  const declaration = camel(name);
161
+ const Name = pascal(name);
162
+ const url = `http://localhost/admin/${name}`;
149
163
  return `// The ${name} admin page is guarded and owns no route of its own — the two facts that separate an
150
- // admin screen from a page, and the two an edit here is most likely to break.
164
+ // admin screen from a page, and the two an edit here is most likely to break — and it renders
165
+ // from the request it is handed.
166
+ import { adminTestCtx } from '@ultimat3/admin';
151
167
  import { knownPermissions } from '@ultimat3/policy';
152
- import { expect, unitTest } from '@ultimat3/testing';
153
- import { ${declaration}Page } from './${name}';
168
+ import { expect, renderView, unitTest } from '@ultimat3/testing';
169
+ ${wrapImport([`${Name}Page`, `${declaration}Page`], `./${name}`)}
170
+
171
+ // The component, called the way the admin's frame calls it once the guard has passed: a ctx for
172
+ // an actor holding the page's own permission, the route params, and the request URL.
173
+ unitTest('the ${name} admin page renders', async () => {
174
+ const view = await renderView(${Name}Page, {
175
+ ctx: adminTestCtx({ granted: ['admin:read', '${permission}'] }),
176
+ params: {},
177
+ url: '${url}',
178
+ });
179
+ expect(view.html).toContain('<h1>');
180
+ expect(view.text).toContain('${url}');
181
+ });
154
182
 
155
183
  // Both facts the frame reads off the declaration, so both are decidable without a request: a path
156
184
  // that is not rooted is X_ADMIN_PAGE_PATH_INVALID, and no permission at all is