@ultimat3/cli 27.6.1 → 27.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -91,7 +91,7 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
91
91
  | `module-imports.ts` | the files a module's source imports, resolved once for both graphs: `referenced` (reload) and `evaluated` (document) |
92
92
  | `cmd-dev.ts` / `dev-boot.ts` / `dev-rebuild.ts` / `dev-route-table.ts` | `x dev`: the command (`cmd-dev.ts`, re-exports `startDev`); the boot — every role in one process, `/_x`, the watcher (`dev-boot.ts`); one save rebuilt as one generation (`dev-rebuild.ts`) |
93
93
  | `runtime-bindings.ts` | which service each binding points at (embedded or external); events follow `realtime.transport` / `urlEnv` |
94
- | `runtime-queue.ts` / `runtime-services.ts` | the db + queue pair; everything else and every ambient accessor |
94
+ | `runtime-queue.ts` / `runtime-services.ts` / `runtime-bus.ts` | db + queue; every other accessor; the bus |
95
95
  | `framework-schema-apply.ts` / `framework-schema-stamp.ts` | the framework tables: `ROLE=migrate`, `x dev` and every CLI command APPLY them in one transaction behind `MIGRATION_LOCK_KEY` (`pg_advisory_xact_lock`) with a bounded `lock_timeout`, then stamp the build on `x_jobs`' table comment; a serving role on an external database runs no DDL and VERIFIES the stamp (`X_FRAMEWORK_SCHEMA_UNAPPLIED`, a major skew is a warning). A comment, never a table: a new framework table moves every app's schema dump |
96
96
  | `runtime-isr.ts` | the ISR controller both boots serve through, ATTACHED to `invalidateTags` and released on stop (`attachedIsr`); `appRoutes` without one builds and attaches its own |
97
97
  | `runtime-mfa-warning.ts` / `runtime-idempotency-scope.ts` | boot warnings an operator can act on: unsealed MFA secrets (web role, `x auth seal-mfa`), a per-process idempotency store after the app loaded |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "27.6.1",
3
+ "version": "27.7.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,33 +39,33 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@babel/core": "^8.0.1",
42
- "@ultimat3/action": "27.6.1",
43
- "@ultimat3/admin": "27.6.1",
44
- "@ultimat3/ai": "27.6.1",
45
- "@ultimat3/auth": "27.6.1",
46
- "@ultimat3/cache": "27.6.1",
47
- "@ultimat3/core": "27.6.1",
48
- "@ultimat3/db": "27.6.1",
49
- "@ultimat3/entity": "27.6.1",
50
- "@ultimat3/flags": "27.6.1",
51
- "@ultimat3/http": "27.6.1",
52
- "@ultimat3/i18n": "27.6.1",
53
- "@ultimat3/jobs": "27.6.1",
54
- "@ultimat3/mail": "27.6.1",
55
- "@ultimat3/manifest": "27.6.1",
56
- "@ultimat3/mcp": "27.6.1",
57
- "@ultimat3/money": "27.6.1",
58
- "@ultimat3/notify": "27.6.1",
59
- "@ultimat3/policy": "27.6.1",
60
- "@ultimat3/pwa": "27.6.1",
61
- "@ultimat3/query": "27.6.1",
62
- "@ultimat3/realtime": "27.6.1",
63
- "@ultimat3/render": "27.6.1",
64
- "@ultimat3/schema": "27.6.1",
65
- "@ultimat3/seo": "27.6.1",
66
- "@ultimat3/storage": "27.6.1",
67
- "@ultimat3/testing": "27.6.1",
68
- "@ultimat3/time": "27.6.1",
42
+ "@ultimat3/action": "27.7.0",
43
+ "@ultimat3/admin": "27.7.0",
44
+ "@ultimat3/ai": "27.7.0",
45
+ "@ultimat3/auth": "27.7.0",
46
+ "@ultimat3/cache": "27.7.0",
47
+ "@ultimat3/core": "27.7.0",
48
+ "@ultimat3/db": "27.7.0",
49
+ "@ultimat3/entity": "27.7.0",
50
+ "@ultimat3/flags": "27.7.0",
51
+ "@ultimat3/http": "27.7.0",
52
+ "@ultimat3/i18n": "27.7.0",
53
+ "@ultimat3/jobs": "27.7.0",
54
+ "@ultimat3/mail": "27.7.0",
55
+ "@ultimat3/manifest": "27.7.0",
56
+ "@ultimat3/mcp": "27.7.0",
57
+ "@ultimat3/money": "27.7.0",
58
+ "@ultimat3/notify": "27.7.0",
59
+ "@ultimat3/policy": "27.7.0",
60
+ "@ultimat3/pwa": "27.7.0",
61
+ "@ultimat3/query": "27.7.0",
62
+ "@ultimat3/realtime": "27.7.0",
63
+ "@ultimat3/render": "27.7.0",
64
+ "@ultimat3/schema": "27.7.0",
65
+ "@ultimat3/seo": "27.7.0",
66
+ "@ultimat3/storage": "27.7.0",
67
+ "@ultimat3/testing": "27.7.0",
68
+ "@ultimat3/time": "27.7.0",
69
69
  "babel-preset-solid": "^1.9.15"
