@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
@@ -2,10 +2,12 @@
2
2
  // `cmd-generate.ts` so a generator's output can be asserted on — by the generator tests, the
3
3
  // scaffold fixture and `x new` — without a command line, an app root or a filesystem.
4
4
 
5
- import { CliNotImplementedError } from './errors';
5
+ import { NotImplementedError } from '@ultimat3/core';
6
+ import { BadFlagError } from './errors';
6
7
  import type { Generator } from './generate-kinds';
7
- import { assertSurfaceSupported, GENERATORS } from './generate-kinds';
8
+ import { assertLiveSupported, assertSurfaceSupported, GENERATORS } from './generate-kinds';
8
9
  import { dedupe } from './generate-write';
10
+ import { quoteArg } from './shell-quote';
9
11
  import type { GeneratedFile, Surface } from './templates';
10
12
  import {
11
13
  actionFiles,
@@ -16,12 +18,15 @@ import {
16
18
  islandFiles,
17
19
  jobFiles,
18
20
  kebab,
21
+ names,
19
22
  policyFiles,
20
23
  queryFiles,
21
24
  resourceFiles,
22
25
  routeFiles,
26
+ shippedGuardFiles,
23
27
  taskFiles,
24
28
  } from './templates';
29
+ import { adminCatalogFiles } from './templates/admin-catalog';
25
30
 
26
31
  export interface GenerateOptions {
27
32
  readonly kind: Generator;
@@ -56,15 +61,27 @@ export interface GenerateOptions {
56
61
  */
57
62
  readonly sliceErrors?: string;
58
63
  /**
59
- * `action`, `mutator`, `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when
64
+ * `action`, `mutator`, `query`, `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when
60
65
  * the feature has no entity yet — and then none is written. Supplied by `run` for `sliceErrors`'s reason — whether the feature is
61
66
  * tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
62
- * `repo.byId`/`repo.listByOrg` calls into a feature whose entity names no tenant column. Read at
67
+ * `repo.byId`/`repo.list` calls into a feature whose entity names no tenant column. Read at
63
68
  * `sliceDir(surface, feature)/entity.ts`.
64
69
  */
65
70
  readonly sliceEntity?: string;
66
- /** `job` and `task`: the slice's `repo.ts` as it stands on disk, absent alongside `sliceEntity`. */
71
+ /** The same kinds: the slice's `repo.ts` as it stands on disk, absent alongside `sliceEntity`. */
67
72
  readonly sliceRepo?: string;
73
+ /**
74
+ * The app's own db package, `@<app>/db` — what every generated `repo.ts` imports the typed handle
75
+ * from. Supplied by `run` (`resolveDbModule`) for `catalogModule`'s reason: the name lives in a
76
+ * manifest on disk. Every kind that can write a slice's `repo.ts` reads it, so it rides on the
77
+ * shared target rather than on one case.
78
+ */
79
+ readonly dbModule?: string;
80
+ /**
81
+ * `resource` only: the app has the frame `x new` writes (`apps/web/shared/shell.tsx`), so the
82
+ * resource's page renders inside it. Read off the disk by `run`, for `catalogModule`'s reason.
83
+ */
84
+ readonly shell?: boolean;
68
85
  }
69
86
 
70
87
  const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
@@ -76,6 +93,26 @@ const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
76
93
  export const sliceDir = (surface: Surface, feature: string): string =>
77
94
  `${DEFAULT_SURFACE_DIR[surface]}/${feature}`;
78
95
 
96
+ /**
97
+ * `x g resource <name> --feature <other>`. A resource IS its feature: every file it composes — the
98
+ * actions, the live query, the job — names the entity after the slice (`../entity`'s `Run`,
99
+ * `canRunWrite`), so a slice under a second name is a set of imports that resolve to nothing. The
100
+ * flag was read and then overwritten with the name, which wrote the slice somewhere the author did
101
+ * not ask for and said nothing. Refused instead; a value that only restates the name is no second
102
+ * name.
103
+ */
104
+ function refuseResourceFeature(options: GenerateOptions, surfaceDir: string): void {
105
+ if (options.kind !== 'resource' || options.feature === undefined) return;
106
+ const named = kebab(options.name);
107
+ if (kebab(options.feature) === named) return;
108
+ throw new BadFlagError({
109
+ flag: 'feature',
110
+ command: 'g resource',
111
+ reason: `a resource names its own slice — "${options.name}" is written to ${surfaceDir}/${named}/ and its page to ${surfaceDir}/${names(options.name).pluralKebab}/ — so --feature ${options.feature} would be a second name for one slice`,
112
+ fix: `x g resource ${quoteArg(options.name)}`,
113
+ });
114
+ }
115
+
79
116
  /**
80
117
  * Pure: returns the files a generator would write. `x g` writes them, the generator test asserts
81
118
  * on them, and nothing has to run a filesystem to review what a generator produces.
@@ -83,11 +120,17 @@ export const sliceDir = (surface: Surface, feature: string): string =>
83
120
  export function generate(options: GenerateOptions): readonly GeneratedFile[] {
84
121
  const surface: Surface = options.surface ?? 'app';
85
122
  assertSurfaceSupported(options.kind, surface, options.name);
123
+ assertLiveSupported(options.kind, options.live === true, options.name);
86
124
  const surfaceDir = DEFAULT_SURFACE_DIR[surface];
125
+ refuseResourceFeature(options, surfaceDir);
87
126
  // Kebab, always: `x g entity BlogPost` wrote `app/BlogPost/` while `resource` wrote
88
127
  // `app/blog-post/`, so one feature grew two slice directories depending on the generator.
89
128
  const feature = kebab(options.feature ?? options.name);
90
- const target = { surfaceDir, feature };
129
+ const target = {
130
+ surfaceDir,
131
+ feature,
132
+ ...(options.dbModule === undefined ? {} : { dbModule: options.dbModule }),
133
+ };
91
134
  switch (options.kind) {
92
135
  case 'resource':
93
136
  return dedupe(
@@ -96,6 +139,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
96
139
  admin: options.admin === true,
97
140
  ...(options.locales === undefined ? {} : { locales: options.locales }),
98
141
  ...(options.catalogModule === undefined ? {} : { catalogModule: options.catalogModule }),
142
+ ...(options.shell === undefined ? {} : { shell: options.shell }),
99
143
  }),
100
144
  );
101
145
  case 'action':
@@ -104,6 +148,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
104
148
  ...target,
105
149
  ...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
106
150
  ...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
151
+ ...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
107
152
  }),
108
153
  );
109
154
  case 'mutator':
@@ -113,16 +158,35 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
113
158
  mutator: true,
114
159
  ...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
115
160
  ...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
161
+ ...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
116
162
  }),
117
163
  );
118
164
  case 'backfill':
119
- return dedupe(backfillFiles(options.name, target));
165
+ return dedupe(
166
+ backfillFiles(options.name, {
167
+ ...target,
168
+ ...(options.locales === undefined ? {} : { locales: options.locales }),
169
+ }),
170
+ );
120
171
  case 'entity':
121
- return dedupe(entityFiles(options.name, target));
172
+ // With the labels the admin reads for it: the entity is an admin screen once it is in the
173
+ // handle, whether or not a resource was generated around it.
174
+ return dedupe([
175
+ ...entityFiles(options.name, target),
176
+ ...adminCatalogFiles(options.name, target, options.locales),
177
+ ]);
122
178
  case 'policy':
123
179
  return dedupe(policyFiles(options.name, target));
124
180
  case 'query':
125
- return dedupe(queryFiles(options.name, { ...target, live: options.live === true }));
181
+ return dedupe(
182
+ queryFiles(options.name, {
183
+ ...target,
184
+ live: options.live === true,
185
+ ...(options.locales === undefined ? {} : { locales: options.locales }),
186
+ ...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
187
+ ...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
188
+ }),
189
+ );
126
190
  case 'job':
127
191
  return dedupe(
128
192
  jobFiles(options.name, {
@@ -143,8 +207,11 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
143
207
  return dedupe(islandFiles(options.name, { dir: options.at ?? `${surfaceDir}/${feature}` }));
144
208
  // No `--at`, no surface, no feature: `guards/` is the one directory the gate discovers, and a
145
209
  // guard that lived anywhere else would need an app-side registration to be found.
210
+ // A name the framework ships a guard under gets THAT guard — how an app adopts one it does
211
+ // not have (`x doctor` names them). Any other name is the app's own convention, from the blank
212
+ // template.
146
213
  case 'guard':
147
- return dedupe(guardFiles(options.name));
214
+ return dedupe(shippedGuardFiles(kebab(options.name)) ?? guardFiles(options.name));
148
215
  case 'admin:page':
149
216
  // A default permission, never none: an empty list is `X_ADMIN_PAGE_UNGUARDED` on sight.
150
217
  return dedupe(
@@ -167,8 +234,8 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
167
234
  }),
168
235
  );
169
236
  default:
170
- throw new CliNotImplementedError({
171
- feature: `generator "${String(options.kind)}"`,
237
+ throw new NotImplementedError({
238
+ cause: `generator "${String(options.kind)}" is not implemented in this build`,
172
239
  fix: `x g ${GENERATORS.join('|')}`,
173
240
  });
174
241
  }
@@ -0,0 +1,76 @@
1
+ // What `x g` could NOT grant, said out loud. `generate-grants.ts` performs the role-map edit
2
+ // where the scaffold keeps one; an app whose roles live elsewhere got no grant and no finding, and
3
+ // every generated endpoint answered 403. Apart from that module because this one only REPORTS:
4
+ // it re-asks the same pure edits of the file as it now stands and names what they could not place.
5
+
6
+ import type { RoleGrant } from './generate-grants';
7
+ import {
8
+ adminGrantsFor,
9
+ grantsForWritten,
10
+ insertGrants,
11
+ insertPermissions,
12
+ writtenTables,
13
+ } from './generate-grants';
14
+ import { containedPath } from './generate-write';
15
+ import type { Finding } from './output';
16
+ import { ROLES_FILE } from './permission-grants';
17
+
18
+ /** `member: 'run:read'; admin: 'run:write', 'runs:read'` — each role, and what its `grants` owes. */
19
+ const linesFor = (grants: readonly RoleGrant[]): string => {
20
+ const byRole = new Map<string, string[]>();
21
+ for (const grant of grants) {
22
+ byRole.set(grant.role, [...(byRole.get(grant.role) ?? []), `'${grant.permission}'`]);
23
+ }
24
+ return [...byRole].map(([role, permissions]) => `${role}: ${permissions.join(', ')}`).join('; ');
25
+ };
26
+
27
+ /** The grants the role map as it stands still lacks: no such role, no `grants`, or undeclared. */
28
+ function stillMissing(source: string, grants: readonly RoleGrant[], admin: readonly RoleGrant[]) {
29
+ const undeclared = insertPermissions(
30
+ source,
31
+ admin.map((grant) => grant.permission),
32
+ ).skipped;
33
+ const unplaced = insertGrants(source, grants).skipped;
34
+ return grants.filter(
35
+ (grant) => unplaced.includes(grant) || undeclared.includes(grant.permission),
36
+ );
37
+ }
38
+
39
+ /**
40
+ * One finding for everything this run declared and no role holds, or none. Asked AFTER
41
+ * `grantGeneratedPermissions`, of the file it left: a grant it placed is not reported, and one it
42
+ * could not place — no role map at the scaffold's path, a map with no such role, a map with no
43
+ * `definePermissions([…])` to declare an admin permission in — is, with the role each belongs to.
44
+ */
45
+ export async function ungrantedByGenerator(
46
+ root: string,
47
+ written: readonly string[],
48
+ ): Promise<readonly Finding[]> {
49
+ const admin = (await writtenTables(root, written)).flatMap(adminGrantsFor);
50
+ const grants = [...grantsForWritten(written), ...admin];
51
+ if (grants.length === 0) return [];
52
+ const file = Bun.file(containedPath(root, ROLES_FILE));
53
+ // The file whose declarations nobody granted: where a reader starts when the map is not ours.
54
+ const declaredIn =
55
+ written.find((path) => path.endsWith('/policy.ts')) ?? written[0] ?? ROLES_FILE;
56
+ if (!(await file.exists())) {
57
+ return [
58
+ {
59
+ code: 'X_PERMISSION_UNGRANTED',
60
+ cause: `${ROLES_FILE} is not in this app, so nothing granted what this run declared — every generated endpoint answers 403 until a role holds: ${linesFor(grants)}`,
61
+ fix: `x policy list --json # names the module that calls defineRoles(); add to its roles' grants — ${linesFor(grants)} — then x verify --only policy`,
62
+ at: declaredIn,
63
+ },
64
+ ];
65
+ }
66
+ const missing = stillMissing(await file.text(), grants, admin);
67
+ if (missing.length === 0) return [];
68
+ return [
69
+ {
70
+ code: 'X_PERMISSION_UNGRANTED',
71
+ cause: `${ROLES_FILE} has no role, no "grants: [...]" list or no definePermissions([...]) these could be added to, so nothing granted: ${linesFor(missing)}`,
72
+ fix: `edit ${ROLES_FILE} — add to each role's grants (and declare any the map does not know in definePermissions([...])) — ${linesFor(missing)} — then x verify --only policy`,
73
+ at: ROLES_FILE,
74
+ },
75
+ ];
76
+ }
@@ -2,6 +2,10 @@
2
2
  // `<feature>:read` and `<feature>:write`; nothing granted them, so every generated endpoint
3
3
  // answered 403 to every actor a role mints. The edit lands in the scaffold's one role map,
4
4
  // `apps/web/shared/roles.ts`: `:read` to `member`, `:write` to `admin` (which inherits member).
5
+ //
6
+ // And a generated ENTITY is a screen at `/admin/<table>`, behind `<table>:read|write|delete` —
7
+ // three permissions `defineAdmin()` derives from the table's own name. They are declared in the
8
+ // role map and granted to `admin`, or the role that runs the admin is refused its newest screen.
5
9
 
6
10
  import { containedPath } from './generate-write';
7
11
  import { ROLES_FILE } from './permission-grants';
@@ -33,6 +37,23 @@ export function grantsForWritten(written: readonly string[]): readonly RoleGrant
33
37
  });
