@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
package/src/cmd-ci.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// `x ci` — one command instead of three. `gh run view` prints a tree of ticks and one cross, and
|
|
2
|
+
// the error is inside a per-job log that is mostly setup noise; for a `verify` job that log tail IS
|
|
3
|
+
// the findings block, with its `X_*` codes and executable `fix:` lines already in it. So this
|
|
4
|
+
// command fetches the runs, opens only the failed steps' log, and hands back the findings the gate
|
|
5
|
+
// already wrote — in the same three-line shape every other Ultimate error is printed in.
|
|
6
|
+
|
|
7
|
+
import { UltimateError } from '@ultimat3/core';
|
|
8
|
+
import type { CiLogLine } from './ci-log';
|
|
9
|
+
import { findingsFrom, jobsInLog, parseLogLines, tailOf } from './ci-log';
|
|
10
|
+
import type { CiJob, CiRun } from './ci-runs';
|
|
11
|
+
import { failedLog, isFailed, isRunning, latestPerWorkflow, listRuns, viewRun } from './ci-runs';
|
|
12
|
+
import type { CliCommand, CommandContext } from './command';
|
|
13
|
+
import { parseIntFlag } from './flag-number';
|
|
14
|
+
import type { GhRepo } from './gh-target';
|
|
15
|
+
import { currentBranch, resolveRepo } from './gh-target';
|
|
16
|
+
import { msg } from './messages';
|
|
17
|
+
import type { CommandResult, Finding, JsonValue } from './output';
|
|
18
|
+
import { flagBool, flagString } from './parse';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every catalog key this command renders, declared — `msg()` answers `⟦key⟧` for a key nobody
|
|
22
|
+
* added, which is loud in a terminal and SILENT to a build. `cmd-ci.test.ts` holds this list
|
|
23
|
+
* against the catalog, so a missing string is a failing test rather than a rendered artefact.
|
|
24
|
+
*/
|
|
25
|
+
export const CI_MESSAGE_KEYS = [
|
|
26
|
+
'cli.ci.failed',
|
|
27
|
+
'cli.ci.green',
|
|
28
|
+
'cli.ci.running',
|
|
29
|
+
'cli.ci.run',
|
|
30
|
+
'cli.ci.job',
|
|
31
|
+
'cli.ci.jobs.other',
|
|
32
|
+
'cli.ci.tail',
|
|
33
|
+
'cli.ci.pending',
|
|
34
|
+
'cli.ci.logs.empty',
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
/** Log lines kept per failed job. Enough to hold a findings block, short enough to read. */
|
|
38
|
+
export const TAIL_LINES = 40;
|
|
39
|
+
|
|
40
|
+
/** How far back a branch's run history is read before "the latest run of each workflow". */
|
|
41
|
+
export const RUN_LOOKBACK = 20;
|
|
42
|
+
|
|
43
|
+
/** No run to triage. The remedy is a branch that has one, or the id of the run in question. */
|
|
44
|
+
export class CiRunNotFoundError extends UltimateError {
|
|
45
|
+
constructor(input: { branch: string; repo: string }) {
|
|
46
|
+
super({
|
|
47
|
+
code: 'X_CI_RUN_NOT_FOUND',
|
|
48
|
+
cause: `no workflow run on ${input.repo} for branch "${input.branch}"`,
|
|
49
|
+
fix: `x ci --branch main --repo ${input.repo} --json`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const RUN_FLAG = { name: 'run', command: 'ci', min: 1, example: 'x ci --run 32484583944 --json' };
|
|
55
|
+
const TAIL_FLAG = { name: 'tail', command: 'ci', min: 1, example: 'x ci --tail 80 --json' };
|
|
56
|
+
|
|
57
|
+
export const ciCommand: CliCommand = {
|
|
58
|
+
spec: {
|
|
59
|
+
name: 'ci',
|
|
60
|
+
summary: 'the workflow runs for this branch, and the findings inside the failed steps log',
|
|
61
|
+
usage: 'x ci [--branch <name>] [--run <id>] [--repo owner/name] [--tail <n>] [--full] [--json]',
|
|
62
|
+
flags: [
|
|
63
|
+
{ name: 'repo', type: 'string', summary: 'owner/name; the checkout own remote by default' },
|
|
64
|
+
{ name: 'branch', type: 'string', summary: 'branch to read runs for; this one by default' },
|
|
65
|
+
{ name: 'run', type: 'string', summary: 'one run id, instead of this branch latest' },
|
|
66
|
+
{
|
|
67
|
+
name: 'tail',
|
|
68
|
+
type: 'string',
|
|
69
|
+
summary: `log lines kept per failed job (default ${TAIL_LINES})`,
|
|
70
|
+
},
|
|
71
|
+
{ name: 'full', type: 'boolean', summary: 'the whole failed-step log, not the tail' },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
75
|
+
const repo = await resolveRepo(ctx, 'ci', flagString(ctx.args, 'repo'));
|
|
76
|
+
const rawRun = flagString(ctx.args, 'run');
|
|
77
|
+
const rawTail = flagString(ctx.args, 'tail');
|
|
78
|
+
const limit = flagBool(ctx.args, 'full')
|
|
79
|
+
? Number.POSITIVE_INFINITY
|
|
80
|
+
: rawTail === undefined
|
|
81
|
+
? TAIL_LINES
|
|
82
|
+
: parseIntFlag(rawTail, TAIL_FLAG);
|
|
83
|
+
if (rawRun !== undefined) {
|
|
84
|
+
const viewed = await viewRun(ctx, repo, parseIntFlag(rawRun, RUN_FLAG));
|
|
85
|
+
return report(repo, viewed.run.branch, [await inspect(ctx, repo, viewed, limit)]);
|
|
86
|
+
}
|
|
87
|
+
const branch = flagString(ctx.args, 'branch') ?? (await currentBranch(ctx));
|
|
88
|
+
const runs = latestPerWorkflow(await listRuns(ctx, repo, branch, RUN_LOOKBACK));
|
|
89
|
+
if (runs.length === 0) throw new CiRunNotFoundError({ branch, repo: repo.slug });
|
|
90
|
+
const inspected: RunReport[] = [];
|
|
91
|
+
for (const run of runs) {
|
|
92
|
+
// Only a failed run is opened. A green run's jobs are 35 rows saying `success`, and fetching
|
|
93
|
+
// them would turn the fast answer ("CI is green") into the slow one.
|
|
94
|
+
inspected.push(
|
|
95
|
+
isFailed(run) ? await inspect(ctx, repo, await viewRun(ctx, repo, run.id), limit) : { run },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return report(repo, branch, inspected);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
interface JobFailure {
|
|
103
|
+
readonly job: string;
|
|
104
|
+
readonly conclusion: string;
|
|
105
|
+
readonly url: string;
|
|
106
|
+
readonly failedSteps: readonly string[];
|
|
107
|
+
readonly tail: readonly string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface RunReport {
|
|
111
|
+
readonly run: CiRun;
|
|
112
|
+
readonly jobs?: readonly CiJob[];
|
|
113
|
+
readonly failures?: readonly JobFailure[];
|
|
114
|
+
readonly findings?: readonly Finding[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const stepFailed = (conclusion: string | null): boolean =>
|
|
118
|
+
conclusion !== null && conclusion !== 'success' && conclusion !== 'skipped';
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* One failed run, opened. The findings are attributed to the job whose lines produced them, and
|
|
122
|
+
* a job the log attributes nothing to falls back to the whole log — a format change on GitHub's
|
|
123
|
+
* side must cost the attribution, never the finding.
|
|
124
|
+
*/
|
|
125
|
+
async function inspect(
|
|
126
|
+
ctx: CommandContext,
|
|
127
|
+
repo: GhRepo,
|
|
128
|
+
viewed: { readonly run: CiRun; readonly jobs: readonly CiJob[] },
|
|
129
|
+
limit: number,
|
|
130
|
+
): Promise<RunReport> {
|
|
131
|
+
const { run, jobs } = viewed;
|
|
132
|
+
if (!isFailed(run)) return { run, jobs };
|
|
133
|
+
const lines = parseLogLines(await failedLog(ctx, repo, run.id));
|
|
134
|
+
const failed = jobs.filter((job) => stepFailed(job.conclusion));
|
|
135
|
+
// A run can fail with no failed JOB — a startup failure, a cancelled matrix — and the log is
|
|
136
|
+
// then the only thing that knows which name to file it under.
|
|
137
|
+
const names = failed.length > 0 ? failed.map((job) => job.name) : jobsInLog(lines);
|
|
138
|
+
const failures: JobFailure[] = [];
|
|
139
|
+
const findings: Finding[] = [];
|
|
140
|
+
for (const name of names) {
|
|
141
|
+
const own = lines.filter((line) => line.job === name);
|
|
142
|
+
const pool: readonly CiLogLine[] = own.length > 0 ? own : lines;
|
|
143
|
+
const job = failed.find((candidate) => candidate.name === name);
|
|
144
|
+
for (const finding of findingsFrom(pool)) {
|
|
145
|
+
findings.push(finding.at === undefined ? { ...finding, at: name } : finding);
|
|
146
|
+
}
|
|
147
|
+
failures.push({
|
|
148
|
+
job: name,
|
|
149
|
+
conclusion: job?.conclusion ?? conclusionOf(run),
|
|
150
|
+
url: job?.url ?? run.url,
|
|
151
|
+
failedSteps: (job?.steps ?? [])
|
|
152
|
+
.filter((step) => stepFailed(step.conclusion))
|
|
153
|
+
.map((step) => step.name),
|
|
154
|
+
tail: tailOf(lines, name, limit),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return { run, jobs, failures, findings: dedupe(findings) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The same block can be reached twice — once per job when the log attributes nothing, once per
|
|
162
|
+
* attempt when a run was re-run — and two copies of one finding read as two problems.
|
|
163
|
+
*/
|
|
164
|
+
function dedupe(findings: readonly Finding[]): readonly Finding[] {
|
|
165
|
+
const seen = new Set<string>();
|
|
166
|
+
return findings.filter((finding) => {
|
|
167
|
+
const key = `${finding.code} ${finding.cause}`;
|
|
168
|
+
if (seen.has(key)) return false;
|
|
169
|
+
seen.add(key);
|
|
170
|
+
return true;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const conclusionOf = (run: CiRun): string => run.conclusion ?? msg('cli.ci.pending');
|
|
175
|
+
|
|
176
|
+
function report(repo: GhRepo, branch: string, reports: readonly RunReport[]): CommandResult {
|
|
177
|
+
const failed = reports.filter((entry) => isFailed(entry.run));
|
|
178
|
+
const running = reports.filter((entry) => isRunning(entry.run));
|
|
179
|
+
const findings = dedupe(reports.flatMap((entry) => entry.findings ?? []));
|
|
180
|
+
return {
|
|
181
|
+
ok: failed.length === 0,
|
|
182
|
+
command: 'ci',
|
|
183
|
+
summary:
|
|
184
|
+
failed.length > 0
|
|
185
|
+
? msg('cli.ci.failed', {
|
|
186
|
+
failed: failed.length,
|
|
187
|
+
runs: reports.length,
|
|
188
|
+
branch,
|
|
189
|
+
findings: findings.length,
|
|
190
|
+
})
|
|
191
|
+
: running.length > 0
|
|
192
|
+
? msg('cli.ci.running', { running: running.length, runs: reports.length, branch })
|
|
193
|
+
: msg('cli.ci.green', { runs: reports.length, branch }),
|
|
194
|
+
findings,
|
|
195
|
+
lines: reports.flatMap(runLines),
|
|
196
|
+
data: {
|
|
197
|
+
repo: repo.slug,
|
|
198
|
+
branch,
|
|
199
|
+
counts: {
|
|
200
|
+
runs: reports.length,
|
|
201
|
+
failed: failed.length,
|
|
202
|
+
running: running.length,
|
|
203
|
+
findings: findings.length,
|
|
204
|
+
},
|
|
205
|
+
runs: reports.map(runJson),
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function runLines(entry: RunReport): readonly string[] {
|
|
211
|
+
const out = [
|
|
212
|
+
msg('cli.ci.run', {
|
|
213
|
+
conclusion: conclusionOf(entry.run),
|
|
214
|
+
workflow: entry.run.workflow,
|
|
215
|
+
url: entry.run.url,
|
|
216
|
+
}),
|
|
217
|
+
];
|
|
218
|
+
const failures = entry.failures ?? [];
|
|
219
|
+
for (const failure of failures) {
|
|
220
|
+
out.push(
|
|
221
|
+
msg('cli.ci.job', {
|
|
222
|
+
conclusion: failure.conclusion,
|
|
223
|
+
job: failure.job,
|
|
224
|
+
steps: failure.failedSteps.join(', '),
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
if (failure.tail.length === 0) {
|
|
228
|
+
out.push(msg('cli.ci.logs.empty', { url: failure.url }));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
out.push(msg('cli.ci.tail', { job: failure.job }));
|
|
232
|
+
for (const line of failure.tail) out.push(` | ${line}`);
|
|
233
|
+
}
|
|
234
|
+
const jobs = entry.jobs;
|
|
235
|
+
if (jobs !== undefined && jobs.length > failures.length) {
|
|
236
|
+
out.push(msg('cli.ci.jobs.other', { count: jobs.length - failures.length }));
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function runJson(entry: RunReport): JsonValue {
|
|
242
|
+
return {
|
|
243
|
+
id: entry.run.id,
|
|
244
|
+
workflow: entry.run.workflow,
|
|
245
|
+
status: entry.run.status,
|
|
246
|
+
conclusion: entry.run.conclusion,
|
|
247
|
+
title: entry.run.title,
|
|
248
|
+
url: entry.run.url,
|
|
249
|
+
createdAt: entry.run.createdAt,
|
|
250
|
+
// Absent rather than empty on a run that was never opened: `jobs: []` on a green run would
|
|
251
|
+
// claim the run has no jobs, which is a different statement from "nobody asked".
|
|
252
|
+
...(entry.jobs === undefined
|
|
253
|
+
? {}
|
|
254
|
+
: {
|
|
255
|
+
jobs: entry.jobs.map((job) => ({
|
|
256
|
+
name: job.name,
|
|
257
|
+
status: job.status,
|
|
258
|
+
conclusion: job.conclusion,
|
|
259
|
+
url: job.url,
|
|
260
|
+
})),
|
|
261
|
+
}),
|
|
262
|
+
...(entry.failures === undefined
|
|
263
|
+
? {}
|
|
264
|
+
: {
|
|
265
|
+
failures: entry.failures.map((failure) => ({
|
|
266
|
+
job: failure.job,
|
|
267
|
+
url: failure.url,
|
|
268
|
+
failedSteps: [...failure.failedSteps],
|
|
269
|
+
tail: [...failure.tail],
|
|
270
|
+
})),
|
|
271
|
+
}),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// `x db backfill`'s wiring alone: which of the four shapes an invocation asked for, and the one
|
|
2
|
+
// finding-and-table projection each answers with. Split from `cmd-db.ts` the way `cmd-db-branch.ts`
|
|
3
|
+
// was — that file had reached the 500-line ceiling, and "which subcommand ran" and "what a sweep
|
|
4
|
+
// pass reports" are two jobs. The facts live in `db-backfill.ts`; nothing here opens a database.
|
|
5
|
+
|
|
6
|
+
import { resolveEnvironment } from '@ultimat3/core';
|
|
7
|
+
import { BackfillPendingError } from '@ultimat3/jobs';
|
|
8
|
+
import { loadApp } from './app-load';
|
|
9
|
+
import type { CommandContext } from './command';
|
|
10
|
+
import type { BackfillAction, BackfillPlanRow } from './db-backfill';
|
|
11
|
+
import {
|
|
12
|
+
listBackfills,
|
|
13
|
+
pendingReport,
|
|
14
|
+
pendingToJson,
|
|
15
|
+
planToJson,
|
|
16
|
+
readAppliedMigrations,
|
|
17
|
+
renderBackfillTable,
|
|
18
|
+
renderPendingTable,
|
|
19
|
+
renderPlanTable,
|
|
20
|
+
runBackfills,
|
|
21
|
+
} from './db-backfill';
|
|
22
|
+
import { BadFlagError } from './errors';
|
|
23
|
+
import { withJobDriver } from './jobs-driver';
|
|
24
|
+
import { backfillToJson } from './jobs-json';
|
|
25
|
+
import { msg } from './messages';
|
|
26
|
+
import type { CommandResult } from './output';
|
|
27
|
+
import { findingFrom } from './output';
|
|
28
|
+
import type { ParsedArgs } from './parse';
|
|
29
|
+
import { flagBool, flagString } from './parse';
|
|
30
|
+
|
|
31
|
+
/** Which of the four questions an invocation asked. `pass` is `<name>` and `--all` both. */
|
|
32
|
+
type BackfillShape = 'list' | 'pending' | 'pass';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every flag that belongs to exactly ONE shape — `x db backfill`'s own usage line, executable.
|
|
36
|
+
*
|
|
37
|
+
* `--name` is absent because it is two things: `--list`'s filter and the pass's target, decided by
|
|
38
|
+
* `readShape` below. Everything else reads for one shape and is ignored by the other two, which is
|
|
39
|
+
* the silence this table ends.
|
|
40
|
+
*/
|
|
41
|
+
const SHAPE_OF_FLAG = Object.freeze<Record<string, BackfillShape>>({
|
|
42
|
+
status: 'list',
|
|
43
|
+
limit: 'list',
|
|
44
|
+
write: 'pass',
|
|
45
|
+
force: 'pass',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** One runnable line per shape, so a refusal hands back the invocation the caller meant. */
|
|
49
|
+
const FIX_OF_SHAPE = Object.freeze<Record<BackfillShape, string>>({
|
|
50
|
+
list: 'x db backfill --list --json',
|
|
51
|
+
pending: 'x db backfill --pending --json',
|
|
52
|
+
pass: 'x db backfill --all --write --json',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const refuseShape = (flag: string, reason: string, fix: string): never => {
|
|
56
|
+
throw new BadFlagError({ flag, command: 'db', reason, fix });
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Exactly one shape, and only the flags that shape reads — refused before anything opens a
|
|
61
|
+
* database, never resolved by precedence.
|
|
62
|
+
*
|
|
63
|
+
* Precedence is what this replaces, and it was silent in the dangerous direction:
|
|
64
|
+
* `x db backfill cleanup --all --write` took the `--all` branch and ENQUEUED every pending sweep
|
|
65
|
+
* while the operator had named one, and `--list --pending` reported the ledger for a command that
|
|
66
|
+
* asked what was unswept. Axiom 1 — one way to do each thing — makes a second reading of one argv
|
|
67
|
+
* a refusal rather than a choice the command makes on the caller's behalf.
|
|
68
|
+
*/
|
|
69
|
+
function readShape(args: ParsedArgs): { readonly shape: BackfillShape; readonly name?: string } {
|
|
70
|
+
const list = flagBool(args, 'list');
|
|
71
|
+
const positional = args.positionals[0];
|
|
72
|
+
const named = flagString(args, 'name');
|
|
73
|
+
// `--name` is a FILTER under `--list` and a target everywhere else, so it selects a shape only
|
|
74
|
+
// where `--list` is absent. Two spellings of one target are still two, and are refused.
|
|
75
|
+
const target = list ? undefined : (positional ?? named);
|
|
76
|
+
if (!list && positional !== undefined && named !== undefined) {
|
|
77
|
+
return refuseShape(
|
|
78
|
+
'name',
|
|
79
|
+
`x db backfill names two backfills ("${positional}" and "${named}") — a pass sweeps the positional or --name, never both`,
|
|
80
|
+
`x db backfill ${positional} --write --json`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const asked = [
|
|
84
|
+
...(list ? ['--list'] : []),
|
|
85
|
+
...(flagBool(args, 'pending') ? ['--pending'] : []),
|
|
86
|
+
...(flagBool(args, 'all') ? ['--all'] : []),
|
|
87
|
+
...(target === undefined ? [] : [target]),
|
|
88
|
+
];
|
|
89
|
+
if (asked.length > 1) {
|
|
90
|
+
// The SECOND shape is the one that would have been dropped, so it is the one the cause names:
|
|
91
|
+
// `--all` won over a named sweep and the operator was never told which of the two ran.
|
|
92
|
+
const second = asked[1] ?? '';
|
|
93
|
+
return refuseShape(
|
|
94
|
+
second.startsWith('--') ? second.slice(2) : 'name',
|
|
95
|
+
`x db backfill was asked for ${asked.join(' and ')} — one shape per invocation: --list, --pending, <name> or --all`,
|
|
96
|
+
`x db backfill ${asked[0]} --json`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (asked.length === 0) {
|
|
100
|
+
return refuseShape(
|
|
101
|
+
'list',
|
|
102
|
+
'x db backfill needs a shape: --list (the ledger), --pending (declared minus completed), <name> or --all (run one, or every pending one)',
|
|
103
|
+
'x db backfill --pending --json',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const shape: BackfillShape = list ? 'list' : flagBool(args, 'pending') ? 'pending' : 'pass';
|
|
107
|
+
for (const [flag, owner] of Object.entries(SHAPE_OF_FLAG)) {
|
|
108
|
+
if (owner === shape || !args.flags.has(flag)) continue;
|
|
109
|
+
refuseShape(
|
|
110
|
+
flag,
|
|
111
|
+
`x db backfill --${flag} belongs to ${owner === 'pass' ? 'a <name>/--all pass' : `--${owner}`}, and this invocation asked for ${asked[0]}`,
|
|
112
|
+
FIX_OF_SHAPE[owner],
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return target === undefined ? { shape } : { shape, name: target };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Four shapes, one subcommand: `--list` reads the `x_backfills` ledger, `--pending` diffs it
|
|
120
|
+
* against what the app DECLARED, and `<name>` / `--all` gate a pass and put it on the queue. A
|
|
121
|
+
* bare `x db backfill` is still refused rather than defaulted — the four answer four different
|
|
122
|
+
* questions, and picking one for the operator is the ambiguity axiom 1 exists to refuse.
|
|
123
|
+
*
|
|
124
|
+
* An empty ledger is `ok: true`. "Nothing has swept this database yet" is an answer to the
|
|
125
|
+
* question asked, and a command that failed over it would be unrunnable on a fresh app.
|
|
126
|
+
*/
|
|
127
|
+
export async function runBackfillCommand(
|
|
128
|
+
ctx: CommandContext,
|
|
129
|
+
root: string,
|
|
130
|
+
): Promise<CommandResult> {
|
|
131
|
+
const asked = readShape(ctx.args);
|
|
132
|
+
if (asked.shape === 'list') return runBackfillList(ctx, root);
|
|
133
|
+
if (asked.shape === 'pending') return runBackfillPending(ctx, root);
|
|
134
|
+
return runBackfillPass(ctx, root, asked.name === undefined ? 'all' : [asked.name]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function runBackfillList(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
138
|
+
return withJobDriver(root, ctx, async (driver) => {
|
|
139
|
+
const rows = await listBackfills(driver, {
|
|
140
|
+
name: flagString(ctx.args, 'name'),
|
|
141
|
+
status: flagString(ctx.args, 'status'),
|
|
142
|
+
limit: flagString(ctx.args, 'limit'),
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
command: 'db',
|
|
147
|
+
summary:
|
|
148
|
+
rows.length === 0
|
|
149
|
+
? msg('cli.db.backfill.empty')
|
|
150
|
+
: msg('cli.db.backfill.listed', { count: rows.length }),
|
|
151
|
+
lines: rows.length === 0 ? [] : renderBackfillTable(rows).map((line) => ` ${line}`),
|
|
152
|
+
data: rows.map(backfillToJson),
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The alarm the framework did not have. Non-zero when anything is unswept, so a cron or a deploy
|
|
159
|
+
* check can read the exit code — a `--json` nobody has to parse to know something is wrong.
|
|
160
|
+
* `loadApp` first: importing the app's modules IS the declaration, and a diff run without it
|
|
161
|
+
* would report a clean database against an empty declaration list.
|
|
162
|
+
*/
|
|
163
|
+
async function runBackfillPending(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
164
|
+
await loadApp(root);
|
|
165
|
+
const environment = resolveEnvironment({ env: ctx.env });
|
|
166
|
+
return withJobDriver(root, ctx, async (driver) => {
|
|
167
|
+
const report = await pendingReport(driver, environment);
|
|
168
|
+
return {
|
|
169
|
+
ok: report.pending.length === 0,
|
|
170
|
+
command: 'db',
|
|
171
|
+
summary:
|
|
172
|
+
report.pending.length === 0
|
|
173
|
+
? msg('cli.db.backfill.swept', { declared: report.rows.length })
|
|
174
|
+
: msg('cli.db.backfill.pending', {
|
|
175
|
+
count: report.pending.length,
|
|
176
|
+
declared: report.rows.length,
|
|
177
|
+
}),
|
|
178
|
+
findings: report.pending.map((row) =>
|
|
179
|
+
findingFrom(new BackfillPendingError({ backfill: row.name, environment })),
|
|
180
|
+
),
|
|
181
|
+
lines: report.rows.length === 0 ? [] : renderPendingTable(report).map((line) => ` ${line}`),
|
|
182
|
+
data: pendingToJson(report),
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* DRY RUN by default: `--write` is never implied, because the alternative is a command whose
|
|
189
|
+
* inspection form writes to a production table. What `--write` does is ENQUEUE — the queue is a
|
|
190
|
+
* job's execution surface, so the sweep runs on the workers already serving the new release
|
|
191
|
+
* rather than inside this process.
|
|
192
|
+
*/
|
|
193
|
+
async function runBackfillPass(
|
|
194
|
+
ctx: CommandContext,
|
|
195
|
+
root: string,
|
|
196
|
+
names: readonly string[] | 'all',
|
|
197
|
+
): Promise<CommandResult> {
|
|
198
|
+
await loadApp(root);
|
|
199
|
+
const environment = resolveEnvironment({ env: ctx.env });
|
|
200
|
+
const write = flagBool(ctx.args, 'write');
|
|
201
|
+
return withJobDriver(root, ctx, async (driver) => {
|
|
202
|
+
const rows = await runBackfills({
|
|
203
|
+
driver,
|
|
204
|
+
names,
|
|
205
|
+
write,
|
|
206
|
+
force: flagBool(ctx.args, 'force'),
|
|
207
|
+
environment,
|
|
208
|
+
appliedMigrations: await readAppliedMigrations(),
|
|
209
|
+
});
|
|
210
|
+
return backfillPassResult(rows, write);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A blocked or deduped name is a finding and a non-zero exit, and every OTHER name still ran —
|
|
216
|
+
* that isolation is what stops one wedged cleanup blocking every later one forever.
|
|
217
|
+
*/
|
|
218
|
+
function backfillPassResult(rows: readonly BackfillPlanRow[], write: boolean): CommandResult {
|
|
219
|
+
const findings = rows.flatMap((row) => (row.finding === null ? [] : [row.finding]));
|
|
220
|
+
// Counted per action, never derived from the total: a deduped pass is neither enqueued nor
|
|
221
|
+
// blocked, and `rows.length - enqueued` reported it as blocked while `--json` reported it as
|
|
222
|
+
// deduped. `planToJson` is the same list, so the two renders now add up to the same run.
|
|
223
|
+
const tally = (action: BackfillAction): number =>
|
|
224
|
+
rows.filter((row) => row.action === action).length;
|
|
225
|
+
return {
|
|
226
|
+
ok: findings.length === 0,
|
|
227
|
+
command: 'db',
|
|
228
|
+
summary: write
|
|
229
|
+
? msg('cli.db.backfill.planned', {
|
|
230
|
+
count: rows.length,
|
|
231
|
+
enqueued: tally('enqueued'),
|
|
232
|
+
deduped: tally('deduped'),
|
|
233
|
+
blocked: tally('blocked'),
|
|
234
|
+
})
|
|
235
|
+
: msg('cli.db.backfill.dryRun', { count: rows.length }),
|
|
236
|
+
findings,
|
|
237
|
+
lines: rows.length === 0 ? [] : renderPlanTable(rows).map((line) => ` ${line}`),
|
|
238
|
+
data: planToJson(rows),
|
|
239
|
+
};
|
|
240
|
+
}
|
package/src/cmd-db-branch.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// `x db branch ls` used to clone a database called `ls`, because the argument was the name.
|
|
4
4
|
// The facts (what a branch is, per mode) are `db-branch.ts`; the client lifetime is here.
|
|
5
5
|
|
|
6
|
+
import { nearestName } from '@ultimat3/core';
|
|
6
7
|
import { createPostgresClient, type DbClient } from '@ultimat3/db';
|
|
7
8
|
import type { CommandContext } from './command';
|
|
8
9
|
import type { BranchRow } from './db-branch';
|
|
@@ -27,7 +28,7 @@ import { resolveServices } from './dev-services';
|
|
|
27
28
|
import { MissingPositionalError, UnknownCommandError } from './errors';
|
|
28
29
|
import { msg } from './messages';
|
|
29
30
|
import type { CommandResult, Finding } from './output';
|
|
30
|
-
import { flagString
|
|
31
|
+
import { flagString } from './parse';
|
|
31
32
|
import { portFromEnv } from './serve';
|
|
32
33
|
import { renderTable } from './table';
|
|
33
34
|
|
|
@@ -46,7 +47,7 @@ const LIST_FIX = `x ${LIST_ARGV}`;
|
|
|
46
47
|
* `x` excluded — the error class adds it.
|
|
47
48
|
*/
|
|
48
49
|
function branchRetry(word: string, name: string | undefined): string {
|
|
49
|
-
const near =
|
|
50
|
+
const near = nearestName(word, [...BRANCH_SUBCOMMANDS]);
|
|
50
51
|
if (near !== undefined) return name === undefined ? LIST_ARGV : `db branch ${near} ${name}`;
|
|
51
52
|
return isBranchName(word) ? `db branch create ${word}` : LIST_ARGV;
|
|
52
53
|
}
|