@codedology/code-forge 0.2.3 → 0.3.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/CHANGELOG.md +15 -0
- package/README.md +2 -0
- package/bin/code-forge.mjs +57 -9
- package/docs/README.md +1 -0
- package/docs/reference/blocks.json +26 -1
- package/package.json +1 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/logs.mjs +899 -0
- package/src/keys/onepassword.mjs +9 -3
- package/src/session/scrub.mjs +266 -0
- package/src/util/error-hint.mjs +92 -0
- package/src/util/error-kind.mjs +32 -0
- package/src/util/error-log.mjs +525 -0
package/src/cli/logs.mjs
ADDED
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code-forge logs` (blocks B27, B28): read, summarize, clear and share the local error log
|
|
3
|
+
* (`~/.code-forge/logs/errors.jsonl`, written by `bin/code-forge.mjs` through `util/error-log.mjs`).
|
|
4
|
+
*
|
|
5
|
+
* logs [--last N] [--json] newest first (default 10), with each error's fingerprint
|
|
6
|
+
* logs summary [--days N] [--json] one row per fingerprint over the last N days (default 30): "seen N times"
|
|
7
|
+
* logs clear [--yes] delete the log after one yes
|
|
8
|
+
* logs path print the log path
|
|
9
|
+
* logs report [--last N] [--kind K] [--verb V] [--note "text"] [--no-ai] [--allow-old] [--dry-run] [--yes]
|
|
10
|
+
*
|
|
11
|
+
* `report` (B28 order): a version check (`npm view`; an old version is warned and asked about,
|
|
12
|
+
* default no, `--yes` included; without a terminal it stops unless `--allow-old`) → the
|
|
13
|
+
* deterministic scrub → a 16 KB cap → a secret check before the AI sees anything → the AI cleaning pass (a closed-book session
|
|
14
|
+
* that only LISTS private substrings; this tool replaces them, `session/scrub.mjs`) → the
|
|
15
|
+
* deterministic scrub again → a final secret check (a hit stops it; the matched text is never
|
|
16
|
+
* printed) → what is shared, a cleaning summary and the full text → Send · Edit in my editor ·
|
|
17
|
+
* Cancel (an edit is scrubbed and checked again) → an extra default-no yes when the AI pass did not
|
|
18
|
+
* run → `gh` after a search for the same fingerprint (comment "happened again" · new issue · cancel),
|
|
19
|
+
* or a prefilled issue-form link plus a search link. It never opens a browser. The repository
|
|
20
|
+
* comes from package.json.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { accessSync, constants as fsConstants, readFileSync, statSync } from 'node:fs';
|
|
25
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { exec as realExec } from '../util/exec.mjs';
|
|
30
|
+
import { capBytes, createScrubber, describeCounts, errorLogPath, fingerprintOf, mergeCounts, packageVersion, readErrorLog, readProjectSlug } from '../util/error-log.mjs';
|
|
31
|
+
import { writeSafe } from '../util/redact.mjs';
|
|
32
|
+
import { currentRunRoot } from '../util/tmp.mjs';
|
|
33
|
+
import { SECRET_LOOKING_PATTERNS } from '../config/secret-patterns.mjs';
|
|
34
|
+
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
35
|
+
import { AI_REPORT_MAX_BYTES, applyItems, describeAiCounts, runAiScrub } from '../session/scrub.mjs';
|
|
36
|
+
|
|
37
|
+
const USAGE =
|
|
38
|
+
'usage: code-forge logs [--last N] [--json] | logs summary [--days N] [--json] | logs clear [--yes] | logs path\n' +
|
|
39
|
+
' code-forge logs report [--last N] [--kind K] [--verb V] [--note "text"] [--no-ai] [--allow-old] [--dry-run] [--yes]\n';
|
|
40
|
+
|
|
41
|
+
const PACKAGE_JSON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
|
|
42
|
+
|
|
43
|
+
/** A prefilled issue link longer than this is cut (browsers and GitHub refuse very long URLs). */
|
|
44
|
+
export const MAX_URL_LENGTH = 7500;
|
|
45
|
+
|
|
46
|
+
/** Printed before every report preview. */
|
|
47
|
+
export const DISCLOSURE =
|
|
48
|
+
'What you will share (public on GitHub): code-forge, Node and OS versions; command names and flag NAMES; exit codes; error types; error messages and crash reports after cleaning.\n' +
|
|
49
|
+
'Never shared: flag values, file contents, your code, keys or tokens, your home folder, project folder or project name.\n';
|
|
50
|
+
|
|
51
|
+
export const CONFIRM_MESSAGE = 'This will be public on GitHub. Send it?';
|
|
52
|
+
|
|
53
|
+
const GH_TIMEOUT_MS = 60_000;
|
|
54
|
+
const NPM_TIMEOUT_MS = 10_000;
|
|
55
|
+
|
|
56
|
+
/** The npm package a version check looks up. */
|
|
57
|
+
export const PACKAGE_NAME = '@codedology/code-forge';
|
|
58
|
+
|
|
59
|
+
/** @typedef {import('../util/error-log.mjs').ErrorEntry} ErrorEntry */
|
|
60
|
+
/** @typedef {{write: (s: string) => unknown}} Out */
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {object} LogsDeps
|
|
64
|
+
* @property {NodeJS.ProcessEnv} [env]
|
|
65
|
+
* @property {string} [cwd]
|
|
66
|
+
* @property {Out} [stdout]
|
|
67
|
+
* @property {Out} [stderr]
|
|
68
|
+
* @property {boolean} [isTTY]
|
|
69
|
+
* @property {{confirm: (o: {message: string, initialValue?: boolean}) => Promise<any>, select: (o: {message: string, options: Array<{value: string, label: string}>, initialValue?: string}) => Promise<any>, isCancel: (v: unknown) => boolean}} [ui]
|
|
70
|
+
* @property {typeof realExec} [exec]
|
|
71
|
+
* @property {(cmd: string) => boolean} [onPath] - whether `cmd` is an executable on PATH.
|
|
72
|
+
* @property {() => Date} [now]
|
|
73
|
+
* @property {any} [pkg] - the package.json object the issue repository is read from (default: this package's).
|
|
74
|
+
* @property {{version: string, node: string, os: string}} [system] - the versions a report names.
|
|
75
|
+
* @property {(argv: string[], env: NodeJS.ProcessEnv) => Promise<{code: number|null, error?: string}>} [runEditor] - runs
|
|
76
|
+
* the editor on the terminal (default: argv only, stdio inherited, never a shell).
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Secret shapes the final report is checked for, after scrubbing. A hit stops the report.
|
|
81
|
+
* @type {ReadonlyArray<{name: string, re: RegExp}>}
|
|
82
|
+
*/
|
|
83
|
+
export const REPORT_SECRET_RULES = Object.freeze([
|
|
84
|
+
{ name: 'API key (sk-)', re: /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{10,}/ },
|
|
85
|
+
{ name: 'GitHub token', re: /(?<![A-Za-z0-9_])(?:gh[opsur]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/ },
|
|
86
|
+
{ name: 'Slack token', re: /(?<![A-Za-z0-9_-])xox[abprs]-[A-Za-z0-9-]{10,}/ },
|
|
87
|
+
{ name: 'AWS access key', re: /(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}(?![0-9A-Z])/ },
|
|
88
|
+
{ name: 'private key', re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
|
|
89
|
+
{ name: 'JWT', re: /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/ },
|
|
90
|
+
// copied without g/y: `.test()` must carry no `lastIndex` state between checks
|
|
91
|
+
...SECRET_LOOKING_PATTERNS.map((re) => ({ name: 'secret-shaped token', re: new RegExp(re.source, re.flags.replace(/[gy]/g, '')) })),
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
/** @param {string} s @returns {number} Shannon entropy in bits per character */
|
|
95
|
+
export function entropy(s) {
|
|
96
|
+
/** @type {Map<string, number>} */
|
|
97
|
+
const freq = new Map();
|
|
98
|
+
for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
|
99
|
+
let bits = 0;
|
|
100
|
+
for (const n of freq.values()) {
|
|
101
|
+
const p = n / s.length;
|
|
102
|
+
bits -= p * Math.log2(p);
|
|
103
|
+
}
|
|
104
|
+
return bits;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A 32+ character run of `[A-Za-z0-9+/_=-]` with at least two of upper/lower/digit and entropy of
|
|
109
|
+
* at least 4 bits per character (a hex hash tops out at 4 and is not flagged; a random token is).
|
|
110
|
+
* @param {string} line
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
function hasHighEntropyRun(line) {
|
|
114
|
+
for (const m of line.matchAll(/[A-Za-z0-9+/_=-]{32,}/g)) {
|
|
115
|
+
const run = m[0];
|
|
116
|
+
const classes = [/[A-Z]/, /[a-z]/, /[0-9]/].filter((re) => re.test(run)).length;
|
|
117
|
+
if (classes >= 2 && entropy(run) >= 4) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The first secret-shaped text in `text` (rule name and 1-based line), or null. The matched text
|
|
124
|
+
* itself is never returned.
|
|
125
|
+
* @param {string} text
|
|
126
|
+
* @returns {{rule: string, line: number}|null}
|
|
127
|
+
*/
|
|
128
|
+
export function findSecret(text) {
|
|
129
|
+
const lines = text.split('\n');
|
|
130
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
131
|
+
for (const r of REPORT_SECRET_RULES) if (r.re.test(lines[i])) return { rule: r.name, line: i + 1 };
|
|
132
|
+
if (hasHighEntropyRun(lines[i])) return { rule: 'high-entropy string', line: i + 1 };
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* `owner/repo` from package.json `bugs.url`, else `repository` (string or `{url}`).
|
|
139
|
+
* @param {any} [pkg]
|
|
140
|
+
* @returns {string|null}
|
|
141
|
+
*/
|
|
142
|
+
export function issueRepo(pkg) {
|
|
143
|
+
let p = pkg;
|
|
144
|
+
if (p === undefined) {
|
|
145
|
+
try {
|
|
146
|
+
p = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8'));
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const candidates = [typeof p?.bugs === 'string' ? p.bugs : p?.bugs?.url, typeof p?.repository === 'string' ? p.repository : p?.repository?.url];
|
|
152
|
+
for (const c of candidates) {
|
|
153
|
+
const m = typeof c === 'string' ? /github\.com[/:]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:[/?#]|$)/.exec(c) : null;
|
|
154
|
+
if (m) return `${m[1]}/${m[2]}`;
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @param {string} pathEnv @returns {(cmd: string) => boolean} */
|
|
160
|
+
function pathLookup(pathEnv) {
|
|
161
|
+
return (cmd) =>
|
|
162
|
+
pathEnv.split(path.delimiter).some((dir) => {
|
|
163
|
+
if (!dir) return false;
|
|
164
|
+
try {
|
|
165
|
+
const f = path.join(dir, cmd);
|
|
166
|
+
accessSync(f, fsConstants.X_OK);
|
|
167
|
+
return statSync(f).isFile();
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @param {ErrorEntry} e */
|
|
175
|
+
function command(e) {
|
|
176
|
+
return e.sub ? `${e.verb} ${e.sub}` : e.verb;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** @param {string|null|undefined} s */
|
|
180
|
+
function firstLine(s) {
|
|
181
|
+
return typeof s === 'string' ? (s.split('\n').find((l) => l.trim().length > 0) ?? '').trim() : '';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** @param {unknown} v @returns {string} a table cell: one line, `|` escaped */
|
|
185
|
+
function cell(v) {
|
|
186
|
+
return String(v ?? '').replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @param {string} text @returns {string} a fenced block whose fence no backtick run inside can close */
|
|
190
|
+
function fenced(text) {
|
|
191
|
+
const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
|
|
192
|
+
const fence = '`'.repeat(longest + 1);
|
|
193
|
+
return `${fence}text\n${text}\n${fence}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The issue title and Markdown body for `entries` (newest first), with the sections of the issue
|
|
198
|
+
* form (`.github/ISSUE_TEMPLATE/error-report.yml`): what you were doing, versions, error details.
|
|
199
|
+
* The fingerprint section is {@link reportFooter}, added after cleaning.
|
|
200
|
+
* @param {ErrorEntry[]} entries
|
|
201
|
+
* @param {{version: string, node: string, os: string, latest?: string|null}} system
|
|
202
|
+
* @param {string|null} note
|
|
203
|
+
* @returns {{title: string, body: string, fps: string[]}} `fps`: distinct fingerprints, newest first.
|
|
204
|
+
*/
|
|
205
|
+
export function buildReport(entries, system, note) {
|
|
206
|
+
const pairs = new Set(entries.map((e) => `${e.verb}\u0000${e.kind}`));
|
|
207
|
+
const title = pairs.size === 1 ? `[error report] ${entries[0].verb} ${entries[0].kind}` : `[error report] ${entries.length} errors`;
|
|
208
|
+
const fps = entries.map(fingerprintOf);
|
|
209
|
+
const out = [
|
|
210
|
+
'## code-forge error report',
|
|
211
|
+
'',
|
|
212
|
+
'### What you were doing',
|
|
213
|
+
'',
|
|
214
|
+
note ? note : '(not given)',
|
|
215
|
+
'',
|
|
216
|
+
'### Versions',
|
|
217
|
+
'',
|
|
218
|
+
`- code-forge: ${system.version}`,
|
|
219
|
+
`- latest on npm: ${system.latest ?? 'unknown'}`,
|
|
220
|
+
`- Node: ${system.node}`,
|
|
221
|
+
`- OS: ${system.os}`,
|
|
222
|
+
'',
|
|
223
|
+
'### Error details',
|
|
224
|
+
'',
|
|
225
|
+
'| # | when | command | exit | kind | fingerprint |',
|
|
226
|
+
'|---|---|---|---|---|---|',
|
|
227
|
+
];
|
|
228
|
+
entries.forEach((e, i) => out.push(`| ${i + 1} | ${cell(e.ts)} | ${cell(command(e))} | ${cell(e.exit)} | ${cell(e.kind)} | ${fps[i]} |`));
|
|
229
|
+
entries.forEach((e, i) => {
|
|
230
|
+
out.push(
|
|
231
|
+
'',
|
|
232
|
+
`#### ${i + 1}. ${command(e)}: ${e.kind}`,
|
|
233
|
+
'',
|
|
234
|
+
`- when: ${e.ts}`,
|
|
235
|
+
`- flags: ${Array.isArray(e.flags) && e.flags.length > 0 ? e.flags.map((f) => `\`${f}\``).join(' ') : 'none'}`,
|
|
236
|
+
`- exit: ${e.exit}`,
|
|
237
|
+
`- kind: ${e.kind}`,
|
|
238
|
+
`- fingerprint: ${fps[i]}`,
|
|
239
|
+
`- code-forge ${e.version}, Node ${e.node}, ${e.platform} ${e.arch}`,
|
|
240
|
+
'',
|
|
241
|
+
'message:',
|
|
242
|
+
'',
|
|
243
|
+
fenced(e.message ?? '(none)'),
|
|
244
|
+
);
|
|
245
|
+
if (e.stack) out.push('', 'stack:', '', fenced(e.stack));
|
|
246
|
+
});
|
|
247
|
+
return { title, body: `${out.join('\n')}\n`, fps: [...new Set(fps)] };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The last section of every report: `Fingerprint: <fp>` (the newest error's) and one hidden
|
|
252
|
+
* `<!-- code-forge-fp: <fp> -->` line per distinct fingerprint, so a later report finds it.
|
|
253
|
+
* @param {string[]} fps - distinct, newest first.
|
|
254
|
+
* @returns {string}
|
|
255
|
+
*/
|
|
256
|
+
export function reportFooter(fps) {
|
|
257
|
+
const lines = ['', '### Fingerprint', '', `Fingerprint: ${fps[0]}`];
|
|
258
|
+
if (fps.length > 1) lines.push('', `Other fingerprints: ${fps.slice(1).join(', ')}`);
|
|
259
|
+
lines.push('', ...fps.map((fp) => `<!-- code-forge-fp: ${fp} -->`));
|
|
260
|
+
return `${lines.join('\n')}\n`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A body's footer (see {@link reportFooter}), when it ends with one. */
|
|
264
|
+
const FOOTER_RE = /\n### Fingerprint\n\nFingerprint: [0-9a-f]{12}\n(?:\nOther fingerprints: [0-9a-f, ]+\n)?\n(?:<!-- code-forge-fp: [0-9a-f]{12} -->[ \t]*(?:\r?\n|$))+\s*$/;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Cut `body` so `title` + `body` stay within `maxBytes` (the AI cost guard), never inside a
|
|
268
|
+
* character; a cut body ends with a note naming that size.
|
|
269
|
+
* @param {string} title @param {string} body @param {number} maxBytes
|
|
270
|
+
* @returns {{body: string, cut: boolean}}
|
|
271
|
+
*/
|
|
272
|
+
export function capReport(title, body, maxBytes) {
|
|
273
|
+
if (Buffer.byteLength(`${title}\n\n${body}`) <= maxBytes) return { body, cut: false };
|
|
274
|
+
const note = `\n\n(report cut to ${maxBytes} bytes)\n`;
|
|
275
|
+
const room = maxBytes - Buffer.byteLength(`${title}\n\n`) - Buffer.byteLength(note);
|
|
276
|
+
return { body: `${capBytes(body, Math.max(0, room))}${note}`, cut: true };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Compare two semver versions: `x.y.z`, then a pre-release is older than its release
|
|
281
|
+
* (`1.2.0-beta.1` < `1.2.0`) and pre-releases compare part by part (numbers as numbers, numbers
|
|
282
|
+
* before words). Build metadata (`+…`) is ignored.
|
|
283
|
+
* @param {string} a @param {string} b
|
|
284
|
+
* @returns {number|null} >0 when a is newer, <0 when older, 0 when equal; null when unreadable.
|
|
285
|
+
*/
|
|
286
|
+
export function compareVersions(a, b) {
|
|
287
|
+
const re = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
288
|
+
const pa = re.exec(a.trim());
|
|
289
|
+
const pb = re.exec(b.trim());
|
|
290
|
+
if (!pa || !pb) return null;
|
|
291
|
+
for (let i = 1; i <= 3; i += 1) {
|
|
292
|
+
const d = Number(pa[i]) - Number(pb[i]);
|
|
293
|
+
if (d !== 0) return d;
|
|
294
|
+
}
|
|
295
|
+
if (pa[4] === undefined || pb[4] === undefined) return pa[4] === pb[4] ? 0 : pa[4] === undefined ? 1 : -1;
|
|
296
|
+
const xa = pa[4].split('.');
|
|
297
|
+
const xb = pb[4].split('.');
|
|
298
|
+
for (let i = 0; i < Math.max(xa.length, xb.length); i += 1) {
|
|
299
|
+
if (xa[i] === undefined) return -1;
|
|
300
|
+
if (xb[i] === undefined) return 1;
|
|
301
|
+
const na = /^\d+$/.test(xa[i]);
|
|
302
|
+
const nb = /^\d+$/.test(xb[i]);
|
|
303
|
+
if (na && nb) {
|
|
304
|
+
const d = Number(xa[i]) - Number(xb[i]);
|
|
305
|
+
if (d !== 0) return d;
|
|
306
|
+
} else if (na !== nb) {
|
|
307
|
+
return na ? -1 : 1;
|
|
308
|
+
} else if (xa[i] !== xb[i]) {
|
|
309
|
+
return xa[i] < xb[i] ? -1 : 1;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** @returns {{version: string, node: string, os: string}} */
|
|
316
|
+
function currentSystem() {
|
|
317
|
+
return { version: packageVersion(), node: process.version, os: `${process.platform} ${os.release()} ${process.arch}` };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* @param {string[]} args
|
|
322
|
+
* @param {LogsDeps} [deps]
|
|
323
|
+
* @returns {Promise<number>}
|
|
324
|
+
*/
|
|
325
|
+
export async function runLogs(args, deps = {}) {
|
|
326
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
327
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
328
|
+
const out = (/** @type {string} */ s) => writeSafe(stdout, s);
|
|
329
|
+
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
330
|
+
const env = deps.env ?? process.env;
|
|
331
|
+
const file = errorLogPath(env.HOME);
|
|
332
|
+
|
|
333
|
+
const sub = args[0] !== undefined && !args[0].startsWith('-') ? args[0] : null;
|
|
334
|
+
const specs = /** @type {Record<string, {values?: string[], booleans?: string[]}>} */ ({
|
|
335
|
+
list: { values: ['last'], booleans: ['json'] },
|
|
336
|
+
summary: { values: ['days'], booleans: ['json'] },
|
|
337
|
+
clear: { booleans: ['yes'] },
|
|
338
|
+
path: {},
|
|
339
|
+
report: { values: ['last', 'kind', 'verb', 'note'], booleans: ['dry-run', 'yes', 'no-ai', 'allow-old'] },
|
|
340
|
+
});
|
|
341
|
+
const name = sub ?? 'list';
|
|
342
|
+
if (!Object.hasOwn(specs, name) || (name === 'list' && sub !== null)) {
|
|
343
|
+
err(`logs: unknown subcommand ${JSON.stringify(sub)}\n${USAGE}`);
|
|
344
|
+
return 2;
|
|
345
|
+
}
|
|
346
|
+
let parsed;
|
|
347
|
+
let n;
|
|
348
|
+
try {
|
|
349
|
+
parsed = parseFlags(sub === null ? args : args.slice(1), specs[name]);
|
|
350
|
+
if (parsed.positionals.length > 0) throw new Error(`unexpected argument ${JSON.stringify(parsed.positionals[0])}`);
|
|
351
|
+
n = intFlag(parsed.flags.last ?? parsed.flags.days, parsed.flags.days !== undefined ? 'days' : 'last');
|
|
352
|
+
} catch (e) {
|
|
353
|
+
err(`logs: ${/** @type {Error} */ (e).message}\n${USAGE}`);
|
|
354
|
+
return 2;
|
|
355
|
+
}
|
|
356
|
+
if (file === null) {
|
|
357
|
+
err('logs: HOME is not set; there is no error log\n');
|
|
358
|
+
return 1;
|
|
359
|
+
}
|
|
360
|
+
const flags = parsed.flags;
|
|
361
|
+
|
|
362
|
+
if (name === 'path') {
|
|
363
|
+
out(`${file}\n`);
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const entries = await readErrorLog(file);
|
|
368
|
+
if (name === 'clear') return clear(file, entries, flags, deps, out, err);
|
|
369
|
+
if (entries === null || entries.length === 0) {
|
|
370
|
+
out(flags.json ? `${JSON.stringify(name === 'summary' ? { days: n ?? 30, counts: [] } : { errors: [] })}\n` : 'no errors logged\n');
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
const newest = [...entries].reverse();
|
|
374
|
+
|
|
375
|
+
if (name === 'list') {
|
|
376
|
+
const shown = newest.slice(0, n ?? 10);
|
|
377
|
+
if (flags.json) out(`${JSON.stringify({ errors: shown })}\n`);
|
|
378
|
+
else for (const e of shown) out(`${e.ts} ${command(e)} ${e.exit} ${e.kind} ${fingerprintOf(e)} ${firstLine(e.message)}\n`);
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (name === 'summary') {
|
|
383
|
+
const days = n ?? 30;
|
|
384
|
+
const since = (deps.now ?? (() => new Date()))().getTime() - days * 86_400_000;
|
|
385
|
+
/** @type {Map<string, {fp: string, verb: string, sub: string|null, kind: string, count: number}>} */
|
|
386
|
+
const counts = new Map();
|
|
387
|
+
for (const e of newest) {
|
|
388
|
+
const t = Date.parse(e.ts);
|
|
389
|
+
if (!(t >= since)) continue;
|
|
390
|
+
const fp = fingerprintOf(e);
|
|
391
|
+
const c = counts.get(fp) ?? { fp, verb: e.verb, sub: e.sub ?? null, kind: e.kind, count: 0 };
|
|
392
|
+
c.count += 1;
|
|
393
|
+
counts.set(fp, c);
|
|
394
|
+
}
|
|
395
|
+
const rows = [...counts.values()].sort((a, b) => b.count - a.count || a.verb.localeCompare(b.verb) || a.kind.localeCompare(b.kind) || a.fp.localeCompare(b.fp));
|
|
396
|
+
if (flags.json) {
|
|
397
|
+
out(`${JSON.stringify({ days, counts: rows })}\n`);
|
|
398
|
+
} else if (rows.length === 0) {
|
|
399
|
+
out(`no errors in the last ${days} days\n`);
|
|
400
|
+
} else {
|
|
401
|
+
out(`errors in the last ${days} days:\n`);
|
|
402
|
+
for (const r of rows) out(`${r.fp} ${r.sub ? `${r.verb} ${r.sub}` : r.verb} ${r.kind} seen ${r.count} ${r.count === 1 ? 'time' : 'times'}\n`);
|
|
403
|
+
}
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return report(newest, n ?? 5, flags, deps, out, err);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* @param {string} file @param {ErrorEntry[]|null} entries
|
|
412
|
+
* @param {Record<string, string|string[]|true>} flags @param {LogsDeps} deps
|
|
413
|
+
* @param {(s: string) => void} out @param {(s: string) => void} err
|
|
414
|
+
*/
|
|
415
|
+
async function clear(file, entries, flags, deps, out, err) {
|
|
416
|
+
if (entries === null) {
|
|
417
|
+
out('no errors logged\n');
|
|
418
|
+
return 0;
|
|
419
|
+
}
|
|
420
|
+
if (!flags.yes) {
|
|
421
|
+
if (!(deps.isTTY ?? process.stdin.isTTY === true)) {
|
|
422
|
+
err('logs: no terminal to confirm; nothing deleted — pass --yes to delete the error log\n');
|
|
423
|
+
return 2;
|
|
424
|
+
}
|
|
425
|
+
const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
426
|
+
const go = await ui.confirm({ message: `Delete the error log (${entries.length} entries)?`, initialValue: false });
|
|
427
|
+
if (ui.isCancel(go) || go !== true) {
|
|
428
|
+
out('cancelled — nothing deleted\n');
|
|
429
|
+
return 1;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
await rm(file, { force: true });
|
|
433
|
+
out(`deleted ${file}\n`);
|
|
434
|
+
return 0;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The latest version on npm, or why it is unknown (never npm's own output).
|
|
439
|
+
* @param {typeof realExec} exec @param {NodeJS.ProcessEnv} env
|
|
440
|
+
* @returns {Promise<{latest: string} | {why: string}>}
|
|
441
|
+
*/
|
|
442
|
+
async function latestOnNpm(exec, env) {
|
|
443
|
+
let res;
|
|
444
|
+
try {
|
|
445
|
+
res = await exec(['npm', 'view', PACKAGE_NAME, 'version'], { env, timeoutMs: NPM_TIMEOUT_MS });
|
|
446
|
+
} catch {
|
|
447
|
+
return { why: 'npm did not run' };
|
|
448
|
+
}
|
|
449
|
+
if (res.result !== 'ok') return { why: res.timedOut ? 'timed out' : res.code !== null ? `npm exit ${res.code}` : 'npm did not run' };
|
|
450
|
+
const v = res.stdout.trim();
|
|
451
|
+
return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(v) ? { latest: v } : { why: 'npm gave no version' };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Ask a yes/no question on the terminal.
|
|
456
|
+
* @param {LogsDeps} deps @param {string} message @param {boolean} initialValue
|
|
457
|
+
* @returns {Promise<boolean>}
|
|
458
|
+
*/
|
|
459
|
+
async function askYes(deps, message, initialValue) {
|
|
460
|
+
const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
461
|
+
const go = await ui.confirm({ message, initialValue });
|
|
462
|
+
return !ui.isCancel(go) && go === true;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Pick one option on the terminal; a cancelled prompt is `cancel`.
|
|
467
|
+
* @param {LogsDeps} deps @param {string} message
|
|
468
|
+
* @param {Array<{value: string, label: string}>} options @param {string} initialValue
|
|
469
|
+
* @returns {Promise<string>}
|
|
470
|
+
*/
|
|
471
|
+
async function choose(deps, message, options, initialValue) {
|
|
472
|
+
const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
473
|
+
const v = await ui.select({ message, options, initialValue });
|
|
474
|
+
return ui.isCancel(v) || typeof v !== 'string' ? 'cancel' : v;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The editor command: `$VISUAL` when it is usable, else `$EDITOR`. Usable means a plain command
|
|
479
|
+
* name or an absolute path, with no spaces or flags (it runs as argv[0], never through a shell).
|
|
480
|
+
* @param {NodeJS.ProcessEnv} env
|
|
481
|
+
* @returns {string|null} null when neither is usable.
|
|
482
|
+
*/
|
|
483
|
+
export function editorCommand(env) {
|
|
484
|
+
for (const raw of [env.VISUAL, env.EDITOR]) {
|
|
485
|
+
if (typeof raw !== 'string' || raw.length === 0 || /\s/.test(raw)) continue;
|
|
486
|
+
if (path.isAbsolute(raw) || /^[A-Za-z0-9._+-]+$/.test(raw)) return raw;
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Run the editor on the terminal: argv only, never a shell.
|
|
493
|
+
* @param {string[]} argv @param {NodeJS.ProcessEnv} env
|
|
494
|
+
* @returns {Promise<{code: number|null, error?: string}>}
|
|
495
|
+
*/
|
|
496
|
+
function runEditorOnTerminal(argv, env) {
|
|
497
|
+
return new Promise((resolve) => {
|
|
498
|
+
const child = spawn(argv[0], argv.slice(1), { env, stdio: 'inherit', shell: false });
|
|
499
|
+
child.on('error', (e) => resolve({ code: null, error: /** @type {NodeJS.ErrnoException} */ (e).code ?? 'error' }));
|
|
500
|
+
child.on('exit', (code) => resolve({ code }));
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* @param {ErrorEntry[]} newest @param {number} last
|
|
506
|
+
* @param {Record<string, string|string[]|true>} flags @param {LogsDeps} deps
|
|
507
|
+
* @param {(s: string) => void} out @param {(s: string) => void} err
|
|
508
|
+
*/
|
|
509
|
+
async function report(newest, last, flags, deps, out, err) {
|
|
510
|
+
const env = deps.env ?? process.env;
|
|
511
|
+
const home = env.HOME ?? '';
|
|
512
|
+
const kind = typeof flags.kind === 'string' ? flags.kind : null;
|
|
513
|
+
const verb = typeof flags.verb === 'string' ? flags.verb : null;
|
|
514
|
+
const selected = newest.filter((e) => (kind === null || e.kind === kind) && (verb === null || e.verb === verb)).slice(0, last);
|
|
515
|
+
if (selected.length === 0) {
|
|
516
|
+
out('no matching errors to report\n');
|
|
517
|
+
return 0;
|
|
518
|
+
}
|
|
519
|
+
const repo = issueRepo(deps.pkg);
|
|
520
|
+
if (repo === null) {
|
|
521
|
+
err('logs: package.json names no GitHub repository to report to\n');
|
|
522
|
+
return 1;
|
|
523
|
+
}
|
|
524
|
+
const exec = deps.exec ?? realExec;
|
|
525
|
+
const tty = deps.isTTY ?? process.stdin.isTTY === true;
|
|
526
|
+
const dryRun = flags['dry-run'] === true;
|
|
527
|
+
const yes = flags.yes === true;
|
|
528
|
+
const system = deps.system ?? currentSystem();
|
|
529
|
+
|
|
530
|
+
// 1. version check: an old version gets a warning and a default-no question
|
|
531
|
+
const npm = await latestOnNpm(exec, env);
|
|
532
|
+
const latest = 'latest' in npm ? npm.latest : null;
|
|
533
|
+
if ('why' in npm) {
|
|
534
|
+
out(`Could not check npm for a newer code-forge (${npm.why}); going on.\n`);
|
|
535
|
+
} else if ((compareVersions(npm.latest, system.version) ?? 0) > 0) {
|
|
536
|
+
out(`You use ${system.version}. ${npm.latest} is out. Many errors are fixed in newer versions: upgrade with npm install -g ${PACKAGE_NAME}@latest and try again first.\n`);
|
|
537
|
+
// never go on silently: a terminal is asked (--yes too, default no); without one, only an
|
|
538
|
+
// explicit --allow-old goes on (a dry run sends nothing and only warns)
|
|
539
|
+
if (!dryRun && flags['allow-old'] !== true) {
|
|
540
|
+
if (!tty) {
|
|
541
|
+
err('logs: an older code-forge than the one on npm; not sent — upgrade first, or pass --allow-old to report from this version\n');
|
|
542
|
+
return 2;
|
|
543
|
+
}
|
|
544
|
+
if (!(await askYes(deps, 'Report anyway?', false))) {
|
|
545
|
+
out('not sent\n');
|
|
546
|
+
return 1;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// 2. deterministic scrub, the 16 KB cap, the AI pass, the deterministic scrub again
|
|
552
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
553
|
+
const scrubber = createScrubber({ home, cwd, slug: await readProjectSlug(cwd) });
|
|
554
|
+
const note = typeof flags.note === 'string' ? flags.note : null;
|
|
555
|
+
const built = buildReport(selected, { ...system, latest }, note);
|
|
556
|
+
let title = scrubber.scrub(built.title);
|
|
557
|
+
let body = capReport(title, scrubber.scrub(built.body), AI_REPORT_MAX_BYTES).body;
|
|
558
|
+
const blocked = () => {
|
|
559
|
+
const hit = findSecret(`${title}\n${body}`);
|
|
560
|
+
if (hit === null) return false;
|
|
561
|
+
out(DISCLOSURE);
|
|
562
|
+
err(`Possible secret found in the report (${hit.rule}, report line ${hit.line}; the title is line 1); not sent. Find it with code-forge logs --json and remove it with code-forge logs clear.\n`);
|
|
563
|
+
return true;
|
|
564
|
+
};
|
|
565
|
+
// a secret the built-in cleaning left is never shown to the AI either
|
|
566
|
+
if (blocked()) return 1;
|
|
567
|
+
/** @type {{status: 'ok', counts: Array<{kind: string, count: number}>} | {status: 'skipped'} | {status: 'unavailable', reason: string}} */
|
|
568
|
+
let ai;
|
|
569
|
+
if (flags['no-ai'] === true) {
|
|
570
|
+
ai = { status: 'skipped' };
|
|
571
|
+
} else {
|
|
572
|
+
const res = await runAiScrub({ report: `${title}\n\n${body}`, cwd }, { ...(deps.exec ? { exec: deps.exec } : {}), env, stderr: { write: () => true } });
|
|
573
|
+
if (res.ok === true) {
|
|
574
|
+
const applied = applyItems([title, body], res.items);
|
|
575
|
+
[title, body] = applied.texts.map((t) => scrubber.scrub(t));
|
|
576
|
+
ai = { status: 'ok', counts: applied.counts };
|
|
577
|
+
} else {
|
|
578
|
+
ai = { status: 'unavailable', reason: /** @type {{reason: string}} */ (res).reason };
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
body += reportFooter(built.fps);
|
|
582
|
+
const cleaned = mergeCounts([...selected.map((e) => e.cleaned), scrubber.counts()]);
|
|
583
|
+
|
|
584
|
+
// 3. the final secret check, then what is shared and the full text
|
|
585
|
+
if (blocked()) return 1;
|
|
586
|
+
out(DISCLOSURE);
|
|
587
|
+
const aiLine =
|
|
588
|
+
ai.status === 'ok'
|
|
589
|
+
? describeAiCounts(ai.counts)
|
|
590
|
+
: ai.status === 'skipped'
|
|
591
|
+
? 'AI cleaning was skipped (--no-ai); only the built-in cleaning ran.'
|
|
592
|
+
: `AI cleaning was not available (${ai.reason}); only the built-in cleaning ran.`;
|
|
593
|
+
out(`${describeCounts(cleaned)}\n${aiLine}\n\nTitle: ${title}\n\n${body}\n`);
|
|
594
|
+
if (dryRun) {
|
|
595
|
+
out('dry run: not sent\n');
|
|
596
|
+
return 0;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// 4. send, edit or cancel
|
|
600
|
+
const aiRan = ai.status === 'ok';
|
|
601
|
+
if (yes) {
|
|
602
|
+
// `--no-ai --yes` is the explicit extra yes; otherwise the AI check that did not run is asked
|
|
603
|
+
// about in a terminal (default no) and refused without one
|
|
604
|
+
if (!aiRan && flags['no-ai'] !== true) {
|
|
605
|
+
if (!tty) {
|
|
606
|
+
err('logs: the AI check did not run and there is no terminal to confirm; not sent — pass --no-ai with --yes to send with only the built-in cleaning\n');
|
|
607
|
+
return 1;
|
|
608
|
+
}
|
|
609
|
+
if (!(await askYes(deps, 'Send without the AI check?', false))) {
|
|
610
|
+
out('not sent\n');
|
|
611
|
+
return 1;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
} else {
|
|
615
|
+
if (!tty) {
|
|
616
|
+
err('logs: no terminal to confirm; not sent — pass --yes to send the report above\n');
|
|
617
|
+
return 2;
|
|
618
|
+
}
|
|
619
|
+
let edited = false;
|
|
620
|
+
for (;;) {
|
|
621
|
+
const options = [{ value: 'send', label: 'Send' }, ...(edited ? [] : [{ value: 'edit', label: 'Edit in my editor' }]), { value: 'cancel', label: 'Cancel' }];
|
|
622
|
+
const pick = await choose(deps, CONFIRM_MESSAGE, options, aiRan ? 'send' : 'cancel');
|
|
623
|
+
if (pick === 'edit') {
|
|
624
|
+
edited = true;
|
|
625
|
+
const next = await editBody(body, env, deps, out);
|
|
626
|
+
if (next === null) continue;
|
|
627
|
+
const cleanedNext = scrubber.scrub(next);
|
|
628
|
+
const again = findSecret(`${title}\n${cleanedNext}`);
|
|
629
|
+
if (again !== null) {
|
|
630
|
+
err(`Possible secret found in the edited report (${again.rule}, report line ${again.line}; the title is line 1); not sent.\n`);
|
|
631
|
+
return 1;
|
|
632
|
+
}
|
|
633
|
+
body = cleanedNext;
|
|
634
|
+
if (!body.includes(`<!-- code-forge-fp: ${built.fps[0]} -->`)) {
|
|
635
|
+
body = `${body.trimEnd()}\n${reportFooter(built.fps)}`;
|
|
636
|
+
out('the fingerprint section was added back\n');
|
|
637
|
+
}
|
|
638
|
+
out(`Title: ${title}\n\n${body}\n`);
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
if (pick !== 'send' || (!aiRan && !(await askYes(deps, 'Send without the AI check?', false)))) {
|
|
642
|
+
out('not sent\n');
|
|
643
|
+
return 1;
|
|
644
|
+
}
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 5. gh (after a search for the same fingerprint) or a link
|
|
650
|
+
const fp = built.fps[0];
|
|
651
|
+
const onPath = deps.onPath ?? pathLookup(env.PATH ?? '');
|
|
652
|
+
let ghReady = false;
|
|
653
|
+
if (onPath('gh')) {
|
|
654
|
+
try {
|
|
655
|
+
ghReady = (await exec(['gh', 'auth', 'status'], { env, timeoutMs: GH_TIMEOUT_MS })).result === 'ok';
|
|
656
|
+
} catch {
|
|
657
|
+
ghReady = false;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (ghReady) {
|
|
661
|
+
const search = await findReported(exec, env, repo, fp);
|
|
662
|
+
if ('why' in search) out(`could not search for an existing report (${search.why}); creating a new one\n`);
|
|
663
|
+
const dup = 'hit' in search ? search.hit : null;
|
|
664
|
+
if (dup !== null) {
|
|
665
|
+
out(`This error was already reported: ${dup.url} (${dup.state})\n`);
|
|
666
|
+
const pick = yes
|
|
667
|
+
? 'comment'
|
|
668
|
+
: await choose(deps, 'What now?', [
|
|
669
|
+
{ value: 'comment', label: 'Add a comment "happened again"' },
|
|
670
|
+
{ value: 'new', label: 'Create a new issue anyway' },
|
|
671
|
+
{ value: 'cancel', label: 'Cancel' },
|
|
672
|
+
], 'comment');
|
|
673
|
+
if (pick === 'cancel') {
|
|
674
|
+
out('not sent\n');
|
|
675
|
+
return 1;
|
|
676
|
+
}
|
|
677
|
+
if (pick === 'comment') {
|
|
678
|
+
const times = newest.filter((e) => fingerprintOf(e) === fp).length;
|
|
679
|
+
const comment = againComment(system, latest, times, fp);
|
|
680
|
+
const why = await ghWithBodyFile(exec, env, comment, (file) => [['gh', 'issue', 'comment', String(dup.number), '--repo', repo, '--body-file', file]]);
|
|
681
|
+
if (why === null) {
|
|
682
|
+
out(`commented: ${dup.url}\n`);
|
|
683
|
+
return 0;
|
|
684
|
+
}
|
|
685
|
+
err(`gh failed (${why}); nothing was sent. Add the comment by hand: ${dup.url}\n`);
|
|
686
|
+
return 1;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// one kind label per distinct kind in the report, newest first, at most 3
|
|
690
|
+
const kinds = [...new Set(selected.map((e) => e.kind))].slice(0, 3);
|
|
691
|
+
const labels = ['--label', 'error-report', ...kinds.flatMap((k) => ['--label', `kind:${k}`])];
|
|
692
|
+
/** @type {string|null} */
|
|
693
|
+
let url = null;
|
|
694
|
+
const why = await ghWithBodyFile(exec, env, body, (file) => {
|
|
695
|
+
const base = ['gh', 'issue', 'create', '--repo', repo, '--title', title, '--body-file', file];
|
|
696
|
+
return [[...base, ...labels], base];
|
|
697
|
+
}, (stdout) => {
|
|
698
|
+
url = stdout.split('\n').map((l) => l.trim()).filter((l) => /^https:\/\//.test(l)).pop() ?? null;
|
|
699
|
+
});
|
|
700
|
+
if (why === null) {
|
|
701
|
+
out(`sent: ${url ?? '(gh printed no issue URL)'}\n`);
|
|
702
|
+
return 0;
|
|
703
|
+
}
|
|
704
|
+
out(`gh failed (${why}); here is a link instead\n`);
|
|
705
|
+
return printLink(repo, title, body, fp, home, deps, out, true);
|
|
706
|
+
}
|
|
707
|
+
return printLink(repo, title, body, fp, home, deps, out, false);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* The comment for an issue that already has this fingerprint.
|
|
712
|
+
* @param {{version: string, node: string, os: string}} system @param {string|null} latest
|
|
713
|
+
* @param {number} times @param {string} fp
|
|
714
|
+
*/
|
|
715
|
+
export function againComment(system, latest, times, fp) {
|
|
716
|
+
return [
|
|
717
|
+
'This error happened again.',
|
|
718
|
+
'',
|
|
719
|
+
`- code-forge: ${system.version}`,
|
|
720
|
+
`- latest on npm: ${latest ?? 'unknown'}`,
|
|
721
|
+
`- Node: ${system.node}`,
|
|
722
|
+
`- OS: ${system.os}`,
|
|
723
|
+
`- times in the local log: ${times}`,
|
|
724
|
+
'',
|
|
725
|
+
`<!-- code-forge-fp: ${fp} -->`,
|
|
726
|
+
'',
|
|
727
|
+
].join('\n');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Edit `body` in the user's editor; null when it could not run (the reason is printed).
|
|
732
|
+
* @param {string} body @param {NodeJS.ProcessEnv} env @param {LogsDeps} deps @param {(s: string) => void} out
|
|
733
|
+
* @returns {Promise<string|null>}
|
|
734
|
+
*/
|
|
735
|
+
async function editBody(body, env, deps, out) {
|
|
736
|
+
const editor = editorCommand(env);
|
|
737
|
+
if (editor === null) {
|
|
738
|
+
out('Cannot open an editor: set VISUAL or EDITOR to a plain command (a name such as vim, or an absolute path, with no spaces or flags).\n');
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
/** @type {string|null} */
|
|
742
|
+
let dir = null;
|
|
743
|
+
try {
|
|
744
|
+
const root = currentRunRoot();
|
|
745
|
+
await mkdir(root, { recursive: true });
|
|
746
|
+
dir = await mkdtemp(path.join(root, 'report-edit-'));
|
|
747
|
+
const file = path.join(dir, 'report.md');
|
|
748
|
+
await writeFile(file, body, { encoding: 'utf8', mode: 0o600 });
|
|
749
|
+
const res = await (deps.runEditor ?? runEditorOnTerminal)([editor, file], env);
|
|
750
|
+
if (res.code !== 0) {
|
|
751
|
+
out(`The editor did not finish (${res.error ?? `exit ${res.code}`}); the text is unchanged.\n`);
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
return await readFile(file, 'utf8');
|
|
755
|
+
} catch {
|
|
756
|
+
out('Could not edit the report; the text is unchanged.\n');
|
|
757
|
+
return null;
|
|
758
|
+
} finally {
|
|
759
|
+
if (dir !== null) await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* An earlier issue holding `fp` in its body (open or closed): `{hit}` (null when there is none),
|
|
765
|
+
* or `{why}` when the search failed (`gh exit n`; never gh's output).
|
|
766
|
+
* @param {typeof realExec} exec @param {NodeJS.ProcessEnv} env @param {string} repo @param {string} fp
|
|
767
|
+
* @returns {Promise<{hit: {number: number, url: string, state: string}|null} | {why: string}>}
|
|
768
|
+
*/
|
|
769
|
+
async function findReported(exec, env, repo, fp) {
|
|
770
|
+
const res = await ghRun(exec, ['gh', 'issue', 'list', '--repo', repo, '--state', 'all', '--search', `${fp} in:body`, '--json', 'number,url,state', '--limit', '5'], env);
|
|
771
|
+
if (res.result !== 'ok') return { why: res.code !== null ? `gh exit ${res.code}` : res.timedOut ? 'gh timed out' : 'gh did not finish' };
|
|
772
|
+
let list;
|
|
773
|
+
try {
|
|
774
|
+
list = JSON.parse(res.stdout);
|
|
775
|
+
} catch {
|
|
776
|
+
return { why: 'gh gave no list' };
|
|
777
|
+
}
|
|
778
|
+
if (!Array.isArray(list)) return { why: 'gh gave no list' };
|
|
779
|
+
const hit = list.find((i) => Number.isSafeInteger(i?.number) && typeof i?.url === 'string' && /^https:\/\//.test(i.url));
|
|
780
|
+
return { hit: hit ? { number: hit.number, url: hit.url, state: typeof hit.state === 'string' ? hit.state.toLowerCase() : 'unknown' } : null };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Write `text` to a private temp file and run the first argv of `argvsFor(file)`; when it fails
|
|
785
|
+
* over a label, run the next one (once).
|
|
786
|
+
* @param {typeof realExec} exec @param {NodeJS.ProcessEnv} env @param {string} text
|
|
787
|
+
* @param {(file: string) => string[][]} argvsFor
|
|
788
|
+
* @param {(stdout: string) => void} [onOk]
|
|
789
|
+
* @returns {Promise<string|null>} null when gh succeeded; else why (`exit n`, never its output).
|
|
790
|
+
*/
|
|
791
|
+
async function ghWithBodyFile(exec, env, text, argvsFor, onOk) {
|
|
792
|
+
/** @type {string|null} */
|
|
793
|
+
let dir = null;
|
|
794
|
+
try {
|
|
795
|
+
let res;
|
|
796
|
+
try {
|
|
797
|
+
const root = currentRunRoot();
|
|
798
|
+
await mkdir(root, { recursive: true });
|
|
799
|
+
dir = await mkdtemp(path.join(root, 'report-'));
|
|
800
|
+
const bodyFile = path.join(dir, 'body.md');
|
|
801
|
+
await writeFile(bodyFile, text, { encoding: 'utf8', mode: 0o600 });
|
|
802
|
+
const [first, retry] = argvsFor(bodyFile);
|
|
803
|
+
res = await ghRun(exec, first, env);
|
|
804
|
+
// gh's stderr is matched to decide the retry, never printed
|
|
805
|
+
if (res.result !== 'ok' && retry && /label/i.test(res.stderr)) res = await ghRun(exec, retry, env);
|
|
806
|
+
} catch {
|
|
807
|
+
return 'could not write the report file';
|
|
808
|
+
}
|
|
809
|
+
if (res.result !== 'ok') return res.code !== null ? `exit ${res.code}` : res.timedOut ? 'timed out' : 'did not finish';
|
|
810
|
+
onOk?.(res.stdout);
|
|
811
|
+
return null;
|
|
812
|
+
} finally {
|
|
813
|
+
if (dir !== null) await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* @param {typeof realExec} exec @param {string[]} argv @param {NodeJS.ProcessEnv} env
|
|
819
|
+
* @returns {Promise<{result: string, code: number|null, timedOut: boolean, stdout: string, stderr: string}>}
|
|
820
|
+
*/
|
|
821
|
+
async function ghRun(exec, argv, env) {
|
|
822
|
+
try {
|
|
823
|
+
return await exec(argv, { env, timeoutMs: GH_TIMEOUT_MS });
|
|
824
|
+
} catch {
|
|
825
|
+
return { result: 'failed', code: null, timedOut: false, stdout: '', stderr: '' };
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* The prefilled issue-form link: the form's `details` field holds the whole body (GitHub fills an
|
|
831
|
+
* issue form's fields from query parameters named by field id).
|
|
832
|
+
* @param {string} repo @param {string} title @param {string} body @param {string} fp
|
|
833
|
+
*/
|
|
834
|
+
export function issueUrl(repo, title, body, fp) {
|
|
835
|
+
return `https://github.com/${repo}/issues/new?template=error-report.yml&title=${encodeURIComponent(title)}&labels=error-report&fingerprint=${fp}&details=${encodeURIComponent(body)}`;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** @param {string} repo @param {string} fp @returns {string} the issue search for one fingerprint */
|
|
839
|
+
export function searchUrl(repo, fp) {
|
|
840
|
+
return `https://github.com/${repo}/issues?q=${fp}`;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Save the full body as `~/.code-forge/logs/report-<ts>.md`.
|
|
845
|
+
* @param {string} body @param {string} home @param {LogsDeps} deps
|
|
846
|
+
* @returns {Promise<{saved: string, name: string}|null>} null when it could not be saved.
|
|
847
|
+
*/
|
|
848
|
+
async function saveReport(body, home, deps) {
|
|
849
|
+
if (!home) return null;
|
|
850
|
+
const stamp = (deps.now ?? (() => new Date()))().toISOString().replace(/[:.]/g, '-');
|
|
851
|
+
const name = `report-${stamp}.md`;
|
|
852
|
+
const logsDir = path.join(home, '.code-forge', 'logs');
|
|
853
|
+
try {
|
|
854
|
+
await mkdir(logsDir, { recursive: true, mode: 0o700 });
|
|
855
|
+
await writeFile(path.join(logsDir, name), body, { encoding: 'utf8', mode: 0o600 });
|
|
856
|
+
return { saved: path.join(logsDir, name), name };
|
|
857
|
+
} catch {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Print the search link for the fingerprint and a prefilled issue link; a body too long for a
|
|
864
|
+
* link is cut (its fingerprint section kept) and saved in full.
|
|
865
|
+
* @param {string} repo @param {string} title @param {string} body @param {string} fp @param {string} home
|
|
866
|
+
* @param {LogsDeps} deps @param {(s: string) => void} out
|
|
867
|
+
* @param {boolean} alwaysSave - save the full body even when it fits (the gh fallback).
|
|
868
|
+
*/
|
|
869
|
+
async function printLink(repo, title, body, fp, home, deps, out, alwaysSave) {
|
|
870
|
+
let url = issueUrl(repo, title, body, fp);
|
|
871
|
+
if (url.length > MAX_URL_LENGTH) {
|
|
872
|
+
const file = await saveReport(body, home, deps);
|
|
873
|
+
const found = FOOTER_RE.exec(body);
|
|
874
|
+
const footer = found ? `${found[0].slice(1).trimEnd()}\n` : '';
|
|
875
|
+
const main = found ? body.slice(0, found.index) : body;
|
|
876
|
+
const tail = (file ? `\n\n(report cut; full report saved at ~/.code-forge/logs/${file.name})\n` : '\n\n(report cut)\n') + (footer ? `\n${footer}` : '');
|
|
877
|
+
const chars = Array.from(main);
|
|
878
|
+
let lo = 0;
|
|
879
|
+
let hi = chars.length;
|
|
880
|
+
while (lo < hi) {
|
|
881
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
882
|
+
if (issueUrl(repo, title, chars.slice(0, mid).join('') + tail, fp).length <= MAX_URL_LENGTH) lo = mid;
|
|
883
|
+
else hi = mid - 1;
|
|
884
|
+
}
|
|
885
|
+
url = issueUrl(repo, title, chars.slice(0, lo).join('') + tail, fp);
|
|
886
|
+
out(file ? `The report is too long for a link: it was cut. Full report saved at ${file.saved}\n` : 'The report is too long for a link: it was cut.\n');
|
|
887
|
+
} else if (alwaysSave) {
|
|
888
|
+
const file = await saveReport(body, home, deps);
|
|
889
|
+
if (file) out(`Full report saved at ${file.saved}\n`);
|
|
890
|
+
}
|
|
891
|
+
out(`Already reported? Search first:\n${searchUrl(repo, fp)}\n`);
|
|
892
|
+
out(`Open this link to file the issue:\n${url}\n`);
|
|
893
|
+
return 0;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** @param {string[]} args @returns {Promise<number>} */
|
|
897
|
+
export default async function logs(args) {
|
|
898
|
+
return runLogs(args);
|
|
899
|
+
}
|