@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,240 @@
|
|
|
1
|
+
// Typechecks what `x new` and `x g` write, the way the user's own `tsc` will: real files on disk,
|
|
2
|
+
// the real workspace packages, the real compiler. A template that merely parses has not been
|
|
3
|
+
// checked — it has moved its failure from this gate to the first command the user runs.
|
|
4
|
+
|
|
5
|
+
// `node:` and not Bun: Bun has no API for a temporary directory (`mkdtempSync` + `tmpdir`), for a
|
|
6
|
+
// symlink (`symlinkSync`, how the sandbox borrows the workspace's node_modules), or for a
|
|
7
|
+
// recursive delete (`rmSync`). `node:path` comes with them — `Bun.write` takes the joined path,
|
|
8
|
+
// but only `resolve`/`sep` can prove that path stayed inside the sandbox.
|
|
9
|
+
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join, resolve, sep } from 'node:path';
|
|
12
|
+
import { ScaffoldPathEscapeError } from './errors';
|
|
13
|
+
import type { Runner } from './exec';
|
|
14
|
+
import { exec } from './exec';
|
|
15
|
+
import { FIXTURE_APP, scaffoldFixture } from './scaffold-fixture';
|
|
16
|
+
import type { GeneratedFile } from './templates';
|
|
17
|
+
|
|
18
|
+
/** Derived from this file, never from cwd, so the harness works from any working directory. */
|
|
19
|
+
export const workspaceRoot = (): string => resolve(import.meta.dir, '..', '..', '..');
|
|
20
|
+
|
|
21
|
+
export interface TypeDiagnostic {
|
|
22
|
+
/** Sandbox-relative path, or '' for a diagnostic the compiler raised about the project itself. */
|
|
23
|
+
readonly file: string;
|
|
24
|
+
readonly line: number;
|
|
25
|
+
readonly code: string;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const AT_FILE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
|
|
30
|
+
const PROJECT_WIDE = /^error (TS\d+): (.*)$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Config errors carry no file, so both shapes are collected: a silent harness is a green lie.
|
|
34
|
+
* Split on `\r?\n`, because a trailing `\r` lands inside the message capture and no `KNOWN_GAPS`
|
|
35
|
+
* entry would match it — a CRLF `tsc` would turn every pinned gap into an unexplained failure.
|
|
36
|
+
*/
|
|
37
|
+
export function parseDiagnostics(output: string): readonly TypeDiagnostic[] {
|
|
38
|
+
const found: TypeDiagnostic[] = [];
|
|
39
|
+
for (const line of output.split(/\r?\n/)) {
|
|
40
|
+
const at = AT_FILE.exec(line);
|
|
41
|
+
if (at !== null) {
|
|
42
|
+
found.push({
|
|
43
|
+
file: at[1] ?? '',
|
|
44
|
+
line: Number.parseInt(at[2] ?? '0', 10),
|
|
45
|
+
code: at[4] ?? '',
|
|
46
|
+
message: at[5] ?? '',
|
|
47
|
+
});
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const wide = PROJECT_WIDE.exec(line.trim());
|
|
51
|
+
if (wide !== null)
|
|
52
|
+
found.push({ file: '', line: 0, code: wide[1] ?? '', message: wide[2] ?? '' });
|
|
53
|
+
}
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface KnownGap {
|
|
58
|
+
/** `ScaffoldVariant.name`. A pin bought by one invocation may not excuse another. */
|
|
59
|
+
readonly variant: string;
|
|
60
|
+
readonly code: string;
|
|
61
|
+
/** Exact sandbox-relative path. A pattern would absolve the same bug in a file nobody pinned. */
|
|
62
|
+
readonly file: string;
|
|
63
|
+
/** Exact `tsc` message. Matched literally so a near-miss regression is not absorbed. */
|
|
64
|
+
readonly message: string;
|
|
65
|
+
/** Who fixes it, and why the template cannot. */
|
|
66
|
+
readonly owner: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// `InvariantColumns` is an index-signature type, so `c.title` is `ColumnExpr | undefined` under
|
|
70
|
+
// `noUncheckedIndexedAccess`. Every hand-written entity in `examples/dummy` reproduces it
|
|
71
|
+
// identically: the fix is a column proxy typed from the entity's own columns, in @ultimat3/entity
|
|
72
|
+
// — a different template cannot avoid it without dropping to `satisfies()`, which would silently
|
|
73
|
+
// stop emitting the Postgres CHECK.
|
|
74
|
+
//
|
|
75
|
+
// Measured, not guessed: no open-keyed form avoids the `| undefined` (index signature, `Record`,
|
|
76
|
+
// and a mapped type over `string` or a template-literal pattern all produce it), and typing the
|
|
77
|
+
// proxy from `columns` only reaches `c` when the element of `invariants:` is itself
|
|
78
|
+
// context-sensitive. `invariant(name, build)` is a *call*, which TypeScript checks before
|
|
79
|
+
// `entity()`'s own `C` is fixed, so `K` falls back to `string` and nothing changes. Making it
|
|
80
|
+
// reach means changing the shape of `invariants:` — a documented primitive, so a major.
|
|
81
|
+
const INVARIANT_PROXY =
|
|
82
|
+
'@ultimat3/entity — type the invariant column proxy from the declared columns (needs a major: ' +
|
|
83
|
+
'the `invariants:` element shape has to become context-sensitive)';
|
|
84
|
+
|
|
85
|
+
/** Every entity the fixture generates. Each one declares the same two invariants. */
|
|
86
|
+
const FIXTURE_ENTITIES = [
|
|
87
|
+
'apps/web/app/credit-note/entity.ts',
|
|
88
|
+
'apps/web/app/invoice/entity.ts',
|
|
89
|
+
'apps/web/app/post/entity.ts',
|
|
90
|
+
] as const;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Diagnostics a template cannot fix, pinned one occurrence at a time. Pinned, never ignored:
|
|
94
|
+
* `unexpectedIn` fails on anything not listed here — including a second copy of a listed
|
|
95
|
+
* diagnostic, because each entry is consumed by exactly one match — and `staleGapsIn` fails when
|
|
96
|
+
* a listed entry stops reproducing, so an entry cannot outlive the bug it describes.
|
|
97
|
+
*/
|
|
98
|
+
export const KNOWN_GAPS: readonly KnownGap[] = FIXTURE_ENTITIES.flatMap((file) =>
|
|
99
|
+
["'c.title' is possibly 'undefined'.", "'c.price' is possibly 'undefined'."].map(
|
|
100
|
+
(message): KnownGap => ({
|
|
101
|
+
variant: 'x new',
|
|
102
|
+
code: 'TS18048',
|
|
103
|
+
file,
|
|
104
|
+
message,
|
|
105
|
+
owner: INVARIANT_PROXY,
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
/** The pins one invocation may spend. Another variant's pins are not its to spend. */
|
|
111
|
+
export const gapsFor = (variant: string): readonly KnownGap[] =>
|
|
112
|
+
KNOWN_GAPS.filter((gap) => gap.variant === variant);
|
|
113
|
+
|
|
114
|
+
const matches = (diagnostic: TypeDiagnostic, gap: KnownGap): boolean =>
|
|
115
|
+
diagnostic.code === gap.code &&
|
|
116
|
+
diagnostic.file === gap.file &&
|
|
117
|
+
diagnostic.message === gap.message;
|
|
118
|
+
|
|
119
|
+
export interface GapPartition {
|
|
120
|
+
/** Diagnostics no unconsumed `KNOWN_GAPS` entry accounts for. */
|
|
121
|
+
readonly unexpected: readonly TypeDiagnostic[];
|
|
122
|
+
/** Entries that found no match — the bug is fixed and the pin has to go. */
|
|
123
|
+
readonly stale: readonly KnownGap[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* One pass, first-fit, each pin spent once. Counting matters: two occurrences of a diagnostic
|
|
128
|
+
* pinned once means a new regression is hiding behind an old bug, so the surplus is unexpected.
|
|
129
|
+
*/
|
|
130
|
+
export function partitionDiagnostics(
|
|
131
|
+
diagnostics: readonly TypeDiagnostic[],
|
|
132
|
+
gaps: readonly KnownGap[] = KNOWN_GAPS,
|
|
133
|
+
): GapPartition {
|
|
134
|
+
const budget = gaps.map((gap) => ({ gap, spent: false }));
|
|
135
|
+
const unexpected: TypeDiagnostic[] = [];
|
|
136
|
+
for (const entry of diagnostics) {
|
|
137
|
+
const slot = budget.find((pin) => !pin.spent && matches(entry, pin.gap));
|
|
138
|
+
if (slot === undefined) unexpected.push(entry);
|
|
139
|
+
else slot.spent = true;
|
|
140
|
+
}
|
|
141
|
+
return { unexpected, stale: budget.filter((pin) => !pin.spent).map((pin) => pin.gap) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Everything the gate refuses: a diagnostic no unconsumed `KNOWN_GAPS` entry accounts for. */
|
|
145
|
+
export const unexpectedIn = (
|
|
146
|
+
diagnostics: readonly TypeDiagnostic[],
|
|
147
|
+
gaps: readonly KnownGap[] = KNOWN_GAPS,
|
|
148
|
+
): readonly TypeDiagnostic[] => partitionDiagnostics(diagnostics, gaps).unexpected;
|
|
149
|
+
|
|
150
|
+
/** Entries that no longer reproduce — the bug is fixed and the pin has to go. */
|
|
151
|
+
export const staleGapsIn = (
|
|
152
|
+
diagnostics: readonly TypeDiagnostic[],
|
|
153
|
+
gaps: readonly KnownGap[] = KNOWN_GAPS,
|
|
154
|
+
): readonly KnownGap[] => partitionDiagnostics(diagnostics, gaps).stale;
|
|
155
|
+
|
|
156
|
+
/** Written beside the app's own tsconfig so the gate inherits every flag the app ships with. */
|
|
157
|
+
const OVERLAY = 'tsconfig.scaffold-check.json';
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The one thing the sandbox may change about the generated project: where its imports resolve.
|
|
161
|
+
* `@ultimat3/*` points at workspace source instead of a published tarball, and the app's own
|
|
162
|
+
* workspace packages resolve without a `bun install` that a sealed test could never run.
|
|
163
|
+
*/
|
|
164
|
+
const overlay = (root: string, app: string): string =>
|
|
165
|
+
`${JSON.stringify(
|
|
166
|
+
{
|
|
167
|
+
extends: './tsconfig.json',
|
|
168
|
+
compilerOptions: {
|
|
169
|
+
noEmit: true,
|
|
170
|
+
paths: {
|
|
171
|
+
'@ultimat3/*': [`${root}/packages/*/src`],
|
|
172
|
+
[`@${app}/web/*`]: ['./apps/web/*'],
|
|
173
|
+
[`@${app}/admin/*`]: ['./apps/admin/*'],
|
|
174
|
+
[`@${app}/*`]: ['./packages/*/src'],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
null,
|
|
179
|
+
2,
|
|
180
|
+
)}\n`;
|
|
181
|
+
|
|
182
|
+
export interface TypecheckOptions {
|
|
183
|
+
readonly files?: readonly GeneratedFile[];
|
|
184
|
+
readonly app?: string;
|
|
185
|
+
readonly runner?: Runner;
|
|
186
|
+
/** Leave the sandbox on disk. For debugging a red gate by hand, never for the gate itself. */
|
|
187
|
+
readonly keep?: boolean;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface TypecheckReport {
|
|
191
|
+
readonly dir: string;
|
|
192
|
+
readonly fileCount: number;
|
|
193
|
+
readonly diagnostics: readonly TypeDiagnostic[];
|
|
194
|
+
readonly output: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* `GeneratedFile.path` is documented as relative-POSIX, not enforced as it. A `..` segment would
|
|
199
|
+
* put template output on the developer's real disk, so the sandbox proves containment before it
|
|
200
|
+
* writes rather than after.
|
|
201
|
+
*/
|
|
202
|
+
export function sandboxPath(dir: string, path: string): string {
|
|
203
|
+
const target = resolve(dir, path);
|
|
204
|
+
if (target !== dir && !target.startsWith(`${dir}${sep}`))
|
|
205
|
+
throw new ScaffoldPathEscapeError({ path, dir });
|
|
206
|
+
return target;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function typecheckScaffold(options: TypecheckOptions = {}): Promise<TypecheckReport> {
|
|
210
|
+
const root = workspaceRoot();
|
|
211
|
+
const app = options.app ?? FIXTURE_APP;
|
|
212
|
+
const files = options.files ?? scaffoldFixture();
|
|
213
|
+
const dir = mkdtempSync(join(tmpdir(), 'x-scaffold-'));
|
|
214
|
+
try {
|
|
215
|
+
for (const file of files) await Bun.write(sandboxPath(dir, file.path), file.contents);
|
|
216
|
+
// The sandbox borrows the workspace's installed dependencies. The gate is about the
|
|
217
|
+
// templates; whether a registry install succeeds is a different question, and a sealed
|
|
218
|
+
// test cannot ask it.
|
|
219
|
+
symlinkSync(join(root, 'node_modules'), join(dir, 'node_modules'), 'dir');
|
|
220
|
+
await Bun.write(join(dir, OVERLAY), overlay(root, app));
|
|
221
|
+
const result = await (options.runner ?? exec)(
|
|
222
|
+
[join(root, 'node_modules', '.bin', 'tsc'), '--noEmit', '--pretty', 'false', '-p', OVERLAY],
|
|
223
|
+
{ cwd: dir },
|
|
224
|
+
);
|
|
225
|
+
const output = [result.stdout, result.stderr].filter((part) => part.length > 0).join('\n');
|
|
226
|
+
return { dir, fileCount: files.length, diagnostics: parseDiagnostics(output), output };
|
|
227
|
+
} finally {
|
|
228
|
+
if (options.keep !== true) rmSync(dir, { recursive: true, force: true });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** One diagnostic per line, in the shape `tsc` prints — a failed gate is a runnable bug report. */
|
|
233
|
+
export const formatDiagnostics = (diagnostics: readonly TypeDiagnostic[]): string =>
|
|
234
|
+
diagnostics
|
|
235
|
+
.map((entry) =>
|
|
236
|
+
entry.file === ''
|
|
237
|
+
? `error ${entry.code}: ${entry.message}`
|
|
238
|
+
: `${entry.file}:${entry.line} ${entry.code}: ${entry.message}`,
|
|
239
|
+
)
|
|
240
|
+
.join('\n');
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Where a repo's shipped source lives, in both shapes the gate runs against: a package monorepo
|
|
2
|
+
// and an app. One list for every step that walks source, because two steps scanning different sets
|
|
3
|
+
// means a finding one of them can never see.
|
|
4
|
+
|
|
5
|
+
export const SOURCE_GLOBS = [
|
|
6
|
+
'packages/*/src/**/*.{ts,tsx}',
|
|
7
|
+
'scripts/**/*.{ts,tsx}',
|
|
8
|
+
'site/**/*.{ts,tsx}',
|
|
9
|
+
'app/**/*.{ts,tsx}',
|
|
10
|
+
'api/**/*.{ts,tsx}',
|
|
11
|
+
'shared/**/*.{ts,tsx}',
|
|
12
|
+
'apps/*/{app,site,api,shared}/**/*.{ts,tsx}',
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A nested example app under `examples/` is not scanned — it runs this same gate from its own
|
|
17
|
+
* root. `dist/` is build output: the sources that produced it are already in the set.
|
|
18
|
+
*/
|
|
19
|
+
export const isVendored = (path: string): boolean =>
|
|
20
|
+
path.includes('node_modules') || path.includes('/dist/') || path.startsWith('dist/');
|
|
21
|
+
|
|
22
|
+
/** Emitted declarations, not authored source — a rule about authored code cannot apply to them. */
|
|
23
|
+
export const isGenerated = (path: string): boolean => path.endsWith('.d.ts');
|
|
24
|
+
|
|
25
|
+
/** Every opt-in suffix (`*.{contract,live,job,eval,e2e}.test.ts`) still ends `.test.ts`. */
|
|
26
|
+
export const isTest = (path: string): boolean => /\.test\.tsx?$/.test(path);
|
|
27
|
+
|
|
28
|
+
/** Every source file under `root`, repo-relative and deduplicated across the globs. */
|
|
29
|
+
export async function* eachSourceFile(root: string): AsyncGenerator<string> {
|
|
30
|
+
const seen = new Set<string>();
|
|
31
|
+
for (const pattern of SOURCE_GLOBS) {
|
|
32
|
+
for await (const path of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
|
|
33
|
+
if (isVendored(path) || seen.has(path)) continue;
|
|
34
|
+
seen.add(path);
|
|
35
|
+
yield path;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/table.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The one fixed-width table renderer the introspection commands share. Column widths come from
|
|
2
|
+
// the content, so output diffs cleanly between runs — and every `list` subcommand lines up the
|
|
3
|
+
// same way, which is the whole reason it is one function and not one per command.
|
|
4
|
+
|
|
5
|
+
/** Header row plus body rows, each padded to the widest cell in its column. */
|
|
6
|
+
export function renderTable(
|
|
7
|
+
header: readonly string[],
|
|
8
|
+
rows: readonly (readonly string[])[],
|
|
9
|
+
): readonly string[] {
|
|
10
|
+
const widths = header.map((title, index) =>
|
|
11
|
+
Math.max(title.length, ...rows.map((row) => (row[index] ?? '').length)),
|
|
12
|
+
);
|
|
13
|
+
const line = (cells: readonly string[]): string =>
|
|
14
|
+
cells
|
|
15
|
+
.map((value, index) => value.padEnd(widths[index] ?? 0))
|
|
16
|
+
.join(' ')
|
|
17
|
+
.trimEnd();
|
|
18
|
+
return [line(header), ...rows.map(line)];
|
|
19
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Pure fact-gathering behind `x tasks`: registered task descriptors plus their next
|
|
2
|
+
// occurrence(s), computed from `@ultimat3/time`'s cron math against an injected `nowMs` — never
|
|
3
|
+
// the wall clock — so a test drives every DST edge with no CLI parsing and no rendering involved.
|
|
4
|
+
|
|
5
|
+
import type { TaskDescriptor, TaskHandle } from '@ultimat3/jobs';
|
|
6
|
+
import { getTask, registeredTasks } from '@ultimat3/jobs';
|
|
7
|
+
import type { CronPhrases } from '@ultimat3/time';
|
|
8
|
+
import {
|
|
9
|
+
describeCron,
|
|
10
|
+
fromEpochMs,
|
|
11
|
+
nextCronOccurrenceMs,
|
|
12
|
+
offsetLabel,
|
|
13
|
+
toZoned,
|
|
14
|
+
} from '@ultimat3/time';
|
|
15
|
+
import { BadFlagError } from './errors';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_COUNT = 5;
|
|
18
|
+
const MAX_COUNT = 50;
|
|
19
|
+
|
|
20
|
+
const pad2 = (value: number): string => String(value).padStart(2, '0');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ISO-8601 rendered with the ZONE's OWN offset (`2026-03-08T03:00:00-04:00`), never collapsed to
|
|
24
|
+
* UTC `Z` — an ambient zone is exactly the bug a task's `tz` exists to prevent. No single
|
|
25
|
+
* "instant in a zone, as ISO" export exists in `@ultimat3/time` to call instead: `toIso` is
|
|
26
|
+
* UTC-`Z` only, `isoDateInZone` is date-only, and `formatWithOffset` renders locale prose, not
|
|
27
|
+
* ISO. So this composes the package's own instant→zoned-wall-clock conversion (`toZoned`) and
|
|
28
|
+
* zone-label helper (`offsetLabel`) rather than a fresh `Intl.DateTimeFormat` call here.
|
|
29
|
+
*/
|
|
30
|
+
function isoInZone(ms: number, zone: string): string {
|
|
31
|
+
const zoned = toZoned(fromEpochMs(ms), zone);
|
|
32
|
+
const date = `${String(zoned.year).padStart(4, '0')}-${pad2(zoned.month)}-${pad2(zoned.day)}`;
|
|
33
|
+
const time = `${pad2(zoned.hour)}:${pad2(zoned.minute)}:${pad2(zoned.second)}`;
|
|
34
|
+
return `${date}T${time}${offsetLabel(zoned.offsetMinutes)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `x tasks list` row: the descriptor plus the next occurrence, ms and rendered alike. */
|
|
38
|
+
export interface TaskFact extends TaskDescriptor {
|
|
39
|
+
readonly nextMs: number;
|
|
40
|
+
readonly next: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toFact(handle: TaskHandle, nowMs: number): TaskFact {
|
|
44
|
+
const descriptor = handle.describe();
|
|
45
|
+
const nextMs = nextCronOccurrenceMs(descriptor.cron, descriptor.tz, nowMs);
|
|
46
|
+
return { ...descriptor, nextMs, next: isoInZone(nextMs, descriptor.tz) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function listTaskFacts(nowMs: number): readonly TaskFact[] {
|
|
50
|
+
return registeredTasks().map((handle) => toFact(handle, nowMs));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function knownTaskNames(): readonly string[] {
|
|
54
|
+
return registeredTasks().map((handle) => handle.name);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function findTaskHandle(name: string): TaskHandle | undefined {
|
|
58
|
+
return getTask(name);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `--count` for `x tasks show`: how many upcoming occurrences to compute. Default 5, clamped to
|
|
63
|
+
* 50 — unbounded would let one `* * * * *` task turn a single command into an unbounded response.
|
|
64
|
+
* Anything that is not a positive integer is refused rather than coerced, same idiom as
|
|
65
|
+
* `parseLimitFlag` in `jobs-report.ts`: past `Number.MAX_SAFE_INTEGER` or with a fractional part,
|
|
66
|
+
* "the number typed" and "the number used" would silently differ.
|
|
67
|
+
*/
|
|
68
|
+
export function parseCountFlag(value: string | undefined): number {
|
|
69
|
+
if (value === undefined) return DEFAULT_COUNT;
|
|
70
|
+
const digits = value.trim();
|
|
71
|
+
const parsed = /^\d+$/.test(digits) ? Number(digits) : Number.NaN;
|
|
72
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
73
|
+
throw new BadFlagError({
|
|
74
|
+
flag: 'count',
|
|
75
|
+
command: 'tasks',
|
|
76
|
+
reason: `expects a positive integer, got "${value}"`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return Math.min(parsed, MAX_COUNT);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TaskOccurrence {
|
|
83
|
+
readonly ms: number;
|
|
84
|
+
readonly at: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `x tasks show <name>`: the descriptor, the human cron phrase, and the next `count` firings. */
|
|
88
|
+
export interface TaskShowFacts {
|
|
89
|
+
readonly descriptor: TaskDescriptor;
|
|
90
|
+
readonly describe: string;
|
|
91
|
+
readonly upcoming: readonly TaskOccurrence[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `phrases` is a parameter for the same reason `describeCron` demands one: this module owns cron
|
|
96
|
+
* math, not words, and a vocabulary hardcoded here would be a second catalog the CLI's own
|
|
97
|
+
* `messages.ts` could never translate. The caller supplies it — `cmd-tasks.ts` from `cli.cron.*`.
|
|
98
|
+
*/
|
|
99
|
+
export function taskShowFacts(
|
|
100
|
+
handle: TaskHandle,
|
|
101
|
+
nowMs: number,
|
|
102
|
+
count: number,
|
|
103
|
+
phrases: CronPhrases,
|
|
104
|
+
): TaskShowFacts {
|
|
105
|
+
const descriptor = handle.describe();
|
|
106
|
+
const upcoming: TaskOccurrence[] = [];
|
|
107
|
+
let cursor = nowMs;
|
|
108
|
+
for (let i = 0; i < count; i += 1) {
|
|
109
|
+
cursor = nextCronOccurrenceMs(descriptor.cron, descriptor.tz, cursor);
|
|
110
|
+
upcoming.push({ ms: cursor, at: isoInZone(cursor, descriptor.tz) });
|
|
111
|
+
}
|
|
112
|
+
return { descriptor, describe: describeCron(descriptor.cron, 'en-US', phrases), upcoming };
|
|
113
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// `x g action` / `x g mutator` — a server-authoritative command, its policy, and the test that
|
|
2
|
+
// pins both. One declaration projects to an HTTP route, an OpenAPI operation, a typed client, a
|
|
3
|
+
// job handle, an MCP tool and these tests; the generator writes the declaration and the tests.
|
|
4
|
+
|
|
5
|
+
import type { FeatureTarget } from './entity';
|
|
6
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
import { names } from './naming';
|
|
8
|
+
|
|
9
|
+
const actionSource = (
|
|
10
|
+
name: NameSet,
|
|
11
|
+
feature: NameSet,
|
|
12
|
+
): string => `// ${name.camel}: one mutation, server-authoritative. Input is validated before the handler runs
|
|
13
|
+
// and the policy is the same object the MCP tool and the HTTP route evaluate.
|
|
14
|
+
// \`t\` comes from @ultimat3/action, not @ultimat3/schema: an action file imports one package.
|
|
15
|
+
|
|
16
|
+
import { action, t } from '@ultimat3/action';
|
|
17
|
+
// One directory up: actions live in \`actions/\`, the feature's errors, policy and repo are the
|
|
18
|
+
// slice's own files and are shared by every action in it.
|
|
19
|
+
|
|
20
|
+
import { ${feature.pascal}NotFoundError } from '../errors';
|
|
21
|
+
import { can${feature.pascal}Write, ${feature.camel}Tag } from '../policy';
|
|
22
|
+
import * as repo from '../repo';
|
|
23
|
+
|
|
24
|
+
export const ${name.camel} = action({
|
|
25
|
+
// orgId is part of the input because the policy decides on it — authz reads the declaration,
|
|
26
|
+
// never the database.
|
|
27
|
+
input: t.object({ id: t.uuid, orgId: t.uuid }),
|
|
28
|
+
output: t.object({ id: t.uuid, title: t.string }),
|
|
29
|
+
policy: can${feature.pascal}Write,
|
|
30
|
+
cache: { invalidates: [${feature.camel}Tag] },
|
|
31
|
+
mcp: { expose: true, description: '${name.raw} — generated, edit the description' },
|
|
32
|
+
async handle({ input }) {
|
|
33
|
+
const row = await repo.byId(input.id);
|
|
34
|
+
if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id });
|
|
35
|
+
return { id: row.id, title: row.title };
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const mutatorSource = (
|
|
41
|
+
name: NameSet,
|
|
42
|
+
feature: NameSet,
|
|
43
|
+
): string => `// ${name.camel}: an action with an optimistic local twin. The local half runs against the client
|
|
44
|
+
// store immediately; the server half is authoritative and reconciles on conflict.
|
|
45
|
+
|
|
46
|
+
import { mutator, t } from '@ultimat3/action';
|
|
47
|
+
import { ${feature.pascal}NotFoundError } from '../errors';
|
|
48
|
+
import { can${feature.pascal}Write } from '../policy';
|
|
49
|
+
import * as repo from '../repo';
|
|
50
|
+
|
|
51
|
+
interface Local${feature.pascal} {
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly title: string;
|
|
54
|
+
readonly pending: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const ${name.camel} = mutator({
|
|
58
|
+
input: t.object({ id: t.uuid, orgId: t.uuid, title: t.string }),
|
|
59
|
+
output: t.object({ id: t.uuid, title: t.string }),
|
|
60
|
+
policy: can${feature.pascal}Write,
|
|
61
|
+
mcp: { expose: true, description: '${name.raw} — generated, edit the description' },
|
|
62
|
+
// tx.table(name) rather than tx.${feature.plural}: the typed accessor exists only once the app
|
|
63
|
+
// augments LocalTables, and generated code cannot assume that has happened yet. The name is the
|
|
64
|
+
// entity's snake_case table, so the local twin and the server row live under one key.
|
|
65
|
+
local(tx, input) {
|
|
66
|
+
tx.table<Local${feature.pascal}>('${feature.table}').update(input.id, {
|
|
67
|
+
title: input.title,
|
|
68
|
+
pending: true,
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
async server(_ctx, input) {
|
|
72
|
+
const row = await repo.byId(input.id);
|
|
73
|
+
if (row === undefined) throw new ${feature.pascal}NotFoundError({ id: input.id });
|
|
74
|
+
return { id: row.id, title: input.title };
|
|
75
|
+
},
|
|
76
|
+
conflict: 'server-wins',
|
|
77
|
+
});
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
const errorsSource = (
|
|
81
|
+
feature: NameSet,
|
|
82
|
+
): string => `// The ${feature.kebab} feature's X_* codes. Never throw a bare Error: an agent reading the failure
|
|
83
|
+
// needs the code, the cause and the exact command that fixes it.
|
|
84
|
+
|
|
85
|
+
import { UltimateError } from '@ultimat3/core';
|
|
86
|
+
|
|
87
|
+
export class ${feature.pascal}NotFoundError extends UltimateError {
|
|
88
|
+
constructor(input: { id: string }) {
|
|
89
|
+
super({
|
|
90
|
+
code: 'X_${feature.kebab.toUpperCase().split('-').join('_')}_NOT_FOUND',
|
|
91
|
+
cause: \`no ${feature.kebab} with id \${input.id}\`,
|
|
92
|
+
fix: 'x db studio to confirm the row exists, or pass an id from the list query',
|
|
93
|
+
docs: 'https://ultimate.dev/errors/X_NOT_FOUND',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
const ID = '00000000-0000-4000-8000-000000000001';
|
|
100
|
+
const ORG = '00000000-0000-4000-8000-000000000002';
|
|
101
|
+
const OTHER_ORG = '00000000-0000-4000-8000-000000000009';
|
|
102
|
+
|
|
103
|
+
/** The declaration-shape assertions that differ between the two primitives. */
|
|
104
|
+
const shapeTest = (name: NameSet, isMutator: boolean): string =>
|
|
105
|
+
isMutator
|
|
106
|
+
? `unitTest('${name.camel} projects both halves and a conflict strategy', () => {
|
|
107
|
+
// The projected names mirror the declaration: local() optimistic, server() authoritative.
|
|
108
|
+
// server() routes through the same invoke() core as every other surface, so it cannot skip
|
|
109
|
+
// the input parse, the policy or the output parse.
|
|
110
|
+
expect(target.describeMutator().kind).toBe('mutator');
|
|
111
|
+
expect(target.conflict).toBe('server-wins');
|
|
112
|
+
expect(typeof target.local).toBe('function');
|
|
113
|
+
expect(typeof target.server).toBe('function');
|
|
114
|
+
});`
|
|
115
|
+
: `unitTest('${name.camel} is a declared action', () => {
|
|
116
|
+
expect(target.kind).toBe('action');
|
|
117
|
+
expect(target.describe().name).toBe('${name.camel}');
|
|
118
|
+
});`;
|
|
119
|
+
|
|
120
|
+
const actionTest = (
|
|
121
|
+
name: NameSet,
|
|
122
|
+
feature: NameSet,
|
|
123
|
+
isMutator: boolean,
|
|
124
|
+
): string => `import { testActor } from '@ultimat3/policy';
|
|
125
|
+
import { contractTest, expect, unitTest } from '@ultimat3/testing';
|
|
126
|
+
import { ${name.camel} } from './${name.kebab}';
|
|
127
|
+
|
|
128
|
+
const id = '${ID}';
|
|
129
|
+
const orgId = '${ORG}';
|
|
130
|
+
const input = { id, orgId${isMutator ? ", title: 'a title'" : ''} };
|
|
131
|
+
|
|
132
|
+
// Named here because every projection needs a stable name and this file does not boot the app.
|
|
133
|
+
// At boot \`registerActions(await import('./actions'))\` stamps the same name onto the same
|
|
134
|
+
// object, so \`${name.camel}.tool()\` works there with nothing to remember.
|
|
135
|
+
const target = ${name.camel}.named('${name.camel}');
|
|
136
|
+
|
|
137
|
+
// Holds the grant, wrong org. That is the interesting actor: a denial here is the predicate
|
|
138
|
+
// deciding, not the permission check, so this test fails if the tenancy rule is ever dropped.
|
|
139
|
+
const outsider = testActor('outsider', {
|
|
140
|
+
orgId: '${OTHER_ORG}',
|
|
141
|
+
permissions: ['${feature.kebab}:write'],
|
|
142
|
+
}).actor;
|
|
143
|
+
|
|
144
|
+
${shapeTest(name, isMutator)}
|
|
145
|
+
|
|
146
|
+
unitTest('${name.camel} rejects input that is not a uuid', async () => {
|
|
147
|
+
await expect(target.input).toRejectInput({ ...input, id: 'not-a-uuid' });
|
|
148
|
+
await expect(target.input).toAcceptInput(input);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
contractTest('${name.camel} passes the contract every action owes', async () => {
|
|
152
|
+
// Three assertions the framework makes for any action, without knowing what this one does:
|
|
153
|
+
// garbage input is rejected, an anonymous actor is denied, and the operation reaches the
|
|
154
|
+
// OpenAPI document. \`.contract()\` is the projection; this loop just runs it.
|
|
155
|
+
for (const contract of target.contract()) await contract.run();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
contractTest('${name.camel} denies a foreign org before the handler runs', async () => {
|
|
159
|
+
// \`.as()\` is the one execution path with the actor swapped, so this denial is the same one
|
|
160
|
+
// HTTP, MCP and the job surface would produce — and no repo call happened to produce it.
|
|
161
|
+
const denied = await target.as(outsider, input).catch((error: unknown) => error);
|
|
162
|
+
expect(denied).toBeUltimateError('X_FORBIDDEN');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
contractTest('${name.camel} projects one MCP tool and one OpenAPI operation', () => {
|
|
166
|
+
// Same policy object on both surfaces — an MCP call cannot reach a different authz path.
|
|
167
|
+
expect(target.tool().policy).toBe(target.policy);
|
|
168
|
+
expect(target.tool().description).not.toBe('');
|
|
169
|
+
expect(target.openapi().operationId).toBe('${name.camel}');
|
|
170
|
+
});
|
|
171
|
+
`;
|
|
172
|
+
|
|
173
|
+
export interface ActionOptions extends FeatureTarget {
|
|
174
|
+
readonly mutator?: boolean;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function actionFiles(rawName: string, target: ActionOptions): readonly GeneratedFile[] {
|
|
178
|
+
const name = names(rawName);
|
|
179
|
+
const feature = names(target.feature);
|
|
180
|
+
const dir = `${target.surfaceDir}/${target.feature}/actions`;
|
|
181
|
+
const isMutator = target.mutator === true;
|
|
182
|
+
return [
|
|
183
|
+
{
|
|
184
|
+
path: `${dir}/${name.kebab}.ts`,
|
|
185
|
+
contents: isMutator ? mutatorSource(name, feature) : actionSource(name, feature),
|
|
186
|
+
},
|
|
187
|
+
{ path: `${dir}/${name.kebab}.test.ts`, contents: actionTest(name, feature, isMutator) },
|
|
188
|
+
{
|
|
189
|
+
path: `${target.surfaceDir}/${target.feature}/errors.ts`,
|
|
190
|
+
contents: errorsSource(feature),
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// `x g resource <name> --admin` — the per-entity half of the admin screen. `defineAdmin()` derives
|
|
2
|
+
// list/detail/create/edit from the entity itself; this file is only the override an app author
|
|
3
|
+
// would otherwise hand-write — title, list columns, page size. Wiring it in is one line in
|
|
4
|
+
// `apps/admin/src/index.ts` (`entities: [..., ${feature}]`, `resources: { ${feature}: ... }`),
|
|
5
|
+
// the same "define once, register once" shape as `packages/db/src/schema.ts`.
|
|
6
|
+
|
|
7
|
+
import type { FeatureTarget } from './entity';
|
|
8
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
9
|
+
import { names } from './naming';
|
|
10
|
+
|
|
11
|
+
const resourceSource = (
|
|
12
|
+
feature: NameSet,
|
|
13
|
+
): string => `// Admin override for ${feature.pluralKebab}. Everything not set here — fields, operations,
|
|
14
|
+
// detail layout — is derived from the entity. Wire this in once:
|
|
15
|
+
// import { ${feature.camel}AdminResource } from '${'@'}app/web/app/${feature.kebab}/admin/resource';
|
|
16
|
+
// defineAdmin({ entities: [..., ${feature.camel}], resources: { ${feature.table}: ${feature.camel}AdminResource } })
|
|
17
|
+
|
|
18
|
+
import type { AdminResourceOptions, AdminRow } from '@ultimat3/admin';
|
|
19
|
+
|
|
20
|
+
export const ${feature.camel}AdminResource: AdminResourceOptions<AdminRow> = {
|
|
21
|
+
titleKey: 'admin.${feature.kebab}.title',
|
|
22
|
+
listFields: ['id', 'title', 'createdAt'],
|
|
23
|
+
pageSize: 25,
|
|
24
|
+
};
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
const resourceTest = (
|
|
28
|
+
feature: NameSet,
|
|
29
|
+
): string => `import { expect, unitTest } from '@ultimat3/testing';
|
|
30
|
+
import { ${feature.camel}AdminResource } from './resource';
|
|
31
|
+
|
|
32
|
+
unitTest('${feature.camel}AdminResource sets a title key and bounded list fields', () => {
|
|
33
|
+
expect(${feature.camel}AdminResource.titleKey).toBe('admin.${feature.kebab}.title');
|
|
34
|
+
expect(${feature.camel}AdminResource.listFields?.length).toBeGreaterThan(0);
|
|
35
|
+
expect(${feature.camel}AdminResource.pageSize).toBeGreaterThan(0);
|
|
36
|
+
});
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
export function adminFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] {
|
|
40
|
+
const feature = names(rawName.length > 0 ? rawName : target.feature);
|
|
41
|
+
const dir = `${target.surfaceDir}/${target.feature}/admin`;
|
|
42
|
+
return [
|
|
43
|
+
{ path: `${dir}/resource.ts`, contents: resourceSource(feature) },
|
|
44
|
+
{ path: `${dir}/resource.test.ts`, contents: resourceTest(feature) },
|
|
45
|
+
];
|
|
46
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The one renderer for a generated `packages/i18n/catalogs/<locale>.json`. Every generator that
|
|
2
|
+
// contributes keys goes through it, so no template hand-writes catalog JSON and none of them can
|
|
3
|
+
// emit the flat dot-key form `@ultimat3/i18n` refuses.
|
|
4
|
+
|
|
5
|
+
import { nestCatalog } from '@ultimat3/i18n';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `{ 'site.home.title': 'Home' }` → the nested JSON a catalog file actually holds.
|
|
9
|
+
*
|
|
10
|
+
* Load-bearing, not cosmetic: `parseNestedCatalog` validates every key segment against
|
|
11
|
+
* `/^[A-Za-z0-9_-]+$/`, so a dot inside a key is `X_CATALOG_INVALID` and a catalog written flat
|
|
12
|
+
* fails `defineCatalogs` at boot — the app never starts. Templates author in the flat form because
|
|
13
|
+
* that is how a key reads at the `t('site.home.title')` call site; `nestCatalog` is the framework's
|
|
14
|
+
* own inverse of the flatten every read does, so the two forms cannot drift.
|
|
15
|
+
*/
|
|
16
|
+
export const catalogJson = (entries: Readonly<Record<string, string>>): string =>
|
|
17
|
+
`${JSON.stringify(nestCatalog(entries), null, 2)}\n`;
|