@ultimat3/cli 1.1.0 → 2.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 +724 -0
- package/README.md +41 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +87 -17
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +13 -7
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +186 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +205 -140
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +87 -14
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +73 -0
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +202 -18
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { GeneratedFile, NameSet } from './naming';
|
|
6
6
|
import { icon } from './scaffold-icon';
|
|
7
|
+
import { rolesFiles } from './scaffold-roles';
|
|
7
8
|
|
|
8
9
|
const webPackage = (app: NameSet): string => `{
|
|
9
10
|
"name": "@${app.kebab}/web",
|
|
@@ -66,29 +67,37 @@ const siteStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
|
66
67
|
|
|
67
68
|
.hero {
|
|
68
69
|
display: grid;
|
|
69
|
-
gap: tokens
|
|
70
|
-
padding: tokens
|
|
71
|
-
background: tokens
|
|
72
|
-
color: tokens
|
|
70
|
+
gap: tokens.space(4);
|
|
71
|
+
padding: tokens.space(8);
|
|
72
|
+
background: tokens.role('bg');
|
|
73
|
+
color: tokens.role('fg');
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
.cta {
|
|
76
77
|
justify-self: start;
|
|
77
|
-
padding: tokens
|
|
78
|
-
border-radius: tokens
|
|
79
|
-
background: tokens
|
|
80
|
-
color: tokens
|
|
78
|
+
padding: tokens.space(2) tokens.space(4);
|
|
79
|
+
border-radius: tokens.radius('md');
|
|
80
|
+
background: tokens.role('accent');
|
|
81
|
+
color: tokens.role('accent-fg');
|
|
81
82
|
}
|
|
82
83
|
`;
|
|
83
84
|
|
|
84
|
-
const sitePageTest =
|
|
85
|
+
const sitePageTest =
|
|
86
|
+
(): string => `// The landing page ships zero JS and declares its metadata. Both are promises the file makes in
|
|
87
|
+
// its config, and both are the kind that rot silently when someone adds one import.
|
|
88
|
+
import { metaContextFor, routeDataFor } from '@ultimat3/render';
|
|
89
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
85
90
|
import { config } from './page';
|
|
86
91
|
|
|
92
|
+
// The same two objects a render builds: \`routeDataFor\` resolves the route's data once, and
|
|
93
|
+
// \`metaContextFor\` wraps it the way every render mode wraps it before calling \`meta\`.
|
|
94
|
+
const ctx = { params: {}, url: 'https://example.test/' };
|
|
95
|
+
|
|
87
96
|
unitTest('the landing page ships zero JS and declares metadata', async () => {
|
|
88
97
|
expect(config.render).toBe('static');
|
|
89
98
|
expect(config.hydrate).toBe('never');
|
|
90
99
|
expect(config.budget.js).toBe('0kb');
|
|
91
|
-
const meta = await config.meta(
|
|
100
|
+
const meta = await config.meta(metaContextFor(ctx, await routeDataFor(config, ctx)));
|
|
92
101
|
expect(meta.title ?? '').not.toBe('');
|
|
93
102
|
});
|
|
94
103
|
`;
|
|
@@ -102,7 +111,12 @@ import { defineRoute } from '@ultimat3/render';
|
|
|
102
111
|
import styles from './page.module.scss';
|
|
103
112
|
|
|
104
113
|
export const config = defineRoute({
|
|
105
|
-
|
|
114
|
+
// 'ssr', not 'stream', and this is not a downgrade: 'stream' needs a boundary to stream into,
|
|
115
|
+
// and the framework has no hole marker yet. Solid's <Suspense> is not it — it throws outside a
|
|
116
|
+
// Solid renderer, and the server JSX factory is inert on purpose. A scaffolded 'stream' route
|
|
117
|
+
// therefore failed x routes with X_ROUTE_MODE_INVALID on the first run, printing a fix nobody
|
|
118
|
+
// could follow. Ship the mode that works. Async data needs no boundary: await it in the page.
|
|
119
|
+
render: 'ssr',
|
|
106
120
|
hydrate: 'visible',
|
|
107
121
|
offline: 'runtime',
|
|
108
122
|
// Auth is a policy, never a route-local flag: one authz system, evaluated everywhere.
|
|
@@ -123,17 +137,20 @@ export function DashboardPage() {
|
|
|
123
137
|
const dashboardStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
124
138
|
|
|
125
139
|
.panel {
|
|
126
|
-
padding: tokens
|
|
127
|
-
background: tokens
|
|
128
|
-
color: tokens
|
|
140
|
+
padding: tokens.space(6);
|
|
141
|
+
background: tokens.role('surface-raised');
|
|
142
|
+
color: tokens.role('fg');
|
|
129
143
|
}
|
|
130
144
|
`;
|
|
131
145
|
|
|
132
|
-
const dashboardTest =
|
|
146
|
+
const dashboardTest =
|
|
147
|
+
(): string => `// The dashboard renders per request, is gated by a policy, and has an offline strategy. Losing
|
|
148
|
+
// the policy is the interesting regression: the page still renders, to anyone.
|
|
149
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
133
150
|
import { config } from './page';
|
|
134
151
|
|
|
135
|
-
unitTest('the dashboard
|
|
136
|
-
expect(config.render).toBe('
|
|
152
|
+
unitTest('the dashboard renders on the server, is gated, and has an offline strategy', () => {
|
|
153
|
+
expect(config.render).toBe('ssr');
|
|
137
154
|
expect(config.policy?.permission).toBe('dashboard:read');
|
|
138
155
|
expect(config.offline).toBe('runtime');
|
|
139
156
|
});
|
|
@@ -160,10 +177,10 @@ const offlineStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
|
160
177
|
|
|
161
178
|
.offline {
|
|
162
179
|
display: grid;
|
|
163
|
-
gap: tokens
|
|
164
|
-
padding: tokens
|
|
165
|
-
background: tokens
|
|
166
|
-
color: tokens
|
|
180
|
+
gap: tokens.space(3);
|
|
181
|
+
padding: tokens.space(8);
|
|
182
|
+
background: tokens.role('bg');
|
|
183
|
+
color: tokens.role('fg-muted');
|
|
167
184
|
}
|
|
168
185
|
`;
|
|
169
186
|
|
|
@@ -187,7 +204,10 @@ export const health = action({
|
|
|
187
204
|
});
|
|
188
205
|
`;
|
|
189
206
|
|
|
190
|
-
const apiTest =
|
|
207
|
+
const apiTest =
|
|
208
|
+
(): string => `// The health action's contract, run as the framework generates it: garbage input refused, the
|
|
209
|
+
// operation in the OpenAPI document. The declaration is the source; this only runs it.
|
|
210
|
+
import { contractTest, expect } from '@ultimat3/testing';
|
|
191
211
|
import { health } from './health';
|
|
192
212
|
|
|
193
213
|
// Named here because every projection needs a stable name and this file does not boot the app.
|
|
@@ -207,39 +227,84 @@ contractTest('health projects one MCP tool and one OpenAPI operation', () => {
|
|
|
207
227
|
`;
|
|
208
228
|
|
|
209
229
|
const sharedTokens =
|
|
210
|
-
(): string => `//
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
230
|
+
(): string => `// This app's authoring layer for stylesheets: \`@use '../../shared/tokens' as t;\` in a
|
|
231
|
+
// \`*.module.scss\` and reach for \`t.role(…)\`. Forwards @ultimat3/ui's token layer verbatim and is
|
|
232
|
+
// where this app's own functions and mixins go.
|
|
233
|
+
//
|
|
234
|
+
// Emits no CSS, and must not: every module is its own Sass compilation, so a \`:root\` block in here
|
|
235
|
+
// would be inlined once per stylesheet that uses it. The custom properties those functions REFER to
|
|
236
|
+
// are defined exactly once, by \`shared/global.scss\`.
|
|
237
|
+
//
|
|
238
|
+
// A raw hex anywhere in the app is a lint failure, because dark theme is not a later project.
|
|
239
|
+
//
|
|
240
|
+
// The API is functions, not variables: \`role('accent')\`, \`space(4)\`, \`radius('md')\`,
|
|
241
|
+
// \`text('lg')\`, \`shadow('sm')\`, plus mixins like \`@include focus-ring\` and \`@include surface\`.
|
|
242
|
+
// Colours are stored as space-separated RGB CHANNELS, so \`role('accent', 0.12)\` gives you a tint
|
|
243
|
+
// without inventing a second token.
|
|
244
|
+
@forward '@ultimat3/ui/tokens';
|
|
245
|
+
`;
|
|
246
|
+
|
|
247
|
+
const sharedGlobalStyle =
|
|
248
|
+
(): string => `// The app document's global layer, and the only stylesheet in this app that emits top-level CSS:
|
|
249
|
+
// @ultimat3/ui's custom properties (\`:root{--color-*;--space-*;…}\`) and then its reset. Every rule
|
|
250
|
+
// a component emits reads those properties through \`var(--…)\`, so without this file the browser
|
|
251
|
+
// drops every one of those declarations and the app renders unstyled.
|
|
252
|
+
//
|
|
253
|
+
// Exactly one file, imported for its side effect by \`global.ts\` — never \`@use\`d from a
|
|
254
|
+
// \`*.module.scss\`. Each module is a separate Sass compilation, so a \`@use\` that emits would
|
|
255
|
+
// duplicate the whole \`:root\` block once per module.
|
|
256
|
+
//
|
|
257
|
+
// This app's own global rules go below the @use, never inside a component module.
|
|
258
|
+
@use '@ultimat3/ui/global.scss';
|
|
259
|
+
`;
|
|
260
|
+
|
|
261
|
+
const sharedGlobalModule =
|
|
262
|
+
(): string => `// The one edge that puts the global stylesheet in this app's module graph. \`shared/\` is loaded by
|
|
263
|
+
// both surfaces and by the framework's own boot scan, so the tokens reach every document without a
|
|
264
|
+
// page having to remember to import them — and \`x verify\` fails with X_STYLES_GLOBAL_MISSING if
|
|
265
|
+
// this edge is ever cut.
|
|
266
|
+
|
|
267
|
+
import './global.scss';
|
|
220
268
|
`;
|
|
221
269
|
|
|
222
270
|
const sharedActor =
|
|
223
271
|
(): string => `// The actor type both surfaces agree on. Policies read this and nothing else, so authz cannot
|
|
224
272
|
// disagree between HTTP, live queries, jobs and MCP.
|
|
273
|
+
import { expandRoles, grantMatches } from '@ultimat3/policy';
|
|
274
|
+
import { roles } from './roles';
|
|
275
|
+
|
|
225
276
|
export interface Actor {
|
|
226
277
|
readonly id: string;
|
|
227
278
|
readonly orgId: string;
|
|
228
279
|
readonly roles: readonly string[];
|
|
229
280
|
}
|
|
230
281
|
|
|
231
|
-
|
|
232
|
-
|
|
282
|
+
/**
|
|
283
|
+
* What this actor may DO, answered from the declared role map. Never \`actor.roles.includes('admin')\`:
|
|
284
|
+
* a role-name comparison is a second authz rule, and it goes stale the moment a role is renamed or
|
|
285
|
+
* a grant moves to another role. \`grantMatches\` is what reads a \`post:*\` wildcard as one.
|
|
286
|
+
*/
|
|
287
|
+
export const holds = (actor: Actor | null, permission: string): boolean =>
|
|
288
|
+
actor !== null &&
|
|
289
|
+
expandRoles(actor.roles, roles).some((grant) => grantMatches(grant, permission));
|
|
233
290
|
`;
|
|
234
291
|
|
|
235
|
-
const sharedActorTest =
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
292
|
+
const sharedActorTest =
|
|
293
|
+
(): string => `// \`holds\` answers from the declared role map, never from a role NAME. An undeclared role must
|
|
294
|
+
// expand to no grants — the branch that turns a typo into an actor who can do everything.
|
|
295
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
296
|
+
import type { Actor } from './actor';
|
|
297
|
+
import { holds } from './actor';
|
|
298
|
+
|
|
299
|
+
const actor = (...names: readonly string[]): Actor => ({ id: 'a', orgId: 'o', roles: names });
|
|
300
|
+
|
|
301
|
+
unitTest('holds answers from the role map, and an anonymous actor holds nothing', () => {
|
|
302
|
+
expect(holds(null, 'dashboard:read')).toBe(false);
|
|
303
|
+
// A role no defineRoles() call declares expands to no grants — never to every grant.
|
|
304
|
+
expect(holds(actor('visitor'), 'dashboard:read')).toBe(false);
|
|
305
|
+
expect(holds(actor('member'), 'dashboard:read')).toBe(true);
|
|
306
|
+
expect(holds(actor('admin'), 'dashboard:read')).toBe(true);
|
|
307
|
+
expect(holds(actor('member'), 'admin:read')).toBe(false);
|
|
243
308
|
});
|
|
244
309
|
`;
|
|
245
310
|
|
|
@@ -293,6 +358,19 @@ const server =
|
|
|
293
358
|
import { join } from 'node:path';
|
|
294
359
|
import { runRole } from '@ultimat3/cli';
|
|
295
360
|
|
|
361
|
+
// MORE THAN ONE REPLICA? Add these two lines, above \`runRole\`:
|
|
362
|
+
//
|
|
363
|
+
// import { configureIdempotency } from '@ultimat3/action';
|
|
364
|
+
// configureIdempotency({ scope: 'shared' });
|
|
365
|
+
//
|
|
366
|
+
// \`idempotent: true\` on an action promises that a retry does not repeat the work. Under the
|
|
367
|
+
// process-scoped default that promise holds inside ONE process — a client retrying
|
|
368
|
+
// \`POST /api/payments/charge\` after a timeout lands on another replica, which has never seen the
|
|
369
|
+
// key, and charges the card twice, silently, with \`x verify\` green. Declaring \`'shared'\` is what
|
|
370
|
+
// makes that a boot error (\`X_IDEMPOTENCY_NOT_SHARED\`) unless a shared store is installed.
|
|
371
|
+
// \`runRole\` installs the Postgres one for you, on the connection it resolved from \`DATABASE_URL\`,
|
|
372
|
+
// so the declaration is all this app owes. It must run before \`runRole\` imports the actions.
|
|
373
|
+
|
|
296
374
|
/**
|
|
297
375
|
* Where the app is. From this file normally — the image's WORKDIR is not the app root's business.
|
|
298
376
|
* A \`--compile\` binary is the exception: its \`import.meta.dir\` is Bun's virtual filesystem, which
|
|
@@ -315,6 +393,11 @@ const prerender =
|
|
|
315
393
|
(): string => `// The static entry. \`x build --target static\` runs this with \`--out <dir>\` and it writes one HTML
|
|
316
394
|
// file per \`render: 'static'\` route — a CDN or an object store then serves site/ with no process
|
|
317
395
|
// behind it. Every other render mode needs a running app and is reported as skipped, never emitted.
|
|
396
|
+
//
|
|
397
|
+
// Skipped is not unweighed: a route that declares a \`budget:\` is rendered in memory and measured
|
|
398
|
+
// whatever its mode, so \`x verify\`'s \`budgets\` step has a number for it. \`unmeasured\` is the list
|
|
399
|
+
// this build could not render — each one is an X_BUDGET_UNMEASURED at the gate, and this is where
|
|
400
|
+
// the reason is.
|
|
318
401
|
|
|
319
402
|
import { join } from 'node:path';
|
|
320
403
|
import { prerenderSite } from '@ultimat3/cli';
|
|
@@ -323,12 +406,15 @@ const root = join(import.meta.dir, '..', '..');
|
|
|
323
406
|
const flag = Bun.argv.indexOf('--out');
|
|
324
407
|
const out = (flag === -1 ? undefined : Bun.argv[flag + 1]) ?? join(root, '.x', 'static');
|
|
325
408
|
// SITE_ORIGIN is what canonical and og:url are built from; the default is only ever a local build.
|
|
326
|
-
|
|
409
|
+
// Property access, not \`Bun.env['SITE_ORIGIN']\`: the scaffolded tsconfig does not set
|
|
410
|
+
// \`noPropertyAccessFromIndexSignature\`, so the bracket form is the one biome's useLiteralKeys
|
|
411
|
+
// reports — a diagnostic in an app's first lint run over a file the app never wrote.
|
|
412
|
+
const origin = Bun.env.SITE_ORIGIN;
|
|
327
413
|
|
|
328
414
|
if (import.meta.main) {
|
|
329
415
|
const report = await prerenderSite({ root, out, ...(origin === undefined ? {} : { origin }) });
|
|
330
416
|
await Bun.stdout.write(
|
|
331
|
-
\`\${JSON.stringify({ ok: true, out: report.out, pages: report.pages.length, skipped: report.skipped })}\\n\`,
|
|
417
|
+
\`\${JSON.stringify({ ok: true, out: report.out, pages: report.pages.length, skipped: report.skipped, unmeasured: report.unmeasured })}\\n\`,
|
|
332
418
|
);
|
|
333
419
|
}
|
|
334
420
|
`;
|
|
@@ -364,11 +450,22 @@ export function appFiles(app: NameSet): readonly GeneratedFile[] {
|
|
|
364
450
|
{ path: 'apps/web/api/health.ts', contents: apiAction() },
|
|
365
451
|
{ path: 'apps/web/api/health.test.ts', contents: apiTest() },
|
|
366
452
|
{ path: 'apps/web/shared/tokens.scss', contents: sharedTokens() },
|
|
453
|
+
{ path: 'apps/web/shared/global.scss', contents: sharedGlobalStyle() },
|
|
454
|
+
{ path: 'apps/web/shared/global.ts', contents: sharedGlobalModule() },
|
|
367
455
|
{ path: 'apps/web/shared/actor.ts', contents: sharedActor() },
|
|
368
456
|
{ path: 'apps/web/shared/actor.test.ts', contents: sharedActorTest() },
|
|
457
|
+
// The app's role map, beside the actor that reads it. `shared/` and not a feature folder:
|
|
458
|
+
// `defineRoles()` merges, so a per-feature call is legal and is how an app ends up with no
|
|
459
|
+
// answer to "which roles exist?" — see `scaffold-roles.ts`.
|
|
460
|
+
...rolesFiles(),
|
|
369
461
|
{ path: 'apps/admin/package.json', contents: adminPackage(app) },
|
|
370
462
|
{ path: 'apps/admin/tsconfig.json', contents: tsconfig() },
|
|
371
|
-
|
|
463
|
+
// `apps/admin/app/admin/page.tsx`, not `apps/admin/app/page.tsx`: the directory IS the URL,
|
|
464
|
+
// relative to the surface root, so the shallower path resolves to `/` and collides with
|
|
465
|
+
// `apps/web/site/page.tsx` — `x dev` loads both surfaces into one route table and the
|
|
466
|
+
// scaffolded app failed its own `x routes` with X_ROUTE_DUPLICATE. `/admin` also matches
|
|
467
|
+
// @ultimat3/admin's own `basePath` default, so the two agree instead of merely not clashing.
|
|
468
|
+
{ path: 'apps/admin/app/admin/page.tsx', contents: adminPage() },
|
|
372
469
|
{ path: 'apps/mobile/README.md', contents: placeholder('mobile', app) },
|
|
373
470
|
{ path: 'apps/desktop/README.md', contents: placeholder('desktop', app) },
|
|
374
471
|
];
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// The subagents `x new` writes into `.claude/agents/`. Scoped by BOUNDARY, never by role: a
|
|
2
|
+
// researcher/coder/reviewer trio has no file set, so it cannot be told what it may not touch. The
|
|
3
|
+
// app's real boundaries are the eight primitives and the surfaces — one agent per boundary, plus
|
|
4
|
+
// `shape`, the read-only one that decides whether there is anything to build at all.
|
|
5
|
+
|
|
6
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
|
|
8
|
+
const shape = (app: NameSet): string => `---
|
|
9
|
+
name: shape
|
|
10
|
+
description: Use BEFORE writing any code, at the idea stage — turns a product request into the one decision that governs everything after it: which of the eight primitives each piece is, which slice and surface it lives in, and the exact \`x g\` invocations. Read-only; it decides, it never builds. Also use when a request seems to need something the framework does not have.
|
|
11
|
+
tools: Read, Glob, Grep, Bash
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
You decide **whether and what to build** in ${app.kebab}. You write no code, and you do not edit
|
|
15
|
+
files. Your answer is a plan a builder can execute without re-deciding anything.
|
|
16
|
+
|
|
17
|
+
## The eight, and there is no ninth
|
|
18
|
+
|
|
19
|
+
\`entity\` · \`policy\` · \`action\` · \`mutator\` · \`query\` · \`job\` · \`route\` · \`task\`
|
|
20
|
+
|
|
21
|
+
| Question | Primitive |
|
|
22
|
+
|---|---|
|
|
23
|
+
| what is stored, and what is always true of it | \`entity\` |
|
|
24
|
+
| who may do it | \`policy\` |
|
|
25
|
+
| a server-authoritative operation with an input and an output | \`action\` |
|
|
26
|
+
| an \`action\` that writes exactly one entity | \`mutator\` |
|
|
27
|
+
| a read, cached and optionally live | \`query\` |
|
|
28
|
+
| durable background work, retried, idempotent | \`job\` |
|
|
29
|
+
| a URL a human visits | \`route\` |
|
|
30
|
+
| work on a schedule | \`task\` |
|
|
31
|
+
|
|
32
|
+
**A request that fits none is not one feature.** Split it until every piece is one of the eight,
|
|
33
|
+
and name the pieces. A capability that genuinely has no home arrives as a **function that returns**
|
|
34
|
+
one of these — never as a ninth kind of thing. If you cannot express it that way, say so plainly and
|
|
35
|
+
stop; that answer is worth more than a plan built on a shape the app cannot hold.
|
|
36
|
+
|
|
37
|
+
## Then place it
|
|
38
|
+
|
|
39
|
+
| Where | What belongs there |
|
|
40
|
+
|---|---|
|
|
41
|
+
| \`apps/web/site/<path>/page.tsx\` | public, SEO-critical, 0kb JS |
|
|
42
|
+
| \`apps/web/app/<slice>/\` | the feature slice: entity, repo, policy, actions, queries, jobs, UI |
|
|
43
|
+
| \`apps/web/api/<name>/route.ts\` | HTTP surface for actions |
|
|
44
|
+
| \`apps/web/shared/\` | tokens, primitives, the actor type — a leaf, imports nothing of yours |
|
|
45
|
+
| \`packages/db/\` | schema, migrations, seed |
|
|
46
|
+
| \`packages/ui/\` | components with no feature knowledge |
|
|
47
|
+
|
|
48
|
+
Read the tree before you place anything. \`x routes\`, \`x actions\`, \`x queries\`, \`x entities\`,
|
|
49
|
+
\`x jobs\`, \`x tasks\` and \`x policy list\` are the registries — check whether the thing already
|
|
50
|
+
exists before proposing it, and add \`--json\` to any of them.
|
|
51
|
+
|
|
52
|
+
## Report
|
|
53
|
+
|
|
54
|
+
\`\`\`
|
|
55
|
+
Build: yes | no — <one line>
|
|
56
|
+
Pieces: <primitive> <name> → <dir> (one line each)
|
|
57
|
+
Generate: x g <kind> <name> --feature <slice> (one line each, in dependency order)
|
|
58
|
+
Existing: <what already covers part of this>
|
|
59
|
+
Refused: <anything that fits no primitive, and why>
|
|
60
|
+
\`\`\`
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const data = (app: NameSet): string => `---
|
|
64
|
+
name: data
|
|
65
|
+
description: Use for anything about what is stored — entity definitions, invariants, indexes, repositories, migrations and seed data in ${app.kebab}. Owns \`packages/db/\` and every \`entity.ts\` and \`repo.ts\`. Not for actions, queries or UI.
|
|
66
|
+
tools: Read, Write, Edit, Glob, Grep, Bash
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
You own the data boundary of ${app.kebab}: \`packages/db/\` plus every \`entity.ts\` and \`repo.ts\` in a
|
|
70
|
+
feature slice. Nothing else. A change outside that set is a collision — report it, do not make it.
|
|
71
|
+
|
|
72
|
+
| Rule | Detail |
|
|
73
|
+
|---|---|
|
|
74
|
+
| The entity is the schema | columns, invariants, indexes and tenancy are declared there, never in SQL |
|
|
75
|
+
| Migrations are generated | edit \`entity.ts\`, then \`x db gen "what changed"\`. Never hand-write a file into \`packages/db/migrations/\` |
|
|
76
|
+
| Destructive is declared | a migration that drops, truncates or retypes carries \`-- destructive: true\`, or the gate refuses it |
|
|
77
|
+
| \`repo.ts\` is the only door | every read and write goes through it; a raw statement anywhere else is authz bypassed |
|
|
78
|
+
| Branch before you break things | \`x db branch create <name>\`, never the shared dev database |
|
|
79
|
+
| Money | \`{ minor, currency }\` — integer minor units and an ISO code, both, always. Never a float |
|
|
80
|
+
| Time | store UTC; a formatted date always names an explicit IANA time zone |
|
|
81
|
+
|
|
82
|
+
Inspect before you change: \`x entities list\`, \`x entities describe <name>\`, both with \`--json\`.
|
|
83
|
+
|
|
84
|
+
Checks: \`bun test <path>/entity.test.ts\`, \`bunx biome check --write <paths>\`, and \`bun run typecheck\`
|
|
85
|
+
once when you are otherwise done. Never \`x verify\` — that belongs to whoever coordinates you.
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
const server = (app: NameSet): string => `---
|
|
89
|
+
name: server
|
|
90
|
+
description: Use for server-authoritative behaviour in ${app.kebab} — actions, mutators, queries, jobs, tasks and policies, plus the HTTP surface under \`apps/web/api/\`. Not for entities, migrations or anything that renders.
|
|
91
|
+
tools: Read, Write, Edit, Glob, Grep, Bash
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
You own the server boundary of ${app.kebab}: \`policy.ts\`, actions, mutators, queries, jobs and tasks
|
|
95
|
+
inside a feature slice, plus \`apps/web/api/\`. Not \`entity.ts\`, not \`repo.ts\`, not anything that
|
|
96
|
+
renders. A change outside that set is a collision — report it, do not make it.
|
|
97
|
+
|
|
98
|
+
| Rule | Detail |
|
|
99
|
+
|---|---|
|
|
100
|
+
| Generate, then edit | \`x g <kind> <name> --feature <slice>\` for every one of them — a hand-written primitive is missing from every registry that projects it |
|
|
101
|
+
| One authz object | the policy decides; a route or a UI that re-checks is a second answer that will drift |
|
|
102
|
+
| Data through the repo | an action calls \`repo.ts\`, never the database |
|
|
103
|
+
| Errors are instructions | never \`throw new Error\` — subclass \`UltimateError\` with a stable \`X_SCREAMING_SNAKE\` code, a cause and a \`fix:\` a caller can actually run |
|
|
104
|
+
| Jobs run at least once | a handler must be idempotent — an upsert or a statement whose second run changes nothing, never \`count + 1\` |
|
|
105
|
+
| Tasks name a time zone | a cron schedule with an ambient zone is a different time twice a year |
|
|
106
|
+
| No \`any\` | \`unknown\` plus a schema parse |
|
|
107
|
+
|
|
108
|
+
Inspect before you change: \`x actions describe <name>\`, \`x queries describe <name>\`, \`x jobs ls\`,
|
|
109
|
+
\`x tasks list\`, \`x policy explain <subject>\` — every one takes \`--json\`.
|
|
110
|
+
|
|
111
|
+
Checks: \`bun test <path>/<file>.test.ts\`, \`bunx biome check --write <paths>\`, and \`bun run typecheck\`
|
|
112
|
+
once when you are otherwise done. Never \`x verify\` — that belongs to whoever coordinates you.
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
const web = (app: NameSet): string => `---
|
|
116
|
+
name: web
|
|
117
|
+
description: Use for anything rendered in ${app.kebab} — pages under \`apps/web/site/\` and \`apps/web/app/\`, islands, components in \`packages/ui/\`, styles, tokens and i18n catalogs. Not for actions, queries, entities or migrations.
|
|
118
|
+
tools: Read, Write, Edit, Glob, Grep, Bash
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
You own the rendering boundary of ${app.kebab}: \`apps/web/site/\`, the pages and UI of
|
|
122
|
+
\`apps/web/app/\`, \`apps/web/shared/\`, \`packages/ui/\` and the i18n catalogs. Not the primitives a page
|
|
123
|
+
calls. A change outside that set is a collision — report it, do not make it.
|
|
124
|
+
|
|
125
|
+
| Rule | Detail |
|
|
126
|
+
|---|---|
|
|
127
|
+
| \`site/\` is 0kb JS | and may not import from \`app/\`. One interactive control on a static page is an island: \`x g island <name> --at <dir>\` |
|
|
128
|
+
| \`shared/\` is a leaf | tokens, primitives, the actor type. It imports nothing of yours |
|
|
129
|
+
| The directory is the URL | \`page.tsx\` under \`site/\`/\`app/\`, \`route.ts\` under \`api/\`. The filename is never the path |
|
|
130
|
+
| Every string through \`t()\` | a literal in a component is a string no locale can ever translate |
|
|
131
|
+
| Semantic tokens only | never a raw hex, in a component or a stylesheet |
|
|
132
|
+
| Dates name a zone | explicit IANA time zone at every call site, no ambient default |
|
|
133
|
+
| A page calls primitives | queries and actions, never a repo and never the database |
|
|
134
|
+
|
|
135
|
+
Inspect before you change: \`x routes --json\`, and \`x i18n check\` for catalog gaps.
|
|
136
|
+
|
|
137
|
+
Checks: \`bun test <path>/page.test.ts\`, \`bunx biome check --write <paths>\`, and \`bun run typecheck\`
|
|
138
|
+
once when you are otherwise done. Never \`x verify\` — that belongs to whoever coordinates you.
|
|
139
|
+
`;
|
|
140
|
+
|
|
141
|
+
/** One agent per boundary, plus the read-only one that runs before there is a boundary to hold. */
|
|
142
|
+
export function claudeAgentFiles(app: NameSet): readonly GeneratedFile[] {
|
|
143
|
+
return [
|
|
144
|
+
{ path: '.claude/agents/shape.md', contents: shape(app) },
|
|
145
|
+
{ path: '.claude/agents/data.md', contents: data(app) },
|
|
146
|
+
{ path: '.claude/agents/server.md', contents: server(app) },
|
|
147
|
+
{ path: '.claude/agents/web.md', contents: web(app) },
|
|
148
|
+
];
|
|
149
|
+
}
|