34
38
  }
35
39
 
40
+ /** A generated entity file: `<surface>/<feature>/entity.ts`. */
41
+ const ENTITY_PATH = /^apps\/[^/]+\/[^/]+\/[a-z0-9-]+\/entity\.ts$/;
42
+ /** `entity('widgets', {` — the name the admin's permissions are spelled with. */
43
+ const ENTITY_NAME = /\bentity\(\s*'([^']+)'/;
44
+
45
+ /**
46
+ * What the admin asks for one table, granted to the role that runs it. The verbs are
47
+ * `@ultimat3/admin`'s `entityPermissionFor` — `read` to list, open and search, `write` to create
48
+ * and edit, `delete` to delete — and the resource is the ENTITY's name, never the feature's.
49
+ */
50
+ export function adminGrantsFor(table: string): readonly RoleGrant[] {
51
+ return ['read', 'write', 'delete'].map((verb) => ({
52
+ role: 'admin',
53
+ permission: `${table}:${verb}`,
54
+ }));
55
+ }
56
+
36
57
  const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
58
 
38
59
  /**
@@ -67,16 +88,69 @@ export function insertGrants(
67
88
  return { source: next, skipped };
68
89
  }
69
90
 
70
- /** Performs `insertGrants` on the app's role map. Answers the paths it rewrote. */
91
+ const DECLARATION = 'definePermissions([';
92
+
93
+ /**
94
+ * `source` with each permission added to its first `definePermissions([…])`, re-wrapped the way
95
+ * Biome prints it. Declared BEFORE it is granted: `can()` refuses a name no call registered, and
96
+ * `defineRoles()` does not — a grant nothing declares is a 500 on the first request that asks.
97
+ * A role map with no such call is left alone and every permission returned in `skipped`.
98
+ */
99
+ export function insertPermissions(
100
+ source: string,
101
+ permissions: readonly string[],
102
+ ): { readonly source: string; readonly skipped: readonly string[] } {
103
+ const open = source.indexOf(DECLARATION);
104
+ const close = open === -1 ? -1 : source.indexOf('])', open);
105
+ if (open === -1 || close === -1) return { source, skipped: [...permissions] };
106
+ const current = [...source.slice(open, close).matchAll(/'([^']+)'/g)].map((m) => m[1] ?? '');
107
+ const added = permissions.filter((permission) => !current.includes(permission));
108
+ if (added.length === 0) return { source, skipped: [] };
109
+ const lineStart = source.lastIndexOf('\n', open) + 1;
110
+ const head = source.slice(lineStart, open);
111
+ const tail = source.slice(close + 2, source.indexOf('\n', close));
112
+ const entries = [...current, ...added].map((permission) => `'${permission}'`);
113
+ const rewritten = wrapList('', `${head}${DECLARATION}`, entries, `])${tail}`);
114
+ return {
115
+ source: `${source.slice(0, lineStart)}${rewritten}${source.slice(source.indexOf('\n', close))}`,
116
+ skipped: [],
117
+ };
118
+ }
119
+
120
+ /** The entity names the written `entity.ts` files declare — read off each file, never guessed. */
121
+ export async function writtenTables(
122
+ root: string,
123
+ written: readonly string[],
124
+ ): Promise<readonly string[]> {
125
+ const tables: string[] = [];
126
+ for (const path of written.filter((candidate) => ENTITY_PATH.test(candidate))) {
127
+ const table = ENTITY_NAME.exec(await Bun.file(containedPath(root, path)).text())?.[1];
128
+ if (table !== undefined) tables.push(table);
129
+ }
130
+ return tables;
131
+ }
132
+
133
+ /**
134
+ * Performs both edits on the app's role map: a written policy's grants, and — for each written
135
+ * entity — the admin's three permissions for its table, declared and granted. Answers the paths
136
+ * it rewrote.
137
+ */
71
138
  export async function grantGeneratedPermissions(
72
139
  root: string,
73
140
  written: readonly string[],
74
141
  ): Promise<readonly string[]> {
75
- const grants = grantsForWritten(written);
142
+ const admin = (await writtenTables(root, written)).flatMap(adminGrantsFor);
143
+ const grants = [...grantsForWritten(written), ...admin];
76
144
  const file = containedPath(root, ROLES_FILE);
77
145
  if (grants.length === 0 || !(await Bun.file(file).exists())) return [];
78
146
  const before = await Bun.file(file).text();
79
- const { source } = insertGrants(before, grants);
147
+ const declared = insertPermissions(
148
+ before,
149
+ admin.map((grant) => grant.permission),
150
+ );
151
+ // Granted only where it could be declared: a grant with no declaration is the 500 above.
152
+ const grantable = grants.filter((grant) => !declared.skipped.includes(grant.permission));
153
+ const { source } = insertGrants(declared.source, grantable);
80
154
  if (source === before) return [];
81
155
  await Bun.write(file, source);
82
156
  return [ROLES_FILE];
@@ -9,8 +9,9 @@ import {
9
9
  MissingSubcommandError,
10
10
  UnknownCommandError,
11
11
  } from './errors';
12
+ import { quoteArg } from './shell-quote';
12
13
  import type { Surface } from './templates';
13
- import { camel, kebab } from './templates/naming';
14
+ import { camel, kebab, plural, singularOf } from './templates/naming';
14
15
 
15
16
  export const GENERATORS = [
16
17
  'resource',
@@ -49,6 +50,24 @@ export function assertSurfaceSupported(kind: Generator, surface: Surface, name:
49
50
  });
50
51
  }