70
70
  }
71
71
  }
@@ -4,8 +4,9 @@
4
4
  // Performed here; where the admin is not a `defineAdmin({ … })` this can add to, the finding names
5
5
  // the two lines.
6
6
 
7
- import { ERROR_DOCS_URL, stripComments } from '@ultimat3/core';
8
- import { containedPath } from './generate-write';
7
+ import { ERROR_DOCS_URL, maskLiterals, stripComments } from '@ultimat3/core';
8
+ import type { GenerateDisk } from './generate-disk';
9
+ import { appDisk } from './generate-disk';
9
10
  import type { Finding } from './output';
10
11
  import { holdsKey, withKeyedLine, withSortedImport } from './source-edit';
11
12
  import type { GeneratedFile } from './templates/naming';
@@ -70,7 +71,8 @@ export function insertAdminResources(
70
71
  let next = source;
71
72
  const missing: AdminResourceEntry[] = [];
72
73
  for (const entry of entries) {
73
- const masked = stripComments(next);
74
+ // Literals masked too: a brace inside a string label is not one of the call's.
75
+ const masked = maskLiterals(next);
74
76
  const call = /\bdefineAdmin\(\s*\{/.exec(masked);
75
77
  const open = call === null ? -1 : call.index + call[0].length - 1;
76
78
  const close = open === -1 ? -1 : closeOf(masked, open);
@@ -140,18 +142,17 @@ export function withAdminResources(
140
142
 
141
143
  /** The overrides among the written paths, each named by what its file and its entity export. */
142
144
  async function entriesFor(
143
- root: string,
145
+ disk: GenerateDisk,
144
146
  written: readonly string[],
145
147
  webModule: string,
146
148
  ): Promise<readonly AdminResourceEntry[]> {
147
149
  const entries: AdminResourceEntry[] = [];
148
150
  for (const path of written) {
149
151
  if (!OVERRIDE_PATH.test(path)) continue;
150
- const entity = Bun.file(containedPath(root, entityPathOf(path)));
151
152
  const entry = entryOf(
152
153
  path,
153
- await Bun.file(containedPath(root, path)).text(),
154
- (await entity.exists()) ? await entity.text() : undefined,
154
+ (await disk.read(path)) ?? '',
155
+ await disk.read(entityPathOf(path)),
155
156
  webModule,
156
157
  );
157
158
  if (entry !== undefined) entries.push(entry);
@@ -169,24 +170,24 @@ export interface AdminRegistration {
169
170
  export async function registerAdminResources(
170
171
  root: string,
171
172
  written: readonly string[],
173
+ disk: GenerateDisk = appDisk(root),
172
174
  ): Promise<AdminRegistration> {
173
- const web = await readWorkspace(root, WEB_WORKSPACE);
174
- const entries = web === undefined ? [] : await entriesFor(root, written, web.name);
175
+ const web = await readWorkspace(root, WEB_WORKSPACE, disk);
176
+ const entries = web === undefined ? [] : await entriesFor(disk, written, web.name);
175
177
  if (entries.length === 0) return { edited: [], findings: [] };
176
- const file = Bun.file(containedPath(root, ADMIN_FILE));
177
- if (!(await file.exists())) {
178
+ const before = await disk.read(ADMIN_FILE);
179
+ if (before === undefined) {
178
180
  return {
179
181
  edited: [],
180
182
  findings: entries.map((entry) => adminResourceUnwiredFinding(entry, 'does not exist')),
181
183
  };
182
184
  }
183
- const before = await file.text();
184
185
  const { source, missing } = insertAdminResources(before, entries);
185
186
  const findings = missing.map((entry) =>
186
187
  adminResourceUnwiredFinding(entry, 'holds no defineAdmin({ … }) call to add to'),
187
188
  );
188
189
  if (source === before) return { edited: [], findings };
189
- await Bun.write(containedPath(root, ADMIN_FILE), source);
190
- const manifest = await declareWorkspaceDependency(root, ADMIN_WORKSPACE, WEB_WORKSPACE);
190
+ await disk.write(ADMIN_FILE, source);
191
+ const manifest = await declareWorkspaceDependency(root, ADMIN_WORKSPACE, WEB_WORKSPACE, disk);
191
192
  return { edited: manifest === undefined ? [ADMIN_FILE] : [ADMIN_FILE, manifest], findings };
192
193
  }
@@ -6,10 +6,13 @@
6
6
 
7
7
  import { ERROR_DOCS_URL } from '@ultimat3/core';
8
8
  import { API_INDEX } from './app-root';
9
+ import type { GenerateDisk } from './generate-disk';
10
+ import { appDisk } from './generate-disk';
9
11
  import { containedPath } from './generate-write';
10
12
  import type { Finding } from './output';
13
+ import type { SourceList } from './source-list-edit';
14
+ import { appendToList, maskOf, readList, readProperties } from './source-list-edit';
11
15
  import { camel } from './templates/naming';
12
- import { wrapList } from './templates/wrap';
13
16
 
14
17
  /** One module to import as a namespace and list under a `defineApi` key. */
15
18
  export interface ApiEntry {
@@ -56,42 +59,30 @@ export function apiEntriesFor(written: readonly string[]): readonly ApiEntry[] {
56
59
  }
57
60
 
58
61
  /**
59
- * Every `[...]` entry of a `key: [...]` list in the `defineApi({ ... })` call, and where it sits:
60
- * `line` is the start of the line holding `key: [`, `start` is just past the `[`, `end` is the `]`.
61
- *
62
- * Searched from `from` (the `defineApi({` call), so a `jobs: [` in a comment or an object above the
63
- * call is never the one edited. `line` is found from the key, never from `start`: in a list already
64
- * wrapped one entry per line the character AT `start` is the newline after `[`, and a backwards
65
- * search from there answered the first ITEM's line — the rewrite then nested a second `jobs: [`
66
- * inside the first and left the old `]` behind.
62
+ * The `key: [...]` lists of the `defineApi({ ... })` call, read off the MASKED text: a `jobs: [` in
63
+ * a comment or an object above the call is never the one edited, and a `]` or a comma inside a
64
+ * comment between two entries ends nothing. (The list was closed at the first `]` in the raw text
65
+ * and split at every comma — a commented list was rewritten with its comment as an entry.)
67
66
  */
68
- const listOf = (
69
- source: string,
70
- key: string,
71
- from: number,
72
- ): { line: number; start: number; end: number; items: string[] } | undefined => {
73
- const open = new RegExp(`\\n([ \\t]*)${key}: \\[`, 'g');
74
- open.lastIndex = from;
75
- const found = open.exec(source);
76
- if (found === null) return undefined;
77
- const line = found.index + 1;
78
- const start = found.index + found[0].length;
79
- const end = source.indexOf(']', start);
80
- if (end === -1) return undefined;
81
- const items = source
82
- .slice(start, end)
83
- .split(',')
84
- .map((item) => item.trim())
85
- .filter((item) => item.length > 0);
86
- return { line, start, end, items };
67
+ const listsOf = (source: string): ((key: string) => SourceList | undefined) | undefined => {
68
+ const masked = maskOf(source);
69
+ const call = /\bdefineApi\(\s*\{/.exec(masked);
70
+ if (call === null) return undefined;
71
+ const properties = readProperties(source, masked, call.index + call[0].length - 1);
72
+ return (key) => {
73
+ const property = properties.find((one) => one.key === key);
74
+ return property === undefined || masked[property.value] !== '['
75
+ ? undefined
76
+ : readList(source, masked, property.value);
77
+ };
87
78
  };
88
79
 
89
80
  /**
90
81
  * `source` with each entry imported and listed. The import joins the `import * as` block in sorted
91
82
  * position; the binding joins its list, which is created after `actions: [...]` when the call has
92
- * none. An index that is not the scaffold's shape — no `defineApi({`, no `actions:` list — is
93
- * returned untouched with the entries in `skipped`, and the `manifest` step's X_JOB_UNREGISTERED
94
- * names the edit.
83
+ * none. An index that is not the scaffold's shape — no `defineApi({`, no `actions:` list — or a
84
+ * list that cannot be edited safely is returned untouched with the entries in `skipped`, and the
85
+ * `manifest` step's X_JOB_UNREGISTERED names the edit.
95
86
  */
96
87
  export function insertApiEntries(
97
88
  source: string,
@@ -101,28 +92,38 @@ export function insertApiEntries(
101
92
  const skipped: ApiEntry[] = [];
102
93
  for (const entry of entries) {
103
94
  const importLine = `import * as ${entry.binding} from '${entry.specifier}';`;
104
- const call = next.indexOf('defineApi({');
105
- const actions = call === -1 ? undefined : listOf(next, 'actions', call);
106
- if (call === -1 || actions === undefined) {
95
+ const listOf = listsOf(next);
96
+ const actions = listOf?.('actions');
97
+ if (listOf === undefined || actions === undefined) {
107
98
  skipped.push(entry);
108
99
  continue;
109
100
  }
110
- const list = listOf(next, entry.key, call);
111
- if (list?.items.includes(entry.binding) === true) continue;
101
+ const list = listOf(entry.key);
102
+ if (list?.entries.some((one) => one.text === entry.binding) === true) continue;
112
103
  if (list === undefined) {
113
104
  // In the order `x new` writes — actions, queries, jobs, tasks: after the nearest list
114
105
  // before it that the call already holds, which is `actions` at the least.
115
106
  const earlier = LIST_ORDER.slice(0, LIST_ORDER.indexOf(entry.key))
116
- .map((key) => listOf(next, key, call))
107
+ .map((key) => listOf(key))
117
108
  .findLast((found) => found !== undefined);
118
- const after = next.indexOf('\n', (earlier ?? actions).end);
109
+ const after = next.indexOf('\n', (earlier ?? actions).close);
119
110
  const line = ` ${entry.key}: [${entry.binding}],`;
120
- next = `${next.slice(0, after + 1)}${line}\n${next.slice(after + 1)}`;
111
+ const candidate = `${next.slice(0, after + 1)}${line}\n${next.slice(after + 1)}`;
112
+ // Read back, as every other edit here is: when the earlier list shares its row with the
113
+ // call's own `});`, "the row after it" is OUTSIDE the call — a property of nothing.
114
+ const landed = listsOf(candidate)?.(entry.key);
115
+ if (landed?.entries.some((one) => one.text === entry.binding) !== true) {
116
+ skipped.push(entry);
117
+ continue;
118
+ }
119
+ next = candidate;
121
120
  } else {
122
- const items = [...list.items, entry.binding];
123
- const indent = /^[ \t]*/.exec(next.slice(list.line))?.[0] ?? '';
124
- const rewritten = wrapList(indent, `${entry.key}: [`, items, ']');
125
- next = `${next.slice(0, list.line)}${rewritten}${next.slice(list.end + 1)}`;
121
+ const edited = appendToList(next, list.open, [entry.binding]);
122
+ if (edited === undefined) {
123
+ skipped.push(entry);
124
+ continue;
125
+ }
126
+ next = edited;
126
127
  }
127
128
  if (!next.includes(importLine)) next = withImport(next, importLine);
128
129
  }
@@ -236,13 +237,13 @@ export async function indexBindingFindings(
236
237
  export async function registerGeneratedPrimitives(
237
238
  root: string,
238
239
  written: readonly string[],
240
+ disk: GenerateDisk = appDisk(root),
239
241
  ): Promise<readonly string[]> {
240
242
  const entries = apiEntriesFor(written);
241
- const file = containedPath(root, API_INDEX);
242
- if (entries.length === 0 || !(await Bun.file(file).exists())) return [];
243
- const before = await Bun.file(file).text();
243
+ const before = entries.length === 0 ? undefined : await disk.read(API_INDEX);
244
+ if (before === undefined) return [];
244
245
  const { source } = insertApiEntries(before, entries);
245
246
  if (source === before) return [];
246
- await Bun.write(file, source);
247
+ await disk.write(API_INDEX, source);
247
248
  return [API_INDEX];
248
249
  }
@@ -4,8 +4,7 @@
4
4
 
5
5
  import { existsSync } from 'node:fs';
6
6
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
7
- import { registerAdminResources } from './admin-registration';
8
- import { indexBindingFindings, registerGeneratedPrimitives } from './api-registration';
7
+ import { indexBindingFindings } from './api-registration';
9
8
  import { writeAppArtifacts } from './app-artifacts';
10
9
  import { appManifest } from './app-manifest';
11
10
  import { requireAppRoot } from './app-root';
@@ -13,19 +12,19 @@ import { generateSpec } from './cmd-generate-spec';
13
12
  import type { CliCommand, CommandContext } from './command';
14
13
  import { invocationOf } from './command';
15
14
  import { localiseCatalogs } from './generate-catalog-locales';
15
+ import { appDisk, plannedDisk } from './generate-disk';
16
16
  import { assertFeatureExists } from './generate-feature';
17
17
  import { generate, sliceDir } from './generate-files';
18
- import { ungrantedByGenerator } from './generate-grant-findings';
19
- import { grantGeneratedPermissions } from './generate-grants';
18
+ import { followUpEdits } from './generate-follow-ups';
20
19
  import type { Generator } from './generate-kinds';
21
20
  import { readFeature, readKind, readName, readPermission, readSurface } from './generate-kinds';
22
21
  import { refusePluralTable } from './generate-plural';
23
22
  import { refuseShadowedTypes, refuseShadowedValues } from './generate-shadow';
23
+ import { declaresOnlyOtherEntities } from './generate-sibling';
24
24
  import { containedPath, planWrites, writeFiles } from './generate-write';
25
- import { declareGeneratedImports } from './generated-imports';
26
- import { registerGeneratedEntities, resolveDbModule } from './handle-registration';
25
+ import { resolveDbModule } from './handle-registration';
27
26
  import { resolveCatalogModule } from './i18n-audit';
28
- import { catalogLocales, syncI18nIndex } from './i18n-index';
27
+ import { catalogLocales } from './i18n-index';
29
28
  import { reproducedFlags } from './invocation-flags';
30
29
  import { msg } from './messages';
31
30
  import type { CommandResult, Finding } from './output';
@@ -33,6 +32,7 @@ import { flagBool, flagList, flagString } from './parse';
33
32
  import { quoteArg } from './shell-quote';
34
33
  import type { GeneratedFile } from './templates';
35
34
  import { kebab, names, resolveLocales } from './templates';
35
+ import { isEntityModule } from './templates/entity-module';
36
36
 
37
37
  // One import path for the generator, unchanged by the split: `index.ts`, `x new` and the scaffold
38
38
  // fixture reach the kinds, the pure file list and the writer through this module, and a second path
@@ -94,6 +94,11 @@ export const generateCommand: CliCommand = {
94
94
  // THIS feature's own `entity.ts`/`repo.ts`, not a default the template gets to assume.
95
95
  const sliceEntity = await readSliceFile(root, kind, slice, 'entity.ts');
96
96
  const sliceRepo = await readSliceFile(root, kind, slice, 'repo.ts');
97
+ // A feature's SECOND entity: `entity.ts` is another table's, so this one lands beside it.
98
+ const sibling =
99
+ kind === 'entity' &&
100
+ featureFlag !== undefined &&
101
+ (await holdsAnotherEntity(root, slice, name));
97
102
  const planFor = (planned: string) =>
98
103
  generate({
99
104
  kind,
@@ -111,6 +116,7 @@ export const generateCommand: CliCommand = {
111
116
  ...(catalogModule === undefined ? {} : { catalogModule }),
112
117
  ...(dbModule === undefined ? {} : { dbModule }),
113
118
  shell,
119
+ sibling,
114
120
  });
115
121
  // A non-default locale's strings arrive marked, and a locale with no catalog yet is refused
116
122
  // with `x i18n add <locale>` — before a dry run answers, so it answers the same files.
@@ -149,14 +155,43 @@ export const generateCommand: CliCommand = {
149
155
  if (flagBool(ctx.args, 'dry-run')) {
150
156
  // The write plan, never the bare file list: what the real run would write, skip and refuse.
151
157
  const plan = await planWrites(root, files, force, invocation);
152
- const findings = [...plan.conflicts, ...bindings];
153
- const planned = findings.length === 0 ? plan.written : [];
158
+ const refused = [...plan.conflicts, ...bindings];
159
+ // And the edits OUTSIDE the slice — role map, handle, API index, manifests, catalog index —
160
+ // run for real against the plan held in memory: the same code, so the same list. A dry run
161
+ // that named five new files while the run rewrote four the caller owns was no plan.
162
+ const planned = new Map(
163
+ files.flatMap((file) =>
164
+ plan.written.includes(file.path) && typeof file.contents === 'string'
165
+ ? [[file.path, file.contents] as const]
166
+ : [],
167
+ ),
168
+ );
169
+ const follow =
170
+ refused.length > 0
171
+ ? { edited: [], findings: [] }
172
+ : await followUpEdits(root, plan.written, dbModule, plannedDisk(root, planned));
173
+ const findings = [...refused, ...follow.findings];
174
+ const touched = refused.length === 0 ? [...plan.written, ...follow.edited] : [];
175
+ // Re-projected after a real write, from the loaded app: named, since their bytes are not
176
+ // knowable without the files on disk.
177
+ const refreshes =
178
+ plan.written.length === 0 || refused.length > 0
179
+ ? []
180
+ : [MANIFEST_FILENAME, 'openapi.json'].filter((file) =>
181
+ existsSync(containedPath(root, file)),
182
+ );
154
183
  return {
155
184
  ok: findings.length === 0,
156
185
  command: 'g',
157
- summary: msg('cli.generate.planned', { count: planned.length, kind, name }),
158
- data: { files: planned, dryRun: true },
159
- lines: planned.map((file) => msg('cli.file.added', { path: file })),
186
+ summary: msg('cli.generate.planned', { count: touched.length, kind, name }),
187
+ data: {
188
+ files: touched,
189
+ // The subset that already exists and would be EDITED, never created.
190
+ edits: follow.edited.filter((path) => existsSync(containedPath(root, path))),
191
+ ...(refreshes.length === 0 ? {} : { refreshes }),
192
+ dryRun: true,
193
+ },
194
+ lines: touched.map((file) => msg('cli.file.added', { path: file })),
160
195
  findings,
161
196
  };
162
197
  }
@@ -164,34 +199,11 @@ export const generateCommand: CliCommand = {
164
199
  bindings.length > 0
165
200
  ? { written: [], conflicts: bindings }
166
201
  : await writeFiles(root, files, force, invocation);
167
- // The three edits a generated primitive needs outside its own slice, performed rather than
168
- // left as findings: a declared permission granted to a role, a job listed in `defineApi`, and
169
- // an entity added to the typed handle its `repo.ts` reads through. Before the manifest load
170
- // below, so the projection sees all three — a repo reading a table the handle lacks would not
171
- // load at all.
172
- const handle = await registerGeneratedEntities(root, report.written, dbModule);
173
- // And a fourth, for `--admin`: the override it wrote, listed where `defineAdmin()` reads it.
174
- const adminWiring = await registerAdminResources(root, report.written);
175
- // And a fifth, last because the others add imports too: every sibling workspace a written file
176
- // imports, declared in the manifest it landed under — or `package-shape` refuses the output.
177
- const edited = [
178
- ...new Set([
179
- ...(await grantGeneratedPermissions(root, report.written)),
180
- ...(await registerGeneratedPrimitives(root, report.written)),
181
- ...handle.edited,
182
- ...adminWiring.edited,
183
- ...(await declareGeneratedImports(root, report.written)),
184
- ]),
185
- ];
186
- // A grant that edit could not place is said so, with the role each permission belongs to: an
187
- // app whose role map is not the scaffold's got no grant and no word, and a 403 on every
188
- // endpoint the run had just written.
189
- const ungranted = await ungrantedByGenerator(root, report.written);
190
- // A locale's catalog existing on disk and the app being able to select it are two different
191
- // facts — see `syncI18nIndex`. Runs before the manifest load below so a route or resource
192
- // this same invocation just wrote never gets projected against a stale catalog registration.
193
- const indexSync =
194
- report.written.length > 0 ? await syncI18nIndex(root) : { registered: true, findings: [] };
202
+ // The edits a generated primitive needs outside its own slice, performed rather than left as
203
+ // findings (`generate-follow-ups.ts`). Before the manifest load below, so the projection sees
204
+ // them all — a repo reading a table the handle lacks would not load at all.
205
+ const follow = await followUpEdits(root, report.written, dbModule, appDisk(root));
206
+ const edited = follow.edited;
195
207
  // Facts, not prose: every `x g` run leaves the route/action/entity/job/policy table current,
196
208
  // the same guarantee `x manifest` makes on its own — an agent reading it after `x g` never
197
209
  // sees a resource that exists on disk but not in the manifest.
@@ -216,14 +228,7 @@ export const generateCommand: CliCommand = {
216
228
  buildId = manifest.buildId;
217
229
  } else loadFailures.push(...findings);
218
230
  }
219
- const findings = [
220
- ...report.conflicts,
221
- ...handle.findings,
222
- ...ungranted,
223
- ...adminWiring.findings,
224
- ...indexSync.findings,
225
- ...loadFailures,
226
- ];
231
+ const findings = [...report.conflicts, ...follow.findings, ...loadFailures];
227
232
  // One list behind all three renderings. The manifest was printed as a `+` line while the count
228
233
  // beside it came from `report.written` alone, so `x g island` said "wrote 2 file(s)" over three
229
234
  // lines — and `--json` carried the shorter list, which is the drift `--json` exists to prevent.
@@ -289,7 +294,7 @@ export function nextSteps(
289
294
  edited: readonly string[],
290
295
  table: string,
291
296
  ): readonly string[] {
292
- if (!written.some((path) => path.endsWith('/entity.ts'))) return [];
297
+ if (!written.some(isEntityModule)) return [];
293
298
  return [
294
299
  ...(edited.some((path) => path.endsWith('package.json')) ? ['bun install'] : []),
295
300
  `bunx x db gen "create ${table}"`,
@@ -322,6 +327,17 @@ async function readSliceErrors(
322
327
  return existsSync(file) ? await Bun.file(file).text() : undefined;
323
328
  }
324
329
 
330
+ /**
331
+ * Whether the slice's `entity.ts` is there and readably declares only tables OTHER than `name`'s
332
+ * (`generate-sibling.ts`). The same entity again — or one this cannot read — is the conflict it
333
+ * always was (`--force` regenerates it); a different one is a second table for the feature, which
334
+ * `--force` would have written OVER the first.
335
+ */
336
+ async function holdsAnotherEntity(root: string, slice: string, name: string): Promise<boolean> {
337
+ const file = containedPath(root, `${slice}/entity.ts`);
338
+ return existsSync(file) && declaresOnlyOtherEntities(await Bun.file(file).text(), name);
339
+ }
340
+
325
341
  /** The generators whose output depends on what the slice's `entity.ts` / `repo.ts` declare. */
326
342
  const READS_SLICE: ReadonlySet<Generator> = new Set([
327
343
  'job',
package/src/dev-boot.ts CHANGED
@@ -113,7 +113,14 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
113
113
  // boot passed nothing here and built its own embedded disk, so an app's `/_storage` routes and its
114
114
  // actions wrote to two different places in `x dev` and to one in production.
115
115
  const appRuntime = await loadAppRuntime(options.root);
116
- const runtime: RunningServices = await startServices(resolved, env, appRuntime);
116
+ // The roles decide whether this boot waits for the bus, exactly as a container's one role does.
117
+ const runtime: RunningServices = await startServices(
118
+ resolved,
119
+ env,
120
+ appRuntime,
121
+ 'apply',
122
+ options.roles ?? DEV_ROLES,
123
+ );
117
124
  // The events binding the boot line reports is the bus the runtime chose, read off it.
118
125
  const services = withRealtimeEvents(resolved, env, runtime.realtime);
119
126
  // `serve.ts`'s `releaseBoot` shape: everything acquired from here on is released, newest first,
@@ -0,0 +1,38 @@
1
+ // The files a generator's follow-up edits read and write, behind one seam — so `x g --dry-run` can
2
+ // run the SAME edits against the plan and answer every path a real run would touch. The dry run
3
+ // listed the files a generator creates and none it edits: the role map, the typed handle, the API
4
+ // index and two manifests changed under a plan that never named them.
5
+
6
+ import { containedPath } from './generate-write';
7
+
8
+ /** App-root-relative reads and writes. A read of a file that is not there answers `undefined`. */
9
+ export interface GenerateDisk {
10
+ read(path: string): Promise<string | undefined>;
11
+ write(path: string, contents: string): Promise<void>;
12
+ }
13
+
14
+ /** The app's own disk. */
15
+ export const appDisk = (root: string): GenerateDisk => ({
16
+ async read(path) {
17
+ const file = Bun.file(containedPath(root, path));
18
+ return (await file.exists()) ? await file.text() : undefined;
19
+ },
20
+ async write(path, contents) {
21
+ await Bun.write(containedPath(root, path), contents);
22
+ },
23
+ });
24
+
25
+ /**
26
+ * The disk as it WOULD stand after a run: `planned` is what the run creates, read before the real
27
+ * file, and a write lands in memory only. Nothing under `root` changes.
28
+ */
29
+ export const plannedDisk = (root: string, planned: ReadonlyMap<string, string>): GenerateDisk => {
30
+ const held = new Map(planned);
31
+ const real = appDisk(root);
32
+ return {
33
+ read: async (path) => held.get(path) ?? (await real.read(path)),
34
+ async write(path, contents) {
35
+ held.set(path, contents);
36
+ },
37
+ };
38
+ };
@@ -82,6 +82,11 @@ export interface GenerateOptions {
82
82
  * resource's page renders inside it. Read off the disk by `run`, for `catalogModule`'s reason.
83
83
  */
84
84
  readonly shell?: boolean;
85
+ /**
86
+ * `entity` only: the feature's `entity.ts` already declares ANOTHER table, so this entity is
87
+ * written beside it as `entity-<name>.ts` + `repo-<name>.ts`. Read off the disk by `run`.
88
+ */
89
+ readonly sibling?: boolean;
85
90
  }
86
91
 
87
92
  const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
@@ -174,7 +179,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
174
179
  // With the labels the admin reads for it: the entity is an admin screen once it is in the
175
180
  // handle, whether or not a resource was generated around it.
176
181
  return dedupe([
177
- ...entityFiles(options.name, target),
182
+ ...entityFiles(options.name, target, { sibling: options.sibling === true }),
178
183
  ...adminCatalogFiles(options.name, target, options.locales),
179
184
  ]);
180
185
  case 'policy':
@@ -0,0 +1,64 @@
1
+ // Everything `x g` edits OUTSIDE the files it creates, in the one order they have to happen: a
2
+ // declared permission granted to a role, a primitive listed in `defineApi`, an entity added to the
3
+ // typed handle, an `--admin` override wired, each new workspace edge declared, and every catalog
4
+ // made selectable. One function over one disk seam, so a real run and a dry run are the same code
5
+ // — and the dry run's list of touched files is what the run touches, not a second guess at it.
6
+
7
+ import { registerAdminResources } from './admin-registration';
8
+ import { registerGeneratedPrimitives } from './api-registration';
9
+ import type { GenerateDisk } from './generate-disk';
10
+ import { ungrantedByGenerator } from './generate-grant-findings';
11
+ import { grantGeneratedPermissions } from './generate-grants';
12
+ import { declareGeneratedImports } from './generated-imports';
13
+ import { registerGeneratedEntities } from './handle-registration';
14
+ import { syncI18nIndex } from './i18n-index';
15
+ import type { Finding } from './output';
16
+ import { CATALOG_ROOT } from './templates';
17
+
18
+ export interface FollowUps {
19
+ /** App-root-relative paths edited or created beyond the generator's own files, each once. */
20
+ readonly edited: readonly string[];
21
+ readonly findings: readonly Finding[];
22
+ }
23
+
24
+ /** The locale each written catalog file is for: a dry run's catalogs are not on the disk yet. */
25
+ const writtenLocales = (written: readonly string[]): readonly string[] =>
26
+ written.flatMap((path) => {
27
+ const stem = path.startsWith(`${CATALOG_ROOT}/`) ? path.slice(CATALOG_ROOT.length + 1) : '';
28
+ return stem.endsWith('.json') && !stem.includes('/') ? [stem.slice(0, -'.json'.length)] : [];
29
+ });
30
+
31
+ export async function followUpEdits(
32
+ root: string,
33
+ written: readonly string[],
34
+ dbModule: string | undefined,
35
+ disk: GenerateDisk,
36
+ ): Promise<FollowUps> {
37
+ const handle = await registerGeneratedEntities(root, written, dbModule, disk);
38
+ const adminWiring = await registerAdminResources(root, written, disk);
39
+ const granted = await grantGeneratedPermissions(root, written, disk);
40
+ const listed = await registerGeneratedPrimitives(root, written, disk);
41
+ // Last of the manifest writers, because the others add imports too: every sibling workspace a
42
+ // written file imports, declared in the manifest it landed under.
43
+ const imports = await declareGeneratedImports(root, written, disk);
44
+ // A grant that edit could not place is said so, with the role each permission belongs to.
45
+ const ungranted = await ungrantedByGenerator(root, written, disk);
46
+ // A catalog on disk and a locale the app can select are two facts — see `syncI18nIndex`.
47
+ const indexSync =
48
+ written.length > 0
49
+ ? await syncI18nIndex(root, disk, writtenLocales(written))
50
+ : { registered: true, findings: [] };
51
+ return {
52
+ edited: [
53
+ ...new Set([
54
+ ...granted,
55
+ ...listed,
56
+ ...handle.edited,
57
+ ...adminWiring.edited,
58
+ ...imports,
59
+ ...(indexSync.edited ?? []),
60
+ ]),
61
+ ].filter((path) => !written.includes(path)),
62
+ findings: [...handle.findings, ...ungranted, ...adminWiring.findings, ...indexSync.findings],
63
+ };
64
+ }