@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.
- package/CLAUDE.md +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/naming.ts +28 -0
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +144 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
- package/src/verify-errors.ts +0 -49
package/src/generate-files.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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
|
-
/**
|
|
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 = {
|
|
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(
|
|
165
|
+
return dedupe(
|
|
166
|
+
backfillFiles(options.name, {
|
|
167
|
+
...target,
|
|
168
|
+
...(options.locales === undefined ? {} : { locales: options.locales }),
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
120
171
|
case 'entity':
|
|
121
|
-
|
|
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(
|
|
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
|
|
171
|
-
|
|
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
|
+
}
|
package/src/generate-grants.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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];
|
package/src/generate-kinds.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/generate-write.ts
CHANGED
|
@@ -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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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(
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
220
|
-
*
|
|
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
|
|
234
|
-
|
|
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[] = [];
|