51
52
 
53
+ /**
54
+ * `--live` is the query generator's. A resource slice already ships a live list query, so the flag
55
+ * on any other generator changed nothing — and a flag accepted and ignored is a second spelling of
56
+ * the same command that an agent then believes did something.
57
+ */
58
+ export function assertLiveSupported(kind: Generator, live: boolean, name: string): void {
59
+ if (!live || kind === 'query') return;
60
+ throw new BadFlagError({
61
+ flag: 'live',
62
+ command: `g ${kind}`,
63
+ reason:
64
+ kind === 'resource'
65
+ ? "--live is the query generator's: a resource slice already ships a live list query"
66
+ : "--live is the query generator's: it declares a query live",
67
+ fix: `x g query ${quoteArg(`${name}-feed`)} --feature ${quoteArg(name)} --live`,
68
+ });
69
+ }
70
+
52
71
  /**
53
72
  * The generator a spelling reaches, or a refusal that leads with the one it is nearest.
54
73
  *
@@ -118,10 +137,42 @@ export function readName(raw: string | undefined, kind: Generator): string {
118
137
  // directory that serves it. Every other generator's name is one directory segment.
119
138
  if (kind === 'route') return readRoutePath(raw);
120
139
  refusePath(raw, 'name', kind);
140
+ refuseNonAscii(raw, kind);
121
141
  refuseBadIdentifier(raw, kind);
142
+ refusePlural(raw, kind);
122
143
  return raw;
123
144
  }
124
145
 
146
+ /**
147
+ * Every derived spelling keeps `[a-zA-Z0-9]` and drops the rest as a separator, so a letter
148
+ * outside ASCII vanished: `x g entity Über` wrote `ber`. Refused, with the name folded to ASCII.
149
+ */
150
+ function refuseNonAscii(raw: string, kind: Generator): void {
151
+ if (!/[^\p{ASCII}]/u.test(raw)) return;
152
+ const folded = kebab(raw.normalize('NFKD').replace(/[^\p{ASCII}]/gu, ''));
153
+ throw new BadFlagError({
154
+ flag: 'name',
155
+ command: `g ${kind}`,
156
+ reason: `"${raw}" holds a letter outside ASCII, and every file, identifier and table name the generator derives from it keeps ASCII letters and digits only`,
157
+ fix: folded === '' ? exampleFor(kind) : ['x g', kind, folded].join(' '),
158
+ });
159
+ }
160
+
161
+ /** The generators that pluralise their name into a table and a page. */
162
+ const PLURALISED: ReadonlySet<Generator> = new Set<Generator>(['resource', 'entity']);
163
+
164
+ /** `x g resource posts` declared `entity('postses')`: a plural name is pluralised again. */
165
+ function refusePlural(raw: string, kind: Generator): void {
166
+ const singular = PLURALISED.has(kind) ? singularOf(raw) : undefined;
167
+ if (singular === undefined) return;
168
+ throw new BadFlagError({
169
+ flag: 'name',
170
+ command: `g ${kind}`,
171
+ reason: `"${raw}" is already plural, and the generator pluralises the name for its table and page — "${plural(camel(raw))}"`,
172
+ fix: ['x g', kind, singular].join(' '),
173
+ });
174
+ }
175
+
125
176
  /** A static segment, or `[param]` / `[...rest]` — the directory convention `x routes` reads. */
