@mcptoolshop/housekeeping 0.0.0 → 1.3.1
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/CHANGELOG.md +103 -0
- package/LICENSE +21 -0
- package/README.es.md +217 -0
- package/README.fr.md +195 -0
- package/README.hi.md +195 -0
- package/README.it.md +195 -0
- package/README.ja.md +199 -0
- package/README.md +246 -5
- package/README.pt-BR.md +222 -0
- package/README.zh.md +197 -0
- package/housekeeping.config.example.json +4 -0
- package/package.json +55 -5
- package/rules/atlas-map.md +30 -0
- package/rules/github-actions.md +59 -0
- package/rules/repo-first.md +12 -0
- package/rules/shipcheck-product-standards.md +37 -0
- package/src/analyze.mjs +1360 -0
- package/src/cli.mjs +316 -0
- package/src/collect.mjs +1184 -0
- package/src/config.mjs +75 -0
- package/src/cost.mjs +271 -0
- package/src/errors.mjs +102 -0
- package/src/gh.mjs +286 -0
- package/src/load.mjs +907 -0
- package/src/lockfile.mjs +272 -0
- package/src/log.mjs +41 -0
- package/src/mcp.mjs +298 -0
- package/src/paths.mjs +31 -0
- package/src/report.mjs +570 -0
- package/src/schema.sql +385 -0
- package/src/workflow-risks.mjs +455 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// housekeeping CLI - query the org operational-health warehouse.
|
|
3
|
+
//
|
|
4
|
+
// Results go to stdout; progress and errors go to stderr. Exit codes are in
|
|
5
|
+
// errors.mjs: 0 ok, 1 user error, 2 runtime error, 3 partial.
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
import { openDb, loadSnapshot, latestSnapshotFile, DB_PATH } from './load.mjs';
|
|
9
|
+
import { analyze, healthScores, RUN_IS_LIVE_MAINLINE_SIGNAL, HEAD_CHECKS_RED } from './analyze.mjs';
|
|
10
|
+
import { collect, DRILL_BUDGET } from './collect.mjs';
|
|
11
|
+
import { preflight } from './gh.mjs';
|
|
12
|
+
import { resolveOrg } from './config.mjs';
|
|
13
|
+
import { writeReport } from './report.mjs';
|
|
14
|
+
import { EXIT, HkError, userError, exitCodeFor, formatError, readOnlyQuery, sweepGaps } from './errors.mjs';
|
|
15
|
+
import { info, isDebug, levelFromFlags, parseLevel, setLevel } from './log.mjs';
|
|
16
|
+
|
|
17
|
+
// Opened by the first command that needs it, so `hk help` and a mistyped
|
|
18
|
+
// command never create a database.
|
|
19
|
+
let db = null;
|
|
20
|
+
const latestSid = () => {
|
|
21
|
+
const row = db.prepare('SELECT MAX(id) AS id FROM snapshot').get();
|
|
22
|
+
if (!row?.id) throw userError('NO_SNAPSHOT', 'no snapshot is loaded', 'run `hk refresh` to sweep, or `hk load <file>` to load one');
|
|
23
|
+
return row.id;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function table(rows, { max = 200 } = {}) {
|
|
27
|
+
if (!rows.length) return console.log('(no rows)');
|
|
28
|
+
const cols = Object.keys(rows[0]);
|
|
29
|
+
const shown = rows.slice(0, max);
|
|
30
|
+
const w = cols.map(c => Math.min(60, Math.max(c.length,
|
|
31
|
+
...shown.map(r => String(r[c] ?? '').length))));
|
|
32
|
+
const line = (cells) => cells.map((s, i) => String(s ?? '').slice(0, w[i]).padEnd(w[i])).join(' ');
|
|
33
|
+
console.log(line(cols));
|
|
34
|
+
console.log(w.map(n => '-'.repeat(n)).join(' '));
|
|
35
|
+
for (const r of shown) console.log(line(cols.map(c => r[c])));
|
|
36
|
+
if (rows.length > shown.length) console.log(`... ${rows.length - shown.length} more rows`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const REPORTS = {
|
|
40
|
+
summary: sid => {
|
|
41
|
+
const s = db.prepare('SELECT * FROM snapshot WHERE id=?').get(sid);
|
|
42
|
+
console.log(`snapshot ${sid} org=${s.org} taken=${s.taken_at} collector=${s.collector_version}`);
|
|
43
|
+
table(db.prepare(`SELECT
|
|
44
|
+
(SELECT COUNT(*) FROM repo WHERE snapshot_id=?1) repos,
|
|
45
|
+
(SELECT COUNT(*) FROM repo WHERE snapshot_id=?1 AND is_archived=1) archived,
|
|
46
|
+
(SELECT COUNT(*) FROM repo WHERE snapshot_id=?1 AND is_private=1) private,
|
|
47
|
+
(SELECT COUNT(*) FROM issue WHERE snapshot_id=?1) open_issues,
|
|
48
|
+
(SELECT COUNT(*) FROM pull_request WHERE snapshot_id=?1) open_prs,
|
|
49
|
+
(SELECT COUNT(*) FROM workflow WHERE snapshot_id=?1) workflows,
|
|
50
|
+
(SELECT COUNT(*) FROM workflow_run WHERE snapshot_id=?1) runs,
|
|
51
|
+
(SELECT COUNT(*) FROM release WHERE snapshot_id=?1) releases,
|
|
52
|
+
(SELECT COUNT(*) FROM finding WHERE snapshot_id=?1) findings,
|
|
53
|
+
(SELECT COUNT(*) FROM collect_error WHERE snapshot_id=?1) collect_errors`).all(sid));
|
|
54
|
+
},
|
|
55
|
+
// Must stay identical to report.mjs's "default branch is red" query: only
|
|
56
|
+
// latest-on-default runs from push-like events count as a broken mainline.
|
|
57
|
+
ci: sid => table(db.prepare(`
|
|
58
|
+
SELECT DISTINCT r.name, r.default_branch branch, r.ci_rollup rollup,
|
|
59
|
+
r.open_prs, r.days_since_push push_age,
|
|
60
|
+
(SELECT GROUP_CONCAT(wr.workflow_name, '; ') FROM workflow_run wr
|
|
61
|
+
WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
|
|
62
|
+
AND ${RUN_IS_LIVE_MAINLINE_SIGNAL}) failing
|
|
63
|
+
FROM repo r WHERE r.snapshot_id=? AND r.is_archived=0
|
|
64
|
+
AND (${HEAD_CHECKS_RED} OR EXISTS (
|
|
65
|
+
SELECT 1 FROM workflow_run wr WHERE wr.snapshot_id=r.snapshot_id AND wr.repo=r.name
|
|
66
|
+
AND ${RUN_IS_LIVE_MAINLINE_SIGNAL}))
|
|
67
|
+
ORDER BY r.name`).all(sid)),
|
|
68
|
+
findings: sid => table(db.prepare(`
|
|
69
|
+
SELECT severity, category, code, COUNT(*) n, COUNT(DISTINCT repo) repos
|
|
70
|
+
FROM finding WHERE snapshot_id=?
|
|
71
|
+
GROUP BY severity, category, code
|
|
72
|
+
ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1
|
|
73
|
+
WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END, n DESC`).all(sid)),
|
|
74
|
+
health: sid => table(healthScores(db, latestOr(sid)).slice(0, 40)),
|
|
75
|
+
prs: sid => table(db.prepare(`
|
|
76
|
+
SELECT repo, number, age_days age, stale_days stale, is_draft draft, mergeable,
|
|
77
|
+
author, SUBSTR(title,1,54) title
|
|
78
|
+
FROM pull_request WHERE snapshot_id=? ORDER BY age_days DESC`).all(sid), { max: 400 }),
|
|
79
|
+
issues: sid => table(db.prepare(`
|
|
80
|
+
SELECT repo, number, age_days age, stale_days stale, comments c,
|
|
81
|
+
author, SUBSTR(title,1,54) title
|
|
82
|
+
FROM issue WHERE snapshot_id=? ORDER BY stale_days DESC`).all(sid), { max: 400 }),
|
|
83
|
+
actions: sid => table(db.prepare(`
|
|
84
|
+
SELECT repo, path, on_triggers, has_paths_filter paths, has_concurrency conc,
|
|
85
|
+
has_workflow_dispatch disp, runners, job_count jobs
|
|
86
|
+
FROM workflow WHERE snapshot_id=? ORDER BY repo, path`).all(sid), { max: 400 }),
|
|
87
|
+
// Gross vs net are printed side by side and never summed. On a public repo
|
|
88
|
+
// GitHub meters at full price and discounts to zero: gross is compute worth
|
|
89
|
+
// managing, net is money owed.
|
|
90
|
+
cost: sid => {
|
|
91
|
+
const lines = db.prepare(
|
|
92
|
+
"SELECT COUNT(*) n FROM billing_usage WHERE snapshot_id=? AND product='actions'").get(sid);
|
|
93
|
+
if (!lines.n) {
|
|
94
|
+
const err = db.prepare(
|
|
95
|
+
"SELECT message FROM collect_error WHERE snapshot_id=? AND stage='billing'").all(sid);
|
|
96
|
+
return console.log(err.length
|
|
97
|
+
? `no cost data - billing API unreadable: ${err[0].message}`
|
|
98
|
+
: 'no cost data in this snapshot - run `hk refresh`');
|
|
99
|
+
}
|
|
100
|
+
table(db.prepare(`SELECT b.repo,
|
|
101
|
+
printf('%.2f', SUM(b.gross)) gross, printf('%.2f', SUM(b.net)) net,
|
|
102
|
+
CASE WHEN r.is_private=1 THEN 'private' ELSE 'public' END vis,
|
|
103
|
+
ROUND(SUM(CASE WHEN b.sku='Actions Linux' THEN b.quantity ELSE 0 END)) linux_min,
|
|
104
|
+
ROUND(SUM(CASE WHEN b.sku='Actions Windows' THEN b.quantity ELSE 0 END)) win_min,
|
|
105
|
+
ROUND(SUM(CASE WHEN b.sku LIKE 'Actions macOS%' THEN b.quantity ELSE 0 END)) mac_min
|
|
106
|
+
FROM billing_usage b LEFT JOIN repo r ON r.snapshot_id=b.snapshot_id AND r.name=b.repo
|
|
107
|
+
WHERE b.snapshot_id=? AND b.product='actions' AND b.sku<>'Actions storage'
|
|
108
|
+
AND b.repo IS NOT NULL
|
|
109
|
+
GROUP BY b.repo ORDER BY SUM(b.gross) DESC`).all(sid), { max: 25 });
|
|
110
|
+
},
|
|
111
|
+
// Per-workflow attribution, restricted to repos whose priced runs agreed with
|
|
112
|
+
// the invoice. A cost claim on a repo that diverged is a guess.
|
|
113
|
+
'cost-workflows': (sid, repo) => table(db.prepare(`
|
|
114
|
+
SELECT c.repo, c.workflow_name workflow, COUNT(*) runs,
|
|
115
|
+
SUM(c.billable_minutes) min, printf('%.2f', SUM(c.cost_usd)) cost,
|
|
116
|
+
printf('%.1f', AVG(c.billed_job_count)) jobs_per_run,
|
|
117
|
+
SUM(CASE WHEN c.conclusion='failure' THEN 1 ELSE 0 END) failed
|
|
118
|
+
FROM run_cost c
|
|
119
|
+
JOIN cost_reconciliation x ON x.snapshot_id=c.snapshot_id AND x.repo=c.repo
|
|
120
|
+
WHERE c.snapshot_id=? AND x.trustworthy=1 AND (?2 IS NULL OR c.repo=?2)
|
|
121
|
+
GROUP BY c.repo, c.workflow_name ORDER BY SUM(c.cost_usd) DESC`).all(sid, repo ?? null)),
|
|
122
|
+
'cost-jobs': (sid, repo) => table(db.prepare(`
|
|
123
|
+
SELECT j.repo, c.workflow_name workflow,
|
|
124
|
+
TRIM(CASE WHEN j.name LIKE '%(%)' THEN SUBSTR(j.name,1,INSTR(j.name,' (')-1)
|
|
125
|
+
ELSE j.name END) job,
|
|
126
|
+
COUNT(*) cells, SUM(j.minutes) min, printf('%.2f', SUM(j.cost_usd)) cost
|
|
127
|
+
FROM run_cost_job j JOIN run_cost c
|
|
128
|
+
ON c.snapshot_id=j.snapshot_id AND c.run_id=j.run_id
|
|
129
|
+
WHERE j.snapshot_id=? AND (?2 IS NULL OR j.repo=?2)
|
|
130
|
+
GROUP BY j.repo, c.workflow_name, job
|
|
131
|
+
ORDER BY SUM(j.cost_usd) DESC`).all(sid, repo ?? null), { max: 40 }),
|
|
132
|
+
versions: sid => table(db.prepare(`
|
|
133
|
+
SELECT name, pkg_name, pkg_version pkg, latest_tag tag,
|
|
134
|
+
latest_release_tag release, npm_latest_version npm, npm_status,
|
|
135
|
+
(SELECT COUNT(*) FROM tag t WHERE t.snapshot_id=r.snapshot_id
|
|
136
|
+
AND t.repo=r.name AND t.is_semver=1 AND t.has_release=0) untagged_rel
|
|
137
|
+
FROM repo r WHERE snapshot_id=? AND pkg_name IS NOT NULL
|
|
138
|
+
ORDER BY name`).all(sid), { max: 200 }),
|
|
139
|
+
repos: sid => table(db.prepare(`
|
|
140
|
+
SELECT name, is_private priv, is_archived arch, primary_language lang,
|
|
141
|
+
disk_usage_kb kb, days_since_push push_age, open_issues iss, open_prs prs,
|
|
142
|
+
release_count rels, ci_rollup, workflow_count wfs
|
|
143
|
+
FROM repo WHERE snapshot_id=? ORDER BY name`).all(sid), { max: 200 }),
|
|
144
|
+
repo: (sid, name) => {
|
|
145
|
+
if (!name) throw userError('USAGE', '`hk repo` needs a repository name', 'hk repo <name>');
|
|
146
|
+
table(db.prepare('SELECT * FROM repo WHERE snapshot_id=? AND name=?').all(sid, name));
|
|
147
|
+
console.log('\n-- findings --');
|
|
148
|
+
table(db.prepare('SELECT severity, code, message, evidence FROM finding WHERE snapshot_id=? AND repo=?').all(sid, name));
|
|
149
|
+
console.log('\n-- workflows --');
|
|
150
|
+
table(db.prepare('SELECT path, on_triggers, has_paths_filter, has_concurrency, runners FROM workflow WHERE snapshot_id=? AND repo=?').all(sid, name));
|
|
151
|
+
console.log('\n-- open PRs --');
|
|
152
|
+
table(db.prepare('SELECT number, age_days, mergeable, author, SUBSTR(title,1,60) title FROM pull_request WHERE snapshot_id=? AND repo=?').all(sid, name));
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
function latestOr(sid) { return sid; }
|
|
157
|
+
|
|
158
|
+
// ---- commands ---------------------------------------------------------------
|
|
159
|
+
// One table drives both the dispatch and `hk help`, so the help text cannot
|
|
160
|
+
// describe a command that does not exist or omit one that does.
|
|
161
|
+
|
|
162
|
+
async function refresh(args) {
|
|
163
|
+
const org = resolveOrg(args.find(a => !a.startsWith('--')));
|
|
164
|
+
db = openDb();
|
|
165
|
+
|
|
166
|
+
// A sweep costs a few hundred API calls. Starting one without the headroom
|
|
167
|
+
// to finish produces a PARTIALLY collected snapshot, and load.mjs stores it
|
|
168
|
+
// without complaint -- a half-swept org then reads as a healthy one. Refuse
|
|
169
|
+
// up front instead. `--force` is there for the case where you accept a
|
|
170
|
+
// partial and know it.
|
|
171
|
+
const force = args.includes('--force');
|
|
172
|
+
// 300 covers the base sweep. The cost drill is the expensive part -- one
|
|
173
|
+
// REST call per priced run on a cold cache -- so its budget is added here
|
|
174
|
+
// rather than discovered halfway through. Capped at 900 because a warm
|
|
175
|
+
// cache spends almost none of it, and refusing an otherwise-healthy sweep
|
|
176
|
+
// over a reserve it will not use is its own failure. If the drill does run
|
|
177
|
+
// out it halts and marks the attribution incomplete; it never truncates
|
|
178
|
+
// silently.
|
|
179
|
+
const pre = await preflight({
|
|
180
|
+
rest: 300 + Math.min(DRILL_BUDGET, 900),
|
|
181
|
+
graphql: 300,
|
|
182
|
+
});
|
|
183
|
+
if (!pre.ok && !force) {
|
|
184
|
+
if (pre.limit === 'secondary') {
|
|
185
|
+
// The quota is fine and we are still blocked: burst/concurrency limit.
|
|
186
|
+
// It is short-lived and has no published clock, so there is nothing to
|
|
187
|
+
// wait *for* -- just fewer callers.
|
|
188
|
+
throw new HkError('RATE_LIMITED',
|
|
189
|
+
`refusing to start: GitHub's secondary rate limit is active (the quota is healthy at core ${pre.core}, graphql ${pre.graphql})`,
|
|
190
|
+
{ hint: 'too many concurrent callers on this token; retry in a few minutes with fewer', retryable: true });
|
|
191
|
+
}
|
|
192
|
+
throw new HkError('RATE_LIMITED',
|
|
193
|
+
`refusing to start: core ${pre.core}, graphql ${pre.graphql} calls remain, and a full sweep would be cut short`,
|
|
194
|
+
{ hint: `the quota resets in ${pre.resetsInS}s; or re-run with --force to accept a partial snapshot`, retryable: true });
|
|
195
|
+
}
|
|
196
|
+
if (pre.unknown) info('[refresh] rate-limit probe unreadable; continuing without a budget check.');
|
|
197
|
+
|
|
198
|
+
const { snapshot } = await collect(org);
|
|
199
|
+
const sid = loadSnapshot(db, snapshot);
|
|
200
|
+
const n = analyze(db, sid);
|
|
201
|
+
info(`[refresh] snapshot ${sid}: ${snapshot.repo_count} repos, ${n} findings`);
|
|
202
|
+
info(`[refresh] wrote ${writeReport(db, sid)}`);
|
|
203
|
+
REPORTS.summary(sid);
|
|
204
|
+
|
|
205
|
+
const gaps = sweepGaps(snapshot);
|
|
206
|
+
if (gaps.length) {
|
|
207
|
+
// The snapshot is stored and the report is written: what was collected is
|
|
208
|
+
// true. But it is not everything that was asked for, and a caller that
|
|
209
|
+
// only reads the exit code must be able to tell.
|
|
210
|
+
for (const g of gaps) console.error(`partial: ${g}`);
|
|
211
|
+
return EXIT.PARTIAL;
|
|
212
|
+
}
|
|
213
|
+
return EXIT.OK;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function load(args) {
|
|
217
|
+
db = openDb();
|
|
218
|
+
const file = args[0] ?? latestSnapshotFile();
|
|
219
|
+
let snapshot;
|
|
220
|
+
try { snapshot = JSON.parse(readFileSync(file, 'utf8')); }
|
|
221
|
+
catch (e) {
|
|
222
|
+
throw userError('SNAPSHOT_UNREADABLE', `cannot read a snapshot from ${file}: ${e.message}`,
|
|
223
|
+
'pass the path of a file under data/snapshots/');
|
|
224
|
+
}
|
|
225
|
+
const sid = loadSnapshot(db, snapshot);
|
|
226
|
+
analyze(db, sid);
|
|
227
|
+
info(`[load] snapshot ${sid} <- ${file}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function report(args) {
|
|
231
|
+
db = openDb();
|
|
232
|
+
const sid = args[0] ? Number(args[0]) : latestSid();
|
|
233
|
+
if (!Number.isInteger(sid)) throw userError('USAGE', `"${args[0]}" is not a snapshot id`, 'hk snapshots lists them');
|
|
234
|
+
info(`[report] wrote ${writeReport(db, sid)}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function sql(args) {
|
|
238
|
+
const q = readOnlyQuery(args.join(' '));
|
|
239
|
+
// A connection that cannot write, as well as a statement check: the
|
|
240
|
+
// warehouse is derived data, and `hk sql` is documented as read-only.
|
|
241
|
+
openDb().close(); // make sure the file and schema exist
|
|
242
|
+
db = new DatabaseSync(DB_PATH, { readOnly: true });
|
|
243
|
+
let rows;
|
|
244
|
+
try { rows = db.prepare(q).all(); }
|
|
245
|
+
catch (e) { throw userError('SQL_FAILED', e.message, 'hk sql "SELECT name FROM sqlite_master" lists the tables'); }
|
|
246
|
+
table(rows, { max: 500 });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const viaReport = name => args => { db = openDb(); return REPORTS[name](latestSid(), ...args); };
|
|
250
|
+
|
|
251
|
+
const COMMANDS = {
|
|
252
|
+
refresh: ['[org] [--force]', 'sweep the org: collect, load, analyze, write the report (network)', refresh],
|
|
253
|
+
load: ['[file]', 'load a snapshot JSON already on disk (the newest by default)', load],
|
|
254
|
+
report: ['[snapshot-id]', 'write reports/AUDIT-<date>.md for a loaded snapshot', report],
|
|
255
|
+
summary: ['', 'snapshot totals (the default command)', viaReport('summary')],
|
|
256
|
+
repos: ['', 'every repo, one line each', viaReport('repos')],
|
|
257
|
+
repo: ['<name>', 'one repo in full, with findings', viaReport('repo')],
|
|
258
|
+
findings: ['', 'finding counts by severity, category and code', viaReport('findings')],
|
|
259
|
+
health: ['', 'per-repo health score, worst first', viaReport('health')],
|
|
260
|
+
ci: ['', 'repos whose default branch is red', viaReport('ci')],
|
|
261
|
+
actions: ['', 'every workflow and its rule-compliance flags', viaReport('actions')],
|
|
262
|
+
cost: ['', 'Actions cost per repo, gross and net side by side', viaReport('cost')],
|
|
263
|
+
'cost-workflows': ['[repo]', 'which workflow spent the minutes', viaReport('cost-workflows')],
|
|
264
|
+
'cost-jobs': ['[repo]', 'which job, with expanded matrix cells', viaReport('cost-jobs')],
|
|
265
|
+
versions: ['', 'package.json against git tag against npm', viaReport('versions')],
|
|
266
|
+
prs: ['', 'every open pull request by age', viaReport('prs')],
|
|
267
|
+
issues: ['', 'every open issue by staleness', viaReport('issues')],
|
|
268
|
+
snapshots: ['', 'the snapshots loaded into the database', () => {
|
|
269
|
+
db = openDb();
|
|
270
|
+
table(db.prepare('SELECT id, taken_at, org, repo_count, duration_ms FROM snapshot ORDER BY id').all());
|
|
271
|
+
}],
|
|
272
|
+
sql: ['"<query>"', 'one read-only SELECT or WITH statement', sql],
|
|
273
|
+
help: ['', 'this text', () => console.log(usage())],
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
function usage() {
|
|
277
|
+
const rows = Object.entries(COMMANDS).map(([name, [args, help]]) => [`hk ${name}${args ? ' ' + args : ''}`, help]);
|
|
278
|
+
const w = Math.max(...rows.map(r => r[0].length));
|
|
279
|
+
return [
|
|
280
|
+
'housekeeping - an operational-health warehouse for a GitHub organization',
|
|
281
|
+
'',
|
|
282
|
+
...rows.map(([l, r]) => ` ${l.padEnd(w)} ${r}`),
|
|
283
|
+
'',
|
|
284
|
+
'Flags, on any command:',
|
|
285
|
+
' --quiet errors only',
|
|
286
|
+
' --verbose one line per GitHub call',
|
|
287
|
+
' --debug stack traces on errors',
|
|
288
|
+
' --help, -h this text',
|
|
289
|
+
'',
|
|
290
|
+
'Exit codes: 0 ok, 1 user error, 2 runtime error, 3 partial (finished, with gaps).',
|
|
291
|
+
].join('\n');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const GLOBAL_FLAGS = new Set(['--quiet', '--verbose', '--debug', '--help', '-h']);
|
|
295
|
+
|
|
296
|
+
async function main(argv) {
|
|
297
|
+
const flags = new Set(argv.filter(a => GLOBAL_FLAGS.has(a)));
|
|
298
|
+
const [cmd = 'summary', ...args] = argv.filter(a => !GLOBAL_FLAGS.has(a));
|
|
299
|
+
if (flags.has('--help') || flags.has('-h')) { console.log(usage()); return EXIT.OK; }
|
|
300
|
+
// Validated here, once, so a mistyped HK_LOG is reported before any work
|
|
301
|
+
// starts and not from the middle of a sweep.
|
|
302
|
+
setLevel(levelFromFlags(flags) ?? parseLevel(process.env.HK_LOG));
|
|
303
|
+
const entry = Object.hasOwn(COMMANDS, cmd) ? COMMANDS[cmd] : null;
|
|
304
|
+
if (!entry) throw userError('USAGE', `unknown command "${cmd}"`, '`hk help` lists the commands');
|
|
305
|
+
return (await entry[2](args)) ?? EXIT.OK;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
310
|
+
} catch (e) {
|
|
311
|
+
// isDebug() can itself throw on a malformed HK_LOG; that must not mask `e`.
|
|
312
|
+
let debug = process.argv.includes('--debug');
|
|
313
|
+
try { debug = debug || isDebug(); } catch { /* reported by the command that read it */ }
|
|
314
|
+
console.error(formatError(e, { debug }));
|
|
315
|
+
process.exitCode = exitCodeFor(e);
|
|
316
|
+
}
|