@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,49 @@
|
|
|
1
|
+
// The three contract files `x verify`'s `package-shape` step requires of every `packages/*` dir —
|
|
2
|
+
// enforced on a generated app exactly like it is on this repo, so `x new` has to ship them itself
|
|
3
|
+
// rather than leave the app's very first gate run red. Split out of scaffold-repo.ts to stay under
|
|
4
|
+
// the file-size ceiling; every `packages/*` entry there calls `packageShapeFiles` once.
|
|
5
|
+
|
|
6
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
|
|
8
|
+
const packageTsconfig = (includes: readonly string[]): string => `{
|
|
9
|
+
"extends": "../../tsconfig.json",
|
|
10
|
+
"include": [${includes.map((glob) => `"${glob}"`).join(', ')}]
|
|
11
|
+
}
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
const packageReadme = (
|
|
15
|
+
app: NameSet,
|
|
16
|
+
name: string,
|
|
17
|
+
description: string,
|
|
18
|
+
): string => `# @${app.kebab}/${name}
|
|
19
|
+
|
|
20
|
+
${description}. Part of the ${app.kebab} monorepo — see the root README for how it fits.
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
const packageClaude = (
|
|
24
|
+
app: NameSet,
|
|
25
|
+
name: string,
|
|
26
|
+
description: string,
|
|
27
|
+
): string => `# @${app.kebab}/${name} — CLAUDE.md
|
|
28
|
+
|
|
29
|
+
${description}.
|
|
30
|
+
|
|
31
|
+
- Gate: \`x verify\` from the repo root — this package has no gate of its own.
|
|
32
|
+
- Exports: \`src/index.ts\`, named exports only, no \`export *\`.
|
|
33
|
+
- Imports: \`@ultimat3/*\` and this app's own \`@${app.kebab}/*\` packages, never a sibling app.
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
/** `src/index.ts` is the package's own file, written separately since its contents differ
|
|
37
|
+
* package to package — these three are identical in shape everywhere, except the tsconfig's
|
|
38
|
+
* `include`: a package whose data sits outside `src/` (i18n's catalog JSON) names an extra glob
|
|
39
|
+
* to reach it, so callers may override the default. */
|
|
40
|
+
export const packageShapeFiles = (
|
|
41
|
+
app: NameSet,
|
|
42
|
+
name: string,
|
|
43
|
+
description: string,
|
|
44
|
+
includes: readonly string[] = ['**/*.ts'],
|
|
45
|
+
): readonly GeneratedFile[] => [
|
|
46
|
+
{ path: `packages/${name}/README.md`, contents: packageReadme(app, name, description) },
|
|
47
|
+
{ path: `packages/${name}/CLAUDE.md`, contents: packageClaude(app, name, description) },
|
|
48
|
+
{ path: `packages/${name}/tsconfig.json`, contents: packageTsconfig(includes) },
|
|
49
|
+
];
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// The config half of what `x new` writes: the one config file, the tooling configs and the
|
|
2
|
+
// workspace packages. Committed defaults only — a fresh clone boots with `x dev` and no env
|
|
3
|
+
// scavenger hunt. The docs, shims and container files live in scaffold-docs.ts.
|
|
4
|
+
|
|
5
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
6
|
+
import { docsFiles } from './scaffold-docs';
|
|
7
|
+
import { i18nFiles } from './scaffold-i18n';
|
|
8
|
+
import { packageShapeFiles } from './scaffold-package-shape';
|
|
9
|
+
|
|
10
|
+
const rootPackage = (app: NameSet, version: string): string => `{
|
|
11
|
+
"name": "${app.kebab}",
|
|
12
|
+
"private": true,
|
|
13
|
+
"type": "module",
|
|
14
|
+
"workspaces": [
|
|
15
|
+
"apps/*",
|
|
16
|
+
"packages/*"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"setup": "bin/setup",
|
|
20
|
+
"dev": "x dev",
|
|
21
|
+
"check": "bin/check",
|
|
22
|
+
"verify": "x verify",
|
|
23
|
+
"typecheck": "tsc -b --pretty",
|
|
24
|
+
"lint": "biome check .",
|
|
25
|
+
"test": "bun test",
|
|
26
|
+
"db:migrate": "x db migrate",
|
|
27
|
+
"db:seed": "bun run packages/db/src/seed.ts"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@biomejs/biome": "^2.4.15",
|
|
31
|
+
"@electric-sql/pglite": "^0.5.4",
|
|
32
|
+
"@types/bun": "^1.3.14",
|
|
33
|
+
"@ultimat3/testing": "^${version}",
|
|
34
|
+
"typescript": "^6.0.3"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@ultimat3/action": "^${version}",
|
|
38
|
+
"@ultimat3/cache": "^${version}",
|
|
39
|
+
"@ultimat3/cli": "^${version}",
|
|
40
|
+
"@ultimat3/core": "^${version}",
|
|
41
|
+
"@ultimat3/db": "^${version}",
|
|
42
|
+
"@ultimat3/entity": "^${version}",
|
|
43
|
+
"@ultimat3/i18n": "^${version}",
|
|
44
|
+
"@ultimat3/jobs": "^${version}",
|
|
45
|
+
"@ultimat3/mcp": "^${version}",
|
|
46
|
+
"@ultimat3/policy": "^${version}",
|
|
47
|
+
"@ultimat3/pwa": "^${version}",
|
|
48
|
+
"@ultimat3/query": "^${version}",
|
|
49
|
+
"@ultimat3/render": "^${version}",
|
|
50
|
+
"@ultimat3/ui": "^${version}",
|
|
51
|
+
"solid-js": "2.0.0-experimental.16"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"bun": ">=1.3.0"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
const rootTsconfig = (app: NameSet): string => `{
|
|
60
|
+
"compilerOptions": {
|
|
61
|
+
"target": "ES2023",
|
|
62
|
+
"module": "ESNext",
|
|
63
|
+
"moduleResolution": "bundler",
|
|
64
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
65
|
+
"types": ["bun"],
|
|
66
|
+
"paths": {
|
|
67
|
+
"@${app.kebab}/*": ["./packages/*/src"]
|
|
68
|
+
},
|
|
69
|
+
"strict": true,
|
|
70
|
+
"noUncheckedIndexedAccess": true,
|
|
71
|
+
"exactOptionalPropertyTypes": true,
|
|
72
|
+
"verbatimModuleSyntax": true,
|
|
73
|
+
"isolatedModules": true,
|
|
74
|
+
"skipLibCheck": true,
|
|
75
|
+
"noEmit": true,
|
|
76
|
+
"resolveJsonModule": true,
|
|
77
|
+
"jsx": "preserve",
|
|
78
|
+
"jsxImportSource": "solid-js"
|
|
79
|
+
},
|
|
80
|
+
"exclude": ["node_modules", "dist", ".x"]
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
const appConfig = (
|
|
85
|
+
app: NameSet,
|
|
86
|
+
): string => `// The one config file. Everything the app needs to boot is here, typed and validated at startup —
|
|
87
|
+
// a missing value fails the boot with the exact command that fixes it, never at the first request.
|
|
88
|
+
// A named export, never a default: the CLI and the runtime both import \`config\` by name.
|
|
89
|
+
import { defineConfig } from '@ultimat3/core';
|
|
90
|
+
|
|
91
|
+
export const config = defineConfig({
|
|
92
|
+
name: '${app.kebab}',
|
|
93
|
+
locales: ['en'],
|
|
94
|
+
defaultLocale: 'en',
|
|
95
|
+
defaultTimeZone: 'UTC',
|
|
96
|
+
defaultCurrency: 'USD',
|
|
97
|
+
// Env KEYS, never the value: the same image deploys to every environment.
|
|
98
|
+
database: { urlEnv: 'DATABASE_URL', poolSize: 10 },
|
|
99
|
+
cache: { driver: 'memory', tiers: ['memo', 'lru'] },
|
|
100
|
+
jobs: { driver: 'postgres', queues: ['${app.kebab}-default'], concurrency: 4 },
|
|
101
|
+
// In-process transport by default; set urlEnv and transport: 'nats' to scale past one node.
|
|
102
|
+
realtime: { enabled: true, tier: 'live-queries', transport: 'memory' },
|
|
103
|
+
pwa: { enabled: true, offline: 'runtime', installPrompt: true },
|
|
104
|
+
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
105
|
+
});
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
const biome = (): string => `{
|
|
109
|
+
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
|
110
|
+
// x.manifest.json and openapi.json are emitted byte-for-byte by \`x manifest\`; a formatter
|
|
111
|
+
// rewriting them puts \`x manifest\` and \`x verify\` in a loop neither can win.
|
|
112
|
+
"files": { "includes": ["**", "!x.manifest.json", "!openapi.json"] },
|
|
113
|
+
"formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 },
|
|
114
|
+
"linter": {
|
|
115
|
+
"rules": {
|
|
116
|
+
"recommended": true,
|
|
117
|
+
"suspicious": { "noExplicitAny": "error" },
|
|
118
|
+
"correctness": { "noUnusedVariables": "error", "noUnusedImports": "error" }
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"javascript": {
|
|
122
|
+
"formatter": { "quoteStyle": "single", "semicolons": "always", "trailingCommas": "all" }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
const bunfig = (): string => `[test]
|
|
128
|
+
root = "."
|
|
129
|
+
# Frozen clock, seeded RNG, sealed network — nondeterminism in a test is a bug.
|
|
130
|
+
preload = ["@ultimat3/testing/preload"]
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
const scssTypes =
|
|
134
|
+
(): string => `// SCSS modules resolve to a class-name map at build time. Ambient because an import cannot
|
|
135
|
+
// reach a declaration file — every surface names this file in its tsconfig "include".
|
|
136
|
+
|
|
137
|
+
declare module '*.module.scss' {
|
|
138
|
+
const classes: Readonly<Record<string, string>>;
|
|
139
|
+
export default classes;
|
|
140
|
+
}
|
|
141
|
+
`;
|
|
142
|
+
|
|
143
|
+
const gitignore = (): string => `node_modules/
|
|
144
|
+
.x/
|
|
145
|
+
dist/
|
|
146
|
+
*.tsbuildinfo
|
|
147
|
+
.env
|
|
148
|
+
.env.*.local
|
|
149
|
+
coverage/
|
|
150
|
+
playwright-report/
|
|
151
|
+
test-results/
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
const envDevelopment =
|
|
155
|
+
(): string => `# Committed non-secret defaults. Per-box secrets go in .env.development.local, which wins.
|
|
156
|
+
# Empty DATABASE_URL means "embedded": x dev runs PGlite in-process, no Docker required.
|
|
157
|
+
DATABASE_URL=
|
|
158
|
+
NATS_URL=
|
|
159
|
+
S3_ENDPOINT=
|
|
160
|
+
PORT=3000
|
|
161
|
+
ROLE=web
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
const domainPackage = (app: NameSet, name: string, description: string): string => `{
|
|
165
|
+
"name": "@${app.kebab}/${name}",
|
|
166
|
+
"version": "0.0.0",
|
|
167
|
+
"private": true,
|
|
168
|
+
"type": "module",
|
|
169
|
+
"description": "${description}",
|
|
170
|
+
"exports": {
|
|
171
|
+
".": "./src/index.ts"
|
|
172
|
+
},
|
|
173
|
+
"scripts": {
|
|
174
|
+
"typecheck": "tsc --noEmit -p ../../tsconfig.json"
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
const domainIndex =
|
|
180
|
+
(): string => `// Pure types and constants. No I/O of any kind: no fs, no network, no database, no env reads.
|
|
181
|
+
export const ROLES = ['owner', 'member', 'viewer'] as const;
|
|
182
|
+
|
|
183
|
+
export type Role = (typeof ROLES)[number];
|
|
184
|
+
|
|
185
|
+
export interface Money {
|
|
186
|
+
readonly minor: number;
|
|
187
|
+
readonly currency: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const zero = (currency: string): Money => ({ minor: 0, currency });
|
|
191
|
+
|
|
192
|
+
export const add = (a: Money, b: Money): Money => {
|
|
193
|
+
if (a.currency !== b.currency) throw new RangeError(\`cannot add \${a.currency} to \${b.currency}\`);
|
|
194
|
+
return { minor: a.minor + b.minor, currency: a.currency };
|
|
195
|
+
};
|
|
196
|
+
`;
|
|
197
|
+
|
|
198
|
+
const domainTest = (): string => `import { expect } from 'bun:test';
|
|
199
|
+
import { unitTest } from '@ultimat3/testing';
|
|
200
|
+
import { add, zero } from './index';
|
|
201
|
+
|
|
202
|
+
unitTest('money adds in minor units', () => {
|
|
203
|
+
expect(add({ minor: 1050, currency: 'USD' }, { minor: 250, currency: 'USD' })).toEqual({
|
|
204
|
+
minor: 1300,
|
|
205
|
+
currency: 'USD',
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
unitTest('money refuses to add across currencies', () => {
|
|
210
|
+
expect(() => add(zero('USD'), zero('EUR'))).toThrow();
|
|
211
|
+
});
|
|
212
|
+
`;
|
|
213
|
+
|
|
214
|
+
const dbIndex =
|
|
215
|
+
(): string => `// Schema and migrations only — no business logic lives in this package. The client itself is
|
|
216
|
+
// @ultimat3/db's: one connection pool, sized by ROLE, shared by every package in the app.
|
|
217
|
+
export type { DbClient, SqlFragment } from '@ultimat3/db';
|
|
218
|
+
export { db, sql, withTransaction } from '@ultimat3/db';
|
|
219
|
+
export * as schema from './schema';
|
|
220
|
+
`;
|
|
221
|
+
|
|
222
|
+
// The four pieces below describe the example slice's table. Under `--no-example` that slice is
|
|
223
|
+
// never written, so each one ships its empty counterpart instead of a reference to a file that is
|
|
224
|
+
// not there — `export { post } from …` alone made `x new --no-example` an app that cannot compile.
|
|
225
|
+
|
|
226
|
+
const SCHEMA_HEADER = `// Every entity the app declares, re-exported here. This list is what the migration generator
|
|
227
|
+
// reads, so an entity that is not exported here does not exist as far as the database is concerned.`;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `bun run db:seed`'s entry point. Identical either way — only the rows differ. Interpolated, not
|
|
231
|
+
* nested, so it carries exactly the escaping a single template literal needs.
|
|
232
|
+
*/
|
|
233
|
+
const SEED_MAIN = `
|
|
234
|
+
|
|
235
|
+
if (import.meta.main) {
|
|
236
|
+
const count = await seed();
|
|
237
|
+
// Bun's stdout, not process.stdout: one runtime, one API. Awaited because the write resolves
|
|
238
|
+
// asynchronously, and this JSON line is the whole output of \`bun run db:seed\`.
|
|
239
|
+
await Bun.stdout.write(\`\${JSON.stringify({ ok: true, seeded: count })}\\n\`);
|
|
240
|
+
}
|
|
241
|
+
`;
|
|
242
|
+
|
|
243
|
+
const dbSchema = (app: NameSet, example: boolean): string =>
|
|
244
|
+
example
|
|
245
|
+
? `${SCHEMA_HEADER}
|
|
246
|
+
export { post } from '@${app.kebab}/web/app/post/entity';
|
|
247
|
+
`
|
|
248
|
+
: `${SCHEMA_HEADER}
|
|
249
|
+
// \`x g entity <name>\` writes the entity; add its export here so the database learns about it.
|
|
250
|
+
export {};
|
|
251
|
+
`;
|
|
252
|
+
|
|
253
|
+
const dbSeed = (app: NameSet, example: boolean): string =>
|
|
254
|
+
example
|
|
255
|
+
? `// Deterministic seed: same rows every time, so a test and a demo see the same database.
|
|
256
|
+
import { db, sql } from '@ultimat3/db';
|
|
257
|
+
|
|
258
|
+
const ORG = '00000000-0000-0000-0000-000000000002';
|
|
259
|
+
|
|
260
|
+
export async function seed(): Promise<number> {
|
|
261
|
+
const rows = [
|
|
262
|
+
{ id: '00000000-0000-0000-0000-000000000101', title: 'Hello ${app.pascal}', minor: 0 },
|
|
263
|
+
{ id: '00000000-0000-0000-0000-000000000102', title: 'Second post', minor: 1900 },
|
|
264
|
+
];
|
|
265
|
+
for (const row of rows) {
|
|
266
|
+
// Idempotent by primary key, so re-seeding a branch database is a no-op rather than a crash.
|
|
267
|
+
await db().execute(sql\`
|
|
268
|
+
insert into posts (id, org_id, title, price_minor, price_currency)
|
|
269
|
+
values (\${row.id}, \${ORG}, \${row.title}, \${row.minor}, 'USD')
|
|
270
|
+
on conflict (id) do nothing\`);
|
|
271
|
+
}
|
|
272
|
+
return rows.length;
|
|
273
|
+
}${SEED_MAIN}`
|
|
274
|
+
: `// Deterministic seed: same rows every time, so a test and a demo see the same database.
|
|
275
|
+
// No entity is declared yet, so there is nothing to insert — the shape stays, so the first
|
|
276
|
+
// \`x g entity\` has one obvious place to seed from.
|
|
277
|
+
|
|
278
|
+
export async function seed(): Promise<number> {
|
|
279
|
+
return 0;
|
|
280
|
+
}${SEED_MAIN}`;
|
|
281
|
+
|
|
282
|
+
const migration = (example: boolean): string =>
|
|
283
|
+
example
|
|
284
|
+
? `-- 0000_initial: the example feature slice. Reversible: the down section is required.
|
|
285
|
+
CREATE TABLE IF NOT EXISTS posts (
|
|
286
|
+
id uuid PRIMARY KEY,
|
|
287
|
+
org_id uuid NOT NULL,
|
|
288
|
+
title varchar(200) NOT NULL,
|
|
289
|
+
price_minor integer NOT NULL DEFAULT 0,
|
|
290
|
+
price_currency char(3) NOT NULL DEFAULT 'USD',
|
|
291
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
292
|
+
);
|
|
293
|
+
CREATE INDEX IF NOT EXISTS posts_org_created_idx ON posts (org_id, created_at);
|
|
294
|
+
|
|
295
|
+
-- down
|
|
296
|
+
-- DROP INDEX IF EXISTS posts_org_created_idx;
|
|
297
|
+
-- DROP TABLE IF EXISTS posts;
|
|
298
|
+
`
|
|
299
|
+
: `-- 0000_initial: no entity is declared yet, so this migration creates nothing. It exists so the
|
|
300
|
+
-- schema hash beside it has a migration to belong to, and \`x verify\` sees no drift on run one.
|
|
301
|
+
-- Reversible: the down section is required.
|
|
302
|
+
|
|
303
|
+
-- down
|
|
304
|
+
`;
|
|
305
|
+
|
|
306
|
+
const uiIndex =
|
|
307
|
+
(): string => `// App components on top of @ultimat3/ui. Same byte budgets as shared/: this package is imported
|
|
308
|
+
// by site/, so a chart library in here costs the landing page.
|
|
309
|
+
export { Card } from './card';
|
|
310
|
+
`;
|
|
311
|
+
|
|
312
|
+
const uiCard = (): string => `import type { JSX } from 'solid-js';
|
|
313
|
+
import styles from './card.module.scss';
|
|
314
|
+
|
|
315
|
+
export interface CardProps {
|
|
316
|
+
readonly title: string;
|
|
317
|
+
readonly children?: JSX.Element;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function Card(props: CardProps) {
|
|
321
|
+
return (
|
|
322
|
+
<section class={styles.card}>
|
|
323
|
+
<h2 class={styles.title}>{props.title}</h2>
|
|
324
|
+
{props.children}
|
|
325
|
+
</section>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
`;
|
|
329
|
+
|
|
330
|
+
const uiCardStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
331
|
+
|
|
332
|
+
.card {
|
|
333
|
+
padding: tokens.$space-4;
|
|
334
|
+
border-radius: tokens.$radius-md;
|
|
335
|
+
background: tokens.$surface-raised;
|
|
336
|
+
color: tokens.$text-primary;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.title {
|
|
340
|
+
font: tokens.$text-heading-sm;
|
|
341
|
+
}
|
|
342
|
+
`;
|
|
343
|
+
|
|
344
|
+
const mcpIndex = (
|
|
345
|
+
app: NameSet,
|
|
346
|
+
): string => `// The app's own MCP tools. Every action with mcp.expose is already a tool; add app-specific
|
|
347
|
+
// read-only helpers here. Authorization is the action's policy, unchanged.
|
|
348
|
+
import * as api from '@${app.kebab}/web/api/health';
|
|
349
|
+
import { registerActions } from '@ultimat3/action';
|
|
350
|
+
import { defineAppMcp } from '@ultimat3/mcp';
|
|
351
|
+
|
|
352
|
+
// Names come from export names, so the registry agrees with the module the app already wrote.
|
|
353
|
+
registerActions(api);
|
|
354
|
+
|
|
355
|
+
// \`include: 'exposed'\` projects straight from the registry. Re-listing the actions here would
|
|
356
|
+
// copy \`mcp: { expose: true }\` into a second place, and the copy goes stale in silence.
|
|
357
|
+
export const mcp = defineAppMcp({
|
|
358
|
+
name: '${app.kebab}',
|
|
359
|
+
include: 'exposed',
|
|
360
|
+
});
|
|
361
|
+
`;
|
|
362
|
+
|
|
363
|
+
const mcpTest = (): string => `import { expect, unitTest } from '@ultimat3/testing';
|
|
364
|
+
import { mcp } from './index';
|
|
365
|
+
|
|
366
|
+
unitTest('the app exposes its actions as MCP tools', () => {
|
|
367
|
+
expect(mcp.tools.length).toBeGreaterThan(0);
|
|
368
|
+
// Every projected tool must describe itself: an agent picks a tool by its description. Assert
|
|
369
|
+
// on the value, not its length — a failure then prints the empty description, not "0 > 0".
|
|
370
|
+
for (const tool of mcp.tools) expect(tool.description).not.toBe('');
|
|
371
|
+
});
|
|
372
|
+
`;
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* `example` reaches only the four files that describe the slice's table — schema, seed, initial
|
|
376
|
+
* migration, and nothing in the catalog. Everything else is the same app either way, which is what
|
|
377
|
+
* `--no-example` promises: the same shape with an empty `app/`.
|
|
378
|
+
*/
|
|
379
|
+
export function repoFiles(
|
|
380
|
+
app: NameSet,
|
|
381
|
+
version: string,
|
|
382
|
+
example: boolean,
|
|
383
|
+
): readonly GeneratedFile[] {
|
|
384
|
+
return [
|
|
385
|
+
...docsFiles(app),
|
|
386
|
+
{ path: 'package.json', contents: rootPackage(app, version) },
|
|
387
|
+
{ path: 'tsconfig.json', contents: rootTsconfig(app) },
|
|
388
|
+
{ path: 'biome.json', contents: biome() },
|
|
389
|
+
{ path: 'bunfig.toml', contents: bunfig() },
|
|
390
|
+
{ path: 'app.config.ts', contents: appConfig(app) },
|
|
391
|
+
{ path: 'types/scss.d.ts', contents: scssTypes() },
|
|
392
|
+
{ path: '.gitignore', contents: gitignore() },
|
|
393
|
+
{ path: '.env.development', contents: envDevelopment() },
|
|
394
|
+
{
|
|
395
|
+
path: 'packages/domain/package.json',
|
|
396
|
+
contents: domainPackage(app, 'domain', 'Pure types and constants, no I/O'),
|
|
397
|
+
},
|
|
398
|
+
...packageShapeFiles(app, 'domain', 'Pure types and constants, no I/O'),
|
|
399
|
+
{ path: 'packages/domain/src/index.ts', contents: domainIndex() },
|
|
400
|
+
{ path: 'packages/domain/src/index.test.ts', contents: domainTest() },
|
|
401
|
+
{
|
|
402
|
+
path: 'packages/db/package.json',
|
|
403
|
+
contents: domainPackage(app, 'db', 'Entity re-exports and SQL migrations, no business logic'),
|
|
404
|
+
},
|
|
405
|
+
...packageShapeFiles(app, 'db', 'Entity re-exports and SQL migrations, no business logic'),
|
|
406
|
+
{ path: 'packages/db/src/index.ts', contents: dbIndex() },
|
|
407
|
+
{ path: 'packages/db/src/schema.ts', contents: dbSchema(app, example) },
|
|
408
|
+
{ path: 'packages/db/src/seed.ts', contents: dbSeed(app, example) },
|
|
409
|
+
{ path: 'packages/db/migrations/0000_initial.sql', contents: migration(example) },
|
|
410
|
+
...i18nFiles(app, version),
|
|
411
|
+
{
|
|
412
|
+
path: 'packages/ui/package.json',
|
|
413
|
+
contents: domainPackage(app, 'ui', 'App components on @ultimat3/ui'),
|
|
414
|
+
},
|
|
415
|
+
...packageShapeFiles(app, 'ui', 'App components on @ultimat3/ui'),
|
|
416
|
+
{ path: 'packages/ui/src/index.ts', contents: uiIndex() },
|
|
417
|
+
{ path: 'packages/ui/src/card.tsx', contents: uiCard() },
|
|
418
|
+
{ path: 'packages/ui/src/card.module.scss', contents: uiCardStyle() },
|
|
419
|
+
{
|
|
420
|
+
path: 'packages/mcp/package.json',
|
|
421
|
+
contents: domainPackage(app, 'mcp', "The app's own MCP tools"),
|
|
422
|
+
},
|
|
423
|
+
...packageShapeFiles(app, 'mcp', "The app's own MCP tools"),
|
|
424
|
+
{ path: 'packages/mcp/src/index.ts', contents: mcpIndex(app) },
|
|
425
|
+
{ path: 'packages/mcp/src/index.test.ts', contents: mcpTest() },
|
|
426
|
+
];
|
|
427
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Decides which files `x test` runs: discovery, the `--filter`/type/`--sample` narrowing, and the
|
|
2
|
+
// validation behind the type positional and `--sample`. Selection only — nothing here splits a
|
|
3
|
+
// shard or spawns a process, so a wrong file list is always this file's bug and never a race.
|
|
4
|
+
|
|
5
|
+
// Bun ships no equivalent: `join` builds the host-separator path from the scan root to a hit.
|
|
6
|
+
// Sizing is Bun's own (`Bun.file().size`), so nothing here reaches for `node:fs`.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { BadFlagError } from './errors';
|
|
9
|
+
import type { ParsedArgs } from './parse';
|
|
10
|
+
import { flagString, nearest } from './parse';
|
|
11
|
+
import type { TestType } from './verify-tests';
|
|
12
|
+
import { TEST_TYPES, typeFilterOf } from './verify-tests';
|
|
13
|
+
|
|
14
|
+
export interface TestFile {
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly bytes: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TEST_GLOB = '**/*.test.ts';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The root `test` script's ignore list, kept identical so `x test` and `bun run test` see one
|
|
23
|
+
* suite. `e2e/` is NOT on it: an opt-in suite that the gate runs but `x test` silently drops is
|
|
24
|
+
* a suite nobody runs until CI says so. `examples/` is, because the reference app is a separate
|
|
25
|
+
* project with its own gate — `x verify` there, not `x test` here.
|
|
26
|
+
*/
|
|
27
|
+
const IGNORED = ['/dist/', '/node_modules/', '/examples/'];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* File size stands in for duration: cheap to read, and it correlates far better than file count.
|
|
31
|
+
* `type`, when given, narrows to exactly the files verify-tests.ts would run for that suite — see
|
|
32
|
+
* `belongsToType` below, the one place that rule is decided.
|
|
33
|
+
*/
|
|
34
|
+
export async function discoverTests(
|
|
35
|
+
root: string,
|
|
36
|
+
filter?: string,
|
|
37
|
+
type?: TestType,
|
|
38
|
+
): Promise<readonly TestFile[]> {
|
|
39
|
+
const files: TestFile[] = [];
|
|
40
|
+
for await (const found of new Bun.Glob(TEST_GLOB).scan({ cwd: root, absolute: false })) {
|
|
41
|
+
const path = found.split('\\').join('/');
|
|
42
|
+
if (IGNORED.some((part) => `/${path}`.includes(part))) continue;
|
|
43
|
+
if (filter !== undefined && !path.includes(filter)) continue;
|
|
44
|
+
if (type !== undefined && !belongsToType(path, type)) continue;
|
|
45
|
+
files.push({ path, bytes: Bun.file(join(root, path)).size });
|
|
46
|
+
}
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Not localeCompare: its ordering depends on the machine's locale, and the split must not.
|
|
51
|
+
export const bySizeThenPath = (a: TestFile, b: TestFile): number =>
|
|
52
|
+
b.bytes - a.bytes || (a.path > b.path ? 1 : -1);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `--sample`'s deterministic slice, reusing the same (size desc, path asc) order `planShards`
|
|
56
|
+
* relies on for sharding — never a random sample, so a rerun keeps the same N files. It exists for
|
|
57
|
+
* the eval loop: that suite is the slowest one, and an agent iterating on a prompt needs a fast
|
|
58
|
+
* partial signal long before the full type finishes. That is exactly why a sampled run is NOT a
|
|
59
|
+
* gate — a pass over the first N files says nothing about the files left out, which is why the
|
|
60
|
+
* result names what actually ran (`cli.test.sampled`, `data.sample`) instead of a plain pass that
|
|
61
|
+
* reads like the whole type went green.
|
|
62
|
+
*/
|
|
63
|
+
export function sampleFiles(files: readonly TestFile[], sample: number): readonly TestFile[] {
|
|
64
|
+
return [...files].sort(bySizeThenPath).slice(0, sample);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* verify-tests.ts owns the one definition of what a file's test type is; `typeFilterOf` is that
|
|
69
|
+
* table's own accessor. Re-declaring the suffixes here would be a second definition, and the two
|
|
70
|
+
* would drift the first time a suite's naming rule changed.
|
|
71
|
+
*/
|
|
72
|
+
const TYPE_FILTERS: readonly (readonly [Exclude<TestType, 'unit'>, string])[] = TEST_TYPES.filter(
|
|
73
|
+
(type): type is Exclude<TestType, 'unit'> => type !== 'unit',
|
|
74
|
+
).map((type) => [type, typeFilterOf(type)] as const);
|
|
75
|
+
|
|
76
|
+
/** unit is everything the five typed suites do not claim, so no file falls between two types. */
|
|
77
|
+
export function belongsToType(path: string, type: TestType): boolean {
|
|
78
|
+
if (type === 'unit') return TYPE_FILTERS.every(([, filter]) => !path.includes(filter));
|
|
79
|
+
return TYPE_FILTERS.some(([typed, filter]) => typed === type && path.includes(filter));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The positional now means exactly one thing — one of the six test types — a deliberate breaking
|
|
84
|
+
* change from the old free-text filter positional. `undefined` means "every type," today's
|
|
85
|
+
* whole-suite behaviour, unchanged.
|
|
86
|
+
*/
|
|
87
|
+
export function readType(raw: string | undefined): TestType | undefined {
|
|
88
|
+
if (raw === undefined) return undefined;
|
|
89
|
+
const known: readonly string[] = TEST_TYPES;
|
|
90
|
+
if (known.includes(raw)) return raw as TestType;
|
|
91
|
+
const suggestion = nearest(raw, known);
|
|
92
|
+
throw new BadFlagError({
|
|
93
|
+
flag: 'type',
|
|
94
|
+
command: 'test',
|
|
95
|
+
reason: `"${raw}" is not a test type (known: ${TEST_TYPES.join(', ')})`,
|
|
96
|
+
fix: suggestion === undefined ? `x test ${TEST_TYPES[0]}` : `x test ${suggestion}`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `--sample` exists for the eval loop: it is the slowest suite, and an agent iterating on a prompt
|
|
102
|
+
* needs a fast partial signal long before the full type finishes. A sampled run is therefore NOT a
|
|
103
|
+
* gate — the caller has to be told it ran a subset, which is why a sampled result carries its own
|
|
104
|
+
* summary line and `data.sample` instead of quietly reporting a subset as if it were everything.
|
|
105
|
+
*/
|
|
106
|
+
export function readSample(args: ParsedArgs): number | undefined {
|
|
107
|
+
const raw = flagString(args, 'sample');
|
|
108
|
+
if (raw === undefined) return undefined;
|
|
109
|
+
const value = /^\d+$/.test(raw) ? Number.parseInt(raw, 10) : Number.NaN;
|
|
110
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
111
|
+
throw new BadFlagError({
|
|
112
|
+
flag: 'sample',
|
|
113
|
+
command: 'test',
|
|
114
|
+
reason: `expects an integer >= 1, got "${raw}"`,
|
|
115
|
+
fix: 'x test eval --sample 5',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The selection, as `NoTestFilesError` wants it: only the parts the caller actually asked for. */
|
|
122
|
+
export function missingSelection(
|
|
123
|
+
type: TestType | undefined,
|
|
124
|
+
filter: string | undefined,
|
|
125
|
+
): { type?: TestType; filter?: string } {
|
|
126
|
+
return {
|
|
127
|
+
...(type === undefined ? {} : { type }),
|
|
128
|
+
...(filter === undefined ? {} : { filter }),
|
|
129
|
+
};
|
|
130
|
+
}
|