@ultimat3/cli 20.1.2 → 20.1.4
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/package.json +29 -29
- package/src/browser-launcher.ts +11 -4
- package/src/cdp-launch.ts +14 -5
- package/src/cmd-dev.ts +4 -0
- package/src/cmd-generate.ts +24 -1
- package/src/cmd-mcp.ts +2 -2
- package/src/cmd-shot.ts +8 -1
- package/src/cmd-test.ts +1 -0
- package/src/cmd-verify.ts +1 -0
- package/src/dev-environment.ts +42 -0
- package/src/error-codes.ts +5 -0
- package/src/exec.ts +33 -2
- package/src/generate-files.ts +24 -2
- package/src/island-capture.ts +1 -0
- package/src/island-harness-script.ts +54 -5
- package/src/island-verdict.ts +11 -0
- package/src/mcp-errors.ts +3 -0
- package/src/mcp-host.ts +9 -1
- package/src/mcp-ui.ts +136 -0
- package/src/output.ts +1 -1
- package/src/scaffold-fixture.ts +42 -0
- package/src/templates/guard-unzoned-date.ts +18 -1
- package/src/templates/index.ts +2 -1
- package/src/templates/job.ts +167 -7
- package/src/templates/scaffold-auth.ts +13 -4
- package/src/templates/slice-foundation.ts +5 -1
- package/src/test-dotenv.ts +125 -0
- package/src/test-shards.ts +21 -1
- package/src/verify-step.ts +7 -0
- package/src/verify-test-run.ts +8 -1
- package/src/verify-tests.ts +7 -1
package/src/mcp-ui.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The dev MCP server's two eyes: `ui.shot` (a route) and `ui.island` (a component's states), as
|
|
2
|
+
// the `DevCapabilities` half `packages/mcp` declares and cannot satisfy — a browser is the CLI's
|
|
3
|
+
// to launch. Both are `x shot` under another name: the same server lookup (a running `x dev` is
|
|
4
|
+
// reused through its lock, otherwise a scratch one boots), the same driver, the same verdict.
|
|
5
|
+
// Nothing here is a new capability; it is the existing one made reachable from inside the loop
|
|
6
|
+
// an agent already works in, so "does it look right" stops needing a hand-written script.
|
|
7
|
+
|
|
8
|
+
// why: Bun exposes no path-join primitive, and the picture's directory is a path an agent opens.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { UltimateError } from '@ultimat3/core';
|
|
11
|
+
import type { UiIslandInput, UiIslandResult, UiShotInput, UiShotResult } from '@ultimat3/mcp';
|
|
12
|
+
import { describeRoutes } from '@ultimat3/render';
|
|
13
|
+
import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
|
|
14
|
+
import { appBrowser } from './browser-launcher';
|
|
15
|
+
import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
|
|
16
|
+
import { islandShot } from './cmd-shot-island';
|
|
17
|
+
import type { Env } from './dev-services';
|
|
18
|
+
import { islandVerdictJson } from './island-verdict';
|
|
19
|
+
import { shotBrowserChoice } from './shot-browser';
|
|
20
|
+
import { devServerFor } from './shot-server';
|
|
21
|
+
import { verdictJson } from './shot-verdict';
|
|
22
|
+
|
|
23
|
+
/** Kernel-picked, as `x shot` picks it: a scratch server never fights another project for :3000. */
|
|
24
|
+
const SCRATCH_PORT = 0;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The route a picture is of has to be a route this app declares WITH a JS budget. The gate
|
|
28
|
+
* refuses an unmeasured route (`X_BUDGET_UNMEASURED`), and a picture of a route nobody has
|
|
29
|
+
* finished declaring is a picture of a draft — an agent judging it would judge the wrong thing.
|
|
30
|
+
* Matched on the declared pattern, so `/links/abc123` finds `/links/:slug`.
|
|
31
|
+
*/
|
|
32
|
+
export interface DeclaredRoute {
|
|
33
|
+
readonly path: string;
|
|
34
|
+
readonly file: string;
|
|
35
|
+
readonly budgetJs: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function assertBudgetedRoute(route: string, declared: readonly DeclaredRoute[]): void {
|
|
39
|
+
const path = route.split('?')[0] ?? route;
|
|
40
|
+
const hit = declared.find((entry) => matches(entry.path, path));
|
|
41
|
+
if (hit === undefined) {
|
|
42
|
+
throw new UltimateError({
|
|
43
|
+
code: 'X_UI_SHOT_ROUTE_UNKNOWN',
|
|
44
|
+
cause: `no route in this app answers ${route}`,
|
|
45
|
+
fix: 'x routes --json # then ui.shot with one of its `path` values',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (hit.budgetJs === null) {
|
|
49
|
+
throw new UltimateError({
|
|
50
|
+
code: 'X_UI_SHOT_ROUTE_UNBUDGETED',
|
|
51
|
+
cause: `${hit.file} declares no budget.js, so x verify would refuse it as X_BUDGET_UNMEASURED — a picture of it would be a picture of a draft`,
|
|
52
|
+
fix: `declare budget: { js: '<n>kb' } in ${hit.file}, then: x build --target static --json && x verify --only budgets --json`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `/links/:slug` matches `/links/abc123`; one segment per `:param`, no globbing. */
|
|
58
|
+
export function matches(pattern: string, path: string): boolean {
|
|
59
|
+
const want = pattern.split('/');
|
|
60
|
+
const have = path.split('/');
|
|
61
|
+
if (want.length !== have.length) return false;
|
|
62
|
+
return want.every((segment, index) => segment.startsWith(':') || segment === have[index]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface UiHostInput {
|
|
66
|
+
readonly root: string;
|
|
67
|
+
readonly env: Env;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function uiCapabilities(input: UiHostInput): {
|
|
71
|
+
shotRoute(shot: UiShotInput): Promise<UiShotResult>;
|
|
72
|
+
shotIsland(island: UiIslandInput): Promise<UiIslandResult>;
|
|
73
|
+
} {
|
|
74
|
+
const { root, env } = input;
|
|
75
|
+
const boot = () => devServerFor(root, env, SCRATCH_PORT);
|
|
76
|
+
// The same choice `x shot` makes from the environment: `PUPPETEER_EXECUTABLE_PATH`, a
|
|
77
|
+
// provider's CDP URL, or the launcher's own discovery.
|
|
78
|
+
const browser = () => shotBrowserChoice({ cdpFlag: undefined, browserFlag: undefined, env });
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
async shotRoute(shot) {
|
|
82
|
+
assertBudgetedRoute(shot.route, describeRoutes());
|
|
83
|
+
const { cdpUrl, executablePath } = browser();
|
|
84
|
+
const driver = await appBrowser({
|
|
85
|
+
root,
|
|
86
|
+
viewport: shot.viewport,
|
|
87
|
+
...(executablePath === undefined ? {} : { executablePath }),
|
|
88
|
+
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
89
|
+
});
|
|
90
|
+
// One directory per (route, viewport, scheme), so two pictures of one route at two widths
|
|
91
|
+
// never overwrite each other and an agent can hold both.
|
|
92
|
+
const outDir = join(
|
|
93
|
+
root,
|
|
94
|
+
SHOT_DIR,
|
|
95
|
+
shotSlug(shot.route),
|
|
96
|
+
`${shot.viewport.width}x${shot.viewport.height}-${shot.colorScheme}`,
|
|
97
|
+
);
|
|
98
|
+
const artifacts = await runShot({
|
|
99
|
+
route: shot.route,
|
|
100
|
+
outDir,
|
|
101
|
+
driver,
|
|
102
|
+
boot,
|
|
103
|
+
settleMs: DEFAULT_SETTLE_MS,
|
|
104
|
+
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
105
|
+
fullPage: shot.fullPage,
|
|
106
|
+
colorScheme: shot.colorScheme,
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
ok: artifacts.verdict.ok,
|
|
110
|
+
image: artifacts.image,
|
|
111
|
+
verdictFile: artifacts.verdictFile,
|
|
112
|
+
verdict: verdictJson(artifacts.verdict),
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async shotIsland(island) {
|
|
117
|
+
const { cdpUrl, executablePath } = browser();
|
|
118
|
+
const artifacts = await islandShot({
|
|
119
|
+
root,
|
|
120
|
+
island: island.island,
|
|
121
|
+
...(island.state === undefined ? {} : { state: island.state }),
|
|
122
|
+
settleMs: DEFAULT_SETTLE_MS,
|
|
123
|
+
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
124
|
+
...(executablePath === undefined ? {} : { executablePath }),
|
|
125
|
+
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
126
|
+
boot,
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
ok: artifacts.verdict.ok,
|
|
130
|
+
dir: artifacts.dir,
|
|
131
|
+
verdictFile: artifacts.verdictFile,
|
|
132
|
+
verdict: islandVerdictJson(artifacts.verdict),
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
package/src/output.ts
CHANGED
|
@@ -64,7 +64,7 @@ export interface CommandResult {
|
|
|
64
64
|
* Which fd this result is written to. `stdout` for every command, absent included — and
|
|
65
65
|
* `stderr` for the one case where fd 1 is not the command's to write on: `x mcp serve
|
|
66
66
|
* --transport stdio`, whose stdout carries JSON-RPC frames, and where the `✓ mcp stdio serving
|
|
67
|
-
*
|
|
67
|
+
* 15 tools` line printed after the loop exits is a malformed frame to whatever is reading.
|
|
68
68
|
*
|
|
69
69
|
* Behaviour, not a fact, exactly like `hold` above — so NEITHER renderer carries it. It says
|
|
70
70
|
* where a rendered line goes, and a payload that also claimed it would be a second answer to a
|
package/src/scaffold-fixture.ts
CHANGED
|
@@ -21,6 +21,31 @@ export const HANDWRITTEN_ERRORS = `import { UltimateError } from '@ultimat3/core
|
|
|
21
21
|
export class LedgerClosedError extends UltimateError {}
|
|
22
22
|
`;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* A feature slice whose entity is NOT tenant-scoped — the shape `x g entity`'s own comment
|
|
26
|
+
* describes for a single-tenant app, and never a shape the generator writes itself. The
|
|
27
|
+
* generator's INPUT only, the same way `HANDWRITTEN_ERRORS` above is: `x g job`/`x g task` read
|
|
28
|
+
* this from a real `entity.ts`, and a fixture that never exercised it compiled only the shape
|
|
29
|
+
* that never failed.
|
|
30
|
+
*/
|
|
31
|
+
export const HANDWRITTEN_ENTITY_NO_TENANT = `import { entity, text, uuid } from '@ultimat3/entity';
|
|
32
|
+
|
|
33
|
+
export const shortLink = entity('short_links', {
|
|
34
|
+
columns: { id: uuid().primaryKey(), url: text({ max: 2000 }) },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export type ShortLink = typeof shortLink.$row;
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
/** The paired `repo.ts`: no `byId`, no `listByOrg` — nothing this slice's job may call. */
|
|
41
|
+
export const HANDWRITTEN_REPO_NO_TENANT = `import { db, sql } from '@ultimat3/db';
|
|
42
|
+
import type { ShortLink } from './entity';
|
|
43
|
+
|
|
44
|
+
export async function list(limit = 50): Promise<readonly ShortLink[]> {
|
|
45
|
+
return db().query<ShortLink>(sql\`select * from short_links order by url limit \${limit}\`);
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
|
|
24
49
|
/**
|
|
25
50
|
* One realistic invocation of every generator, on top of `x new --example`. Names differ from
|
|
26
51
|
* their feature on purpose: `x g query invoice --feature invoice` would collide with the entity
|
|
@@ -47,6 +72,23 @@ export const FIXTURE_GENERATORS: readonly GenerateOptions[] = [
|
|
|
47
72
|
{ kind: 'job', name: 'sweep-invoices', feature: 'invoice' },
|
|
48
73
|
{ kind: 'backfill', name: 'reindex-invoices', feature: 'invoice' },
|
|
49
74
|
{ kind: 'task', name: 'nightly-sweep', feature: 'invoice' },
|
|
75
|
+
// The other shape both templates have: a feature whose entity names no tenant column, so the
|
|
76
|
+
// job/task must not assume one — compiled here beside the tenant-scoped pair above, exactly as
|
|
77
|
+
// `ping-invoice`/`touch-invoice` compile the action's other shape beside `send-invoice`.
|
|
78
|
+
{
|
|
79
|
+
kind: 'job',
|
|
80
|
+
name: 'purge-orphans',
|
|
81
|
+
feature: 'short-link',
|
|
82
|
+
sliceEntity: HANDWRITTEN_ENTITY_NO_TENANT,
|
|
83
|
+
sliceRepo: HANDWRITTEN_REPO_NO_TENANT,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
kind: 'task',
|
|
87
|
+
name: 'nightly-purge',
|
|
88
|
+
feature: 'short-link',
|
|
89
|
+
sliceEntity: HANDWRITTEN_ENTITY_NO_TENANT,
|
|
90
|
+
sliceRepo: HANDWRITTEN_REPO_NO_TENANT,
|
|
91
|
+
},
|
|
50
92
|
{ kind: 'route', name: 'pricing', surface: 'site' },
|
|
51
93
|
{ kind: 'route', name: 'billing', surface: 'app' },
|
|
52
94
|
// `--at`, pointed at the `site/` route above: an island's whole reason to exist is a 0kb page
|
|
@@ -74,10 +74,17 @@ export function unzonedDates(files: readonly SourceFile[]): readonly Finding[] {
|
|
|
74
74
|
const open = match.index + match[0].length - 1;
|
|
75
75
|
if (argumentsOf(text, open).includes('timeZone')) continue;
|
|
76
76
|
const line = lineOf(text, match.index);
|
|
77
|
+
// The bare \`.toLocaleString(\` is ALSO \`Number.prototype.toLocaleString\`, and a regex cannot
|
|
78
|
+
// tell a count from a date. The match stands — a date formatted this way is the defect this
|
|
79
|
+
// guard exists for — but the fix names the number exit too, because \`{ timeZone }\` is not
|
|
80
|
+
// one: a number ignores it, and an author following the fix verbatim would ship a lie.
|
|
81
|
+
const bare = match[0].trim() === '.toLocaleString(';
|
|
77
82
|
findings.push({
|
|
78
83
|
code: CODE,
|
|
79
84
|
cause: \`\${file.path}:\${line} calls \${match[0].trim()}) with no timeZone — it formats in whatever zone the process happens to run in, so one row reads as two different days across two containers\`,
|
|
80
|
-
fix:
|
|
85
|
+
fix: bare
|
|
86
|
+
? \`in \${file.path}: a Date → pass an explicit IANA zone, at.toLocaleString(locale, { timeZone: 'UTC' }); a NUMBER → new Intl.NumberFormat(locale).format(n) instead — then: x verify\`
|
|
87
|
+
: \`pass an explicit IANA zone in \${file.path} — toLocaleDateString(locale, { timeZone: 'UTC' }) — then: x verify\`,
|
|
81
88
|
at: file.path,
|
|
82
89
|
});
|
|
83
90
|
}
|
|
@@ -130,6 +137,16 @@ unitTest('Intl.DateTimeFormat and toLocaleTimeString are the same rule', () => {
|
|
|
130
137
|
expect(unzonedDates(file("at.toLocaleTimeString('en-US');"))).toHaveLength(1);
|
|
131
138
|
});
|
|
132
139
|
|
|
140
|
+
unitTest('the bare toLocaleString names the number exit, since a count matches it too', () => {
|
|
141
|
+
const findings = unzonedDates(file("const shown = count.toLocaleString('en-US');"));
|
|
142
|
+
expect(findings).toHaveLength(1);
|
|
143
|
+
expect(findings[0]?.fix).toContain('Intl.NumberFormat');
|
|
144
|
+
expect(findings[0]?.fix).toContain('timeZone');
|
|
145
|
+
// The dated forms are unambiguous and keep the zone-only fix.
|
|
146
|
+
const dated = unzonedDates(file("at.toLocaleDateString('en-US');"));
|
|
147
|
+
expect(dated[0]?.fix).not.toContain('Intl.NumberFormat');
|
|
148
|
+
});
|
|
149
|
+
|
|
133
150
|
unitTest('a commented-out call is a note, not a call', () => {
|
|
134
151
|
expect(unzonedDates(file("// at.toLocaleDateString('en-US');"))).toEqual([]);
|
|
135
152
|
});
|
package/src/templates/index.ts
CHANGED
|
@@ -12,7 +12,8 @@ export { entityFiles } from './entity';
|
|
|
12
12
|
export { guardCode, guardFiles } from './guard';
|
|
13
13
|
export type { IslandOptions } from './island';
|
|
14
14
|
export { islandFiles } from './island';
|
|
15
|
-
export {
|
|
15
|
+
export type { JobOptions } from './job';
|
|
16
|
+
export { isTenantScopedSlice, jobFiles, taskFiles } from './job';
|
|
16
17
|
export { CATALOG_ROOT, catalogPath, DEFAULT_LOCALES, resolveLocales } from './locales';
|
|
17
18
|
// All three members of the `GeneratedFile` union, not two: the barrel exported the union and the
|
|
18
19
|
// foundation variant only, so a consumer could hold a `GeneratedFile` and had no name to narrow it
|
package/src/templates/job.ts
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// both; the generated test pins them through a real driver, because a key that is not stable is a
|
|
4
4
|
// job that runs twice and a tenant that is not declared is a job that reads the wrong org's rows.
|
|
5
5
|
|
|
6
|
+
import { stripComments } from '../ts-scan';
|
|
6
7
|
import type { FeatureTarget } from './entity';
|
|
7
8
|
import type { GeneratedFile, NameSet } from './naming';
|
|
8
9
|
import { names } from './naming';
|
|
9
|
-
import { sliceFoundation } from './slice-foundation';
|
|
10
|
+
import { sliceExports, sliceFoundation } from './slice-foundation';
|
|
10
11
|
|
|
11
12
|
const jobSource = (
|
|
12
13
|
name: NameSet,
|
|
@@ -39,6 +40,42 @@ export const ${name.camel} = job({
|
|
|
39
40
|
});
|
|
40
41
|
`;
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The other shape: this feature's own `entity.ts` names no tenant column (or names `'none'`), or
|
|
45
|
+
* its `repo.ts` does not export the `byId`/`listByOrg` pair the tenant-scoped body above calls —
|
|
46
|
+
* checked by `isTenantScopedSlice` against what is actually on the app's disk, never assumed. The
|
|
47
|
+
* body imports neither `../entity` nor `../repo`, so it compiles whether this feature has an
|
|
48
|
+
* entity yet or has one with no tenant, and `tenant: 'none'` is stated rather than defaulted so a
|
|
49
|
+
* reviewer sees the decision instead of an absence.
|
|
50
|
+
*/
|
|
51
|
+
const neutralJobSource = (
|
|
52
|
+
name: NameSet,
|
|
53
|
+
): string => `// ${name.camel}: multi-step durable work with no tenant behind it. Each step is retried
|
|
54
|
+
// independently and its result is stored under its name — step names are stable identifiers, not
|
|
55
|
+
// labels. \`t\` comes from @ultimat3/jobs, not @ultimat3/schema: a job file imports one package.
|
|
56
|
+
|
|
57
|
+
import { job, t } from '@ultimat3/jobs';
|
|
58
|
+
|
|
59
|
+
export const ${name.camel} = job({
|
|
60
|
+
input: t.object({ id: t.uuid }),
|
|
61
|
+
// This feature has no tenant column to derive an org from — either it has no entity yet, or its
|
|
62
|
+
// entity names none. \`tenant: 'none'\` STRIPS the org from the run rather than leaving one
|
|
63
|
+
// behind, so a tenant-scoped read added later fails closed with X_TENANCY_ACTOR_ORG_REQUIRED
|
|
64
|
+
// instead of reading whichever org the enqueuer happened to hold. Once this feature's entity
|
|
65
|
+
// declares \`tenant: 'orgId'\` and its repo exports \`byId\`/\`listByOrg\`, the next \`x g job\` in
|
|
66
|
+
// this slice scaffolds the tenant-scoped shape above instead.
|
|
67
|
+
tenant: 'none',
|
|
68
|
+
idempotencyKey: ({ id }) => \`${name.kebab}:\${id}\`,
|
|
69
|
+
retry: { attempts: 5, backoff: 'exponential' },
|
|
70
|
+
async run({ step }) {
|
|
71
|
+
await step.run('process', async () => {
|
|
72
|
+
// TODO: this job's own work.
|
|
73
|
+
});
|
|
74
|
+
return { processed: true };
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
`;
|
|
78
|
+
|
|
42
79
|
const taskSource = (
|
|
43
80
|
name: NameSet,
|
|
44
81
|
jobName: NameSet,
|
|
@@ -65,6 +102,25 @@ export const ${name.camel} = task({
|
|
|
65
102
|
});
|
|
66
103
|
`;
|
|
67
104
|
|
|
105
|
+
/** The task's other shape: enqueues the neutral job above, so its payload carries no `orgId`. */
|
|
106
|
+
const neutralTaskSource = (
|
|
107
|
+
name: NameSet,
|
|
108
|
+
jobName: NameSet,
|
|
109
|
+
): string => `// ${name.camel}: a scheduled trigger. Tasks only enqueue jobs — the work itself is durable and
|
|
110
|
+
// retryable, and the schedule carries an explicit IANA time zone.
|
|
111
|
+
|
|
112
|
+
import { task } from '@ultimat3/jobs';
|
|
113
|
+
import { ${jobName.camel} } from '../jobs/${jobName.kebab}';
|
|
114
|
+
|
|
115
|
+
export const ${name.camel} = task({
|
|
116
|
+
cron: '0 3 * * *',
|
|
117
|
+
tz: 'UTC',
|
|
118
|
+
// No org in the payload: the job this enqueues declares \`tenant: 'none'\`, because this
|
|
119
|
+
// feature's entity names no tenant column (or has none yet).
|
|
120
|
+
enqueue: () => [[${jobName.camel}, { id: '00000000-0000-4000-8000-000000000001' }]],
|
|
121
|
+
});
|
|
122
|
+
`;
|
|
123
|
+
|
|
68
124
|
const jobTest = (
|
|
69
125
|
name: NameSet,
|
|
70
126
|
): string => `// ${name.camel} against a real driver: enqueue, drain, assert. Retries and the dead-letter path
|
|
@@ -121,6 +177,63 @@ jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
|
|
|
121
177
|
});
|
|
122
178
|
`;
|
|
123
179
|
|
|
180
|
+
/** The job test's other shape: no `orgId` anywhere, and a `tenantFor` that reads `undefined`. */
|
|
181
|
+
const neutralJobTest = (
|
|
182
|
+
name: NameSet,
|
|
183
|
+
): string => `// ${name.camel} against a real driver: enqueue, drain, assert. Retries and the dead-letter path
|
|
184
|
+
// are the framework's, so what this pins is that THIS job's steps run and are idempotent.
|
|
185
|
+
import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
|
|
186
|
+
import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
|
|
187
|
+
import { ${name.camel} } from './${name.kebab}';
|
|
188
|
+
|
|
189
|
+
const id = '00000000-0000-4000-8000-000000000001';
|
|
190
|
+
const input = { id };
|
|
191
|
+
// The key this job owes, spelled once. Named rather than inlined so the assertion below carries
|
|
192
|
+
// the job's own name and still fits the formatter width the app's \`lint\` step enforces.
|
|
193
|
+
const expectedKey = \`${name.kebab}:\${id}\`;
|
|
194
|
+
|
|
195
|
+
// The driver is process-global, so it is installed and released around this file rather than
|
|
196
|
+
// left behind for whichever test happens to run next.
|
|
197
|
+
beforeAll(() => {
|
|
198
|
+
setJobDriver(createMemoryDriver());
|
|
199
|
+
});
|
|
200
|
+
afterAll(resetJobDriver);
|
|
201
|
+
|
|
202
|
+
jobTest('${name.camel} declares a key and a retry policy', () => {
|
|
203
|
+
expect(${name.camel}.kind).toBe('job');
|
|
204
|
+
expect(${name.camel}.idempotencyKeyFor(input)).toBe(expectedKey);
|
|
205
|
+
expect(${name.camel}.retry.attempts).toBeGreaterThan(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
jobTest('${name.camel} derives the same key for the same input', () => {
|
|
209
|
+
const key = ${name.camel}.idempotencyKeyFor(input);
|
|
210
|
+
expect(${name.camel}.idempotencyKeyFor(input)).toBe(key);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
jobTest('${name.camel} declares no tenant', () => {
|
|
214
|
+
// This feature's entity names no tenant column (or has none yet) — \`tenant: 'none'\` is the
|
|
215
|
+
// declaration for that, and it strips one rather than inheriting the worker's, which is what
|
|
216
|
+
// stands between a read added here later and X_TENANCY_ACTOR_ORG_REQUIRED, or worse, another
|
|
217
|
+
// org's rows if this ever gains one.
|
|
218
|
+
expect(${name.camel}.tenantFor(input)).toBeUndefined();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
jobTest('${name.camel} projects itself into the manifest', () => {
|
|
222
|
+
const described = ${name.camel}.describe();
|
|
223
|
+
expect(described.queue).toBe('default');
|
|
224
|
+
expect(described.retry.attempts).toBe(5);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
|
|
228
|
+
// The whole point of the key: an at-least-once caller may enqueue twice and the work still
|
|
229
|
+
// happens once. \`.enqueue()\` is the one queue path — a job is never run inline.
|
|
230
|
+
const first = await ${name.camel}.enqueue(input);
|
|
231
|
+
expect(first.deduped).toBe(false);
|
|
232
|
+
const again = await ${name.camel}.enqueue(input);
|
|
233
|
+
expect(again.deduped).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
`;
|
|
236
|
+
|
|
124
237
|
const taskTest = (
|
|
125
238
|
name: NameSet,
|
|
126
239
|
jobName: NameSet,
|
|
@@ -163,28 +276,75 @@ jobTest('${name.camel} fires its declared entries', async () => {
|
|
|
163
276
|
});
|
|
164
277
|
`;
|
|
165
278
|
|
|
166
|
-
export
|
|
279
|
+
export interface JobOptions extends FeatureTarget {
|
|
280
|
+
/**
|
|
281
|
+
* This feature's own `entity.ts` as it stands on disk, or absent when the feature has none yet.
|
|
282
|
+
* Supplied by `run` for the same reason `ActionOptions.sliceErrors` is: whether the slice is
|
|
283
|
+
* tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
|
|
284
|
+
* `repo.byId`/`repo.listByOrg` calls into a feature whose repo never declared them —
|
|
285
|
+
* `x g task purgeOrphans --feature links` on a `links` slice with no `orgId` produced files that
|
|
286
|
+
* did not compile.
|
|
287
|
+
*/
|
|
288
|
+
readonly sliceEntity?: string;
|
|
289
|
+
/** This feature's own `repo.ts` as it stands on disk, or absent alongside `sliceEntity`. */
|
|
290
|
+
readonly sliceRepo?: string;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Whether `x g job`/`x g task` may assume the tenant-scoped shape: an `entity.ts` this feature
|
|
295
|
+
* does not have yet is about to be scaffolded fresh by `sliceFoundation` below, tenant-scoped by
|
|
296
|
+
* default — so absent counts as scoped. One that exists is trusted over that default: it declares
|
|
297
|
+
* a real, non-`'none'` `tenant`, AND its `repo.ts` actually exports the `byId`/`listByOrg` pair the
|
|
298
|
+
* tenant-scoped body calls. Both have to hold — an entity that still names `tenant: 'orgId'` after
|
|
299
|
+
* an author trimmed `listByOrg` out of `repo.ts` (or never generated one) is not a slice this job
|
|
300
|
+
* can read through either.
|
|
301
|
+
*/
|
|
302
|
+
export function isTenantScopedSlice(
|
|
303
|
+
sliceEntity: string | undefined,
|
|
304
|
+
sliceRepo: string | undefined,
|
|
305
|
+
): boolean {
|
|
306
|
+
if (sliceEntity === undefined) return true;
|
|
307
|
+
const declaresTenant = /\btenant\s*:\s*'(?!none')[^']+'/.test(stripComments(sliceEntity));
|
|
308
|
+
if (!declaresTenant) return false;
|
|
309
|
+
if (sliceRepo === undefined) return true;
|
|
310
|
+
return sliceExports(sliceRepo, 'byId') && sliceExports(sliceRepo, 'listByOrg');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function jobFiles(rawName: string, target: JobOptions): readonly GeneratedFile[] {
|
|
167
314
|
const name = names(rawName);
|
|
168
315
|
const dir = `${target.surfaceDir}/${target.feature}/jobs`;
|
|
316
|
+
const scoped = isTenantScopedSlice(target.sliceEntity, target.sliceRepo);
|
|
169
317
|
return [
|
|
170
318
|
// The job's steps read through `../repo`, which carries `../entity` for its row type. No
|
|
171
319
|
// policy: a job has no request behind it and evaluates none, so a generated one would be a
|
|
172
320
|
// file nobody asked for. `x g task` inherits this by composing `jobFiles` below.
|
|
173
|
-
|
|
174
|
-
|
|
321
|
+
// Only for the tenant-scoped shape: the neutral job below reads neither module, and a slice
|
|
322
|
+
// this feature does not own the tenancy of is not this generator's to scaffold an entity into.
|
|
323
|
+
...(scoped ? sliceFoundation(target, ['entity']) : []),
|
|
324
|
+
{
|
|
325
|
+
path: `${dir}/${name.kebab}.ts`,
|
|
326
|
+
contents: scoped ? jobSource(name) : neutralJobSource(name),
|
|
327
|
+
},
|
|
175
328
|
// `.job.test.ts`, because the gate types a test by its FILENAME: a `jobTest` in a plain
|
|
176
329
|
// `<name>.test.ts` runs under `unit`, and `x test job` answers X_TEST_NO_FILES in an app that
|
|
177
330
|
// is full of them. Same lesson `x g route` already carries for `page.e2e.test.ts`.
|
|
178
|
-
{
|
|
331
|
+
{
|
|
332
|
+
path: `${dir}/${name.kebab}.job.test.ts`,
|
|
333
|
+
contents: scoped ? jobTest(name) : neutralJobTest(name),
|
|
334
|
+
},
|
|
179
335
|
];
|
|
180
336
|
}
|
|
181
337
|
|
|
182
|
-
export function taskFiles(rawName: string, target:
|
|
338
|
+
export function taskFiles(rawName: string, target: JobOptions): readonly GeneratedFile[] {
|
|
183
339
|
const name = names(rawName);
|
|
184
340
|
const jobName = names(`${rawName}-job`);
|
|
185
341
|
const dir = `${target.surfaceDir}/${target.feature}/tasks`;
|
|
342
|
+
const scoped = isTenantScopedSlice(target.sliceEntity, target.sliceRepo);
|
|
186
343
|
return [
|
|
187
|
-
{
|
|
344
|
+
{
|
|
345
|
+
path: `${dir}/${name.kebab}.ts`,
|
|
346
|
+
contents: scoped ? taskSource(name, jobName) : neutralTaskSource(name, jobName),
|
|
347
|
+
},
|
|
188
348
|
// A task's test is a `jobTest` too — it drives a queue — so it takes the same suffix.
|
|
189
349
|
{ path: `${dir}/${name.kebab}.job.test.ts`, contents: taskTest(name, jobName) },
|
|
190
350
|
...jobFiles(`${rawName}-job`, target),
|
|
@@ -23,9 +23,13 @@ const devActor = (
|
|
|
23
23
|
// \`X_CONFIG_INVALID\` — which is what a scaffolded app did on its very first \`x dev\`.
|
|
24
24
|
//
|
|
25
25
|
// DEVELOPMENT ONLY, and the guard is the point: a viewer that followed this to staging would sign
|
|
26
|
-
// every visitor in as an admin.
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// every visitor in as an admin. FAILS CLOSED (\`fallback: 'production'\`) rather than trusting
|
|
27
|
+
// \`tryResolveEnvironment\`'s own default: that default is \`development\`, so a process that named
|
|
28
|
+
// NEITHER \`ULTIMATE_ENV\` nor \`NODE_ENV\` would otherwise read as development too — indistinguishable
|
|
29
|
+
// from the one this file exists to allow. \`x dev\` declares \`ULTIMATE_ENV=development\` for exactly
|
|
30
|
+
// this reason (whenever neither key is already set), so a bare \`x dev\` still installs this viewer;
|
|
31
|
+
// \`bun test\` sets \`NODE_ENV=test\`, so it does not install there either — a fixture mints its own
|
|
32
|
+
// actor, and a second one arriving from a cookie would decide which actor a test is about.
|
|
29
33
|
//
|
|
30
34
|
// REPLACE IT with the real thing: resolve a session cookie to a row, and return that actor.
|
|
31
35
|
// Everything downstream — pages, policies, live subscribers, MCP tools — reads what this returns.
|
|
@@ -78,7 +82,7 @@ export const devActorFor = (role: DevRole): Actor => ({
|
|
|
78
82
|
export function installDevAuthenticator(
|
|
79
83
|
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
80
84
|
): boolean {
|
|
81
|
-
if (tryResolveEnvironment({ env }) !== 'development') return false;
|
|
85
|
+
if (tryResolveEnvironment({ env, fallback: 'production' }) !== 'development') return false;
|
|
82
86
|
configureAuthenticator((request) => devActorFor(devRoleFrom(request.header('cookie'))));
|
|
83
87
|
logger.warn('every request is answered as a development viewer', {
|
|
84
88
|
role: DEFAULT_DEV_ROLE,
|
|
@@ -133,6 +137,11 @@ unitTest('it installs in development and in no other environment', () => {
|
|
|
133
137
|
|
|
134
138
|
expect(installDevAuthenticator({ ULTIMATE_ENV: 'production' })).toBe(false);
|
|
135
139
|
expect(installDevAuthenticator({ ULTIMATE_ENV: 'staging' })).toBe(false);
|
|
140
|
+
// FAILS CLOSED: a process naming NEITHER key is production here, never the default-development
|
|
141
|
+
// a bare \`tryResolveEnvironment({ env })\` would answer. \`x dev\` is what makes a real \`x dev\`
|
|
142
|
+
// still install this viewer — it declares \`ULTIMATE_ENV=development\` before this module loads,
|
|
143
|
+
// for exactly the process this call simulates having none of.
|
|
144
|
+
expect(installDevAuthenticator({})).toBe(false);
|
|
136
145
|
expect(configuredAuthenticator()).toBeUndefined();
|
|
137
146
|
|
|
138
147
|
expect(installDevAuthenticator({ ULTIMATE_ENV: 'development' })).toBe(true);
|
|
@@ -115,8 +115,12 @@ const listedExports = (code: string): readonly string[] =>
|
|
|
115
115
|
*/
|
|
116
116
|
export function sliceExports(source: string, name: string): boolean {
|
|
117
117
|
const code = stripComments(source);
|
|
118
|
+
// `(?:async\s+)?` before `function`: an `export async function byId` — every repo function
|
|
119
|
+
// `x g entity` scaffolds — matched neither this nor `listedExports`, so a caller checking for
|
|
120
|
+
// `byId`/`listByOrg` on a real repo.ts always read `false`. `async` has no meaning before
|
|
121
|
+
// `class`/`const`/`let`/`var`/`enum`, so it is scoped to `function` only.
|
|
118
122
|
const declared = new RegExp(
|
|
119
|
-
`\\bexport\\s+(?:abstract\\s+)?(?:class|const|let|var|function|enum)\\s+${name}\\b`,
|
|
123
|
+
`\\bexport\\s+(?:abstract\\s+)?(?:class|const|let|var|(?:async\\s+)?function|enum)\\s+${name}\\b`,
|
|
120
124
|
);
|
|
121
125
|
return declared.test(code) || listedExports(code).includes(name);
|
|
122
126
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Single responsibility: which env keys reached THIS `x` process only because Bun auto-loaded
|
|
2
|
+
// `.env.development` / `.env.development.local` at startup — so a `bun test` child this process
|
|
3
|
+
// spawns (`test-shards.ts`, `verify-tests.ts`, `verify-test-run.ts`, `mcp-host.ts`'s `runTests`)
|
|
4
|
+
// does not inherit a key a bare `bun test` would never have seen.
|
|
5
|
+
//
|
|
6
|
+
// PROBED ON BUN 1.4.2, and the probe decided the rule: an ambient env var SHADOWS a dotenv file's
|
|
7
|
+
// own value for the same key (`FOO=dev bun test` with a fixture `.env.test` declaring `FOO=test`
|
|
8
|
+
// still read `FOO=dev` inside the test). So a leaked key is never made safe by a legitimate test
|
|
9
|
+
// file (`.env`, `.env.test`, …) also declaring it — if we left it in place, the leaked value would
|
|
10
|
+
// go on shadowing that file's own value exactly as it shadows `.env.test` above. The only question
|
|
11
|
+
// worth asking is the one `devOnlyLeakedKeys` answers: does this process's CURRENT value match
|
|
12
|
+
// what `.env.development`/`.env.development.local` would have set? If yes, delete it and let the
|
|
13
|
+
// child's own dotenv load (or absence of one) answer instead. If the current value differs, this
|
|
14
|
+
// process's env holds something dotenv did not put there — a real export, CI, `.env.local` — and
|
|
15
|
+
// deleting it is not this function's call to make.
|
|
16
|
+
//
|
|
17
|
+
// KNOWN LIMITATION, stated rather than hidden: a real ambient value that happens to COINCIDE with
|
|
18
|
+
// the dev file's value is indistinguishable from a leak from here — there is no pre-dotenv
|
|
19
|
+
// snapshot to compare against. This function resolves that ambiguity toward closing the leak.
|
|
20
|
+
//
|
|
21
|
+
// A SECOND PARSER, not a call to `env-example.ts`'s `parseEnvKeys`: that function is deliberately
|
|
22
|
+
// keys-only ("half of them are placeholders" — it feeds `.env.example` rendering, where a secret's
|
|
23
|
+
// value must never appear), and widening it to return values would change what a template
|
|
24
|
+
// generator ships. This one exists to COMPARE values, a different job with a different file.
|
|
25
|
+
|
|
26
|
+
import { readFileSync } from 'node:fs'; // why: Bun ships no synchronous file read with a graceful-missing return.
|
|
27
|
+
// why: Bun exposes no path-join primitive.
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
|
|
30
|
+
/** Bun's own grammar, matched against `env-example.ts`'s `ENV_KEY_RE` (kept separate: see header). */
|
|
31
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One dotenv file's key/value pairs. A `Map`, not an object literal: a key of `constructor` or
|
|
35
|
+
* `__proto__` passes `ENV_KEY_RE` and reads back as a function on `Object.prototype`, the exact
|
|
36
|
+
* defect this repo's `verify-tests.ts` header names thirteen instances of.
|
|
37
|
+
*
|
|
38
|
+
* Quoting: a value opening with `"` or `'` runs to its closing quote (or end of value if the
|
|
39
|
+
* dotenv file never closes it), `#` included — Bun does not stop a quoted value at an internal
|
|
40
|
+
* `#`. An unquoted value stops at the first `#`, which is where an inline comment starts.
|
|
41
|
+
*/
|
|
42
|
+
export function parseDotenvValues(text: string): ReadonlyMap<string, string> {
|
|
43
|
+
const values = new Map<string, string>();
|
|
44
|
+
for (const raw of text.split('\n')) {
|
|
45
|
+
const line = raw.trim().replace(/^export\s+/, '');
|
|
46
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
47
|
+
const separator = line.indexOf('=');
|
|
48
|
+
if (separator <= 0) continue;
|
|
49
|
+
const key = line.slice(0, separator).trim();
|
|
50
|
+
if (!ENV_KEY_RE.test(key)) continue;
|
|
51
|
+
const rest = line.slice(separator + 1).trim();
|
|
52
|
+
const quote = rest.startsWith('"') || rest.startsWith("'") ? rest[0] : undefined;
|
|
53
|
+
if (quote === undefined) {
|
|
54
|
+
const hash = rest.indexOf('#');
|
|
55
|
+
values.set(key, (hash >= 0 ? rest.slice(0, hash) : rest).trim());
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const closing = rest.indexOf(quote, 1);
|
|
59
|
+
values.set(key, closing >= 0 ? rest.slice(1, closing) : rest.slice(1));
|
|
60
|
+
}
|
|
61
|
+
return values;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DevOnlyLeakInput {
|
|
65
|
+
/** `.env.development`'s text, or `''` when the file does not exist. */
|
|
66
|
+
readonly devText: string;
|
|
67
|
+
/** `.env.development.local`'s text, or `''` — wins over `devText` for a shared key, Bun's own precedence. */
|
|
68
|
+
readonly devLocalText: string;
|
|
69
|
+
/** This process's environment, as `exec.ts` would spawn a child with it (before any override). */
|
|
70
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The pure decision, no filesystem: every key `.env.development`/`.env.development.local` would
|
|
75
|
+
* set, where `env`'s CURRENT value for that key is exactly what the file would have set. See this
|
|
76
|
+
* file's header for why a legitimate test file declaring the same key does not exempt it.
|
|
77
|
+
*/
|
|
78
|
+
export function devOnlyLeakedKeys(input: DevOnlyLeakInput): readonly string[] {
|
|
79
|
+
const merged = new Map(parseDotenvValues(input.devText));
|
|
80
|
+
for (const [key, value] of parseDotenvValues(input.devLocalText)) merged.set(key, value);
|
|
81
|
+
const leaked: string[] = [];
|
|
82
|
+
for (const [key, devValue] of merged) {
|
|
83
|
+
// `Object.hasOwn` FIRST: `key` is data (a name out of a dotenv file), and `env['constructor']`
|
|
84
|
+
// on a plain object answers `Object.prototype`'s member, not `undefined` — the rule
|
|
85
|
+
// `scripts/proto-index.ts` ratchets, with its own sanctioned repair.
|
|
86
|
+
if (Object.hasOwn(input.env, key) && input.env[key] === devValue) leaked.push(key);
|
|
87
|
+
}
|
|
88
|
+
return leaked;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A missing dotenv file is the common case (no `.env.development.local` in most checkouts). */
|
|
92
|
+
function readIfPresent(path: string): string {
|
|
93
|
+
try {
|
|
94
|
+
return readFileSync(path, 'utf8');
|
|
95
|
+
} catch {
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The env override a `bun test` child spawned FROM `root` should get: every dev-only leaked key
|
|
102
|
+
* deleted. `exec.ts`'s `ExecOptions.env` reads an `undefined` value as "unset for this child,
|
|
103
|
+
* even though the parent has it" — see that file for why the merge could not otherwise express
|
|
104
|
+
* a deletion.
|
|
105
|
+
*
|
|
106
|
+
* `root` is the app/repo root the caller already resolved — NOT necessarily `process.cwd()`, which
|
|
107
|
+
* is what Bun actually auto-loaded `.env.development` relative to at this process's own startup.
|
|
108
|
+
* The two agree for every command that boots from the repo/app root, which is every one of them
|
|
109
|
+
* today; a future command invoked from a subdirectory would fail SAFE here (the file this reads
|
|
110
|
+
* would differ from the one Bun loaded, values would not match, and nothing gets stripped) rather
|
|
111
|
+
* than stripping the wrong key.
|
|
112
|
+
*/
|
|
113
|
+
export function testEnvOverrides(
|
|
114
|
+
root: string,
|
|
115
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
116
|
+
): Readonly<Record<string, string | undefined>> {
|
|
117
|
+
const leaked = devOnlyLeakedKeys({
|
|
118
|
+
devText: readIfPresent(join(root, '.env.development')),
|
|
119
|
+
devLocalText: readIfPresent(join(root, '.env.development.local')),
|
|
120
|
+
env,
|
|
121
|
+
});
|
|
122
|
+
const overrides: Record<string, string | undefined> = {};
|
|
123
|
+
for (const key of leaked) overrides[key] = undefined;
|
|
124
|
+
return overrides;
|
|
125
|
+
}
|