@codedology/code-forge 0.2.2 → 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 +22 -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/keys.mjs +2 -1
- package/src/cli/logs.mjs +899 -0
- package/src/install/wizard/run.mjs +7 -2
- package/src/keys/onepassword.mjs +14 -4
- 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
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local error log (B27): one JSON line per failed verb in `~/.code-forge/logs/errors.jsonl`.
|
|
3
|
+
*
|
|
4
|
+
* `bin/code-forge.mjs` calls {@link logVerbFailure} after a verb exits non-zero or throws. Every
|
|
5
|
+
* entry holds the verb, its subcommand word, its flag NAMES (never values, never positionals), the
|
|
6
|
+
* exit code, a kind, the last stderr text the verb wrote and (for a crash) the stack — redacted
|
|
7
|
+
* with `redact` and then scrubbed: the home dir, the working dir, the project slug, emails, op://
|
|
8
|
+
* references and 1Password item IDs are replaced by placeholders. Each entry records how many
|
|
9
|
+
* replacements each rule made (`cleaned`), so `logs report` can say what was cleaned, and (B28) a
|
|
10
|
+
* fingerprint `fp` ({@link fingerprint}) that groups the same error in `logs summary` and finds an
|
|
11
|
+
* earlier issue for it.
|
|
12
|
+
*
|
|
13
|
+
* Logging never throws and never changes the verb's exit code. `CODE_FORGE_NO_ERROR_LOG=1` turns
|
|
14
|
+
* it off; no HOME means no log. The file is cut to its newest half when it grows over 1 MB.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
19
|
+
import { appendFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { redact } from './redact.mjs';
|
|
23
|
+
|
|
24
|
+
export const ERROR_LOG_MAX_BYTES = 1024 * 1024;
|
|
25
|
+
export const MESSAGE_MAX_BYTES = 2048;
|
|
26
|
+
export const STACK_MAX_BYTES = 4096;
|
|
27
|
+
export const NO_ERROR_LOG_ENV = 'CODE_FORGE_NO_ERROR_LOG';
|
|
28
|
+
|
|
29
|
+
const PACKAGE_JSON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The subcommand words each verb knows; the first positional is logged as `sub` only when it is
|
|
33
|
+
* one of these (anything else could be a user value: a path, an id, a name).
|
|
34
|
+
* @type {Readonly<Record<string, readonly string[]>>}
|
|
35
|
+
*/
|
|
36
|
+
export const SUBCOMMANDS = Object.freeze({
|
|
37
|
+
block: ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'],
|
|
38
|
+
gates: ['detect', 'run', 'secret-scan', 'safe-edit', 'scope', 'acceptance', 'transcript-grep'],
|
|
39
|
+
jev: ['ask'],
|
|
40
|
+
keys: ['list', 'set', 'test', 'remove'],
|
|
41
|
+
ledger: ['calibration', 'outcome', 'tail'],
|
|
42
|
+
logs: ['summary', 'clear', 'path', 'report'],
|
|
43
|
+
plan: ['check'],
|
|
44
|
+
proof: ['tier', 'export', 'lock', 'unlock', 'restore', 'red-green'],
|
|
45
|
+
run: ['start', 'status', 'end'],
|
|
46
|
+
tools: ['install'],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The scrub rules, in the order a summary lists them. `label` is singular and plural.
|
|
51
|
+
* @type {ReadonlyArray<{rule: string, one: string, many: string}>}
|
|
52
|
+
*/
|
|
53
|
+
export const SCRUB_RULES = Object.freeze([
|
|
54
|
+
{ rule: 'home', one: 'home path', many: 'home paths' },
|
|
55
|
+
{ rule: 'project_path', one: 'project path', many: 'project paths' },
|
|
56
|
+
{ rule: 'project_name', one: 'project name', many: 'project names' },
|
|
57
|
+
{ rule: 'email', one: 'email', many: 'emails' },
|
|
58
|
+
{ rule: 'op_ref', one: '1Password reference', many: '1Password references' },
|
|
59
|
+
{ rule: 'item_id', one: '1Password item ID', many: '1Password item IDs' },
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every placeholder text this module (and `redact`) puts into a message: the AI pass of a report
|
|
64
|
+
* (`session/scrub.mjs`) must never replace part of one. `~` (the home dir) is no `<…>` token.
|
|
65
|
+
*/
|
|
66
|
+
export const SCRUB_PLACEHOLDERS = Object.freeze(['<project>', '<slug>', '<email>', '<ref>', '<item-id>', '<unprintable error>', '[REDACTED]']);
|
|
67
|
+
|
|
68
|
+
/** @typedef {{rule: string, count: number}} CleanCount */
|
|
69
|
+
/** @typedef {{home?: string|null, cwd?: string|null, slug?: string|null}} ScrubContext */
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @typedef {object} ErrorEntry
|
|
73
|
+
* @property {string} ts
|
|
74
|
+
* @property {string} version
|
|
75
|
+
* @property {string} node
|
|
76
|
+
* @property {string} platform
|
|
77
|
+
* @property {string} arch
|
|
78
|
+
* @property {string} verb
|
|
79
|
+
* @property {string|null} sub
|
|
80
|
+
* @property {string[]} flags
|
|
81
|
+
* @property {number} exit
|
|
82
|
+
* @property {string} kind
|
|
83
|
+
* @property {string|null} message
|
|
84
|
+
* @property {string|null} stack
|
|
85
|
+
* @property {CleanCount[]} cleaned
|
|
86
|
+
* @property {string} [fp] - the fingerprint (B28); entries written before B28 have none.
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/** @param {string} s */
|
|
90
|
+
function escapeRegExp(s) {
|
|
91
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A path and its real path (macOS `/var` → `/private/var`), longest first; a path shorter than 2
|
|
96
|
+
* characters (`/`) is never scrubbed.
|
|
97
|
+
* @param {string|null|undefined} p
|
|
98
|
+
* @returns {string[]}
|
|
99
|
+
*/
|
|
100
|
+
function pathVariants(p) {
|
|
101
|
+
if (typeof p !== 'string' || p.length < 2) return [];
|
|
102
|
+
const out = new Set([p.replace(/[/\\]+$/, '')]);
|
|
103
|
+
try {
|
|
104
|
+
out.add(realpathSync(p));
|
|
105
|
+
} catch {
|
|
106
|
+
// not on disk: the given form only
|
|
107
|
+
}
|
|
108
|
+
return [...out].filter((v) => v.length >= 2).sort((a, b) => b.length - a.length);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A scrubber for one context. `scrub(text)` replaces, in this order: op:// references, the working
|
|
113
|
+
* dir (when it is not the home dir), the home dir, emails, the project slug (as a whole word),
|
|
114
|
+
* 1Password item IDs (26 lowercase letters and digits). `counts()` gives the replacements made so
|
|
115
|
+
* far per rule, in {@link SCRUB_RULES} order, rules with 0 left out. A later pass can add its own
|
|
116
|
+
* `{rule, count}` items to the same list.
|
|
117
|
+
* @param {ScrubContext} [ctx]
|
|
118
|
+
*/
|
|
119
|
+
export function createScrubber(ctx = {}) {
|
|
120
|
+
const homes = pathVariants(ctx.home);
|
|
121
|
+
const cwds = pathVariants(ctx.cwd).filter((c) => !homes.includes(c));
|
|
122
|
+
/** @type {Array<[string, string, string]>} every path form, longest first: the most specific one wins */
|
|
123
|
+
const paths = [
|
|
124
|
+
...cwds.map((c) => /** @type {[string, string, string]} */ (['project_path', c, '<project>'])),
|
|
125
|
+
...homes.map((h) => /** @type {[string, string, string]} */ (['home', h, '~'])),
|
|
126
|
+
].sort((a, b) => b[1].length - a[1].length);
|
|
127
|
+
const slug = typeof ctx.slug === 'string' && ctx.slug.length > 0 ? ctx.slug : null;
|
|
128
|
+
/** @type {Array<[string, RegExp, string]>} */
|
|
129
|
+
const steps = [['op_ref', /op:\/\/(?:[^/\r\n"'`<>]+\/[^/\r\n"'`<>]+\/)?[^\s"'`<>)\]]+/g, 'op://<ref>']];
|
|
130
|
+
// a whole path name: `/Users/bob` never eats the start of `/Users/bobby`, but is replaced before
|
|
131
|
+
// any other character (`/`, `.`, `)`, `,`, a backtick, the end)
|
|
132
|
+
for (const [rule, p, to] of paths) steps.push([rule, new RegExp(`${escapeRegExp(p)}(?![A-Za-z0-9_\\-])`, 'g'), to]);
|
|
133
|
+
steps.push(['email', /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g, '<email>']);
|
|
134
|
+
// a whole word, but never the scheme of a `scheme://` (`op://<ref>` with the slug "op")
|
|
135
|
+
if (slug) steps.push(['project_name', new RegExp(`(?<![A-Za-z0-9_<-])${escapeRegExp(slug)}(?![A-Za-z0-9_>-])(?!:\\/\\/)`, 'g'), '<slug>']);
|
|
136
|
+
steps.push(['item_id', /(?<![A-Za-z0-9])[a-z0-9]{26}(?![A-Za-z0-9])/g, '<item-id>']);
|
|
137
|
+
|
|
138
|
+
/** @type {Map<string, number>} */
|
|
139
|
+
const tally = new Map();
|
|
140
|
+
return {
|
|
141
|
+
/** @param {string} text @returns {string} */
|
|
142
|
+
scrub(text) {
|
|
143
|
+
let out = text;
|
|
144
|
+
for (const [rule, re, to] of steps) {
|
|
145
|
+
out = out.replace(re, () => {
|
|
146
|
+
tally.set(rule, (tally.get(rule) ?? 0) + 1);
|
|
147
|
+
return to;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
},
|
|
152
|
+
/** @returns {CleanCount[]} */
|
|
153
|
+
counts() {
|
|
154
|
+
return SCRUB_RULES.filter((r) => (tally.get(r.rule) ?? 0) > 0).map((r) => ({ rule: r.rule, count: /** @type {number} */ (tally.get(r.rule)) }));
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Add up `{rule, count}` lists; rules keep the order they first appear in (SCRUB_RULES first).
|
|
161
|
+
* @param {CleanCount[][]} lists
|
|
162
|
+
* @returns {CleanCount[]}
|
|
163
|
+
*/
|
|
164
|
+
export function mergeCounts(lists) {
|
|
165
|
+
/** @type {Map<string, number>} */
|
|
166
|
+
const total = new Map(SCRUB_RULES.map((r) => [r.rule, 0]));
|
|
167
|
+
for (const list of lists) {
|
|
168
|
+
for (const item of Array.isArray(list) ? list : []) {
|
|
169
|
+
if (typeof item?.rule === 'string' && Number.isSafeInteger(item.count) && item.count > 0) {
|
|
170
|
+
total.set(item.rule, (total.get(item.rule) ?? 0) + item.count);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return [...total].filter(([, n]) => n > 0).map(([rule, count]) => ({ rule, count }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* "Cleaned: 2 home paths, 1 email." / "Cleaned: nothing needed cleaning."
|
|
179
|
+
* @param {CleanCount[]} counts
|
|
180
|
+
* @returns {string}
|
|
181
|
+
*/
|
|
182
|
+
export function describeCounts(counts) {
|
|
183
|
+
const parts = counts.filter((c) => c.count > 0).map((c) => {
|
|
184
|
+
const r = SCRUB_RULES.find((x) => x.rule === c.rule);
|
|
185
|
+
const label = r ? (c.count === 1 ? r.one : r.many) : c.rule.replace(/_/g, ' ');
|
|
186
|
+
return `${c.count} ${label}`;
|
|
187
|
+
});
|
|
188
|
+
return parts.length > 0 ? `Cleaned: ${parts.join(', ')}.` : 'Cleaned: nothing needed cleaning.';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @param {string|null|undefined} home
|
|
193
|
+
* @returns {string|null} the log path, or null without a home.
|
|
194
|
+
*/
|
|
195
|
+
export function errorLogPath(home) {
|
|
196
|
+
return typeof home === 'string' && home.length > 0 ? path.join(home, '.code-forge', 'logs', 'errors.jsonl') : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Flag names from argv; values and positionals are dropped. `--name` is cut at `=`; a short flag
|
|
201
|
+
* is `-` plus its first letter (`-pSECRET` → `-p`); nothing after a bare `--` is read. A
|
|
202
|
+
* single-dash argument right after a `--name` without `=` may be that flag's value (`--msg -Hi`),
|
|
203
|
+
* so it is dropped (when unsure, leave it out).
|
|
204
|
+
* @param {string[]} args
|
|
205
|
+
* @returns {string[]}
|
|
206
|
+
*/
|
|
207
|
+
export function flagNames(args) {
|
|
208
|
+
const out = [];
|
|
209
|
+
let afterLong = false;
|
|
210
|
+
for (const arg of args) {
|
|
211
|
+
if (typeof arg !== 'string') {
|
|
212
|
+
afterLong = false;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (arg === '--') break;
|
|
216
|
+
const long = /^--([A-Za-z][A-Za-z0-9-]{0,39})(=|$)/.exec(arg);
|
|
217
|
+
if (long) {
|
|
218
|
+
out.push(`--${long[1]}`);
|
|
219
|
+
afterLong = long[2] === '';
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const short = /^-([A-Za-z])/.exec(arg);
|
|
223
|
+
if (short && !afterLong) out.push(`-${short[1]}`);
|
|
224
|
+
afterLong = false;
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {string} verb @param {string[]} args
|
|
231
|
+
* @returns {string|null} the first positional when it is a known subcommand word of `verb`.
|
|
232
|
+
*/
|
|
233
|
+
export function subcommandOf(verb, args) {
|
|
234
|
+
const first = args.find((a) => typeof a === 'string' && !a.startsWith('-'));
|
|
235
|
+
const known = Object.hasOwn(SUBCOMMANDS, verb) ? SUBCOMMANDS[verb] : [];
|
|
236
|
+
return first !== undefined && known.includes(first) ? first : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The first `max` bytes of `text`, never ending inside a character.
|
|
241
|
+
* @param {string} text @param {number} max
|
|
242
|
+
* @returns {string}
|
|
243
|
+
*/
|
|
244
|
+
export function capBytes(text, max) {
|
|
245
|
+
const buf = Buffer.from(text, 'utf8');
|
|
246
|
+
if (buf.length <= max) return text;
|
|
247
|
+
// back off to the start of a character: never cut inside one (only when the text is cut)
|
|
248
|
+
let end = max;
|
|
249
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80) end -= 1;
|
|
250
|
+
return buf.subarray(0, end).toString('utf8');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `project.slug` from `<cwd>/.code-forge.yml`, or null.
|
|
255
|
+
* @param {string} cwd
|
|
256
|
+
* @returns {Promise<string|null>}
|
|
257
|
+
*/
|
|
258
|
+
export async function readProjectSlug(cwd) {
|
|
259
|
+
try {
|
|
260
|
+
const text = await readFile(path.join(cwd, '.code-forge.yml'), 'utf8');
|
|
261
|
+
// imported here: the router must still start where the yaml package is absent
|
|
262
|
+
const YAML = await import('yaml');
|
|
263
|
+
const slug = YAML.parse(text)?.project?.slug;
|
|
264
|
+
return typeof slug === 'string' && slug.length > 0 ? slug : null;
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** @returns {string} this package's version, or `unknown`. */
|
|
271
|
+
export function packageVersion() {
|
|
272
|
+
try {
|
|
273
|
+
const v = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')).version;
|
|
274
|
+
return typeof v === 'string' ? v : 'unknown';
|
|
275
|
+
} catch {
|
|
276
|
+
return 'unknown';
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Record what is written to `stream` until `stop()`; every write still reaches the stream.
|
|
282
|
+
* @param {NodeJS.WritableStream} stream
|
|
283
|
+
* @returns {{stop: () => string|null}} `stop()` restores `write` and gives the last non-blank text.
|
|
284
|
+
*/
|
|
285
|
+
export function captureStderr(stream) {
|
|
286
|
+
const hadOwn = Object.hasOwn(stream, 'write');
|
|
287
|
+
const original = stream.write;
|
|
288
|
+
/** @type {string|null} */
|
|
289
|
+
let last = null;
|
|
290
|
+
/** @param {any} chunk @param {...any} rest */
|
|
291
|
+
const wrapper = function (chunk, ...rest) {
|
|
292
|
+
try {
|
|
293
|
+
const text = typeof chunk === 'string' ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk).toString('utf8') : '';
|
|
294
|
+
if (text.trim().length > 0) last = text;
|
|
295
|
+
} catch {
|
|
296
|
+
// recording never breaks a write
|
|
297
|
+
}
|
|
298
|
+
return original.call(stream, chunk, ...rest);
|
|
299
|
+
};
|
|
300
|
+
stream.write = /** @type {any} */ (wrapper);
|
|
301
|
+
return {
|
|
302
|
+
stop() {
|
|
303
|
+
if (stream.write === wrapper) {
|
|
304
|
+
if (hadOwn) stream.write = original;
|
|
305
|
+
else delete (/** @type {any} */ (stream)).write;
|
|
306
|
+
}
|
|
307
|
+
return last;
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Append one line, then cut the file to its newest half when it is over `maxBytes`.
|
|
314
|
+
* @param {string} file @param {string} line - one JSON text, no newline.
|
|
315
|
+
* @param {number} [maxBytes]
|
|
316
|
+
*/
|
|
317
|
+
export async function appendLine(file, line, maxBytes = ERROR_LOG_MAX_BYTES) {
|
|
318
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
319
|
+
await appendFile(file, `${line}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
320
|
+
const { size } = await stat(file);
|
|
321
|
+
if (size <= maxBytes) return;
|
|
322
|
+
const lines = (await readFile(file, 'utf8')).split('\n').filter((l) => l.length > 0);
|
|
323
|
+
const budget = Math.floor(size / 2);
|
|
324
|
+
const kept = [];
|
|
325
|
+
let used = 0;
|
|
326
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
327
|
+
const bytes = Buffer.byteLength(lines[i], 'utf8') + 1;
|
|
328
|
+
if (kept.length > 0 && used + bytes > budget) break;
|
|
329
|
+
kept.unshift(lines[i]);
|
|
330
|
+
used += bytes;
|
|
331
|
+
}
|
|
332
|
+
// Accepted race: a line another code-forge process appends between the read above and the
|
|
333
|
+
// rename below is lost. Failing verbs are rare and the log is best effort.
|
|
334
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
335
|
+
try {
|
|
336
|
+
await writeFile(tmp, `${kept.join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
337
|
+
await rename(tmp, file);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
340
|
+
throw err;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* @typedef {object} FailureInput
|
|
346
|
+
* @property {string} verb
|
|
347
|
+
* @property {string[]} args
|
|
348
|
+
* @property {number} exit
|
|
349
|
+
* @property {string|null} [kind] - the kind the verb reported, if any.
|
|
350
|
+
* @property {boolean} [threw] - whether the verb threw (a thrown `undefined` is a crash too).
|
|
351
|
+
* @property {unknown} [thrown] - what the verb threw.
|
|
352
|
+
* @property {string|null} [stderrText] - the last stderr text the verb wrote.
|
|
353
|
+
* @property {NodeJS.ProcessEnv} [env]
|
|
354
|
+
* @property {string} [cwd]
|
|
355
|
+
* @property {Date} [now]
|
|
356
|
+
*/
|
|
357
|
+
|
|
358
|
+
const KIND_WORD = /^[a-z][a-z0-9_]{0,39}$/;
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {() => unknown} get
|
|
362
|
+
* @returns {string} `String(get())`, or `<unprintable error>` when reading or printing it throws.
|
|
363
|
+
*/
|
|
364
|
+
function safeString(get) {
|
|
365
|
+
try {
|
|
366
|
+
return String(get());
|
|
367
|
+
} catch {
|
|
368
|
+
return '<unprintable error>';
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The message as a fingerprint sees it (B28). Dropped: quoted strings, `<…>` placeholders,
|
|
374
|
+
* path-like words (anything holding a `/`), ISO dates and clock times, hex runs of 7+ characters
|
|
375
|
+
* holding a digit (hashes, ids), and standalone numbers of 4+ digits (pids, ports, sizes, ms).
|
|
376
|
+
* Kept: shorter numbers (an HTTP status such as 404, a count) and digits inside a word (`E404`,
|
|
377
|
+
* `1Password`, `v22`). Whitespace runs become one space. So the same error with other ids, times
|
|
378
|
+
* or paths gives the same text, while `HTTP 404` and `HTTP 500` stay apart.
|
|
379
|
+
* @param {string|null|undefined} message
|
|
380
|
+
* @returns {string}
|
|
381
|
+
*/
|
|
382
|
+
export function normalizeMessage(message) {
|
|
383
|
+
if (typeof message !== 'string') return '';
|
|
384
|
+
return message
|
|
385
|
+
.replace(/"[^"\n]*"|(?<![A-Za-z0-9])'[^'\n]*'|`[^`\n]*`/g, ' ')
|
|
386
|
+
.replace(/<[^<>\n]*>/g, ' ')
|
|
387
|
+
.replace(/\S*\/\S*/g, ' ')
|
|
388
|
+
.replace(/\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/g, ' ')
|
|
389
|
+
.replace(/\b\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?\b/g, ' ')
|
|
390
|
+
.replace(/\b(?=[0-9a-f]*\d)[0-9a-f]{7,}\b/gi, ' ')
|
|
391
|
+
.replace(/(?<![A-Za-z0-9_.])\d{4,}(?:\.\d+)?(?![A-Za-z0-9_])/g, ' ')
|
|
392
|
+
.replace(/\s+/g, ' ')
|
|
393
|
+
.trim();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The first 12 hex characters of sha256(verb, sub, kind, normalized message) (B28).
|
|
398
|
+
* @param {{verb: string, sub?: string|null, kind: string, message?: string|null}} e
|
|
399
|
+
* @returns {string}
|
|
400
|
+
*/
|
|
401
|
+
export function fingerprint(e) {
|
|
402
|
+
const text = [e.verb, e.sub ?? '', e.kind, normalizeMessage(e.message)].join('\n');
|
|
403
|
+
return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 12);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* An entry's stored fingerprint, or one computed now (an entry written before B28 has none).
|
|
408
|
+
* @param {ErrorEntry} e
|
|
409
|
+
* @returns {string}
|
|
410
|
+
*/
|
|
411
|
+
export function fingerprintOf(e) {
|
|
412
|
+
return typeof e.fp === 'string' && /^[0-9a-f]{12}$/.test(e.fp) ? e.fp : fingerprint(e);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Build the scrubbed entry (no I/O except reading the project slug).
|
|
417
|
+
* @param {FailureInput} input
|
|
418
|
+
* @returns {Promise<ErrorEntry>}
|
|
419
|
+
*/
|
|
420
|
+
export async function buildEntry(input) {
|
|
421
|
+
const env = input.env ?? process.env;
|
|
422
|
+
const cwd = input.cwd ?? process.cwd();
|
|
423
|
+
const crashed = input.threw === true;
|
|
424
|
+
const scrubber = createScrubber({ home: env.HOME ?? null, cwd, slug: await readProjectSlug(cwd) });
|
|
425
|
+
const clean = (/** @type {string} */ s) => scrubber.scrub(redact(s));
|
|
426
|
+
// a verb-reported kind must be a short snake_case word; anything else is logged as `error`
|
|
427
|
+
const reported = input.kind === undefined || input.kind === null ? null : typeof input.kind === 'string' && KIND_WORD.test(input.kind) ? input.kind : 'error';
|
|
428
|
+
const kind = crashed ? 'crash' : reported ?? (input.exit === 2 ? 'usage' : 'error');
|
|
429
|
+
/** @type {any} */
|
|
430
|
+
const err = input.thrown;
|
|
431
|
+
const rawMessage = crashed ? safeString(() => err?.message ?? err) : input.stderrText ?? null;
|
|
432
|
+
let rawStack = null;
|
|
433
|
+
try {
|
|
434
|
+
rawStack = crashed && typeof err?.stack === 'string' ? err.stack : null;
|
|
435
|
+
} catch {
|
|
436
|
+
// a throwing `stack` getter: no stack
|
|
437
|
+
}
|
|
438
|
+
const message = rawMessage === null ? null : capBytes(clean(rawMessage.trim()), MESSAGE_MAX_BYTES);
|
|
439
|
+
const stack = rawStack === null ? null : capBytes(clean(rawStack), STACK_MAX_BYTES);
|
|
440
|
+
const flags = flagNames(input.args).map(clean);
|
|
441
|
+
const sub = subcommandOf(input.verb, input.args);
|
|
442
|
+
const verbName = clean(input.verb);
|
|
443
|
+
return {
|
|
444
|
+
ts: (input.now ?? new Date()).toISOString(),
|
|
445
|
+
version: packageVersion(),
|
|
446
|
+
node: process.version,
|
|
447
|
+
platform: process.platform,
|
|
448
|
+
arch: process.arch,
|
|
449
|
+
verb: verbName,
|
|
450
|
+
sub,
|
|
451
|
+
flags,
|
|
452
|
+
exit: input.exit,
|
|
453
|
+
kind,
|
|
454
|
+
message,
|
|
455
|
+
stack,
|
|
456
|
+
cleaned: scrubber.counts(),
|
|
457
|
+
fp: fingerprint({ verb: verbName, sub, kind, message }),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Log one failed verb. Never throws; returns the entry written, or null when nothing was written
|
|
463
|
+
* (exit 0, logging off, no HOME, or a write error).
|
|
464
|
+
* @param {FailureInput} input
|
|
465
|
+
* @returns {Promise<ErrorEntry|null>}
|
|
466
|
+
*/
|
|
467
|
+
export async function writeVerbFailure(input) {
|
|
468
|
+
try {
|
|
469
|
+
if (input.exit === 0 && input.threw !== true) return null;
|
|
470
|
+
const env = input.env ?? process.env;
|
|
471
|
+
if (loggingOff(env)) return null;
|
|
472
|
+
const file = errorLogPath(env.HOME);
|
|
473
|
+
if (file === null) return null;
|
|
474
|
+
const entry = await buildEntry(input);
|
|
475
|
+
await appendLine(file, JSON.stringify(entry));
|
|
476
|
+
return entry;
|
|
477
|
+
} catch {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* @param {NodeJS.ProcessEnv} env
|
|
484
|
+
* @returns {boolean} whether `CODE_FORGE_NO_ERROR_LOG` turns logging off.
|
|
485
|
+
*/
|
|
486
|
+
export function loggingOff(env) {
|
|
487
|
+
const flag = env[NO_ERROR_LOG_ENV];
|
|
488
|
+
return typeof flag === 'string' && flag !== '' && flag !== '0';
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Log one failed verb. Never throws; returns whether a line was written.
|
|
493
|
+
* @param {FailureInput} input
|
|
494
|
+
* @returns {Promise<boolean>}
|
|
495
|
+
*/
|
|
496
|
+
export async function logVerbFailure(input) {
|
|
497
|
+
return (await writeVerbFailure(input)) !== null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Every well-formed entry, oldest first (lines that do not parse are skipped).
|
|
502
|
+
* @param {string|null} file
|
|
503
|
+
* @returns {Promise<ErrorEntry[]|null>} null when there is no log file.
|
|
504
|
+
*/
|
|
505
|
+
export async function readErrorLog(file) {
|
|
506
|
+
if (file === null) return null;
|
|
507
|
+
let text;
|
|
508
|
+
try {
|
|
509
|
+
text = await readFile(file, 'utf8');
|
|
510
|
+
} catch {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
/** @type {ErrorEntry[]} */
|
|
514
|
+
const out = [];
|
|
515
|
+
for (const line of text.split('\n')) {
|
|
516
|
+
if (line.trim().length === 0) continue;
|
|
517
|
+
try {
|
|
518
|
+
const e = JSON.parse(line);
|
|
519
|
+
if (e && typeof e === 'object' && typeof e.ts === 'string' && typeof e.verb === 'string') out.push(e);
|
|
520
|
+
} catch {
|
|
521
|
+
// a torn line: skipped
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return out;
|
|
525
|
+
}
|