126
177
  const ROUTE_SEGMENT = /^(?:[A-Za-z0-9][A-Za-z0-9_-]*|\[(?:\.\.\.)?[A-Za-z][A-Za-z0-9_-]*\])$/;
127
178
 
@@ -0,0 +1,46 @@
1
+ // A generator name whose type spelling the emitted code ALSO uses as a global or a local alias:
2
+ // `x g resource promise` imports `type { Promise }` beside `Promise<Row>`, and `row` writes
3
+ // `type Row = Row`. Decided on the planned files themselves, so a template that starts using
4
+ // another global is covered the day it does — and the names that clash nowhere stay allowed.
5
+
6
+ import { BadFlagError } from './errors';
7
+ import type { Generator } from './generate-kinds';
8
+ import type { GeneratedFile } from './templates';
9
+ import { kebab, names } from './templates/naming';
10
+
11
+ /** The emitted file that imports `type` from the slice AND uses the same name as its own. */
12
+ function shadowingFile(files: readonly GeneratedFile[], type: string): string | undefined {
13
+ // A pascal spelling is `[A-Za-z0-9]` only (`naming.ts`), so it splices into a pattern as is.
14
+ const imported = new RegExp(
15
+ `import\\s+(?:type\\s+)?\\{[^}]*\\b${type}\\b[^}]*\\}\\s+from\\s+'\\.`,
16
+ );
17
+ const reused = new RegExp(
18
+ `\\b${type}<|^\\s*(?:export\\s+)?(?:type\\s+${type}\\s*=|interface\\s+${type}\\b)`,
19
+ 'm',
20
+ );
21
+ return files.find(
22
+ (file) =>
23
+ typeof file.contents === 'string' &&
24
+ /\.tsx?$/.test(file.path) &&
25
+ imported.test(file.contents) &&
26
+ reused.test(file.contents),
27
+ )?.path;
28
+ }
29
+
30
+ /** Refuses `name` when its type spelling would shadow a name the planned files rely on. */
31
+ export function refuseShadowedTypes(
32
+ files: readonly GeneratedFile[],
33
+ kind: Generator,
34
+ name: string,
35
+ ): void {
36
+ const type = names(name).pascal;
37
+ const file = shadowingFile(files, type);
38
+ if (file === undefined) return;
39
+ const noun = kind.split(':').at(-1) ?? kind;
40
+ throw new BadFlagError({
41
+ flag: 'name',
42
+ command: `g ${kind}`,
43
+ reason: `"${name}" becomes the type ${type}, and ${file} imports it beside its own use of ${type} — the global or local type the template means is shadowed and the file does not compile`,
44
+ fix: ['x g', kind, `${kebab(name)}-${noun}`].join(' '),
45
+ });
46
+ }
@@ -9,7 +9,7 @@ import { existsSync } from 'node:fs';
9
9
  import { resolve, sep } from 'node:path';
