@ultimat3/cli 22.15.0 → 23.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 +23 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -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 +25 -1
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- 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/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +9 -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/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- 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 +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/permission-grants.ts +41 -9
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +6 -0
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -157
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -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/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 +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- 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 +142 -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-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- 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 +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- 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 +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- 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/generate-files.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
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 { BadFlagError, CliNotImplementedError } from './errors';
|
|
6
6
|
import type { Generator } from './generate-kinds';
|
|
7
|
-
import { assertSurfaceSupported, GENERATORS } from './generate-kinds';
|
|
7
|
+
import { assertLiveSupported, assertSurfaceSupported, GENERATORS } from './generate-kinds';
|
|
8
8
|
import { dedupe } from './generate-write';
|
|
9
|
+
import { quoteArg } from './shell-quote';
|
|
9
10
|
import type { GeneratedFile, Surface } from './templates';
|
|
10
11
|
import {
|
|
11
12
|
actionFiles,
|
|
@@ -16,12 +17,15 @@ import {
|
|
|
16
17
|
islandFiles,
|
|
17
18
|
jobFiles,
|
|
18
19
|
kebab,
|
|
20
|
+
names,
|
|
19
21
|
policyFiles,
|
|
20
22
|
queryFiles,
|
|
21
23
|
resourceFiles,
|
|
22
24
|
routeFiles,
|
|
25
|
+
shippedGuardFiles,
|
|
23
26
|
taskFiles,
|
|
24
27
|
} from './templates';
|
|
28
|
+
import { adminCatalogFiles } from './templates/admin-catalog';
|
|
25
29
|
|
|
26
30
|
export interface GenerateOptions {
|
|
27
31
|
readonly kind: Generator;
|
|
@@ -56,15 +60,27 @@ export interface GenerateOptions {
|
|
|
56
60
|
*/
|
|
57
61
|
readonly sliceErrors?: string;
|
|
58
62
|
/**
|
|
59
|
-
* `action`, `mutator`, `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when
|
|
63
|
+
* `action`, `mutator`, `query`, `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when
|
|
60
64
|
* the feature has no entity yet — and then none is written. Supplied by `run` for `sliceErrors`'s reason — whether the feature is
|
|
61
65
|
* tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
|
|
62
|
-
* `repo.byId`/`repo.
|
|
66
|
+
* `repo.byId`/`repo.list` calls into a feature whose entity names no tenant column. Read at
|
|
63
67
|
* `sliceDir(surface, feature)/entity.ts`.
|
|
64
68
|
*/
|
|
65
69
|
readonly sliceEntity?: string;
|
|
66
|
-
/**
|
|
70
|
+
/** The same kinds: the slice's `repo.ts` as it stands on disk, absent alongside `sliceEntity`. */
|
|
67
71
|
readonly sliceRepo?: string;
|
|
72
|
+
/**
|
|
73
|
+
* The app's own db package, `@<app>/db` — what every generated `repo.ts` imports the typed handle
|
|
74
|
+
* from. Supplied by `run` (`resolveDbModule`) for `catalogModule`'s reason: the name lives in a
|
|
75
|
+
* manifest on disk. Every kind that can write a slice's `repo.ts` reads it, so it rides on the
|
|
76
|
+
* shared target rather than on one case.
|
|
77
|
+
*/
|
|
78
|
+
readonly dbModule?: string;
|
|
79
|
+
/**
|
|
80
|
+
* `resource` only: the app has the frame `x new` writes (`apps/web/shared/shell.tsx`), so the
|
|
81
|
+
* resource's page renders inside it. Read off the disk by `run`, for `catalogModule`'s reason.
|
|
82
|
+
*/
|
|
83
|
+
readonly shell?: boolean;
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
|
|
@@ -76,6 +92,26 @@ const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
|
|
|
76
92
|
export const sliceDir = (surface: Surface, feature: string): string =>
|
|
77
93
|
`${DEFAULT_SURFACE_DIR[surface]}/${feature}`;
|
|
78
94
|
|
|
95
|
+
/**
|
|
96
|
+
* `x g resource <name> --feature <other>`. A resource IS its feature: every file it composes — the
|
|
97
|
+
* actions, the live query, the job — names the entity after the slice (`../entity`'s `Run`,
|
|
98
|
+
* `canRunWrite`), so a slice under a second name is a set of imports that resolve to nothing. The
|
|
99
|
+
* flag was read and then overwritten with the name, which wrote the slice somewhere the author did
|
|
100
|
+
* not ask for and said nothing. Refused instead; a value that only restates the name is no second
|
|
101
|
+
* name.
|
|
102
|
+
*/
|
|
103
|
+
function refuseResourceFeature(options: GenerateOptions, surfaceDir: string): void {
|
|
104
|
+
if (options.kind !== 'resource' || options.feature === undefined) return;
|
|
105
|
+
const named = kebab(options.name);
|
|
106
|
+
if (kebab(options.feature) === named) return;
|
|
107
|
+
throw new BadFlagError({
|
|
108
|
+
flag: 'feature',
|
|
109
|
+
command: 'g resource',
|
|
110
|
+
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`,
|
|
111
|
+
fix: `x g resource ${quoteArg(options.name)}`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
79
115
|
/**
|
|
80
116
|
* Pure: returns the files a generator would write. `x g` writes them, the generator test asserts
|
|
81
117
|
* on them, and nothing has to run a filesystem to review what a generator produces.
|
|
@@ -83,11 +119,17 @@ export const sliceDir = (surface: Surface, feature: string): string =>
|
|
|
83
119
|
export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
84
120
|
const surface: Surface = options.surface ?? 'app';
|
|
85
121
|
assertSurfaceSupported(options.kind, surface, options.name);
|
|
122
|
+
assertLiveSupported(options.kind, options.live === true, options.name);
|
|
86
123
|
const surfaceDir = DEFAULT_SURFACE_DIR[surface];
|
|
124
|
+
refuseResourceFeature(options, surfaceDir);
|
|
87
125
|
// Kebab, always: `x g entity BlogPost` wrote `app/BlogPost/` while `resource` wrote
|
|
88
126
|
// `app/blog-post/`, so one feature grew two slice directories depending on the generator.
|
|
89
127
|
const feature = kebab(options.feature ?? options.name);
|
|
90
|
-
const target = {
|
|
128
|
+
const target = {
|
|
129
|
+
surfaceDir,
|
|
130
|
+
feature,
|
|
131
|
+
...(options.dbModule === undefined ? {} : { dbModule: options.dbModule }),
|
|
132
|
+
};
|
|
91
133
|
switch (options.kind) {
|
|
92
134
|
case 'resource':
|
|
93
135
|
return dedupe(
|
|
@@ -96,6 +138,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
96
138
|
admin: options.admin === true,
|
|
97
139
|
...(options.locales === undefined ? {} : { locales: options.locales }),
|
|
98
140
|
...(options.catalogModule === undefined ? {} : { catalogModule: options.catalogModule }),
|
|
141
|
+
...(options.shell === undefined ? {} : { shell: options.shell }),
|
|
99
142
|
}),
|
|
100
143
|
);
|
|
101
144
|
case 'action':
|
|
@@ -104,6 +147,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
104
147
|
...target,
|
|
105
148
|
...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
|
|
106
149
|
...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
|
|
150
|
+
...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
|
|
107
151
|
}),
|
|
108
152
|
);
|
|
109
153
|
case 'mutator':
|
|
@@ -113,16 +157,35 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
113
157
|
mutator: true,
|
|
114
158
|
...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
|
|
115
159
|
...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
|
|
160
|
+
...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
|
|
116
161
|
}),
|
|
117
162
|
);
|
|
118
163
|
case 'backfill':
|
|
119
|
-
return dedupe(
|
|
164
|
+
return dedupe(
|
|
165
|
+
backfillFiles(options.name, {
|
|
166
|
+
...target,
|
|
167
|
+
...(options.locales === undefined ? {} : { locales: options.locales }),
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
120
170
|
case 'entity':
|
|
121
|
-
|
|
171
|
+
// With the labels the admin reads for it: the entity is an admin screen once it is in the
|
|
172
|
+
// handle, whether or not a resource was generated around it.
|
|
173
|
+
return dedupe([
|
|
174
|
+
...entityFiles(options.name, target),
|
|
175
|
+
...adminCatalogFiles(options.name, target, options.locales),
|
|
176
|
+
]);
|
|
122
177
|
case 'policy':
|
|
123
178
|
return dedupe(policyFiles(options.name, target));
|
|
124
179
|
case 'query':
|
|
125
|
-
return dedupe(
|
|
180
|
+
return dedupe(
|
|
181
|
+
queryFiles(options.name, {
|
|
182
|
+
...target,
|
|
183
|
+
live: options.live === true,
|
|
184
|
+
...(options.locales === undefined ? {} : { locales: options.locales }),
|
|
185
|
+
...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
|
|
186
|
+
...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
126
189
|
case 'job':
|
|
127
190
|
return dedupe(
|
|
128
191
|
jobFiles(options.name, {
|
|
@@ -143,8 +206,11 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
143
206
|
return dedupe(islandFiles(options.name, { dir: options.at ?? `${surfaceDir}/${feature}` }));
|
|
144
207
|
// No `--at`, no surface, no feature: `guards/` is the one directory the gate discovers, and a
|
|
145
208
|
// guard that lived anywhere else would need an app-side registration to be found.
|
|
209
|
+
// A name the framework ships a guard under gets THAT guard — how an app adopts one it does
|
|
210
|
+
// not have (`x doctor` names them). Any other name is the app's own convention, from the blank
|
|
211
|
+
// template.
|
|
146
212
|
case 'guard':
|
|
147
|
-
return dedupe(guardFiles(options.name));
|
|
213
|
+
return dedupe(shippedGuardFiles(kebab(options.name)) ?? guardFiles(options.name));
|
|
148
214
|
case 'admin:page':
|
|
149
215
|
// A default permission, never none: an empty list is `X_ADMIN_PAGE_UNGUARDED` on sight.
|
|
150
216
|
return dedupe(
|
|
@@ -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,6 +9,7 @@ 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
14
|
import { camel, kebab } from './templates/naming';
|
|
14
15
|
|
|
@@ -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
|
*
|
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
|
/**
|
|
@@ -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
|
/**
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The workspace edges `x g` just wrote, declared in the manifest of the workspace each file landed
|
|
2
|
+
// in. `package-shape` refuses an import its owner does not declare (X_WORKSPACE_DEP_UNDECLARED), and
|
|
3
|
+
// a generator that emits one hands the author a red gate for the generator's own output — the edit
|
|
4
|
+
// is mechanical, so the generator makes it. Shipped source only, the checker's own scope.
|
|
5
|
+
|
|
6
|
+
import { containedPath } from './generate-write';
|
|
7
|
+
import { isGenerated, isTest } from './source-files';
|
|
8
|
+
import { withDependency } from './workspace-dep-edit';
|
|
9
|
+
import { importedPackages, scanWorkspaces, type WorkspaceNode } from './workspace-graph';
|
|
10
|
+
|
|
11
|
+
const SOURCE = /\.tsx?$/;
|
|
12
|
+
|
|
13
|
+
/** Per owning workspace, the sibling workspaces its new files import and it does not declare. */
|
|
14
|
+
async function missingEdges(
|
|
15
|
+
root: string,
|
|
16
|
+
written: readonly string[],
|
|
17
|
+
): Promise<Map<WorkspaceNode, Map<string, string>>> {
|
|
18
|
+
const missing = new Map<WorkspaceNode, Map<string, string>>();
|
|
19
|
+
const sources = written.filter(
|
|
20
|
+
(path) => SOURCE.test(path) && !isTest(path) && !isGenerated(path),
|
|
21
|
+
);
|
|
22
|
+
if (sources.length === 0) return missing;
|
|
23
|
+
const { nodes } = await scanWorkspaces(root);
|
|
24
|
+
const byName = new Map(nodes.map((node) => [node.name, node]));
|
|
25
|
+
// Deepest directory first, as the checker reads it: a nested workspace's files are its own.
|
|
26
|
+
const owners = [...nodes].sort((left, right) => right.dir.length - left.dir.length);
|
|
27
|
+
for (const path of sources) {
|
|
28
|
+
const owner = owners.find((node) => path.startsWith(`${node.dir}/`));
|
|
29
|
+
if (owner === undefined) continue;
|
|
30
|
+
const text = await Bun.file(containedPath(root, path)).text();
|
|
31
|
+
for (const name of importedPackages(text, path)) {
|
|
32
|
+
const target = byName.get(name);
|
|
33
|
+
if (target === undefined || target === owner || owner.dependencies.includes(name)) continue;
|
|
34
|
+
const edges = missing.get(owner) ?? new Map<string, string>();
|
|
35
|
+
edges.set(name, target.version ?? '0.0.0');
|
|
36
|
+
missing.set(owner, edges);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return missing;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Writes each owning manifest once, holding every new edge; answers the manifests it rewrote. */
|
|
43
|
+
export async function declareGeneratedImports(
|
|
44
|
+
root: string,
|
|
45
|
+
written: readonly string[],
|
|
46
|
+
): Promise<readonly string[]> {
|
|
47
|
+
const edited: string[] = [];
|
|
48
|
+
for (const [owner, edges] of await missingEdges(root, written)) {
|
|
49
|
+
const path = `${owner.dir}/package.json`;
|
|
50
|
+
const file = Bun.file(containedPath(root, path));
|
|
51
|
+
const before = await file.text();
|
|
52
|
+
let after = before;
|
|
53
|
+
for (const [name, version] of edges) after = withDependency(after, name, version) ?? after;
|
|
54
|
+
if (after === before) continue;
|
|
55
|
+
await Bun.write(containedPath(root, path), after);
|
|
56
|
+
edited.push(path);
|
|
57
|
+
}
|
|
58
|
+
return edited;
|
|
59
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// One read of the app per gate run, shared by every guard. Every guard `SHIPPED_GUARD_NAMES` lists
|
|
2
|
+
// (`templates/scaffold-guards.ts`), each walking the tree and re-reading every stylesheet, is one
|
|
3
|
+
// walk per guard for one answer, and two guards compiling the same module is Sass run twice — so
|
|
4
|
+
// the runner hands each `check` these, memoised for the run.
|
|
5
|
+
|
|
6
|
+
// why: `join` builds the host-separator path a glob entry is read at; Bun ships no path API.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export interface GuardFile {
|
|
10
|
+
/** App-root-relative POSIX path, so a finding names the file an author opens. */
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly text: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A stylesheet as the build compiles it. */
|
|
16
|
+
export interface GuardStylesheet {
|
|
17
|
+
readonly css: string;
|
|
18
|
+
/** `hero` → its scoped name. Empty for a sheet that is not a `.module.scss`. */
|
|
19
|
+
readonly classes: Readonly<Record<string, string>>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GuardSources {
|
|
23
|
+
/**
|
|
24
|
+
* Every file under the app root matching `glob`, sorted. Dependencies, build output and caches
|
|
25
|
+
* are never the app, so they are never returned. Asked twice, walked once.
|
|
26
|
+
*/
|
|
27
|
+
files(glob: string): Promise<readonly GuardFile[]>;
|
|
28
|
+
/**
|
|
29
|
+
* The stylesheet at an app-root-relative path, compiled by the compiler the build uses.
|
|
30
|
+
* `undefined` when the file is absent or does not compile: that refusal is the build's
|
|
31
|
+
* (`X_PRERENDER_FAILED`, with the Sass error), never a guard's to repeat.
|
|
32
|
+
*/
|
|
33
|
+
compiled(path: string): Promise<GuardStylesheet | undefined>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Path segments that are never the app's own source. */
|
|
37
|
+
const NOT_SOURCE = /(?:^|\/)(?:node_modules|dist|\.x|\.git)\//;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `seed` is text the gate step ALREADY read — the `boundaries` step reads every surface file for
|
|
41
|
+
* its own import rules before any guard runs, and a guard that walks `apps/**` would read each of
|
|
42
|
+
* them a second time. Seeded, a path is read at most once per step; anything the seed does not
|
|
43
|
+
* hold (a stylesheet, a test, a package) is read here on first ask, as before.
|
|
44
|
+
*/
|
|
45
|
+
export function guardSources(
|
|
46
|
+
root: string,
|
|
47
|
+
seed: readonly { readonly path: string; readonly source: string }[] = [],
|
|
48
|
+
): GuardSources {
|
|
49
|
+
const texts = new Map<string, Promise<string>>();
|
|
50
|
+
for (const file of seed) texts.set(file.path, Promise.resolve(file.source));
|
|
51
|
+
const walks = new Map<string, Promise<readonly GuardFile[]>>();
|
|
52
|
+
const sheets = new Map<string, Promise<GuardStylesheet | undefined>>();
|
|
53
|
+
|
|
54
|
+
const textOf = (path: string): Promise<string> => {
|
|
55
|
+
let text = texts.get(path);
|
|
56
|
+
if (text === undefined) {
|
|
57
|
+
text = Bun.file(join(root, path)).text();
|
|
58
|
+
texts.set(path, text);
|
|
59
|
+
}
|
|
60
|
+
return text;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const walk = async (glob: string): Promise<readonly GuardFile[]> => {
|
|
64
|
+
const paths: string[] = [];
|
|
65
|
+
for await (const entry of new Bun.Glob(glob).scan({ cwd: root, absolute: false })) {
|
|
66
|
+
const path = entry.split('\\').join('/');
|
|
67
|
+
if (!NOT_SOURCE.test(path)) paths.push(path);
|
|
68
|
+
}
|
|
69
|
+
paths.sort();
|
|
70
|
+
return Promise.all(paths.map(async (path) => ({ path, text: await textOf(path) })));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const compile = async (path: string): Promise<GuardStylesheet | undefined> => {
|
|
74
|
+
try {
|
|
75
|
+
const source = await textOf(path);
|
|
76
|
+
// Imported here, not at the top: Sass loads on the first compile, and a gate run whose
|
|
77
|
+
// guards compile nothing must not pay for the compiler (`sass-lazy.test.ts`).
|
|
78
|
+
const { compileStylesheet } = await import('@ultimat3/render/server');
|
|
79
|
+
const { css, classes } = compileStylesheet(join(root, path), source);
|
|
80
|
+
return { css, classes };
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
files(glob) {
|
|
88
|
+
let found = walks.get(glob);
|
|
89
|
+
if (found === undefined) {
|
|
90
|
+
found = walk(glob);
|
|
91
|
+
walks.set(glob, found);
|
|
92
|
+
}
|
|
93
|
+
return found;
|
|
94
|
+
},
|
|
95
|
+
compiled(path) {
|
|
96
|
+
let sheet = sheets.get(path);
|
|
97
|
+
if (sheet === undefined) {
|
|
98
|
+
sheet = compile(path);
|
|
99
|
+
sheets.set(path, sheet);
|
|
100
|
+
}
|
|
101
|
+
return sheet;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|