@ultimat3/cli 21.0.0 → 22.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 (234) hide show
  1. package/CLAUDE.md +84 -1650
  2. package/README.md +40 -2
  3. package/package.json +30 -30
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-load.ts +37 -5
  8. package/src/app-openapi.ts +27 -0
  9. package/src/app-permissions.ts +0 -0
  10. package/src/app-root.ts +1 -1
  11. package/src/async-pages.ts +32 -0
  12. package/src/bin.ts +7 -1
  13. package/src/boundary-findings.ts +36 -0
  14. package/src/browser-launcher-fake-html.ts +80 -0
  15. package/src/browser-launcher-fake.ts +165 -0
  16. package/src/browser-launcher-port.ts +159 -0
  17. package/src/browser-launcher.ts +26 -133
  18. package/src/budgets.ts +11 -1
  19. package/src/cdp-shot-a11y.ts +77 -0
  20. package/src/cdp-shot-clock.ts +14 -0
  21. package/src/cdp-shot-driver.ts +150 -0
  22. package/src/cdp-shot-element.ts +147 -0
  23. package/src/cdp-shot-errors.ts +62 -0
  24. package/src/cdp-shot-keys.ts +152 -0
  25. package/src/cdp-shot-page.ts +230 -0
  26. package/src/cdp-shot-watch.ts +241 -0
  27. package/src/cmd-affected-spec.ts +24 -0
  28. package/src/cmd-affected.ts +3 -19
  29. package/src/cmd-build-spec.ts +16 -0
  30. package/src/cmd-build.ts +46 -20
  31. package/src/cmd-ci-spec.ts +24 -0
  32. package/src/cmd-ci.ts +17 -21
  33. package/src/cmd-db-branch.ts +2 -2
  34. package/src/cmd-db-spec.ts +99 -0
  35. package/src/cmd-db.ts +15 -94
  36. package/src/cmd-deploy-helm.ts +136 -0
  37. package/src/cmd-deploy-spec.ts +40 -0
  38. package/src/cmd-deploy.ts +111 -43
  39. package/src/cmd-dev-spec.ts +25 -0
  40. package/src/cmd-dev.ts +55 -36
  41. package/src/cmd-docs-spec.ts +16 -0
  42. package/src/cmd-docs.ts +2 -11
  43. package/src/cmd-doctor-spec.ts +21 -0
  44. package/src/cmd-doctor.ts +4 -18
  45. package/src/cmd-env-spec.ts +18 -0
  46. package/src/cmd-env.ts +4 -13
  47. package/src/cmd-errors-spec.ts +23 -0
  48. package/src/cmd-errors.ts +3 -17
  49. package/src/cmd-fix-spec.ts +18 -0
  50. package/src/cmd-fix.ts +4 -13
  51. package/src/cmd-generate-spec.ts +31 -0
  52. package/src/cmd-generate.ts +46 -42
  53. package/src/cmd-i18n-spec.ts +16 -0
  54. package/src/cmd-i18n.ts +7 -14
  55. package/src/cmd-jobs-spec.ts +56 -0
  56. package/src/cmd-jobs.ts +3 -50
  57. package/src/cmd-manifest-spec.ts +15 -0
  58. package/src/cmd-manifest.ts +19 -20
  59. package/src/cmd-mcp-spec.ts +21 -0
  60. package/src/cmd-mcp.ts +2 -15
  61. package/src/cmd-new-spec.ts +33 -0
  62. package/src/cmd-new.ts +38 -33
  63. package/src/cmd-policy-spec.ts +13 -0
  64. package/src/cmd-policy.ts +2 -9
  65. package/src/cmd-pr-spec.ts +39 -0
  66. package/src/cmd-pr.ts +5 -65
  67. package/src/cmd-registries-spec.ts +32 -0
  68. package/src/cmd-registries.ts +4 -25
  69. package/src/cmd-routes-spec.ts +12 -0
  70. package/src/cmd-routes.ts +2 -7
  71. package/src/cmd-secrets-spec.ts +19 -0
  72. package/src/cmd-secrets.ts +22 -34
  73. package/src/cmd-shot-island.ts +12 -13
  74. package/src/cmd-shot-spec.ts +53 -0
  75. package/src/cmd-shot.ts +11 -60
  76. package/src/cmd-tasks-spec.ts +21 -0
  77. package/src/cmd-tasks.ts +2 -16
  78. package/src/cmd-test-spec.ts +54 -0
  79. package/src/cmd-test.ts +4 -49
  80. package/src/cmd-verify-spec.ts +28 -0
  81. package/src/cmd-verify.ts +3 -23
  82. package/src/db-seed.ts +4 -2
  83. package/src/dev-dashboard.ts +4 -4
  84. package/src/dev-lock.ts +18 -2
  85. package/src/dev-port.ts +21 -0
  86. package/src/dev-route-table.ts +3 -3
  87. package/src/dispatch.ts +12 -5
  88. package/src/document-styles.ts +1 -1
  89. package/src/drift.ts +4 -18
  90. package/src/error-catalog.ts +16 -9
  91. package/src/error-codes.ts +28 -30
  92. package/src/error-pages.ts +19 -7
  93. package/src/errors.ts +14 -0
  94. package/src/favicon.ts +2 -2
  95. package/src/fix-imports.ts +1 -1
  96. package/src/fix-scan.ts +2 -9
  97. package/src/flag-reads.ts +1 -2
  98. package/src/foreign-text.ts +36 -0
  99. package/src/framework-schema.ts +5 -6
  100. package/src/generate-feature.ts +42 -0
  101. package/src/generate-files.ts +7 -3
  102. package/src/generate-grants.ts +83 -0
  103. package/src/generate-kinds.ts +56 -4
  104. package/src/i18n-index.ts +59 -10
  105. package/src/icon-assets.ts +1 -1
  106. package/src/image-prepare.ts +14 -0
  107. package/src/import-scan.ts +63 -0
  108. package/src/index.ts +67 -348
  109. package/src/invocation-flags.ts +26 -0
  110. package/src/island-bundle.ts +6 -1
  111. package/src/island-capture.ts +6 -6
  112. package/src/island-harness-script.ts +6 -2
  113. package/src/island-realtime.ts +15 -8
  114. package/src/island-shot.ts +5 -0
  115. package/src/island-store.ts +131 -0
  116. package/src/island-verdict.ts +1 -1
  117. package/src/job-registration.ts +42 -0
  118. package/src/jobs-driver.ts +2 -2
  119. package/src/load-findings.ts +51 -0
  120. package/src/mcp-db-target.ts +1 -1
  121. package/src/mcp-errors.ts +28 -27
  122. package/src/mcp-host.ts +27 -12
  123. package/src/mcp-ui-diff.ts +27 -0
  124. package/src/mcp-ui-inspect.ts +4 -4
  125. package/src/mcp-ui-interact.ts +21 -15
  126. package/src/mcp-ui.ts +19 -15
  127. package/src/measure-database.ts +73 -0
  128. package/src/measure-paths.ts +74 -0
  129. package/src/measure-scope.ts +74 -0
  130. package/src/messages.ts +1 -3
  131. package/src/metrics-endpoint.ts +1 -1
  132. package/src/otlp-export.ts +1 -1
  133. package/src/output.ts +6 -0
  134. package/src/permission-grants.ts +86 -0
  135. package/src/prerender-out.ts +25 -0
  136. package/src/prerender.ts +121 -102
  137. package/src/pwa-artifacts.ts +3 -3
  138. package/src/reexport-manifest.ts +2 -1
  139. package/src/registry.ts +80 -56
  140. package/src/role-realtime.ts +36 -0
  141. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  142. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  143. package/src/role-start-types.ts +112 -0
  144. package/src/{dev-roles.ts → role-start.ts} +42 -115
  145. package/src/{dev-sync.ts → role-sync.ts} +18 -8
  146. package/src/root-env.ts +67 -0
  147. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  148. package/src/{dev-services.ts → runtime-bindings.ts} +39 -16
  149. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  150. package/src/runtime-jobs.ts +87 -0
  151. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +18 -5
  152. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  153. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  154. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  155. package/src/runtime-realtime.ts +55 -0
  156. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  157. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  158. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  159. package/src/scaffold-fixture.ts +28 -6
  160. package/src/scaffold-typecheck.ts +6 -3
  161. package/src/schema-drift.ts +7 -1
  162. package/src/script-csp.ts +5 -2
  163. package/src/secrets-rotation.ts +59 -0
  164. package/src/serve-boot.ts +192 -0
  165. package/src/serve-drain.ts +24 -0
  166. package/src/serve-entry.ts +6 -0
  167. package/src/serve-env.ts +116 -0
  168. package/src/serve-types.ts +55 -0
  169. package/src/serve.ts +44 -347
  170. package/src/shot-server.ts +2 -2
  171. package/src/shot-settle.ts +10 -1
  172. package/src/shot-theme.ts +3 -3
  173. package/src/shot-verdict.ts +16 -7
  174. package/src/signal-shred.ts +27 -0
  175. package/src/solid-loader.ts +26 -2
  176. package/src/static-report.ts +8 -1
  177. package/src/templates/action.ts +30 -16
  178. package/src/templates/entity.ts +12 -7
  179. package/src/templates/index.ts +1 -1
  180. package/src/templates/job.ts +10 -7
  181. package/src/templates/policy.ts +20 -2
  182. package/src/templates/resource-create.ts +127 -0
  183. package/src/templates/resource-form-island.ts +49 -12
  184. package/src/templates/resource.ts +10 -4
  185. package/src/templates/scaffold-app.ts +4 -1
  186. package/src/templates/scaffold-auth.ts +3 -1
  187. package/src/templates/scaffold-container-compose.ts +184 -0
  188. package/src/templates/scaffold-container.ts +24 -159
  189. package/src/templates/scaffold-dashboard-example.ts +2 -2
  190. package/src/templates/scaffold-db-package.ts +16 -3
  191. package/src/templates/scaffold-demo-org.ts +41 -0
  192. package/src/templates/scaffold-entries.ts +1 -1
  193. package/src/templates/scaffold-helm-templates.ts +66 -7
  194. package/src/templates/scaffold-helm.ts +27 -5
  195. package/src/templates/scaffold-i18n.ts +15 -10
  196. package/src/templates/scaffold-repo.ts +13 -10
  197. package/src/templates/scaffold-roles.ts +38 -10
  198. package/src/templates/slice-foundation.ts +1 -1
  199. package/src/templates/wrap.ts +4 -1
  200. package/src/test-passes.ts +2 -1
  201. package/src/test-workers.ts +26 -0
  202. package/src/ts-scan.ts +3 -6
  203. package/src/tsconfig-references.ts +1 -2
  204. package/src/verify-checks.ts +41 -39
  205. package/src/verify-e2e.ts +10 -7
  206. package/src/verify-run.ts +11 -1
  207. package/src/verify-step.ts +3 -3
  208. package/src/verify-tests.ts +1 -26
  209. package/src/verify-typecheck.ts +28 -0
  210. package/src/web-binding.ts +2 -2
  211. package/src/workspace-graph.ts +10 -33
  212. package/src/cdp-browser.ts +0 -94
  213. package/src/cdp-connection.ts +0 -247
  214. package/src/cdp-e2e-page.ts +0 -180
  215. package/src/cdp-e2e-session.ts +0 -199
  216. package/src/cdp-errors.ts +0 -56
  217. package/src/cdp-launch.ts +0 -193
  218. package/src/cdp-offline-script.ts +0 -73
  219. package/src/cdp-pipe.ts +0 -77
  220. package/src/e2e-app.ts +0 -103
  221. package/src/e2e-browser-handle.ts +0 -55
  222. package/src/e2e-dom-fixture.ts +0 -117
  223. package/src/e2e-driver.ts +0 -117
  224. package/src/e2e-errors.ts +0 -117
  225. package/src/e2e-evaluate.ts +0 -156
  226. package/src/e2e-locator.ts +0 -86
  227. package/src/e2e-page.ts +0 -153
  228. package/src/e2e-preload.ts +0 -64
  229. package/src/e2e-probe.ts +0 -23
  230. package/src/e2e-selection.ts +0 -182
  231. package/src/e2e-spawn.ts +0 -169
  232. package/src/measurement-actor.ts +0 -26
  233. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
  234. /package/src/{dev-render.ts → runtime-render.ts} +0 -0