10
10
  import { ERROR_DOCS_URL } from '@ultimat3/core';
11
11
  import { GenerateJsonInvalidError, ScaffoldPathEscapeError } from './errors';
12
- import { mergeJsonDeep } from './json-merge';
12
+ import { mergeJsonDeep, mergeJsonInPlace, sortJsonDeep } from './json-merge';
13
13
  import type { Finding } from './output';
14
14
  import type { GeneratedFile } from './templates';
15
15
 
@@ -28,33 +28,18 @@ function parseJsonObject(text: string): Record<string, unknown> | undefined {
28
28
  }
29
29
 
30
30
  /**
31
- * Every key of every level, sorted. A catalog is authored NESTED (`{ nav: { home: … } }`) and this
32
- * sorted the top level only, so the keys INSIDE `app` kept the order their generators ran in —
33
- * `x g route zebra` then `x g route alpha` wrote different bytes from the same two runs in the
34
- * other order, which is the reordering diff the sort exists to prevent. Arrays keep their order:
35
- * a list's order is its content.
31
+ * A generator's OWN catalog bytes: every key of every level sorted, 2-space indent, trailing
32
+ * newline. A catalog is authored NESTED (`{ nav: { home: … } }`) and sorting the top level only
33
+ * left the keys inside `app` in the order their generators ran — `x g route zebra` then
34
+ * `x g route alpha` wrote different bytes from the same two runs in the other order. The sort is
35
+ * `json-merge.ts`'s `sortJsonDeep`, by code unit: `localeCompare` is `Intl`-backed, so the bytes
36
+ * would move with the machine's locale and its ICU version.
36
37
  *
37
- * CODE UNIT, never `localeCompare`: it is `Intl`-backed, so the bytes a catalog is written with
38
- * moved with the machine's locale and its ICU version — `x g route` on a `tr-TR` box and the same
39
- * command in CI produced two orderings of one file, which is exactly the reordering diff this sort
40
- * exists to prevent, one layer down. `@ultimat3/render` states the same rule as `byCodeUnit`; the
41
- * comparison is inlined rather than imported because that one is package-internal and this is the
42
- * whole of it.
38
+ * For a fresh file and for `dedupe`, where every contributor is a generator. A file already on
39
+ * disk is merged in ITS order (`mergeJsonInPlace`), never re-sorted.
43
40
  */
