@ultimat3/cli 1.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/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +60 -0
- package/src/app-agents-md.ts +27 -0
- package/src/app-boundaries.ts +206 -0
- package/src/app-evals.ts +74 -0
- package/src/app-load.ts +136 -0
- package/src/app-manifest.ts +137 -0
- package/src/app-openapi.ts +12 -0
- package/src/app-root.ts +57 -0
- package/src/bin.ts +17 -0
- package/src/boundary-cuts.ts +219 -0
- package/src/budgets.ts +92 -0
- package/src/cmd-build.ts +109 -0
- package/src/cmd-db.ts +187 -0
- package/src/cmd-deploy.ts +124 -0
- package/src/cmd-dev.ts +286 -0
- package/src/cmd-doctor.ts +178 -0
- package/src/cmd-errors.ts +99 -0
- package/src/cmd-fix.ts +126 -0
- package/src/cmd-generate.ts +434 -0
- package/src/cmd-help.ts +94 -0
- package/src/cmd-i18n.ts +212 -0
- package/src/cmd-jobs.ts +237 -0
- package/src/cmd-manifest.ts +97 -0
- package/src/cmd-mcp.ts +176 -0
- package/src/cmd-new.ts +133 -0
- package/src/cmd-planned.ts +119 -0
- package/src/cmd-policy.ts +136 -0
- package/src/cmd-registries.ts +195 -0
- package/src/cmd-routes.ts +73 -0
- package/src/cmd-tasks.ts +151 -0
- package/src/cmd-test.ts +109 -0
- package/src/cmd-verify.ts +265 -0
- package/src/command.ts +33 -0
- package/src/dev-assets.ts +177 -0
- package/src/dev-dashboard.ts +242 -0
- package/src/dev-hooks.ts +51 -0
- package/src/dev-policy.ts +82 -0
- package/src/dev-queue.ts +109 -0
- package/src/dev-render.ts +129 -0
- package/src/dev-replicator.ts +92 -0
- package/src/dev-roles.ts +246 -0
- package/src/dev-runtime.ts +203 -0
- package/src/dev-services.ts +75 -0
- package/src/dev-traces.ts +141 -0
- package/src/dispatch.ts +98 -0
- package/src/drift.ts +86 -0
- package/src/error-catalog.ts +156 -0
- package/src/error-contract.ts +212 -0
- package/src/errors.ts +367 -0
- package/src/exec.ts +70 -0
- package/src/hold.ts +48 -0
- package/src/i18n-audit.ts +183 -0
- package/src/index.ts +179 -0
- package/src/jobs-drain.ts +151 -0
- package/src/jobs-json.ts +134 -0
- package/src/jobs-report.ts +132 -0
- package/src/jobs-table.ts +34 -0
- package/src/json-merge.ts +40 -0
- package/src/mcp-db-target.ts +50 -0
- package/src/mcp-errors.ts +99 -0
- package/src/mcp-host.ts +282 -0
- package/src/mcp-test-output.ts +57 -0
- package/src/messages.ts +119 -0
- package/src/output.ts +174 -0
- package/src/parse.ts +243 -0
- package/src/policy-facts.ts +196 -0
- package/src/policy-fixture.ts +71 -0
- package/src/registry.ts +73 -0
- package/src/scaffold-fixture.ts +69 -0
- package/src/scaffold-typecheck.ts +240 -0
- package/src/source-files.ts +38 -0
- package/src/table.ts +19 -0
- package/src/tasks-facts.ts +113 -0
- package/src/templates/action.ts +193 -0
- package/src/templates/admin.ts +46 -0
- package/src/templates/catalog-json.ts +17 -0
- package/src/templates/entity.ts +157 -0
- package/src/templates/index.ts +23 -0
- package/src/templates/job.ts +148 -0
- package/src/templates/locales.ts +93 -0
- package/src/templates/naming.ts +97 -0
- package/src/templates/policy.ts +120 -0
- package/src/templates/query.ts +116 -0
- package/src/templates/resource.ts +199 -0
- package/src/templates/route.ts +138 -0
- package/src/templates/scaffold-app.ts +320 -0
- package/src/templates/scaffold-docs.ts +156 -0
- package/src/templates/scaffold-i18n.ts +149 -0
- package/src/templates/scaffold-icon.ts +54 -0
- package/src/templates/scaffold-package-shape.ts +49 -0
- package/src/templates/scaffold-repo.ts +427 -0
- package/src/test-select.ts +130 -0
- package/src/test-shards.ts +188 -0
- package/src/thrown-by.ts +24 -0
- package/src/ts-scan.ts +217 -0
- package/src/verify-step.ts +83 -0
- package/src/verify-tests.ts +166 -0
- package/src/version-loader.ts +16 -0
- package/src/workspace-checks.ts +288 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// `x g entity <name>` — a table, its domain type and its invariants, plus the repo that owns the
|
|
2
|
+
// only DB access for the feature. Emitted as strings rather than copied fixture files so the
|
|
3
|
+
// generator output is typed, diffable and testable from a unit test.
|
|
4
|
+
|
|
5
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
6
|
+
import { names } from './naming';
|
|
7
|
+
|
|
8
|
+
export interface FeatureTarget {
|
|
9
|
+
/** `apps/web/app` or `apps/web/site` — the surface the feature lives in. */
|
|
10
|
+
readonly surfaceDir: string;
|
|
11
|
+
readonly feature: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const entitySource = (
|
|
15
|
+
name: NameSet,
|
|
16
|
+
snake: string,
|
|
17
|
+
table: string,
|
|
18
|
+
): string => `// The ${name.camel} table, its domain type and its invariants. No I/O beyond the column
|
|
19
|
+
// definitions: repo.ts owns every query that touches this table.
|
|
20
|
+
|
|
21
|
+
import { entity, invariant, money, text, timestamp, uuid } from '@ultimat3/entity';
|
|
22
|
+
|
|
23
|
+
export const ${name.camel} = entity('${table}', {
|
|
24
|
+
// Naming the tenant column is what turns tenancy on: a read without an org predicate then
|
|
25
|
+
// fails with X_TENANCY_UNSCOPED instead of leaking another org's rows.
|
|
26
|
+
tenant: 'orgId',
|
|
27
|
+
columns: {
|
|
28
|
+
id: uuid().primaryKey(),
|
|
29
|
+
orgId: uuid(),
|
|
30
|
+
title: text({ max: 200 }),
|
|
31
|
+
// One property, two physical columns: price_minor bigint + price_currency char(3).
|
|
32
|
+
// Money is integer minor units plus an ISO code, never a float.
|
|
33
|
+
price: money(),
|
|
34
|
+
// Always timestamptz. Stored UTC; formatted at the edge with an explicit IANA time zone.
|
|
35
|
+
createdAt: timestamp().defaultNow(),
|
|
36
|
+
},
|
|
37
|
+
// Each rule runs in the app on write AND as a Postgres CHECK — one declaration, both sides.
|
|
38
|
+
invariants: [
|
|
39
|
+
invariant('${snake}_title_not_blank', (c) => c.title.trimmed().minLength(1)),
|
|
40
|
+
invariant('${snake}_price_non_negative', (c) => c.price.minor.atLeast(0)),
|
|
41
|
+
],
|
|
42
|
+
indexes: [{ on: ['orgId', 'createdAt'] }],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export type ${name.pascal} = typeof ${name.camel}.$row;
|
|
46
|
+
|
|
47
|
+
// What leaves the server: an action writes \`output: ${name.pascal}View\` and the shape is the
|
|
48
|
+
// columns', never a second declaration to keep in sync. The tenant column is not in it — an org
|
|
49
|
+
// id is the caller's context, not the client's data.
|
|
50
|
+
export const ${name.pascal}View = ${name.camel}.$view(['id', 'title', 'price', 'createdAt']);
|
|
51
|
+
export type ${name.pascal}View = typeof ${name.pascal}View.$row;
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
const repoSource = (
|
|
55
|
+
name: NameSet,
|
|
56
|
+
table: string,
|
|
57
|
+
): string => `// The only module allowed to query the ${name.pluralKebab} table. Routes call actions and
|
|
58
|
+
// queries; actions call services; services call this.
|
|
59
|
+
// \`db()\` is the ambient handle: inside a transaction it IS the transaction, so these functions
|
|
60
|
+
// join the caller's transaction without knowing one is open.
|
|
61
|
+
|
|
62
|
+
import { db, sql } from '@ultimat3/db';
|
|
63
|
+
import { dbDrift, newId } from '@ultimat3/entity';
|
|
64
|
+
import type { ${name.pascal} } from './entity';
|
|
65
|
+
|
|
66
|
+
export async function byId(id: string): Promise<${name.pascal} | undefined> {
|
|
67
|
+
const row = await db().one<${name.pascal}>(sql\`select * from ${table} where id = \${id}\`);
|
|
68
|
+
return row ?? undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function listByOrg(orgId: string, limit = 50): Promise<readonly ${name.pascal}[]> {
|
|
72
|
+
// Ordered and bounded: an unordered page is a different page on every request.
|
|
73
|
+
return db().query<${name.pascal}>(
|
|
74
|
+
sql\`select * from ${table} where org_id = \${orgId} order by created_at desc limit \${limit}\`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function insert(row: Omit<${name.pascal}, 'id' | 'createdAt'>): Promise<${name.pascal}> {
|
|
79
|
+
// Money is two physical columns — integer minor units plus the ISO code, never a float.
|
|
80
|
+
const created = await db().one<${name.pascal}>(sql\`
|
|
81
|
+
insert into ${table} (id, org_id, title, price_minor, price_currency)
|
|
82
|
+
values (\${newId()}, \${row.orgId}, \${row.title}, \${row.price.minor}, \${row.price.currency})
|
|
83
|
+
returning *\`);
|
|
84
|
+
if (created === null) throw dbDrift('${table}', 'id');
|
|
85
|
+
return created;
|
|
86
|
+
}
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
const entityTest = (
|
|
90
|
+
name: NameSet,
|
|
91
|
+
snake: string,
|
|
92
|
+
table: string,
|
|
93
|
+
): string => `import { expect, unitTest } from '@ultimat3/testing';
|
|
94
|
+
import type { ${name.pascal} } from './entity';
|
|
95
|
+
import { ${name.pascal}View, ${name.camel} } from './entity';
|
|
96
|
+
|
|
97
|
+
const row = (over: Partial<${name.pascal}> = {}): ${name.pascal} => ({
|
|
98
|
+
id: '00000000-0000-4000-8000-000000000001',
|
|
99
|
+
orgId: '00000000-0000-4000-8000-000000000002',
|
|
100
|
+
title: 'valid title',
|
|
101
|
+
// \`money()\` puts \`MoneyValue\` on the row, whose minor units are bigint — the column is a
|
|
102
|
+
// Postgres bigint, and a JS number would silently lose precision above 2^53 minor units.
|
|
103
|
+
price: { minor: 1000n, currency: 'USD' },
|
|
104
|
+
createdAt: new Date(0),
|
|
105
|
+
...over,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
unitTest('${name.camel} declares a table with invariants', () => {
|
|
109
|
+
expect(${name.camel}.$name).toBe('${table}');
|
|
110
|
+
expect(${name.camel}.$tenantColumn).toBe('orgId');
|
|
111
|
+
const named = ${name.camel}.$invariants.map((rule) => rule.name);
|
|
112
|
+
expect(named).toContain('${snake}_title_not_blank');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
unitTest('${name.camel} describes itself for the manifest', () => {
|
|
116
|
+
// \`$describe()\` is what x manifest, /_x and the MCP dev tools all read — one projection, so
|
|
117
|
+
// a column added below reaches every one of them without a second declaration.
|
|
118
|
+
const described = ${name.camel}.$describe();
|
|
119
|
+
expect(described.orgScoped).toBe(true);
|
|
120
|
+
// One \`price\` property, two physical columns: integer minor units plus the ISO code. The
|
|
121
|
+
// description is where that shows, and it is what fails here if money ever becomes a float.
|
|
122
|
+
expect(described.columns.map((column) => column.column)).toContain('price_minor');
|
|
123
|
+
expect(described.columns.map((column) => column.column)).toContain('price_currency');
|
|
124
|
+
expect(described.invariants.map((rule) => rule.name)).toContain('${snake}_price_non_negative');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
unitTest('${name.pascal}View projects the row an action returns, without the tenant', () => {
|
|
128
|
+
expect(${name.pascal}View.$keys).toEqual(['id', 'title', 'price', 'createdAt']);
|
|
129
|
+
// The org id is the caller's context, never the client's data: a view that leaked it would
|
|
130
|
+
// let a response carry a tenant boundary the policy already decided.
|
|
131
|
+
expect(${name.pascal}View.$keys).not.toContain('orgId');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
unitTest('${name.camel} invariants reject a blank title and a negative price', () => {
|
|
135
|
+
expect(() => ${name.camel}.$assert(row())).not.toThrow();
|
|
136
|
+
expect(() => ${name.camel}.$assert(row({ title: ' ' }))).toThrow();
|
|
137
|
+
expect(() => ${name.camel}.$assert(row({ price: { minor: -1n, currency: 'USD' } }))).toThrow();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
unitTest('${name.camel} parses a row through its own columns', () => {
|
|
141
|
+
// \`$parse\` is the entity's own coercion, so a row read back from SQL and a row built in a
|
|
142
|
+
// test go through the same code — a drifting column type fails here first.
|
|
143
|
+
const parsed = ${name.camel}.$parse(row());
|
|
144
|
+
expect(parsed.title).toBe('valid title');
|
|
145
|
+
expect(parsed.price.currency).toBe('USD');
|
|
146
|
+
});
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
export function entityFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] {
|
|
150
|
+
const name = names(rawName);
|
|
151
|
+
const dir = `${target.surfaceDir}/${target.feature}`;
|
|
152
|
+
return [
|
|
153
|
+
{ path: `${dir}/entity.ts`, contents: entitySource(name, name.snake, name.table) },
|
|
154
|
+
{ path: `${dir}/entity.test.ts`, contents: entityTest(name, name.snake, name.table) },
|
|
155
|
+
{ path: `${dir}/repo.ts`, contents: repoSource(name, name.table) },
|
|
156
|
+
];
|
|
157
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Public surface of the template modules. Templates are string modules, not copied fixture files,
|
|
2
|
+
// so `x g` output is typed, reviewable in a diff and testable without touching the filesystem.
|
|
3
|
+
|
|
4
|
+
export type { ActionOptions } from './action';
|
|
5
|
+
export { actionFiles } from './action';
|
|
6
|
+
export { adminFiles } from './admin';
|
|
7
|
+
export type { FeatureTarget } from './entity';
|
|
8
|
+
export { entityFiles } from './entity';
|
|
9
|
+
export { jobFiles, taskFiles } from './job';
|
|
10
|
+
export { CATALOG_ROOT, catalogPath, DEFAULT_LOCALES, resolveLocales } from './locales';
|
|
11
|
+
export type { GeneratedFile, NameSet } from './naming';
|
|
12
|
+
export { camel, kebab, names, pascal, plural, titleKey } from './naming';
|
|
13
|
+
export { policyFiles } from './policy';
|
|
14
|
+
export type { QueryOptions } from './query';
|
|
15
|
+
export { queryFiles } from './query';
|
|
16
|
+
export type { ResourceOptions } from './resource';
|
|
17
|
+
export { resourceFiles } from './resource';
|
|
18
|
+
export type { RouteOptions, Surface } from './route';
|
|
19
|
+
export { routeFiles } from './route';
|
|
20
|
+
export { appFiles } from './scaffold-app';
|
|
21
|
+
export { docsFiles, EXECUTABLE_FILES } from './scaffold-docs';
|
|
22
|
+
export { i18nIndex } from './scaffold-i18n';
|
|
23
|
+
export { repoFiles } from './scaffold-repo';
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// `x g job` / `x g task` — durable background work and the cron trigger that enqueues it. The
|
|
2
|
+
// idempotency key is required by the type, so the generator always emits one; the generated test
|
|
3
|
+
// pins it through a real driver, because a key that is not stable is a job that runs twice.
|
|
4
|
+
|
|
5
|
+
import type { FeatureTarget } from './entity';
|
|
6
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
import { names } from './naming';
|
|
8
|
+
|
|
9
|
+
const jobSource = (
|
|
10
|
+
name: NameSet,
|
|
11
|
+
): string => `// ${name.camel}: multi-step durable work. Each step is retried independently and its result is
|
|
12
|
+
// stored under its name — step names are stable identifiers, not labels.
|
|
13
|
+
// \`t\` comes from @ultimat3/jobs, not @ultimat3/schema: a job file imports one package.
|
|
14
|
+
|
|
15
|
+
import { job, t } from '@ultimat3/jobs';
|
|
16
|
+
import * as repo from '../repo';
|
|
17
|
+
|
|
18
|
+
export const ${name.camel} = job({
|
|
19
|
+
input: t.object({ id: t.uuid }),
|
|
20
|
+
idempotencyKey: ({ id }) => \`${name.kebab}:\${id}\`,
|
|
21
|
+
retry: { attempts: 5, backoff: 'exponential' },
|
|
22
|
+
async run({ input, step }) {
|
|
23
|
+
const row = await step.run('load', () => repo.byId(input.id));
|
|
24
|
+
if (row === undefined) return { skipped: true };
|
|
25
|
+
await step.run('process', async () => {
|
|
26
|
+
await repo.listByOrg(row.orgId, 1);
|
|
27
|
+
});
|
|
28
|
+
return { skipped: false };
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
const taskSource = (
|
|
34
|
+
name: NameSet,
|
|
35
|
+
jobName: NameSet,
|
|
36
|
+
): string => `// ${name.camel}: a scheduled trigger. Tasks only enqueue jobs — the work itself is durable and
|
|
37
|
+
// retryable, and the schedule carries an explicit IANA time zone.
|
|
38
|
+
|
|
39
|
+
import { task } from '@ultimat3/jobs';
|
|
40
|
+
import { ${jobName.camel} } from '../jobs/${jobName.kebab}';
|
|
41
|
+
|
|
42
|
+
export const ${name.camel} = task({
|
|
43
|
+
cron: '0 3 * * *',
|
|
44
|
+
tz: 'UTC',
|
|
45
|
+
enqueue: () => [[${jobName.camel}, { id: '00000000-0000-4000-8000-000000000001' }]],
|
|
46
|
+
});
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
const jobTest = (
|
|
50
|
+
name: NameSet,
|
|
51
|
+
): string => `import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
|
|
52
|
+
import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
|
|
53
|
+
import { ${name.camel} } from './${name.kebab}';
|
|
54
|
+
|
|
55
|
+
const id = '00000000-0000-4000-8000-000000000001';
|
|
56
|
+
|
|
57
|
+
// The driver is process-global, so it is installed and released around this file rather than
|
|
58
|
+
// left behind for whichever test happens to run next.
|
|
59
|
+
beforeAll(() => {
|
|
60
|
+
setJobDriver(createMemoryDriver());
|
|
61
|
+
});
|
|
62
|
+
afterAll(resetJobDriver);
|
|
63
|
+
|
|
64
|
+
jobTest('${name.camel} declares an idempotency key and a retry policy', () => {
|
|
65
|
+
expect(${name.camel}.kind).toBe('job');
|
|
66
|
+
expect(${name.camel}.idempotencyKeyFor({ id })).toBe(\`${name.kebab}:\${id}\`);
|
|
67
|
+
expect(${name.camel}.retry.attempts).toBeGreaterThan(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
jobTest('${name.camel} derives the same key for the same input', () => {
|
|
71
|
+
expect(${name.camel}.idempotencyKeyFor({ id })).toBe(${name.camel}.idempotencyKeyFor({ id }));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
jobTest('${name.camel} projects itself into the manifest', () => {
|
|
75
|
+
const described = ${name.camel}.describe();
|
|
76
|
+
expect(described.queue).toBe('default');
|
|
77
|
+
expect(described.retry.attempts).toBe(5);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
|
|
81
|
+
// The whole point of the key: an at-least-once caller may enqueue twice and the work still
|
|
82
|
+
// happens once. \`.enqueue()\` is the one queue path — a job is never run inline.
|
|
83
|
+
const first = await ${name.camel}.enqueue({ id });
|
|
84
|
+
expect(first.deduped).toBe(false);
|
|
85
|
+
const again = await ${name.camel}.enqueue({ id });
|
|
86
|
+
expect(again.deduped).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
const taskTest = (
|
|
91
|
+
name: NameSet,
|
|
92
|
+
jobName: NameSet,
|
|
93
|
+
): string => `import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
|
|
94
|
+
import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
|
|
95
|
+
import { ${jobName.camel} } from '../jobs/${jobName.kebab}';
|
|
96
|
+
import { ${name.camel} } from './${name.kebab}';
|
|
97
|
+
|
|
98
|
+
beforeAll(() => {
|
|
99
|
+
setJobDriver(createMemoryDriver());
|
|
100
|
+
});
|
|
101
|
+
afterAll(resetJobDriver);
|
|
102
|
+
|
|
103
|
+
jobTest('${name.camel} declares a cron with an explicit time zone', () => {
|
|
104
|
+
expect(${name.camel}.kind).toBe('task');
|
|
105
|
+
expect(${name.camel}.cron.split(' ')).toHaveLength(5);
|
|
106
|
+
expect(${name.camel}.tz).toBe('UTC');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
jobTest('${name.camel} enqueues ${jobName.camel} and nothing else', () => {
|
|
110
|
+
const pairs = ${name.camel}.entries();
|
|
111
|
+
expect(pairs).toHaveLength(1);
|
|
112
|
+
expect(pairs[0]?.[0]).toBe(${jobName.camel});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
jobTest('${name.camel} describes its schedule and its jobs', () => {
|
|
116
|
+
const described = ${name.camel}.describe();
|
|
117
|
+
expect(described.tz).toBe('UTC');
|
|
118
|
+
expect(described.jobs).toHaveLength(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
jobTest('${name.camel} fires its entries onto the same queue the scheduler would', async () => {
|
|
122
|
+
// \`.enqueue()\` is the backfill path: the declared entries, through the facade a job handle
|
|
123
|
+
// uses, with no scheduler and no leader involved.
|
|
124
|
+
const results = await ${name.camel}.enqueue();
|
|
125
|
+
expect(results).toHaveLength(1);
|
|
126
|
+
expect(results[0]?.job).toBe(${jobName.camel}.name);
|
|
127
|
+
});
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
export function jobFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] {
|
|
131
|
+
const name = names(rawName);
|
|
132
|
+
const dir = `${target.surfaceDir}/${target.feature}/jobs`;
|
|
133
|
+
return [
|
|
134
|
+
{ path: `${dir}/${name.kebab}.ts`, contents: jobSource(name) },
|
|
135
|
+
{ path: `${dir}/${name.kebab}.test.ts`, contents: jobTest(name) },
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function taskFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] {
|
|
140
|
+
const name = names(rawName);
|
|
141
|
+
const jobName = names(`${rawName}-job`);
|
|
142
|
+
const dir = `${target.surfaceDir}/${target.feature}/tasks`;
|
|
143
|
+
return [
|
|
144
|
+
{ path: `${dir}/${name.kebab}.ts`, contents: taskSource(name, jobName) },
|
|
145
|
+
{ path: `${dir}/${name.kebab}.test.ts`, contents: taskTest(name, jobName) },
|
|
146
|
+
...jobFiles(`${rawName}-job`, target),
|
|
147
|
+
];
|
|
148
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The one locale resolver for generated catalogs: the default set, validation, canonical form and
|
|
2
|
+
// dedupe. A locale is not a label here, it is a file stem — `packages/i18n/catalogs/<locale>.json`
|
|
3
|
+
// — so an unvalidated tag is a path, and `--locales=../../../../tmp` would write outside the app.
|
|
4
|
+
|
|
5
|
+
import { BadFlagError, ScaffoldPathEscapeError } from '../errors';
|
|
6
|
+
|
|
7
|
+
/** What a generated catalog ships for when the caller names no locale. */
|
|
8
|
+
export const DEFAULT_LOCALES: readonly string[] = ['en'];
|
|
9
|
+
|
|
10
|
+
/** Where every generated catalog lands. The locale is the file's stem, hence the containment. */
|
|
11
|
+
export const CATALOG_ROOT = 'packages/i18n/catalogs';
|
|
12
|
+
|
|
13
|
+
/** The catalog layout, written down once: `x g route` and `x g resource` both merge into it. */
|
|
14
|
+
export const catalogPath = (locale: string): string => `${CATALOG_ROOT}/${locale}.json`;
|
|
15
|
+
|
|
16
|
+
/** The runnable form of the flag, used as the fix on every rejection below. */
|
|
17
|
+
const LOCALES_FIX = 'x g resource <name> --locales=en,es';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Anything that could steer a write out of `CATALOG_ROOT`. A path segment is safe exactly when it
|
|
21
|
+
* holds no separator, no NUL and is not a dot segment, so this is a complete check at this level —
|
|
22
|
+
* `writeFiles` proves containment again on the assembled path.
|
|
23
|
+
*/
|
|
24
|
+
const escapesCatalogRoot = (tag: string): boolean =>
|
|
25
|
+
tag.startsWith('.') || tag.includes('/') || tag.includes('\\') || tag.includes('\0');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The repo's own definition of a BCP-47 tag — the predicate `defineConfig` validates `locales`
|
|
29
|
+
* with. `Intl` canonicalizes (`EN` → `en`, `zh-Hant` stays) and throws on anything that is not a
|
|
30
|
+
* tag, which is why `x-priv`, `en_US` and `1234` never reach the filesystem.
|
|
31
|
+
*/
|
|
32
|
+
const canonicalTag = (tag: string): string | undefined => {
|
|
33
|
+
try {
|
|
34
|
+
const canonical = Intl.getCanonicalLocales(tag);
|
|
35
|
+
return canonical.length === 1 ? canonical[0] : undefined;
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The invocation a rejection is reported against. `x g --locales=…` is not the only caller —
|
|
43
|
+
* `x i18n add <locale>` reaches this same validator — and a cause naming a command and a flag the
|
|
44
|
+
* user never typed is the misdirection axiom 4 exists to refuse.
|
|
45
|
+
*/
|
|
46
|
+
export interface LocaleFlagContext {
|
|
47
|
+
/** The runnable fix printed on a rejection. Defaults to the `x g` form. */
|
|
48
|
+
readonly fix?: string;
|
|
49
|
+
/** The command as typed, without the `x`. */
|
|
50
|
+
readonly command?: string;
|
|
51
|
+
/** The flag or argument the locale arrived on. */
|
|
52
|
+
readonly flag?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every locale a generated catalog ships for: trimmed, validated, lowercased and deduped, or the
|
|
57
|
+
* default when the caller names none. Loud rather than lenient — a typo silently resolved to `en`
|
|
58
|
+
* writes a catalog the app never reads, and a traversal silently dropped writes one it never sees.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveLocales(
|
|
61
|
+
requested?: readonly string[],
|
|
62
|
+
// A bare string is still the fix, so every `x g` call site reads exactly as it did.
|
|
63
|
+
context: string | LocaleFlagContext = LOCALES_FIX,
|
|
64
|
+
): readonly string[] {
|
|
65
|
+
if (requested === undefined) return DEFAULT_LOCALES;
|
|
66
|
+
const options: LocaleFlagContext = typeof context === 'string' ? { fix: context } : context;
|
|
67
|
+
const fix = options.fix ?? LOCALES_FIX;
|
|
68
|
+
const command = options.command ?? 'g';
|
|
69
|
+
const flag = options.flag ?? 'locales';
|
|
70
|
+
const resolved: string[] = [];
|
|
71
|
+
for (const raw of requested) {
|
|
72
|
+
const tag = raw.trim();
|
|
73
|
+
// `--locales=en,,es` is a typing artefact, not a request for a nameless catalog.
|
|
74
|
+
if (tag.length === 0) continue;
|
|
75
|
+
if (escapesCatalogRoot(tag)) {
|
|
76
|
+
throw new ScaffoldPathEscapeError({ path: `${CATALOG_ROOT}/${tag}`, dir: CATALOG_ROOT, fix });
|
|
77
|
+
}
|
|
78
|
+
const canonical = canonicalTag(tag);
|
|
79
|
+
if (canonical === undefined) {
|
|
80
|
+
throw new BadFlagError({
|
|
81
|
+
flag,
|
|
82
|
+
command,
|
|
83
|
+
reason: `"${tag}" is not a BCP-47 locale`,
|
|
84
|
+
fix,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
// Lowercase, because the tag is a file stem: `en-US.json` and `en-us.json` are one file on a
|
|
88
|
+
// case-insensitive filesystem, and `zh-hant` is the normalized form @ultimat3/i18n resolves to.
|
|
89
|
+
const stem = canonical.toLowerCase();
|
|
90
|
+
if (!resolved.includes(stem)) resolved.push(stem);
|
|
91
|
+
}
|
|
92
|
+
return resolved.length === 0 ? DEFAULT_LOCALES : resolved;
|
|
93
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Name derivation for generators. One place, because `x g resource post` has to agree with itself
|
|
2
|
+
// across eight emitted files — a second casing helper is how `Post`/`post`/`posts` drift apart.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A catalog is one file per locale that many generators contribute keys to; a whole-file write
|
|
6
|
+
* would delete every key already in it, so a `'json'` file merges instead of overwriting. Always
|
|
7
|
+
* text: `cmd-generate.ts`'s `dedupe`/`mergeJsonFile` parse and merge `contents` as a JSON object,
|
|
8
|
+
* a step that only ever runs against this variant.
|
|
9
|
+
*/
|
|
10
|
+
export interface GeneratedJsonFile {
|
|
11
|
+
/** POSIX path relative to the app root. */
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly contents: string;
|
|
14
|
+
readonly merge: 'json';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every other generated file. Text in almost every case, but the scaffolded app icon is bytes,
|
|
19
|
+
* not prose: a PNG cannot survive a UTF-8 string round-trip (every byte above 0x7F comes back
|
|
20
|
+
* mangled), so `contents` has to admit raw bytes too — `Bun.write` already accepts either, so
|
|
21
|
+
* nothing downstream needs a second write path.
|
|
22
|
+
*/
|
|
23
|
+
export interface GeneratedSourceFile {
|
|
24
|
+
/** POSIX path relative to the app root. */
|
|
25
|
+
readonly path: string;
|
|
26
|
+
readonly contents: string | Uint8Array;
|
|
27
|
+
readonly merge?: undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Split on `merge` rather than widening one shape's `contents` in place, so the split is
|
|
32
|
+
* load-bearing, not cosmetic: a `merge: 'json'` file's `contents` stays a plain `string` at the
|
|
33
|
+
* type level, which is what stops a byte-carrying file from ever reaching
|
|
34
|
+
* `cmd-generate.ts`'s JSON parser — the compiler refuses the call before the code can run.
|
|
35
|
+
*/
|
|
36
|
+
export type GeneratedFile = GeneratedJsonFile | GeneratedSourceFile;
|
|
37
|
+
|
|
38
|
+
const words = (input: string): readonly string[] =>
|
|
39
|
+
input
|
|
40
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
41
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
42
|
+
.filter((part) => part.length > 0)
|
|
43
|
+
.map((part) => part.toLowerCase());
|
|
44
|
+
|
|
45
|
+
export const kebab = (input: string): string => words(input).join('-');
|
|
46
|
+
|
|
47
|
+
export const camel = (input: string): string =>
|
|
48
|
+
words(input)
|
|
49
|
+
.map((word, index) => (index === 0 ? word : `${word[0]?.toUpperCase() ?? ''}${word.slice(1)}`))
|
|
50
|
+
.join('');
|
|
51
|
+
|
|
52
|
+
export const pascal = (input: string): string => {
|
|
53
|
+
const value = camel(input);
|
|
54
|
+
return (value[0]?.toUpperCase() ?? '') + value.slice(1);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Deliberately naive: English -s/-es/-ies. A generator name is one word in practice. */
|
|
58
|
+
export const plural = (input: string): string => {
|
|
59
|
+
if (/(s|x|z|ch|sh)$/.test(input)) return `${input}es`;
|
|
60
|
+
if (/[^aeiou]y$/.test(input)) return `${input.slice(0, -1)}ies`;
|
|
61
|
+
return `${input}s`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const titleKey = (input: string): string => `app.${kebab(input)}.title`;
|
|
65
|
+
|
|
66
|
+
export interface NameSet {
|
|
67
|
+
readonly raw: string;
|
|
68
|
+
readonly kebab: string;
|
|
69
|
+
readonly camel: string;
|
|
70
|
+
readonly pascal: string;
|
|
71
|
+
readonly plural: string;
|
|
72
|
+
readonly pluralKebab: string;
|
|
73
|
+
/** Singular snake_case. Constraint and index names. */
|
|
74
|
+
readonly snake: string;
|
|
75
|
+
/**
|
|
76
|
+
* The table identifier: plural snake_case. Derived here rather than at each call site because
|
|
77
|
+
* the entity, the repo SQL, the query source and the mutator's local table all name the same
|
|
78
|
+
* table — and Postgres lowercases every unquoted identifier, so a hyphen would have to be
|
|
79
|
+
* quoted forever.
|
|
80
|
+
*/
|
|
81
|
+
readonly table: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function names(input: string): NameSet {
|
|
85
|
+
const base = camel(input);
|
|
86
|
+
const pluralKebab = kebab(plural(base));
|
|
87
|
+
return {
|
|
88
|
+
raw: input,
|
|
89
|
+
kebab: kebab(input),
|
|
90
|
+
camel: base,
|
|
91
|
+
pascal: pascal(input),
|
|
92
|
+
plural: plural(base),
|
|
93
|
+
pluralKebab,
|
|
94
|
+
snake: kebab(input).split('-').join('_'),
|
|
95
|
+
table: pluralKebab.split('-').join('_'),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// `x g policy <feature>` — one authz rule set, evaluated identically by the HTTP route, the live
|
|
2
|
+
// query, the job and the MCP tool. Two authz systems is how frameworks die; the generated test
|
|
3
|
+
// pins the denial branch, because a policy that only has passing tests is a policy nobody trusts.
|
|
4
|
+
|
|
5
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
6
|
+
import { names } from './naming';
|
|
7
|
+
|
|
8
|
+
/** Biome would rewrap this itself, so the generator emits the already-formatted form. */
|
|
9
|
+
const permissionSet = (feature: NameSet): string => {
|
|
10
|
+
const read = `'${feature.kebab}:read'`;
|
|
11
|
+
const write = `'${feature.kebab}:write'`;
|
|
12
|
+
const line = `export const ${feature.camel}Permissions = definePermissions([${read}, ${write}]);`;
|
|
13
|
+
return line.length <= 100
|
|
14
|
+
? line
|
|
15
|
+
: `export const ${feature.camel}Permissions = definePermissions([\n ${read},\n ${write},\n]);`;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const policySource = (
|
|
19
|
+
feature: NameSet,
|
|
20
|
+
): string => `// Authz for the ${feature.kebab} feature. Every branch here is reachable from every surface.
|
|
21
|
+
// Predicates are synchronous on purpose: a live query re-evaluates one per subscriber per patch,
|
|
22
|
+
// so an await here would be a database round trip per row per connected client.
|
|
23
|
+
//
|
|
24
|
+
// A predicate always receives { input, actor, row, ctx }, whichever surface called it. These rules
|
|
25
|
+
// decide on the input, so row is null. A rule about an already-loaded row reads row instead —
|
|
26
|
+
// never reach for a row through input:
|
|
27
|
+
// can<${feature.pascal}Scope, ${feature.pascal}Row>('${feature.kebab}:write', ({ actor, row }) =>
|
|
28
|
+
// row?.ownerId === actor?.id)
|
|
29
|
+
|
|
30
|
+
import { tag } from '@ultimat3/cache';
|
|
31
|
+
import { can, definePermissions } from '@ultimat3/policy';
|
|
32
|
+
|
|
33
|
+
// The permission set, declared rather than assumed. The augmentation narrows \`can()\` to these
|
|
34
|
+
// strings, so a typo is a build error instead of a rule that silently never matches; the
|
|
35
|
+
// definePermissions() call is the same set at runtime, and it has to run before any can() below.
|
|
36
|
+
declare module '@ultimat3/policy' {
|
|
37
|
+
interface PermissionRegistry {
|
|
38
|
+
'${feature.kebab}:read': true;
|
|
39
|
+
'${feature.kebab}:write': true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
${permissionSet(feature)}
|
|
44
|
+
|
|
45
|
+
/** What a write invalidates and a read depends on — one tag, both directions. */
|
|
46
|
+
export const ${feature.camel}Tag = tag('${feature.kebab}');
|
|
47
|
+
|
|
48
|
+
/** What every ${feature.kebab} rule needs to decide. Actions and queries both accept it. */
|
|
49
|
+
export interface ${feature.pascal}Scope {
|
|
50
|
+
readonly orgId: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// \`can()\` checks the grant first and the predicate second, so a denial distinguishes "you may
|
|
54
|
+
// never do this" from "you may, but not in that org" — an agent can act on the difference.
|
|
55
|
+
// The predicates below add tenancy only; the grant is never re-checked by hand.
|
|
56
|
+
|
|
57
|
+
/** Read is org-scoped: an actor sees rows in their own org and nothing else. */
|
|
58
|
+
export const can${feature.pascal}Read = can<${feature.pascal}Scope>(
|
|
59
|
+
'${feature.kebab}:read',
|
|
60
|
+
({ actor, input }) => actor !== null && actor.orgId === input.orgId,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
/** Write is the same tenancy rule on a second permission — grant the two separately in roles. */
|
|
64
|
+
export const can${feature.pascal}Write = can<${feature.pascal}Scope>(
|
|
65
|
+
'${feature.kebab}:write',
|
|
66
|
+
({ actor, input }) => actor !== null && actor.orgId === input.orgId,
|
|
67
|
+
);
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const policyTest = (feature: NameSet): string => `import { testActor } from '@ultimat3/policy';
|
|
71
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
72
|
+
import { can${feature.pascal}Read, can${feature.pascal}Write } from './policy';
|
|
73
|
+
|
|
74
|
+
const org = '00000000-0000-4000-8000-000000000002';
|
|
75
|
+
const otherOrg = '00000000-0000-4000-8000-000000000009';
|
|
76
|
+
|
|
77
|
+
// Direct grants rather than roles: the role map is app-global and defineRoles() replaces it
|
|
78
|
+
// wholesale, so a generated test that installed one would decide authz for every other test in
|
|
79
|
+
// the process. \`permissions\` is the same check one layer down.
|
|
80
|
+
const reader = testActor('reader', { orgId: org, permissions: ['${feature.kebab}:read'] }).actor;
|
|
81
|
+
const writer = testActor('writer', {
|
|
82
|
+
orgId: org,
|
|
83
|
+
permissions: ['${feature.kebab}:read', '${feature.kebab}:write'],
|
|
84
|
+
}).actor;
|
|
85
|
+
const outsider = testActor('outsider', {
|
|
86
|
+
orgId: otherOrg,
|
|
87
|
+
permissions: ['${feature.kebab}:read', '${feature.kebab}:write'],
|
|
88
|
+
}).actor;
|
|
89
|
+
|
|
90
|
+
unitTest('${feature.camel} read denies anonymous and cross-org actors', async () => {
|
|
91
|
+
await expect(can${feature.pascal}Read).toDenyPolicy({ actor: null, input: { orgId: org } });
|
|
92
|
+
await expect(can${feature.pascal}Read).toDenyPolicy({ actor: outsider, input: { orgId: org } });
|
|
93
|
+
await expect(can${feature.pascal}Read).not.toDenyPolicy({ actor: reader, input: { orgId: org } });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
unitTest('${feature.camel} write denies an actor holding only the read grant', async () => {
|
|
97
|
+
// The outsider holds the grant and is still denied: the predicate is a second, independent
|
|
98
|
+
// gate, and this is the assertion that fails if someone deletes it.
|
|
99
|
+
await expect(can${feature.pascal}Write).toDenyPolicy({ actor: reader, input: { orgId: org } });
|
|
100
|
+
await expect(can${feature.pascal}Write).toDenyPolicy({ actor: outsider, input: { orgId: org } });
|
|
101
|
+
await expect(can${feature.pascal}Write).not.toDenyPolicy({ actor: writer, input: { orgId: org } });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
unitTest('${feature.camel} rules name the permission they require', () => {
|
|
105
|
+
expect(can${feature.pascal}Read.permissions).toEqual(['${feature.kebab}:read']);
|
|
106
|
+
expect(can${feature.pascal}Write.permissions).toEqual(['${feature.kebab}:write']);
|
|
107
|
+
});
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
export function policyFiles(
|
|
111
|
+
rawName: string,
|
|
112
|
+
target: { readonly surfaceDir: string; readonly feature: string },
|
|
113
|
+
): readonly GeneratedFile[] {
|
|
114
|
+
const feature = names(rawName.length > 0 ? rawName : target.feature);
|
|
115
|
+
const dir = `${target.surfaceDir}/${target.feature}`;
|
|
116
|
+
return [
|
|
117
|
+
{ path: `${dir}/policy.ts`, contents: policySource(feature) },
|
|
118
|
+
{ path: `${dir}/policy.test.ts`, contents: policyTest(feature) },
|
|
119
|
+
];
|
|
120
|
+
}
|