@@ -15,15 +15,19 @@ import { wrapImport } from './wrap';
15
15
  * (ai-maxxing's `fleet`, with `HostNotFoundError` and `SessionNotFoundError`) declares the errors
16
16
  * it has and not this one. Importing it anyway is a file that fails at import.
17
17
  */
18
- const missingLookup = (feature: NameSet): string =>
19
- ` // No lookup by id: ../errors declares no ${feature.pascal}NotFoundError, and a row that is not
18
+ const missingLookup = (feature: NameSet, entity: boolean): string =>
19
+ entity
20
+ ? ` // No lookup by id: ../errors declares no ${feature.pascal}NotFoundError, and a row that is not
20
21
  // there needs one to be thrown for it. Declare it there — the shape \`x g resource\` writes —
21
- // then read the row through ../repo and throw it when the read answers nothing.`;
22
+ // then read the row through ../repo and throw it when the read answers nothing.`
23
+ : ` // No lookup by id: this feature has no entity, and a generator writing into a slice never
24
+ // invents one. \`x g entity ${feature.kebab}\` declares the table; this body is yours until then.`;
22
25
 
23
26
  const actionSource = (
24
27
  name: NameSet,
25
28
  feature: NameSet,
26
29
  lookup: boolean,
30
+ entity: boolean,
27
31
  ): string => `// ${name.camel}: one mutation, server-authoritative. Input is validated before the handler runs
28
32
  // and the policy is the same object the MCP tool and the HTTP route evaluate.
29
33
  // \`t\` comes from @ultimat3/action, not @ultimat3/schema: an action file imports one package.
@@ -40,14 +44,15 @@ export const ${name.camel} = action({
40
44
  output: t.object({ id: t.uuid${lookup ? ', title: t.string' : ''} }),
41
45
  policy: can${feature.pascal}Write,
42
46
  cache: { invalidates: [${feature.camel}Tag] },
43
- mcp: { expose: true, description: '${name.raw} — edit this description' },
47
+ // No \`mcp\`: a tool's description is what an agent reads to decide to call it, and a placeholder
48
+ // there is worse than no tool. Write one, then \`mcp: { expose: true, description: '…' }\`.
44
49
  async handle({ input }) {
45
50
  ${
46
51
  lookup
47
52
  ? ` const row = await repo.byId(input.id);
48
53
  if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id });