44
- function sortDeep(value: unknown): unknown {
45
- if (Array.isArray(value)) return value.map(sortDeep);
46
- if (typeof value !== 'object' || value === null) return value;
47
- return Object.fromEntries(
48
- Object.entries(value)
49
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
50
- .map(([key, entry]) => [key, sortDeep(entry)]),
51
- );
52
- }
53
-
54
- /** Deterministic catalog bytes: sorted keys, 2-space indent, trailing newline — a diff shows only
55
- * the keys a run actually changed, never a reordering. */
56
41
  function prettyJson(value: Record<string, unknown>): string {
57
- return `${JSON.stringify(sortDeep(value), null, 2)}\n`;
42
+ return `${JSON.stringify(sortJsonDeep(value), null, 2)}\n`;
58
43
  }
59
44
 
60
45
  /**
@@ -117,7 +102,7 @@ export function containedPath(root: string, path: string): string {
117
102
  dir: base,
118
103
  // Command first, the caveat behind a `#`: the line runs verbatim and the shell drops the
119
104
  // rest. `x g <kind> <name>` pasted into bash is a redirect, not a command.
120
- fix: `x g resource posts --dry-run # name every file relative to the app root, no ".." segment`,
105
+ fix: `x g resource post --dry-run # name every file relative to the app root, no ".." segment`,
121
106
  });
122
107
  return target;
123
108
  }
@@ -156,10 +141,13 @@ async function planJsonMerge(
156
141
  }
157
142
  // An existing key wins because it may hold a human translation; only the new keys are added.
158
143
  // Deep, so a nested catalog gains `site.blog.title` without losing the rest of `site`.
159
- const { merged, gained } = mergeJsonDeep(existing, generated);
144
+ const { gained } = mergeJsonDeep(existing, generated);
160
145
  // Every key the generator wants is already there — leave the file untouched and unclaimed.
161
146
  if (!gained) return { kind: 'skip' };
162
- return { kind: 'write', file, absolute, contents: prettyJson(merged) };
147
+ // In the file's OWN order: the diff a run leaves is the keys it added, never a re-sort of a
148
+ // catalog somebody keeps grouped by screen.
149
+ const merged = mergeJsonInPlace(existing, generated);
150
+ return { kind: 'write', file, absolute, contents: `${JSON.stringify(merged, null, 2)}\n` };
163
151
  }
164
152
 
165
153
  /**
@@ -210,14 +198,56 @@ function planFile(
210
198
  return { kind: 'write', file, absolute, contents: file.contents };
211
199
  }
212
200
 
201
+ /** Every file's plan, decided without touching the disk — the first of `writeFiles`' two passes. */
202
+ async function planAll(
203
+ root: string,
204
+ files: readonly GeneratedFile[],
205
+ force: boolean,
206
+ invocation: string,
207
+ ): Promise<readonly WritePlan[]> {
208
+ const plans: WritePlan[] = [];
209
+ for (const file of files) {
210
+ const absolute = containedPath(root, file.path);
211
+ plans.push(
212
+ file.merge === 'json'
213
+ ? await planJsonMerge(file, absolute)
214
+ : planFile(file, absolute, force, invocation),
215
+ );
216
+ }
217
+ return plans;
218
+ }
219
+
220
+ const conflictsOf = (plans: readonly WritePlan[]): readonly Finding[] =>
221
+ plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
222
+
223
+ /**
224
+ * What `writeFiles` WOULD do, and nothing else: the paths it would write and the conflicts that
225
+ * would stop it. `x g --dry-run` answers with this — the bare file list said "would write 33"
226
+ * where the real run was `X_GENERATE_CONFLICT`, and listed slice modules the run skips as writes.
227
+ */
228
+ export async function planWrites(
229
+ root: string,
230
+ files: readonly GeneratedFile[],
231
+ force: boolean,
232
+ invocation = 'x g <kind> <name>',
233
+ ): Promise<WriteReport> {
234
+ const plans = await planAll(root, files, force, invocation);
235
+ const conflicts = conflictsOf(plans);
236
+ if (conflicts.length > 0) return { written: [], conflicts };
237
+ return {
238
+ written: plans.flatMap((plan) => (plan.kind === 'write' ? [plan.file.path] : [])),
239
+ conflicts: [],
240
+ };
241
+ }
242
+
213
243
  /**
214
244
  * Never clobbers, and never half-writes. A generator that overwrites is a generator nobody runs
215
245
  * twice; a generator that lands four of seven files and then reports a conflict is worse, because
216
246
  * the next run conflicts on the files the failed one wrote.
217
247
  *
218
248
  * Two passes, and the split is the point: the first decides — containment, existence, whether a
219
- * catalog can be merged into — and touches nothing, the second writes only when the first found
220
- * no conflict at all. Containment was already proven up front and the rest was not.
249
+ * catalog can be merged into — and touches nothing (`planWrites` is that pass alone), the second
250
+ * writes only when the first found no conflict at all.
221
251
  */
222
252
  export async function writeFiles(
223
253
  root: string,
@@ -230,16 +260,8 @@ export async function writeFiles(
230
260
  */
231
261
  invocation = 'x g <kind> <name>',
232
262
  ): Promise<WriteReport> {
233
- const plans: WritePlan[] = [];
234
- for (const file of files) {
235
- const absolute = containedPath(root, file.path);
236
- plans.push(
237
- file.merge === 'json'
238
- ? await planJsonMerge(file, absolute)
239
- : planFile(file, absolute, force, invocation),
240
- );
241
- }
242
- const conflicts = plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
263
+ const plans = await planAll(root, files, force, invocation);
264
+ const conflicts = conflictsOf(plans);
243
265
  if (conflicts.length > 0) return { written: [], conflicts };
244
266
 
245
267
  const written: string[] = [];