@ultimat3/cli 6.0.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +65 -5
- package/README.md +8 -3
- package/package.json +25 -24
- package/src/affected.ts +320 -0
- package/src/app-boundaries.ts +55 -5
- package/src/bin.ts +6 -3
- package/src/browser-launcher.ts +109 -0
- package/src/ci-log.ts +0 -0
- package/src/ci-runs.ts +179 -0
- package/src/cmd-affected.ts +109 -0
- package/src/cmd-build.ts +29 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-db-backfill.ts +240 -0
- package/src/cmd-db-branch.ts +3 -2
- package/src/cmd-db.ts +35 -156
- package/src/cmd-deploy.ts +37 -3
- package/src/cmd-dev.ts +7 -1
- package/src/cmd-errors.ts +2 -3
- package/src/cmd-fix.ts +3 -3
- package/src/cmd-i18n.ts +67 -5
- package/src/cmd-jobs.ts +27 -4
- package/src/cmd-mcp.ts +18 -9
- package/src/cmd-new.ts +91 -4
- package/src/cmd-policy.ts +3 -2
- package/src/cmd-pr.ts +359 -0
- package/src/cmd-registries.ts +3 -2
- package/src/cmd-shot.ts +382 -0
- package/src/cmd-tasks.ts +9 -4
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +47 -6
- package/src/dev-cache.ts +1 -1
- package/src/dev-lock.ts +124 -12
- package/src/dev-queue.ts +12 -7
- package/src/dev-replicator.ts +3 -7
- package/src/dev-roles-fixture.ts +1 -1
- package/src/dev-roles.ts +40 -8
- package/src/dev-runtime.ts +96 -4
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- package/src/drift.ts +52 -7
- package/src/error-codes.ts +21 -0
- package/src/framework-scope.ts +57 -5
- package/src/generate-kinds.ts +19 -1
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-registration.ts +67 -4
- package/src/index.ts +38 -1
- package/src/island-bundle.ts +62 -3
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/jobs-report.ts +10 -13
- package/src/mcp-errors.ts +12 -0
- package/src/messages.ts +76 -0
- package/src/output.ts +22 -2
- package/src/parse.ts +81 -37
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/realtime-browser-probe-fixture.ts +9 -0
- package/src/registry.ts +8 -0
- package/src/runtime-overrides.ts +11 -3
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +360 -0
- package/src/static-report.ts +219 -0
- package/src/sync-authenticator.ts +86 -14
- package/src/templates/guard-bare-error.ts +122 -0
- package/src/templates/guard-raw-colour.ts +138 -0
- package/src/templates/guard-untranslated-string.ts +138 -0
- package/src/templates/guard-unzoned-date.ts +142 -0
- package/src/templates/index.ts +4 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +130 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +20 -41
- package/src/templates/route.ts +15 -2
- package/src/templates/scaffold-app.ts +13 -78
- package/src/templates/scaffold-container.ts +30 -4
- package/src/templates/scaffold-db-package.ts +46 -7
- package/src/templates/scaffold-docs.ts +24 -13
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +37 -6
- package/src/test-select.ts +4 -3
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +11 -1
- package/src/verify-run.ts +25 -3
- package/src/verify-step.ts +11 -2
- package/src/verify-tests.ts +11 -3
- package/src/workspace-graph.ts +241 -0
- package/src/write-line.ts +23 -5
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The app's own browser library, resolved from the app's own `node_modules` — never a dependency
|
|
2
|
+
// of this package. `@ultimat3/scraping` declares the launcher's shape structurally (`cdp-port.ts`)
|
|
3
|
+
// precisely so the framework can drive a browser without shipping one, and `x shot` is a CLI
|
|
4
|
+
// command holding to the same bargain: the app installs `puppeteer-core`, the CLI asks for it.
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { UltimateError } from '@ultimat3/core';
|
|
8
|
+
import type { CdpLauncherLike, ScrapeDriver } from '@ultimat3/scraping';
|
|
9
|
+
import { localBrowser } from '@ultimat3/scraping';
|
|
10
|
+
import { docsFor } from './error-codes';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The one library this works against. Playwright is not an alternative and is not a flag:
|
|
14
|
+
* `packages/scraping/src/cdp-port.ts` records that its `connectOverCDP` cannot perform the
|
|
15
|
+
* WebSocket upgrade under Bun (oven-sh/bun#9911), verified against puppeteer-core 25.8.0.
|
|
16
|
+
*/
|
|
17
|
+
export const BROWSER_PACKAGE = 'puppeteer-core';
|
|
18
|
+
|
|
19
|
+
/** Where a browser binary is named when the flag does not name one. Read in this order. */
|
|
20
|
+
export const BROWSER_PATH_VARS = ['PUPPETEER_EXECUTABLE_PATH', 'CHROME_PATH'] as const;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A missing browser is an instruction, not a crash (axiom 4). The cause distinguishes the two
|
|
24
|
+
* shapes — nothing resolved, or something resolved that is not a launcher — while the fix is the
|
|
25
|
+
* same install either way, because both are answered by putting the real package in the app.
|
|
26
|
+
*/
|
|
27
|
+
export class ShotBrowserMissingError extends UltimateError {
|
|
28
|
+
constructor(input: { root: string; detail: string }) {
|
|
29
|
+
super({
|
|
30
|
+
code: 'X_SHOT_BROWSER_MISSING',
|
|
31
|
+
cause: `x shot drives a real browser and ${BROWSER_PACKAGE} ${input.detail} from ${input.root}`,
|
|
32
|
+
// One literal, not `bun add -d ${BROWSER_PACKAGE}`: `fix-scan.ts` can only read a fix that IS
|
|
33
|
+
// one literal, and a fix line the gate cannot read is a fix line nothing holds to the
|
|
34
|
+
// contract. `browser-launcher.test.ts` pins it against the constant instead.
|
|
35
|
+
fix: 'bun add -d puppeteer-core',
|
|
36
|
+
docs: docsFor('X_SHOT_BROWSER_MISSING'),
|
|
37
|
+
meta: { root: input.root, package: BROWSER_PACKAGE },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Structural, because this is somebody else's module: a namespace object, a CJS `default`, or a
|
|
44
|
+
* transpiled interop wrapper are all shapes `import()` legitimately hands back, and only one
|
|
45
|
+
* question decides — is there a `launch` to call?
|
|
46
|
+
*/
|
|
47
|
+
const launcherIn = (module: unknown): CdpLauncherLike | undefined => {
|
|
48
|
+
if (typeof module !== 'object' || module === null) return undefined;
|
|
49
|
+
const candidate = module as { launch?: unknown; default?: unknown };
|
|
50
|
+
if (typeof candidate.launch === 'function') return candidate as CdpLauncherLike;
|
|
51
|
+
// `module.exports.default = module.exports` is a real CJS interop shape, so the self-reference is
|
|
52
|
+
// refused rather than followed: one unbounded recursion here is a stack overflow instead of the
|
|
53
|
+
// instruction this whole function exists to produce.
|
|
54
|
+
if (candidate.default === undefined || candidate.default === candidate) return undefined;
|
|
55
|
+
return launcherIn(candidate.default);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export interface AppBrowserOptions {
|
|
59
|
+
readonly root: string;
|
|
60
|
+
/** `--browser`, then `PUPPETEER_EXECUTABLE_PATH`, then `CHROME_PATH`. */
|
|
61
|
+
readonly executablePath?: string | undefined;
|
|
62
|
+
/** Test seam: the resolver and the loader, so a test proves the refusal without an install. */
|
|
63
|
+
readonly resolve?: (specifier: string, from: string) => string;
|
|
64
|
+
readonly load?: (path: string) => Promise<unknown>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The path a run will launch, or `undefined` for "let the library find its own". */
|
|
68
|
+
export const executablePathFrom = (
|
|
69
|
+
flag: string | undefined,
|
|
70
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
71
|
+
): string | undefined => {
|
|
72
|
+
if (flag !== undefined && flag.length > 0) return flag;
|
|
73
|
+
for (const name of BROWSER_PATH_VARS) {
|
|
74
|
+
const value = env[name];
|
|
75
|
+
if (value !== undefined && value.length > 0) return value;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** True when a named executable is really there — a bad `--browser` is refused before a boot. */
|
|
81
|
+
export const browserBinaryExists = (path: string): boolean => existsSync(path);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The app's `puppeteer-core`, as a `ScrapeDriver`. Resolved FROM THE APP ROOT rather than from
|
|
85
|
+
* this module: `import('puppeteer-core')` here would find the CLI's own tree, which by design has
|
|
86
|
+
* no such dependency, and would answer "missing" for an app that installed it correctly.
|
|
87
|
+
*/
|
|
88
|
+
export async function appBrowser(options: AppBrowserOptions): Promise<ScrapeDriver> {
|
|
89
|
+
const resolve = options.resolve ?? ((specifier, from) => Bun.resolveSync(specifier, from));
|
|
90
|
+
const load = options.load ?? ((path: string) => import(path) as Promise<unknown>);
|
|
91
|
+
let entry: string;
|
|
92
|
+
try {
|
|
93
|
+
entry = resolve(BROWSER_PACKAGE, options.root);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new ShotBrowserMissingError({ root: options.root, detail: 'does not resolve' });
|
|
96
|
+
}
|
|
97
|
+
const launcher = launcherIn(await load(entry));
|
|
98
|
+
if (launcher === undefined) {
|
|
99
|
+
throw new ShotBrowserMissingError({
|
|
100
|
+
root: options.root,
|
|
101
|
+
detail: `resolved to ${entry}, which exports no launch()`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return localBrowser({
|
|
105
|
+
launcher,
|
|
106
|
+
headless: true,
|
|
107
|
+
...(options.executablePath === undefined ? {} : { executablePath: options.executablePath }),
|
|
108
|
+
});
|
|
109
|
+
}
|
package/src/ci-log.ts
ADDED
|
Binary file
|
package/src/ci-runs.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Which workflow runs `x ci` is about, and the three `gh run` calls that answer it. Selection
|
|
2
|
+
// lives here rather than in the command so "the latest run of each workflow on this branch" is one
|
|
3
|
+
// testable function over plain rows instead of a shape only a subprocess can produce.
|
|
4
|
+
|
|
5
|
+
import { t } from '@ultimat3/schema';
|
|
6
|
+
import type { GhHost } from './gh';
|
|
7
|
+
import { ghJson, runGh } from './gh';
|
|
8
|
+
import type { GhRepo } from './gh-target';
|
|
9
|
+
|
|
10
|
+
/** The fields both `gh run list` and `gh run view` accept, named once so the two agree. */
|
|
11
|
+
export const RUN_FIELDS =
|
|
12
|
+
'databaseId,status,conclusion,headBranch,displayTitle,workflowName,url,createdAt';
|
|
13
|
+
|
|
14
|
+
const RUN_ROW = t.object({
|
|
15
|
+
databaseId: t.number,
|
|
16
|
+
status: t.string,
|
|
17
|
+
conclusion: t.nullable(t.string),
|
|
18
|
+
headBranch: t.string,
|
|
19
|
+
displayTitle: t.string,
|
|
20
|
+
workflowName: t.string,
|
|
21
|
+
url: t.string,
|
|
22
|
+
createdAt: t.string,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const STEP = t.object({ name: t.string, conclusion: t.nullable(t.string), number: t.number });
|
|
26
|
+
|
|
27
|
+
const JOB = t.object({
|
|
28
|
+
name: t.string,
|
|
29
|
+
status: t.string,
|
|
30
|
+
conclusion: t.nullable(t.string),
|
|
31
|
+
url: t.string,
|
|
32
|
+
steps: t.array(STEP),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const RUN_LIST = t.array(RUN_ROW);
|
|
36
|
+
const RUN_VIEW = RUN_ROW.extend({ jobs: t.array(JOB) });
|
|
37
|
+
|
|
38
|
+
export interface CiStep {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly conclusion: string | null;
|
|
41
|
+
readonly number: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CiJob {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly status: string;
|
|
47
|
+
readonly conclusion: string | null;
|
|
48
|
+
readonly url: string;
|
|
49
|
+
readonly steps: readonly CiStep[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CiRun {
|
|
53
|
+
readonly id: number;
|
|
54
|
+
readonly status: string;
|
|
55
|
+
readonly conclusion: string | null;
|
|
56
|
+
readonly branch: string;
|
|
57
|
+
readonly title: string;
|
|
58
|
+
readonly workflow: string;
|
|
59
|
+
readonly url: string;
|
|
60
|
+
readonly createdAt: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The conclusions that mean "this run is not green". `cancelled` and `timed_out` are in it because
|
|
65
|
+
* a cancelled run has told the reader nothing about their change, and a command that reported it
|
|
66
|
+
* as passing would be a green verdict over an unanswered question. `skipped` is not: a workflow
|
|
67
|
+
* whose conditions did not match was never asked.
|
|
68
|
+
*/
|
|
69
|
+
export const FAILED_CONCLUSIONS: readonly string[] = [
|
|
70
|
+
'failure',
|
|
71
|
+
'cancelled',
|
|
72
|
+
'timed_out',
|
|
73
|
+
'startup_failure',
|
|
74
|
+
'action_required',
|
|
75
|
+
'stale',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
export const isFailed = (run: CiRun): boolean =>
|
|
79
|
+
run.conclusion !== null && FAILED_CONCLUSIONS.includes(run.conclusion);
|
|
80
|
+
|
|
81
|
+
export const isRunning = (run: CiRun): boolean => run.status !== 'completed';
|
|
82
|
+
|
|
83
|
+
interface RunRow {
|
|
84
|
+
readonly databaseId: number;
|
|
85
|
+
readonly status: string;
|
|
86
|
+
readonly conclusion: string | null;
|
|
87
|
+
readonly headBranch: string;
|
|
88
|
+
readonly displayTitle: string;
|
|
89
|
+
readonly workflowName: string;
|
|
90
|
+
readonly url: string;
|
|
91
|
+
readonly createdAt: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const runOf = (row: RunRow): CiRun => ({
|
|
95
|
+
id: row.databaseId,
|
|
96
|
+
status: row.status,
|
|
97
|
+
conclusion: row.conclusion,
|
|
98
|
+
branch: row.headBranch,
|
|
99
|
+
title: row.displayTitle,
|
|
100
|
+
workflow: row.workflowName,
|
|
101
|
+
url: row.url,
|
|
102
|
+
createdAt: row.createdAt,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The newest run of EACH workflow, which is the honest answer to "is CI green on this branch".
|
|
107
|
+
* Taking the newest run overall reports whichever workflow happened to finish last — this repo
|
|
108
|
+
* runs `ci` and `deploy-social-demo` off the same push, so half the time that is a verdict about
|
|
109
|
+
* a workflow the caller was not asking about.
|
|
110
|
+
*/
|
|
111
|
+
export function latestPerWorkflow(runs: readonly CiRun[]): readonly CiRun[] {
|
|
112
|
+
const newest = new Map<string, CiRun>();
|
|
113
|
+
for (const run of runs) {
|
|
114
|
+
const held = newest.get(run.workflow);
|
|
115
|
+
if (held === undefined || run.createdAt > held.createdAt) newest.set(run.workflow, run);
|
|
116
|
+
}
|
|
117
|
+
return [...newest.values()];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function listRuns(
|
|
121
|
+
host: GhHost,
|
|
122
|
+
repo: GhRepo,
|
|
123
|
+
branch: string,
|
|
124
|
+
limit: number,
|
|
125
|
+
): Promise<readonly CiRun[]> {
|
|
126
|
+
const rows = await ghJson(
|
|
127
|
+
host,
|
|
128
|
+
[
|
|
129
|
+
'run',
|
|
130
|
+
'list',
|
|
131
|
+
'--repo',
|
|
132
|
+
repo.slug,
|
|
133
|
+
'--branch',
|
|
134
|
+
branch,
|
|
135
|
+
'--limit',
|
|
136
|
+
String(limit),
|
|
137
|
+
'--json',
|
|
138
|
+
RUN_FIELDS,
|
|
139
|
+
],
|
|
140
|
+
RUN_LIST,
|
|
141
|
+
{
|
|
142
|
+
label: `gh run list --branch ${branch}`,
|
|
143
|
+
fix: `x ci --branch ${branch} --repo ${repo.slug} --json`,
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
return rows.map(runOf);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One run, with its jobs — `gh run view --json` carries both, so this is one round trip. */
|
|
150
|
+
export async function viewRun(
|
|
151
|
+
host: GhHost,
|
|
152
|
+
repo: GhRepo,
|
|
153
|
+
id: number,
|
|
154
|
+
): Promise<{ readonly run: CiRun; readonly jobs: readonly CiJob[] }> {
|
|
155
|
+
const viewed = await ghJson(
|
|
156
|
+
host,
|
|
157
|
+
['run', 'view', String(id), '--repo', repo.slug, '--json', `${RUN_FIELDS},jobs`],
|
|
158
|
+
RUN_VIEW,
|
|
159
|
+
{ label: `gh run view ${id}`, fix: `x ci --run ${id} --repo ${repo.slug} --json` },
|
|
160
|
+
);
|
|
161
|
+
return { run: runOf(viewed), jobs: viewed.jobs };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The failed steps' log, and only those. `--log` is the whole run — setup, caches, every green
|
|
166
|
+
* step — where `--log-failed` is the part a triage starts from, which is the difference between
|
|
167
|
+
* one command and three.
|
|
168
|
+
*/
|
|
169
|
+
export async function failedLog(host: GhHost, repo: GhRepo, id: number): Promise<string> {
|
|
170
|
+
const result = await runGh(
|
|
171
|
+
host,
|
|
172
|
+
['run', 'view', String(id), '--repo', repo.slug, '--log-failed'],
|
|
173
|
+
{
|
|
174
|
+
label: `gh run view ${id} --log-failed`,
|
|
175
|
+
fix: `gh run view ${id} --repo ${repo.slug} --log # the whole log, when the failed steps carry none`,
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
return result.stdout;
|
|
179
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// `x affected` — the workspaces a diff forces a re-test of, closed transitively over the workspace
|
|
2
|
+
// graph. `x verify` runs everything, which is right for a gate and wrong for the loop an agent
|
|
3
|
+
// iterates in; without this command that agent invents its own scoping, and an invented one that
|
|
4
|
+
// misses a transitive dependent is a green checkmark on a broken repo.
|
|
5
|
+
//
|
|
6
|
+
// CLI wiring only. What a diff touches is `affected.ts`, what the workspaces are is
|
|
7
|
+
// `workspace-graph.ts` — the `cmd-jobs.ts` / `jobs-report.ts` split, repeated.
|
|
8
|
+
|
|
9
|
+
import { affectedScope, DEFAULT_BASE } from './affected';
|
|
10
|
+
import type { CliCommand, CommandContext } from './command';
|
|
11
|
+
import { msg } from './messages';
|
|
12
|
+
import type { CommandResult, JsonValue } from './output';
|
|
13
|
+
import { flagBool } from './parse';
|
|
14
|
+
import { renderTable } from './table';
|
|
15
|
+
|
|
16
|
+
const HEADER = ['workspace', 'dir'] as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every catalog row the affected surface renders — this command's four and the one `x test
|
|
20
|
+
* --affected` prints when nothing is affected. One list, because the two commands are one surface
|
|
21
|
+
* and a per-file list would leave whichever half nobody thought about unchecked.
|
|
22
|
+
*
|
|
23
|
+
* The rule it exists for: `msg()` answers `⟦key⟧` for a key the catalog lacks, which is loud in the
|
|
24
|
+
* terminal and completely silent to a build, so a command can ship a summary no locale renders.
|
|
25
|
+
* `cmd-affected.test.ts` holds `messages.ts` to this list.
|
|
26
|
+
*/
|
|
27
|
+
export const AFFECTED_MESSAGE_KEYS = [
|
|
28
|
+
'cli.affected.count',
|
|
29
|
+
'cli.affected.none',
|
|
30
|
+
'cli.affected.rootWide',
|
|
31
|
+
'cli.affected.dirty',
|
|
32
|
+
'cli.test.affected.none',
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
export const affectedCommand: CliCommand = {
|
|
36
|
+
spec: {
|
|
37
|
+
name: 'affected',
|
|
38
|
+
summary: 'the workspaces a diff touches, and every workspace that depends on one of them',
|
|
39
|
+
usage: 'x affected [--base <ref>] [--dirty] [--paths] [--json]',
|
|
40
|
+
flags: [
|
|
41
|
+
{
|
|
42
|
+
name: 'base',
|
|
43
|
+
type: 'string',
|
|
44
|
+
summary: `git ref to diff against, merge-base style (default: ${DEFAULT_BASE})`,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'dirty',
|
|
48
|
+
type: 'boolean',
|
|
49
|
+
summary: 'also count uncommitted work — every agent sharing this checkout, not only yours',
|
|
50
|
+
},
|
|
51
|
+
{ name: 'paths', type: 'boolean', summary: 'print bare directories instead of a table' },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
55
|
+
// The one resolver `x test --affected` narrows with, so this command reports exactly what that
|
|
56
|
+
// one runs. It reads `--base` before git is spawned (a malformed ref must not cost a
|
|
57
|
+
// subprocess) and takes the diff at the CHECKOUT root, which is what git prints paths against.
|
|
58
|
+
const { selection, root, plan } = await affectedScope({
|
|
59
|
+
runner: ctx.runner,
|
|
60
|
+
cwd: ctx.cwd,
|
|
61
|
+
args: ctx.args,
|
|
62
|
+
command: 'affected',
|
|
63
|
+
});
|
|
64
|
+
const params = { base: selection.base, changed: plan.changed.length };
|
|
65
|
+
const table = renderTable(
|
|
66
|
+
HEADER,
|
|
67
|
+
plan.workspaces.map((workspace) => [workspace.name, workspace.dir]),
|
|
68
|
+
).map((line) => ` ${line}`);
|
|
69
|
+
const data: JsonValue = {
|
|
70
|
+
base: selection.base,
|
|
71
|
+
dirty: selection.dirty,
|
|
72
|
+
root,
|
|
73
|
+
changed: [...plan.changed],
|
|
74
|
+
ignored: [...plan.ignored],
|
|
75
|
+
rootWide: [...plan.rootWide],
|
|
76
|
+
workspaces: plan.workspaces.map((workspace) => ({
|
|
77
|
+
name: workspace.name,
|
|
78
|
+
dir: workspace.dir,
|
|
79
|
+
})),
|
|
80
|
+
paths: plan.workspaces.map((workspace) => workspace.dir),
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
// An empty answer is a fact, not a failure: a `.md`-only diff genuinely re-checks nothing,
|
|
84
|
+
// and reporting it red would fail a build for editing a doc. The count is in the summary and
|
|
85
|
+
// `data.changed`/`data.ignored` say what was looked at, so "green because nothing is
|
|
86
|
+
// affected" is never mistaken for "green because everything passed".
|
|
87
|
+
ok: true,
|
|
88
|
+
command: 'affected',
|
|
89
|
+
summary:
|
|
90
|
+
plan.workspaces.length === 0
|
|
91
|
+
? msg('cli.affected.none', params)
|
|
92
|
+
: msg('cli.affected.count', { ...params, count: plan.workspaces.length }),
|
|
93
|
+
lines: [
|
|
94
|
+
...(selection.dirty ? [msg('cli.affected.dirty')] : []),
|
|
95
|
+
...(plan.rootWide.length === 0
|
|
96
|
+
? []
|
|
97
|
+
: [msg('cli.affected.rootWide', { files: plan.rootWide.join(', ') })]),
|
|
98
|
+
...(plan.workspaces.length === 0
|
|
99
|
+
? []
|
|
100
|
+
: // `--paths` changes only what the human sees; `--json` carries both projections on
|
|
101
|
+
// every run, so the two renderers can never state different sets.
|
|
102
|
+
flagBool(ctx.args, 'paths')
|
|
103
|
+
? plan.workspaces.map((workspace) => workspace.dir)
|
|
104
|
+
: table),
|
|
105
|
+
],
|
|
106
|
+
data,
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
package/src/cmd-build.ts
CHANGED
|
@@ -14,6 +14,13 @@ import { execOutput } from './exec';
|
|
|
14
14
|
import { msg } from './messages';
|
|
15
15
|
import type { CommandResult } from './output';
|
|
16
16
|
import { flagString } from './parse';
|
|
17
|
+
import type { StaticReport } from './static-report';
|
|
18
|
+
import {
|
|
19
|
+
readStaticReport,
|
|
20
|
+
removeStaticReport,
|
|
21
|
+
renderStaticReport,
|
|
22
|
+
staticReportData,
|
|
23
|
+
} from './static-report';
|
|
17
24
|
|
|
18
25
|
export const BUILD_TARGETS = ['docker', 'binary', 'static'] as const;
|
|
19
26
|
|
|
@@ -111,14 +118,19 @@ export function preflightResult(verify: CommandResult): CommandResult {
|
|
|
111
118
|
* `✗ built docker`; and the builder's own logs went only into `lines`, which is declared human-only
|
|
112
119
|
* and which `renderJson` drops — so CI, which runs `--json`, got the exit code and nothing to act
|
|
113
120
|
* on. The output now rides in `data` and `lines` renders that same string.
|
|
121
|
+
*
|
|
122
|
+
* `report` is the static target's inventory, and BOTH renderers get it: `--json` is the house rule,
|
|
123
|
+
* but an agent reading terminal output is the primary developer here, so a silent human path is the
|
|
124
|
+
* same defect in a different costume. `lines` still carries nothing `data` does not (#242).
|
|
114
125
|
*/
|
|
115
126
|
export function buildResult(input: {
|
|
116
127
|
readonly target: BuildTarget;
|
|
117
128
|
readonly artifact: string;
|
|
118
129
|
readonly command: readonly string[];
|
|
119
130
|
readonly result: ExecResult;
|
|
131
|
+
readonly report?: StaticReport;
|
|
120
132
|
}): CommandResult {
|
|
121
|
-
const { result, target } = input;
|
|
133
|
+
const { report, result, target } = input;
|
|
122
134
|
const output = result.ok ? '' : execOutput(result);
|
|
123
135
|
return {
|
|
124
136
|
ok: result.ok,
|
|
@@ -139,8 +151,13 @@ export function buildResult(input: {
|
|
|
139
151
|
artifact: input.artifact,
|
|
140
152
|
durationMs: result.durationMs,
|
|
141
153
|
...(result.ok ? {} : { output }),
|
|
154
|
+
...staticReportData(report),
|
|
142
155
|
},
|
|
143
|
-
lines: result.ok
|
|
156
|
+
lines: result.ok
|
|
157
|
+
? report === undefined
|
|
158
|
+
? []
|
|
159
|
+
: renderStaticReport(report)
|
|
160
|
+
: output.split('\n'),
|
|
144
161
|
};
|
|
145
162
|
}
|
|
146
163
|
|
|
@@ -178,11 +195,20 @@ export const buildCommand: CliCommand = {
|
|
|
178
195
|
flagString(ctx.args, 'out') ?? join(root, '.x', target === 'static' ? 'static' : 'app');
|
|
179
196
|
const tag = flagString(ctx.args, 'tag') ?? 'ultimate-app:dev';
|
|
180
197
|
const command = argsFor(target, { root, tag, out });
|
|
198
|
+
// Removed BEFORE the builder runs, so a build that writes no inventory can never be reported
|
|
199
|
+
// with the last one's: a stale emitted list is worse than none, because it reads as this run's.
|
|
200
|
+
if (target === 'static') await removeStaticReport(root);
|
|
201
|
+
const result = await ctx.runner(command, { cwd: root });
|
|
202
|
+
// `prerenderSite` writes it; an app whose `apps/web/prerender.ts` does not call that writes no
|
|
203
|
+
// `.x/build-stats.json` either, and `x verify`'s `budgets` step already reds that app with
|
|
204
|
+
// `X_BUDGET_UNMEASURED` — so the absence needs no second code here.
|
|
205
|
+
const report = target === 'static' && result.ok ? await readStaticReport(root) : undefined;
|
|
181
206
|
return buildResult({
|
|
182
207
|
target,
|
|
183
208
|
artifact: target === 'docker' ? tag : out,
|
|
184
209
|
command,
|
|
185
|
-
result
|
|
210
|
+
result,
|
|
211
|
+
...(report === undefined ? {} : { report }),
|
|
186
212
|
});
|
|
187
213
|
},
|
|
188
214
|
};
|