49
54
  return { id: row.id, title: row.title };`
50
- : `${missingLookup(feature)}
55
+ : `${missingLookup(feature, entity)}
51
56
  return { id: input.id };`
52
57
  }
53
58
  },
@@ -58,6 +63,7 @@ const mutatorSource = (
58
63
  name: NameSet,
59
64
  feature: NameSet,
60
65
  lookup: boolean,
66
+ entity: boolean,
61
67
  ): string => `// ${name.camel}: an action with an optimistic local twin. The local half runs against the client
62
68
  // store immediately; the server half is authoritative and reconciles on conflict.
63
69
 
@@ -74,7 +80,7 @@ export const ${name.camel} = mutator({
74
80
  input: t.object({ id: t.uuid, orgId: t.uuid, title: t.string }),
75
81
  output: t.object({ id: t.uuid, title: t.string }),
76
82
  policy: can${feature.pascal}Write,
77
- mcp: { expose: true, description: '${name.raw} — edit this description' },
83
+ // No \`mcp\` until a real description is written: see \`x g action\`.
78
84
  // tx.table(name) rather than tx.${feature.plural}: the typed accessor exists only once the app
79
85
  // augments LocalTables, and generated code cannot assume that has happened yet. The name is the
80
86
  // entity's snake_case table, so the local twin and the server row live under one key.
@@ -90,7 +96,7 @@ ${
90
96
  ? ` const row = await repo.byId(input.id);
91
97
  if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id });
92
98
  return { id: row.id, title: input.title };`
93
- : `${missingLookup(feature)}
99
+ : `${missingLookup(feature, entity)}
94
100
  return { id: input.id, title: input.title };`
95
101
  }
96
102
  },
@@ -190,11 +196,10 @@ contractTest('${name.camel} denies a foreign org', async () => {
190
196
  expect(denied).toBeUltimateError('X_FORBIDDEN');
191
197
  });
192
198
 
193
- contractTest('${name.camel} projects one tool and one operation', () => {
194
- // Same policy object on both surfaces — an MCP call cannot reach a different authz path.
195
- expect(target.tool().policy).toBe(target.policy);
196
- expect(target.tool().description).not.toBe('');
199
+ // No MCP tool until it is described: a placeholder description misleads the agent reading it.
200
+ contractTest('${name.camel} projects one operation and no tool', () => {
197
201
  expect(target.openapi().operationId).toBe('${name.camel}');
202
+ expect(target.mcp?.expose ?? false).toBe(false);
198
203
  });
199
204
  `;
200
205
 
@@ -207,6 +212,12 @@ export interface ActionOptions extends FeatureTarget {
207
212
  * `sliceExports` finds it there.
208
213
  */
209
214
  readonly sliceErrors?: string;
215
+ /**
216
+ * The slice's `entity.ts`, or absent when the feature has none. Absent, the action reads no row
217
+ * and the generator writes no entity — only `x g entity` and `x g resource` create a feature's
218
+ * data. `x g resource` passes the entity it is writing in the same run.
219
+ */
220
+ readonly sliceEntity?: string;
210
221
  }
211
222
 
212
223
  export function actionFiles(rawName: string, target: ActionOptions): readonly GeneratedFile[] {
@@ -214,19 +225,22 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
214
225
  const feature = names(target.feature);
215
226
  const dir = `${target.surfaceDir}/${target.feature}/actions`;
216
227
  const isMutator = target.mutator === true;
228
+ const entity = target.sliceEntity !== undefined;
217
229
  const lookup =
218
- target.sliceErrors === undefined ||
219
- sliceExports(target.sliceErrors, `${feature.pascal}NotFoundError`);
230
+ entity &&
231
+ (target.sliceErrors === undefined ||
232
+ sliceExports(target.sliceErrors, `${feature.pascal}NotFoundError`));
220
233
  return [
221
234
  // The three slice modules this action's source imports — `../errors`, `../policy`, `../repo`
222
235
  // (which comes with `../entity`, its row type). Composed rather than assumed: `x g action`
223
236
  // into a slice no `x g resource` had created emitted all three imports and wrote none of them.
224
- ...sliceFoundation(target, ['entity', 'policy', 'errors']),
237
+ // With no entity, only the policy: a lookup-free body imports neither ../errors nor ../repo.
238
+ ...sliceFoundation(target, entity ? ['entity', 'policy', 'errors'] : ['policy']),
225
239
  {
226
240
  path: `${dir}/${name.kebab}.ts`,
227
241
  contents: isMutator
228
- ? mutatorSource(name, feature, lookup)
229
- : actionSource(name, feature, lookup),
242
+ ? mutatorSource(name, feature, lookup, entity)
243
+ : actionSource(name, feature, lookup, entity),
230
244
  },
231
245
  // TWO test files, because the gate types a test by its FILENAME and this declaration owes two
232
246
  // suites: the input parse is a `unit` assertion and the three projections are `contract` ones.
@@ -68,7 +68,7 @@ const repoSource = (name: NameSet, table: string): string => {
68
68
  const row = name.pascal;
69
69
  const byIdCall = wrapList(
70
70
  ' ',
71
- `const row = await db().one<${row}>(`,
71
+ 'const row = await db().one<Physical>(',
72
72
  [`sql\`select * from ${table} where id = \${id}\``],
73
73
  ');',
74
74
  );
@@ -90,32 +90,37 @@ const repoSource = (name: NameSet, table: string): string => {
90
90
  // join the caller's transaction without knowing one is open.
91
91
 
92
92
  import { db, sql } from '@ultimat3/db';
93
- import { dbDrift, newId } from '@ultimat3/entity';
94
- import type { ${name.pascal} } from './entity';
93
+ import { dbDrift, decodeRow, newId } from '@ultimat3/entity';
94
+ ${wrapImport([`type ${name.pascal}`, name.camel], './entity')}
95
+
96
+ // What \`select *\` answers: snake_case columns, and money as three of them. Never cast to the row
97
+ // type — \`decodeRow\` is the entity's own reading of it, so \`price\` is a Money again.
98
+ type Physical = Readonly<Record<string, unknown>>;
95
99
 
96
100
  export async function byId(id: string): Promise<${name.pascal} | undefined> {
97
101
  ${byIdCall}
98
- return row ?? undefined;
102
+ return row === null ? undefined : decodeRow(${name.camel}, row);
99
103
  }
100
104
 
101
105
  ${listSignature}
102
106
  // Ordered and bounded: an unordered page is a different page on every request.
103
- return db().query<${name.pascal}>(
107
+ const rows = await db().query<Physical>(
104
108
  sql\`select * from ${table} where org_id = \${orgId} order by created_at desc limit \${limit}\`,
105
109
  );
110
+ return rows.map((row) => decodeRow(${name.camel}, row));
106
111
  }
107
112
 
108
113
  ${insertSignature}
109
114
  // Money is three physical columns — integer minor units, the ISO code, and the scale, never a
110
115
  // float. \`scale ?? null\`: an amount at the currency's own minor unit carries no scale at all,
111
116
  // and writing \`0\` for it would claim whole units — a 100x reinterpretation of the price.
112
- const created = await db().one<${name.pascal}>(sql\`
117
+ const created = await db().one<Physical>(sql\`
113
118
  insert into ${table} (id, org_id, title, price_minor, price_currency, price_scale)
114
119
  values (\${newId()}, \${row.orgId}, \${row.title}, \${row.price.minor}, \${row.price.currency},
115
120
  \${row.price.scale ?? null})
116
121
  returning *\`);
117
122
  if (created === null) throw dbDrift('${table}', 'id');
118
- return created;
123
+ return decodeRow(${name.camel}, created);
119
124
  }
120
125
  `;
121
126
  };
@@ -45,7 +45,7 @@ export { entryFiles } from './scaffold-entries';
45
45
  export { errorPageFiles, PWA_COLORS } from './scaffold-errors';
46
46
  // The nine guards `x new` ships, distinct from `guardFiles` above, which is `x g guard <name>`.
47
47
  export { scaffoldGuardFiles } from './scaffold-guards';
48
- export { i18nIndex } from './scaffold-i18n';
48
+ export { i18nIndex, localeEntry, localeImport } from './scaffold-i18n';
49
49
  export { repoFiles } from './scaffold-repo';
50
50
  export { shellFiles } from './scaffold-shell';
51
51
  export { siteFiles } from './scaffold-site';
@@ -3,11 +3,12 @@
3
3
  // both; the generated test pins them through a real driver, because a key that is not stable is a
4
4
  // job that runs twice and a tenant that is not declared is a job that reads the wrong org's rows.
5
5
 
6
- import { stripComments } from '../ts-scan';
6
+ import { stripComments } from '@ultimat3/core';
7
7
  import type { FeatureTarget } from './entity';
8
8
  import type { GeneratedFile, NameSet } from './naming';
9
9
  import { names } from './naming';
10
10
  import { sliceExports, sliceFoundation } from './slice-foundation';
11
+ import { wrapList } from './wrap';
11
12
 
12
13
  const jobSource = (
13
14
  name: NameSet,
@@ -69,7 +70,8 @@ export const ${name.camel} = job({
69
70
  retry: { attempts: 5, backoff: 'exponential' },
70
71
  async run({ step }) {
71
72
  await step.run('process', async () => {
72
- // TODO: this job's own work.
73
+ // This job's own work. No table is read here: this feature has no entity, and a generator
74
+ // writing into a slice never invents one — \`x g entity\` declares it.
73
75
  });
74
76
  return { processed: true };
75
77
  },
@@ -117,7 +119,7 @@ export const ${name.camel} = task({
117
119
  tz: 'UTC',
118
120
  // No org in the payload: the job this enqueues declares \`tenant: 'none'\`, because this
119
121
  // feature's entity names no tenant column (or has none yet).
120
- enqueue: () => [[${jobName.camel}, { id: '00000000-0000-4000-8000-000000000001' }]],
122
+ ${wrapList(' ', 'enqueue: () => [', [`[${jobName.camel}, { id: '00000000-0000-4000-8000-000000000001' }]`], '],')}
121
123
  });
122
124
  `;
123
125
 
@@ -291,9 +293,10 @@ export interface JobOptions extends FeatureTarget {
291
293
  }
292
294
 
293
295
  /**
294
- * Whether `x g job`/`x g task` may assume the tenant-scoped shape: an `entity.ts` this feature
295
- * does not have yet is about to be scaffolded fresh by `sliceFoundation` below, tenant-scoped by
296
- * default — so absent counts as scoped. One that exists is trusted over that default: it declares
296
+ * Whether `x g job`/`x g task` may assume the tenant-scoped shape. A feature with no `entity.ts` is
297
+ * NOT: only `x g entity` and `x g resource` create a feature's data, and counting absent as scoped
298
+ * made `x g task nightly` lay down an `entity('nightlies', { title, price })` that the `drift` step
299
+ * then demanded a migration for. One that exists is trusted: it declares
297
300
  * a real, non-`'none'` `tenant`, AND its `repo.ts` actually exports the `byId`/`listByOrg` pair the
298
301
  * tenant-scoped body calls. Both have to hold — an entity that still names `tenant: 'orgId'` after
299
302
  * an author trimmed `listByOrg` out of `repo.ts` (or never generated one) is not a slice this job
@@ -303,7 +306,7 @@ export function isTenantScopedSlice(
303
306
  sliceEntity: string | undefined,
304
307
  sliceRepo: string | undefined,
305
308
  ): boolean {
306
- if (sliceEntity === undefined) return true;
309
+ if (sliceEntity === undefined) return false;
307
310
  const declaresTenant = /\btenant\s*:\s*'(?!none')[^']+'/.test(stripComments(sliceEntity));
308
311
  if (!declaresTenant) return false;
309
312
  if (sliceRepo === undefined) return true;
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { GeneratedFile, NameSet } from './naming';
6
6
  import { names } from './naming';
7
- import { wrapList } from './wrap';
7
+ import { wrapImport, wrapList } from './wrap';
8
8
 
9
9
  /** Biome would rewrap this itself, so the generator emits the already-formatted form. */
10
10
  const permissionSet = (feature: NameSet): string =>
@@ -65,6 +65,15 @@ export const can${feature.pascal}Write = can<${feature.pascal}Scope>(
65
65
  '${feature.kebab}:write',
66
66
  ({ actor, input }) => actor !== null && actor.orgId === input.orgId,
67
67
  );
68
+
69
+ /**
70
+ * Create takes no org from its input — the row is written under the actor's own — so the tenancy
71
+ * rule is that the actor HAS one. Same grant as write.
72
+ */
73
+ export const can${feature.pascal}Create = can(
74
+ '${feature.kebab}:write',
75
+ ({ actor }) => actor !== null && typeof actor.orgId === 'string' && actor.orgId !== '',
76
+ );
68
77
  `;
69
78
 
70
79
  const policyTest = (
@@ -73,7 +82,7 @@ const policyTest = (
73
82
  // only read. A policy whose tests all pass is a policy nobody has tried to get past.
74
83
  import { testActor } from '@ultimat3/policy';
75
84
  import { expect, unitTest } from '@ultimat3/testing';
76
- import { can${feature.pascal}Read, can${feature.pascal}Write } from './policy';
85
+ ${wrapImport([`can${feature.pascal}Create`, `can${feature.pascal}Read`, `can${feature.pascal}Write`], './policy')}
77
86
 
78
87
  const org = '00000000-0000-4000-8000-000000000002';
79
88
  const otherOrg = '00000000-0000-4000-8000-000000000009';
@@ -108,9 +117,18 @@ unitTest('${feature.camel} write denies the read-only actor', async () => {
108
117
  await expect(can${feature.pascal}Write).not.toDenyPolicy({ actor: writer, input });
109
118
  });
110
119
 
120
+ unitTest('${feature.camel} create denies the read-only and the orgless actor', async () => {
121
+ const orgless = testActor('orgless', { permissions: [read, write] }).actor;
122
+ await expect(can${feature.pascal}Create).toDenyPolicy({ actor: null, input: {} });
123
+ await expect(can${feature.pascal}Create).toDenyPolicy({ actor: reader, input: {} });
124
+ await expect(can${feature.pascal}Create).toDenyPolicy({ actor: orgless, input: {} });
125
+ await expect(can${feature.pascal}Create).not.toDenyPolicy({ actor: writer, input: {} });
126
+ });
127
+
111
128
  unitTest('${feature.camel} rules name the permission they require', () => {
112
129
  expect(can${feature.pascal}Read.permissions).toEqual(['${feature.kebab}:read']);
113
130
  expect(can${feature.pascal}Write.permissions).toEqual(['${feature.kebab}:write']);
131
+ expect(can${feature.pascal}Create.permissions).toEqual(['${feature.kebab}:write']);
114
132
  });
115
133
  `;
116
134
 
@@ -0,0 +1,127 @@
1
+ // `x g resource`'s create action: a real insert. It used to be `x g action`'s generic body — read a
2
+ // row by id, throw NotFound — so the route the resource named `create` could never create, and its
3
+ // input was `{ id, orgId }` while the form posted `{ title }`. The input is the entity's own view of
4
+ // the columns a caller supplies; the org comes from the actor, never from the body.
5
+
6
+ import type { GeneratedFile, NameSet } from './naming';
7
+ import { names } from './naming';
8
+ import { wrapImport, wrapList } from './wrap';
9
+
10
+ /** The columns a caller supplies: the resource entity's, minus `id`, `orgId` and `createdAt`. */
11
+ export const CREATE_COLUMNS = ['title', 'price'] as const;
12
+
13
+ const createSource = (
14
+ feature: NameSet,
15
+ ): string => `// create${feature.pascal}: inserts one ${feature.kebab} in the caller's org and returns it.
16
+ // The input is a view of the entity's own columns, so a column added there is one edit here —
17
+ // the list below — and never a second schema to keep in step.
18
+
19
+ import { action } from '@ultimat3/action';
20
+ import { tenancyActorOrgRequired } from '@ultimat3/entity';
21
+ ${wrapImport([`${feature.pascal}View`, feature.camel], '../entity')}
22
+ ${wrapImport([`can${feature.pascal}Create`, `${feature.camel}Tag`], '../policy')}
23
+ import * as service from '../service';
24
+
25
+ /** What a caller supplies. \`id\`, \`orgId\` and \`createdAt\` are the server's to write. */
26
+ ${wrapList(
27
+ '',
28
+ `export const Create${feature.pascal}Input = ${feature.camel}.$view([`,
29
+ CREATE_COLUMNS.map((column) => `'${column}'`),
30
+ ']);',
31
+ )}
32
+
33
+ export const create${feature.pascal} = action({
34
+ input: Create${feature.pascal}Input,
35
+ output: ${feature.pascal}View,
36
+ policy: can${feature.pascal}Create,
37
+ cache: { invalidates: [${feature.camel}Tag] },
38
+ // No \`mcp\`: an MCP tool's description IS what an agent reads to decide to call it, and a
39
+ // placeholder there is worse than no tool. Write one, then \`mcp: { expose: true, description }\`.
40
+ async handle({ input, ctx }) {
41
+ // The tenant is the actor's. The policy already refused an actor with none; this is the same
42
+ // fact for the type checker, and the framework's own refusal if a caller skips the policy.
43
+ const orgId = ctx.actor.orgId;
44
+ if (typeof orgId !== 'string' || orgId === '') {
45
+ throw tenancyActorOrgRequired({
46
+ entityName: '${feature.camel}',
47
+ operation: 'create',
48
+ actorId: ctx.actor.id,
49
+ actorKind: ctx.actor.kind,
50
+ });
51
+ }
52
+ return service.create({ ...input, orgId });
53
+ },
54
+ });
55
+ `;
56
+
57
+ const ORG = '00000000-0000-4000-8000-000000000002';
58
+
59
+ const createUnitTest = (
60
+ feature: NameSet,
61
+ ): string => `// create${feature.pascal}: its declared shape and the input it refuses — answered by the
62
+ // declaration alone, so they belong to the \`unit\` step.
63
+ import { expect, unitTest } from '@ultimat3/testing';
64
+ import { create${feature.pascal} } from './create-${feature.kebab}';
65
+
66
+ const target = create${feature.pascal}.named('create${feature.pascal}');
67
+ const input = { title: 'A ${feature.kebab}', price: { minor: 1200, currency: 'USD' } };
68
+
69
+ unitTest('create${feature.pascal} is a declared action', () => {
70
+ expect(target.kind).toBe('action');
71
+ expect(target.describe().name).toBe('create${feature.pascal}');
72
+ });
73
+
74
+ unitTest('create${feature.pascal} takes the columns a caller supplies', async () => {
75
+ await expect(target.input).toAcceptInput(input);
76
+ await expect(target.input).toRejectInput({ price: input.price });
77
+ });
78
+ `;
79
+
80
+ const createContractTest = (
81
+ feature: NameSet,
82
+ ): string => `// create${feature.pascal}: the contract every action owes, and the orgless actor it denies before
83
+ // the handler runs — the org a row is written under is the actor's, so an actor with none is refused.
84
+ import { testActor } from '@ultimat3/policy';
85
+ import { contractTest, expect } from '@ultimat3/testing';
86
+ import { create${feature.pascal} } from './create-${feature.kebab}';
87
+
88
+ const target = create${feature.pascal}.named('create${feature.pascal}');
89
+ const input = { title: 'A ${feature.kebab}', price: { minor: 1200, currency: 'USD' } };
90
+
91
+ // Holds the grant and belongs to no org: the denial is the predicate deciding, not the grant.
92
+ const orgless = testActor('orgless', { permissions: ['${feature.kebab}:write'] }).actor;
93
+ // And one that holds both, so the refusal above is not simply "every actor is refused".
94
+ const writer = testActor('writer', {
95
+ orgId: '${ORG}',
96
+ permissions: ['${feature.kebab}:write'],
97
+ }).actor;
98
+
99
+ contractTest('create${feature.pascal} passes the action contract', async () => {
100
+ for (const contract of target.contract()) await contract.run();
101
+ });
102
+
103
+ contractTest('create${feature.pascal} needs an actor with an org', async () => {
104
+ expect(await target.as(orgless, input).catch((error: unknown) => error)).toBeUltimateError(
105
+ 'X_FORBIDDEN',
106
+ );
107
+ await expect(target.policy).not.toDenyPolicy({ actor: writer, input });
108
+ });
109
+
110
+ contractTest('create${feature.pascal} projects one operation', () => {
111
+ expect(target.openapi().operationId).toBe('create${feature.pascal}');
112
+ });
113
+ `;
114
+
115
+ /** The create action and its two suites, under `<slice>/actions/create-<feature>`. */
116
+ export function resourceCreateFiles(rawName: string, sliceDir: string): readonly GeneratedFile[] {
117
+ const feature = names(rawName);
118
+ const dir = `${sliceDir}/actions`;
119
+ return [
120
+ { path: `${dir}/create-${feature.kebab}.ts`, contents: createSource(feature) },
121
+ { path: `${dir}/create-${feature.kebab}.test.ts`, contents: createUnitTest(feature) },
122
+ {
123
+ path: `${dir}/create-${feature.kebab}.contract.test.ts`,
124
+ contents: createContractTest(feature),
125
+ },
126
+ ];
127
+ }
@@ -32,12 +32,14 @@ const formIslandSource = (
32
32
  // the PAGE, which is one directory across from this one:
33
33
  // const ${feature.pascal}Form = island({
34
34
  // src: '${specifier}',
35
- // props: ['endpoint', 'locale', 'labels'],
35
+ // props: ['endpoint', 'locale', 'currency', 'labels'],
36
36
  // });
37
- // <${feature.pascal}Form endpoint={derivePath('create${feature.pascal}').path} locale={locale} labels={labels} />
37
+ // <${feature.pascal}Form endpoint={derivePath('create${feature.pascal}').path} locale={locale}
38
+ // currency="USD" labels={labels} />
38
39
  // A string has no import edge, so the page's bundle graph stays the page's (axiom 6).
39
40
 
40
41
  import { clientTransport } from '@ultimat3/core';
42
+ import { fromDecimal } from '@ultimat3/money';
41
43
  import { Button, Form, Input, setSolidRuntime, UiProvider } from '@ultimat3/ui';
42
44
  import type { JSX } from 'solid-js';
43
45
  import {
@@ -58,8 +60,11 @@ export interface ${feature.pascal}FormProps {
58
60
  readonly endpoint: string;
59
61
  /** The request's own locale. A browser has no ambient one the server ever agreed to. */
60
62
  readonly locale: string;
63
+ /** The ISO code the price is entered in. The amount is typed as a decimal and sent as minor units. */
64
+ readonly currency: string;
61
65
  readonly labels: {
62
66
  readonly title: string;
67
+ readonly price: string;
63
68
  readonly submit: string;
64
69
  readonly saved: string;
65
70
  readonly retry: string;
@@ -78,14 +83,23 @@ type SaveState = 'idle' | 'saved' | 'failed';
78
83
  */
79
84
  function ${feature.pascal}FormBody(props: ${feature.pascal}FormProps): JSX.Element {
80
85
  const [title, setTitle] = createSignal('');
86
+ const [amount, setAmount] = createSignal('');
81
87
  const [state, setState] = createSignal<SaveState>('idle');
82
88
 
83
89
  // A refusal and a request that never got a response both REJECT here — the transport turns a
84
90
  // non-2xx into its code and an offline \`fetch\` into X_CLIENT_TRANSPORT_FAILED — and both are the
85
91
  // outcome \`retry\` exists for. Without the catch the rejection escapes \`void send()\` unhandled.
92
+ // The body is the create action's input: the entity's own columns, the price as integer minor
93
+ // units. \`fromDecimal\` reads the typed digits as a string, never a float, and refuses what is not
94
+ // a number — the same \`retry\` state, because the server would refuse it too.
86
95
  const send = async (): Promise<void> => {
87
96
  try {
88
- await clientTransport({ method: 'POST', url: props.endpoint, body: { title: title() } });
97
+ const price = fromDecimal(amount(), props.currency);
98
+ await clientTransport({
99
+ method: 'POST',
100
+ url: props.endpoint,
101
+ body: { title: title(), price },
102
+ });
89
103
  setState('saved');
90
104
  } catch {
91
105
  setState('failed');
@@ -110,6 +124,12 @@ function ${feature.pascal}FormBody(props: ${feature.pascal}FormProps): JSX.Eleme
110
124
  value={title()}
111
125
  onInput={(event) => setTitle(event.currentTarget.value)}
112
126
  />
127
+ <Input
128
+ aria-label={props.labels.price}
129
+ inputmode="decimal"
130
+ value={amount()}
131
+ onInput={(event) => setAmount(event.currentTarget.value)}
132
+ />
113
133
  <Button type="submit">{props.labels.submit}</Button>
114
134
  <p data-role="status" role="status" aria-live="polite">
115
135
  {status()}
@@ -174,7 +194,13 @@ const APP_ROOT = join(import.meta.dir, ${upToAppRoot(dir)});
174
194
  const ISLAND = '${dir}/${feature.kebab}-form.island.tsx';
175
195
  const ENDPOINT = '/api/${feature.kebab}/create-${feature.kebab}';
176
196
 
177
- const LABELS = { title: 'Title', submit: 'Save', saved: 'Saved', retry: 'Try again' };
197
+ const LABELS = {
198
+ title: 'Title',
199
+ price: 'Price',
200
+ submit: 'Save',
201
+ saved: 'Saved',
202
+ retry: 'Try again',
203
+ };
178
204
 
179
205
  const calls: { url: string; body: Record<string, unknown> }[] = [];
180
206
 
@@ -191,7 +217,7 @@ beforeAll(async () => {
191
217
  build: buildIslands,
192
218
  root: APP_ROOT,
193
219
  file: ISLAND,
194
- props: { endpoint: ENDPOINT, locale: 'en', labels: LABELS },
220
+ props: { endpoint: ENDPOINT, locale: 'en', currency: 'USD', labels: LABELS },
195
221
  // What the server rendered inside the island's wrapper. \`mount\` replaces it.
196
222
  shell: '<p>Loading</p>',
197
223
  globals: {
@@ -244,17 +270,25 @@ describe('the ${feature.kebab} form island', () => {
244
270
  expect(mounted.code).not.toMatch(/\\bReact\\b/);
245
271
  });
246
272
 
247
- test('the field tracks, and submit posts what was typed', async () => {
248
- const field: FakeElement | null = mounted.find('input');
249
- expect(field).not.toBeNull();
250
- if (field !== null) field.value = 'First ${feature.camel}';
273
+ test('the fields track, and submit posts the create input', async () => {
274
+ const title: FakeElement | null = mounted.find('input[aria-label="Title"]');
275
+ const price: FakeElement | null = mounted.find('input[aria-label="Price"]');
276
+ expect(title).not.toBeNull();
277
+ expect(price).not.toBeNull();
278
+ if (title !== null) title.value = 'First ${feature.camel}';
279
+ if (price !== null) price.value = '12.50';
251
280
  // \`false\` means no handler ran — an island whose onInput never reached the DOM looks
252
281
  // identical to a selector typo otherwise.
253
- expect(mounted.fire(field, 'input')).toBe(true);
282
+ expect(mounted.fire(title, 'input')).toBe(true);
283
+ expect(mounted.fire(price, 'input')).toBe(true);
254
284
  expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
255
285
  await statusSettled();
256
286
 
257
- expect(calls).toEqual([{ url: ENDPOINT, body: { title: 'First ${feature.camel}' } }]);
287
+ const body = {
288
+ title: 'First ${feature.camel}',
289
+ price: { minor: 1250, currency: 'USD' },
290
+ };
291
+ expect(calls).toEqual([{ url: ENDPOINT, body }]);
258
292
  });
259
293
 
260
294
  test('the status line answers the response', () => {
@@ -300,10 +334,12 @@ import type { ${feature.pascal}FormProps } from './${feature.kebab}-form.island'
300
334
 
301
335
  /** What a working render hands the form — the baseline the state below departs from. */
302
336
  const BASE = {
303
- endpoint: '/api/create-${feature.kebab}',
337
+ endpoint: '/api/${feature.pluralKebab}/create',
304
338
  locale: 'en',
339
+ currency: 'USD',
305
340
  labels: {
306
341
  title: 'Title',
342
+ price: 'Price',
307
343
  submit: 'Save',
308
344
  saved: 'Saved',
309
345
  retry: 'That did not save. Try again.',
@@ -327,6 +363,7 @@ export const ${feature.camel}FormStates = defineIslandStates({
327
363
  locale: 'de',
328
364
  labels: {
329
365
  title: 'Bezeichnung des Beitrags',
366
+ price: 'Preis',
330
367
  submit: 'Änderungen speichern',
331
368
  saved: 'Änderungen gespeichert',
332
369
  retry: 'Das konnte nicht gespeichert werden. Bitte erneut versuchen.',
@@ -13,6 +13,7 @@ import type { GeneratedFile, NameSet } from './naming';
13
13
  import { names, pascal } from './naming';
14
14
  import { policyFiles } from './policy';
15
15
  import { queryFiles } from './query';
16
+ import { resourceCreateFiles } from './resource-create';
16
17
  import { formIslandFiles } from './resource-form-island';
17
18
  import { routeDir, routeFiles } from './route';
18
19
 
@@ -178,13 +179,18 @@ export function resourceFiles(rawName: string, target: ResourceOptions): readonl
178
179
  // question and only one of them reaches `routeFiles`.
179
180
  const pageDir = routeDir('app', feature.pluralKebab);
180
181
  const locales = resolveLocales(target.locales);
182
+ const entity = entityFiles(rawName, slice);
183
+ // The entity this same call writes, handed to the generators composed below as if it were on
184
+ // disk: they write into a slice that HAS data, so they read it rather than a neutral body.
185
+ const sliceEntity = String(entity.find((file) => file.path.endsWith('/entity.ts'))?.contents);
181
186
  return [
182
- ...entityFiles(rawName, slice),
187
+ ...entity,
183
188
  ...policyFiles(rawName, slice),
184
- ...actionFiles(`create-${feature.kebab}`, slice),
185
- ...actionFiles(`archive-${feature.kebab}`, slice),
189
+ // Not `x g action`'s body: a resource's create INSERTS (`resource-create.ts`).
190
+ ...resourceCreateFiles(rawName, dir),
191
+ ...actionFiles(`archive-${feature.kebab}`, { ...slice, sliceEntity }),
186
192
  ...queryFiles(`${feature.camel}List`, { ...slice, live: true }),
187
- ...jobFiles(`reindex-${feature.kebab}`, slice),
193
+ ...jobFiles(`reindex-${feature.kebab}`, { ...slice, sliceEntity }),
188
194
  { path: `${dir}/service.ts`, contents: serviceSource(feature) },
189
195
  { path: `${dir}/service.test.ts`, contents: serviceTest(feature) },
190
196
  { path: `${dir}/ui.tsx`, contents: uiSource(feature, target.catalogModule) },
@@ -7,6 +7,7 @@ import type { GeneratedFile, NameSet } from './naming';
7
7
  import { apiFiles } from './scaffold-api';
8
8
  import { authFiles } from './scaffold-auth';
9
9
  import { dashboardFiles } from './scaffold-dashboard';
10
+ import { demoOrgFiles } from './scaffold-demo-org';
10
11
  import { entryFiles } from './scaffold-entries';
11
12
  import { errorPageFiles } from './scaffold-errors';
12
13
  import { httpFiles } from './scaffold-http';
@@ -309,11 +310,13 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
309
310
  { path: 'apps/web/shared/global.ts', contents: sharedGlobalModule() },
310
311
  { path: 'apps/web/shared/actor.ts', contents: sharedActor() },
311
312
  { path: 'apps/web/shared/actor.test.ts', contents: sharedActorTest() },
313
+ // The one org the dev actor, the seed and the dashboard all name — `scaffold-demo-org.ts`.
314
+ ...demoOrgFiles(),
312
315
  // The app's role map, beside the actor that reads it. `shared/` and not a feature folder:
313
316
  // `defineRoles()` merges, so a per-feature call is legal and is how an app ends up with no
314
317
  // answer to "which roles exist?" — see `scaffold-roles.ts`.
315
318
  ...httpFiles(app),
316
- ...rolesFiles(),
319
+ ...rolesFiles(example),
317
320
  { path: 'apps/admin/package.json', contents: adminPackage(app) },
318
321
  { path: 'apps/admin/tsconfig.json', contents: tsconfig() },
319
322
  // `apps/admin/app/admin/page.tsx`, not `apps/admin/app/page.tsx`: the directory IS the URL,
@@ -35,6 +35,7 @@ const devActor = (
35
35
  // Everything downstream — pages, policies, live subscribers, MCP tools — reads what this returns.
36
36
  import { type Actor, logger, tryResolveEnvironment } from '@ultimat3/core';
37
37
  import { configureAuthenticator, readCookie } from '@ultimat3/http';
38
+ import { DEMO_ORG_ID } from '../../shared/demo-org';
38
39
 
39
40
  /** Set it to a role from \`apps/web/shared/roles.ts\` to browse as that role. */
40
41
  export const DEV_ROLE_COOKIE = '${app.kebab}_dev_role';
@@ -66,7 +67,8 @@ export const devRoleFrom = (cookieHeader: string | null): DevRole => {
66
67
  export const devActorFor = (role: DevRole): Actor => ({
67
68
  kind: 'user',
68
69
  id: 'dev-actor',
69
- orgId: 'dev-org',
70
+ // The seed's org, never an invented string: a generated tenant policy compares it with a uuid.
71
+ orgId: DEMO_ORG_ID,
70
72
  roles: [role],
71
73
  // Both required, and both deliberately empty: \`scopes\` is the framework's own escape hatch
72
74
  // (\`tenancy:cross\`) and \`permissions\` is a DIRECT grant that bypasses the role map — a