@oaklandzoo/ostup 0.7.0 → 0.8.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/bin/cli.mjs +25 -1
- package/package.json +1 -1
- package/src/doctor.mjs +186 -0
- package/src/exec.mjs +48 -11
- package/src/tracer.mjs +116 -0
package/bin/cli.mjs
CHANGED
|
@@ -5,12 +5,13 @@ import { dirname, resolve } from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { loadDotEnv } from '../src/env-loader.mjs';
|
|
7
7
|
import { setDryRun } from '../src/exec.mjs';
|
|
8
|
+
import { startRun, endRun, disableTracer, traceError } from '../src/tracer.mjs';
|
|
8
9
|
|
|
9
10
|
loadDotEnv();
|
|
10
11
|
|
|
11
12
|
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
13
|
|
|
13
|
-
const SUBCOMMANDS = new Set(['init', 'update', 'brief', 'export-pro']);
|
|
14
|
+
const SUBCOMMANDS = new Set(['init', 'update', 'brief', 'export-pro', 'doctor']);
|
|
14
15
|
|
|
15
16
|
async function readPkg() {
|
|
16
17
|
const raw = await readFile(resolve(PKG_ROOT, 'package.json'), 'utf8');
|
|
@@ -44,6 +45,7 @@ function parseArgs(argv) {
|
|
|
44
45
|
else if (a === '--output') flags.output = argv[++i];
|
|
45
46
|
else if (a.startsWith('--output=')) flags.output = a.slice('--output='.length);
|
|
46
47
|
else if (a === '--white-label') flags.whiteLabel = true;
|
|
48
|
+
else if (a === '--no-log') flags.noLog = true;
|
|
47
49
|
else if (a.startsWith('-')) {
|
|
48
50
|
process.stderr.write(`unknown flag: ${a}\n`);
|
|
49
51
|
process.exit(1);
|
|
@@ -66,6 +68,7 @@ function printHelp() {
|
|
|
66
68
|
' init Scaffold a new project (interactive or with --yes).',
|
|
67
69
|
' brief Run the 10-question operator intake; write docs/brief.md + brief.json.',
|
|
68
70
|
' export-pro Bundle brief + brand + content + initial PRD into a ZIP for client handoff.',
|
|
71
|
+
' doctor Self-diagnosis: preflight + auth + permissions + disk + Chrome. Read-only.',
|
|
69
72
|
' update Refresh bundled templates from the pinned source.',
|
|
70
73
|
'',
|
|
71
74
|
'Flags for `ostup init`:',
|
|
@@ -93,6 +96,7 @@ function printHelp() {
|
|
|
93
96
|
'Global flags:',
|
|
94
97
|
' --version, -v Print version and exit.',
|
|
95
98
|
' --help, -h Print this help and exit.',
|
|
99
|
+
' --no-log Disable auto-logging to ~/.ostup/logs/. (default: write a log per run)',
|
|
96
100
|
'',
|
|
97
101
|
].join('\n')
|
|
98
102
|
);
|
|
@@ -171,6 +175,18 @@ if (subcommand === 'export-pro') {
|
|
|
171
175
|
}
|
|
172
176
|
}
|
|
173
177
|
|
|
178
|
+
if (subcommand === 'doctor') {
|
|
179
|
+
const { runDoctor, printDoctorReport } = await import('../src/doctor.mjs');
|
|
180
|
+
try {
|
|
181
|
+
const result = await runDoctor();
|
|
182
|
+
process.stdout.write(printDoctorReport(result));
|
|
183
|
+
process.exit(result.failCount > 0 ? 1 : 0);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
process.stderr.write(`doctor failed: ${err.message}\n`);
|
|
186
|
+
process.exit(2);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
174
190
|
// subcommand === 'init'
|
|
175
191
|
if (flags.kitOnly) {
|
|
176
192
|
const { scaffold } = await import('../src/scaffold.mjs');
|
|
@@ -185,12 +201,20 @@ if (flags.kitOnly) {
|
|
|
185
201
|
}
|
|
186
202
|
}
|
|
187
203
|
|
|
204
|
+
if (flags.noLog) disableTracer();
|
|
205
|
+
const logPath = await startRun({ label: 'init' });
|
|
206
|
+
if (logPath) process.stdout.write(`Logging this run to ${logPath}\n`);
|
|
207
|
+
|
|
188
208
|
const { runMvp } = await import('../src/mvp-flow.mjs');
|
|
189
209
|
try {
|
|
190
210
|
await runMvp({ flags });
|
|
211
|
+
await endRun({ ok: true });
|
|
191
212
|
process.exit(0);
|
|
192
213
|
} catch (err) {
|
|
214
|
+
await traceError('init', err);
|
|
215
|
+
await endRun({ ok: false });
|
|
193
216
|
process.stderr.write(`${err.message}\n`);
|
|
217
|
+
if (logPath) process.stderr.write(`Full run log: ${logPath}\n`);
|
|
194
218
|
const userErrors = new Set([
|
|
195
219
|
'NO_TTY',
|
|
196
220
|
'USER_ABORT',
|
package/package.json
CHANGED
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// doctor.mjs: self-diagnosis subcommand. Runs preflight + auth checks + permissions + reports.
|
|
2
|
+
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join, resolve, dirname } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { preflight } from './preflight.mjs';
|
|
9
|
+
import { checkGithubAuth, checkVercelAuth } from './credential-prompts.mjs';
|
|
10
|
+
|
|
11
|
+
function runOk(cmd) {
|
|
12
|
+
try {
|
|
13
|
+
execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function runCapture(cmd) {
|
|
21
|
+
try {
|
|
22
|
+
return execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function runDoctor() {
|
|
29
|
+
const findings = [];
|
|
30
|
+
let okCount = 0;
|
|
31
|
+
let failCount = 0;
|
|
32
|
+
let warnCount = 0;
|
|
33
|
+
|
|
34
|
+
function report(level, label, detail) {
|
|
35
|
+
findings.push({ level, label, detail });
|
|
36
|
+
if (level === 'ok') okCount++;
|
|
37
|
+
else if (level === 'fail') failCount++;
|
|
38
|
+
else warnCount++;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// === Preflight ===
|
|
42
|
+
const pf = preflight();
|
|
43
|
+
if (pf.ok) {
|
|
44
|
+
report('ok', 'preflight', 'all required binaries present (node 20+, git, gh, vercel)');
|
|
45
|
+
} else {
|
|
46
|
+
report('fail', `preflight: ${pf.failed?.name || 'unknown'}`, pf.reason || pf.failed?.fix || '(no detail)');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// === Node version detail ===
|
|
50
|
+
const nodeVer = runCapture('node --version');
|
|
51
|
+
if (nodeVer) {
|
|
52
|
+
report('ok', 'node', nodeVer);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// === Git config ===
|
|
56
|
+
const gitName = runCapture('git config --global user.name');
|
|
57
|
+
const gitEmail = runCapture('git config --global user.email');
|
|
58
|
+
if (gitName && gitEmail) {
|
|
59
|
+
report('ok', 'git config', `${gitName} <${gitEmail}>`);
|
|
60
|
+
} else {
|
|
61
|
+
report('fail', 'git config', 'user.name or user.email not set; commits will fail. Run: git config --global user.name "Your Name" && git config --global user.email you@example.com');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// === GitHub auth ===
|
|
65
|
+
const gh = checkGithubAuth();
|
|
66
|
+
if (gh.ok) {
|
|
67
|
+
const ghUser = runCapture('gh api user --jq .login') || '(unknown)';
|
|
68
|
+
report('ok', 'gh auth', `authenticated as ${ghUser} (source: ${gh.source})`);
|
|
69
|
+
// Check scopes
|
|
70
|
+
const scopes = runCapture('gh auth status 2>&1 | grep -oE "Token scopes:.*"');
|
|
71
|
+
if (scopes) {
|
|
72
|
+
report('ok', 'gh scopes', scopes.replace('Token scopes:', '').trim());
|
|
73
|
+
if (!scopes.includes('delete_repo')) {
|
|
74
|
+
report('warn', 'gh delete_repo scope', 'missing; synthetic test cleanup will require manual deletion. Run: gh auth refresh -h github.com -s delete_repo');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
report('fail', 'gh auth', 'not authenticated; run: gh auth login');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// === Vercel auth ===
|
|
82
|
+
const v = checkVercelAuth();
|
|
83
|
+
if (v.ok) {
|
|
84
|
+
const vUser = runCapture('vercel whoami');
|
|
85
|
+
report('ok', 'vercel auth', `authenticated as ${vUser || '(unknown)'} (source: ${v.source})`);
|
|
86
|
+
|
|
87
|
+
// Detect personal-account-as-scope risk
|
|
88
|
+
const teams = runCapture('vercel teams ls 2>&1 | grep -E "^✔|^\\*" | head -5');
|
|
89
|
+
if (teams) {
|
|
90
|
+
report('ok', 'vercel teams', teams.replace(/\n/g, '; '));
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
report('fail', 'vercel auth', 'not authenticated; run: vercel login');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// === Vercel AI Gateway key (optional but needed for /generate-image) ===
|
|
97
|
+
if (process.env.VERCEL_AI_GATEWAY_KEY) {
|
|
98
|
+
report('ok', 'VERCEL_AI_GATEWAY_KEY', 'present (image generation enabled)');
|
|
99
|
+
} else {
|
|
100
|
+
report('warn', 'VERCEL_AI_GATEWAY_KEY', 'not set; /generate-image will fail until set. Get key at https://vercel.com/dashboard/ai-gateway');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// === Chrome for screenshot verification ===
|
|
104
|
+
const chromeMac = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
105
|
+
if (existsSync(chromeMac)) {
|
|
106
|
+
report('ok', 'chrome', 'present (visual verification supported per CLAUDE.md Part 19)');
|
|
107
|
+
} else if (runOk('which chromium 2>/dev/null') || runOk('which google-chrome 2>/dev/null')) {
|
|
108
|
+
report('ok', 'chrome', 'chromium or google-chrome on PATH');
|
|
109
|
+
} else {
|
|
110
|
+
report('warn', 'chrome', 'no Chrome detected; visual verification per CLAUDE.md Part 19 will not work. Install from https://www.google.com/chrome/');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// === Disk space in home dir ===
|
|
114
|
+
const dfHome = runCapture(`df -k ${homedir()} | tail -1 | awk '{print $4}'`);
|
|
115
|
+
if (dfHome) {
|
|
116
|
+
const kbFree = parseInt(dfHome, 10);
|
|
117
|
+
if (Number.isFinite(kbFree) && kbFree < 1024 * 1024) {
|
|
118
|
+
// less than 1 GB free
|
|
119
|
+
report('warn', 'disk space', `${(kbFree / 1024).toFixed(0)} MB free in ${homedir()}; scaffolds may fail (npm install needs ~500 MB)`);
|
|
120
|
+
} else {
|
|
121
|
+
report('ok', 'disk space', `${(kbFree / 1024 / 1024).toFixed(1)} GB free in ${homedir()}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// === Memory dir creatable ===
|
|
126
|
+
const memTest = join(homedir(), '.claude', 'projects', '__ostup_doctor_test__', 'memory');
|
|
127
|
+
try {
|
|
128
|
+
const { mkdir, rm } = await import('node:fs/promises');
|
|
129
|
+
await mkdir(memTest, { recursive: true });
|
|
130
|
+
await rm(memTest, { recursive: true, force: true });
|
|
131
|
+
report('ok', 'memory dir', 'can create ~/.claude/projects/<project>/memory at scaffold time');
|
|
132
|
+
} catch (err) {
|
|
133
|
+
report('fail', 'memory dir', `cannot create ~/.claude/projects/__ostup_doctor_test__/memory: ${err.message}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// === Log dir creatable ===
|
|
137
|
+
try {
|
|
138
|
+
const { mkdir } = await import('node:fs/promises');
|
|
139
|
+
await mkdir(join(homedir(), '.ostup', 'logs'), { recursive: true });
|
|
140
|
+
report('ok', 'log dir', `~/.ostup/logs/ writable (run logs land here)`);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
report('fail', 'log dir', `cannot create ~/.ostup/logs/: ${err.message}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// === npm version (we don't call `npm bin` anymore, but warn on very old npm) ===
|
|
146
|
+
const npmVer = runCapture('npm --version');
|
|
147
|
+
if (npmVer) {
|
|
148
|
+
const major = parseInt(npmVer.split('.')[0], 10);
|
|
149
|
+
if (major < 9) {
|
|
150
|
+
report('warn', 'npm version', `${npmVer} is old; recommend npm 9+. Run: npm install -g npm@latest`);
|
|
151
|
+
} else {
|
|
152
|
+
report('ok', 'npm', npmVer);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// === ostup tarball install location (sanity) ===
|
|
157
|
+
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
158
|
+
report('ok', 'ostup install', pkgRoot);
|
|
159
|
+
|
|
160
|
+
return { findings, okCount, warnCount, failCount };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function printDoctorReport({ findings, okCount, warnCount, failCount }) {
|
|
164
|
+
const lines = [];
|
|
165
|
+
lines.push('');
|
|
166
|
+
lines.push('=====================================');
|
|
167
|
+
lines.push(' ostup doctor: self-diagnosis');
|
|
168
|
+
lines.push('=====================================');
|
|
169
|
+
lines.push('');
|
|
170
|
+
for (const f of findings) {
|
|
171
|
+
const sym = f.level === 'ok' ? ' OK ' : f.level === 'warn' ? ' WARN ' : ' FAIL ';
|
|
172
|
+
lines.push(`[${sym}] ${f.label.padEnd(28)} ${f.detail || ''}`);
|
|
173
|
+
}
|
|
174
|
+
lines.push('');
|
|
175
|
+
lines.push(`Summary: ${okCount} OK, ${warnCount} WARN, ${failCount} FAIL`);
|
|
176
|
+
lines.push('');
|
|
177
|
+
if (failCount > 0) {
|
|
178
|
+
lines.push('Fix the FAILs before running `ostup init`. WARNs are optional.');
|
|
179
|
+
} else if (warnCount > 0) {
|
|
180
|
+
lines.push('No blockers. WARNs are optional improvements.');
|
|
181
|
+
} else {
|
|
182
|
+
lines.push('Everything clean. Ready to scaffold.');
|
|
183
|
+
}
|
|
184
|
+
lines.push('');
|
|
185
|
+
return lines.join('\n');
|
|
186
|
+
}
|
package/src/exec.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// exec.mjs: uniform shell call wrapper with logging, dry-run capture, and step-aware error formatting.
|
|
2
2
|
import { execa } from 'execa';
|
|
3
|
+
import { traceStep, getLogPath } from './tracer.mjs';
|
|
3
4
|
|
|
4
5
|
const HINTS = {
|
|
5
6
|
'gh repo create': 'Confirm the repo name is not already taken under this owner.',
|
|
@@ -56,19 +57,55 @@ export async function run(step, cmd, args = [], opts = {}) {
|
|
|
56
57
|
return { stdout: '', stderr: '', exitCode: 0, dryRun: true };
|
|
57
58
|
}
|
|
58
59
|
if (!_quiet) process.stdout.write(`[${step}] start: ${display}\n`);
|
|
60
|
+
const start = Date.now();
|
|
59
61
|
try {
|
|
60
|
-
|
|
62
|
+
const result = await _runner(cmd, args, { ...opts });
|
|
63
|
+
const duration = Date.now() - start;
|
|
64
|
+
await traceStep({
|
|
65
|
+
step, cmd, args,
|
|
66
|
+
stdout: result?.stdout || '',
|
|
67
|
+
stderr: result?.stderr || '',
|
|
68
|
+
exitCode: result?.exitCode || 0,
|
|
69
|
+
durationMs: duration,
|
|
70
|
+
});
|
|
71
|
+
return result;
|
|
61
72
|
} catch (err) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
const duration = Date.now() - start;
|
|
74
|
+
const stdout = (err && err.stdout || '').toString();
|
|
75
|
+
const stderr = (err && err.stderr || '').toString();
|
|
76
|
+
const message = (err && (err.shortMessage || err.message) || '').toString().trim();
|
|
77
|
+
|
|
78
|
+
// Capture both stdout AND stderr (was stderr-only). Some tools (vercel, gh) output errors to stdout.
|
|
79
|
+
const stdoutTail = stdout.split('\n').slice(-15).join('\n').trim();
|
|
80
|
+
const stderrTail = stderr.split('\n').slice(-15).join('\n').trim();
|
|
81
|
+
const logPath = getLogPath();
|
|
82
|
+
|
|
83
|
+
const lines = [`[${step}] FAIL`, ` Command: ${display}`];
|
|
84
|
+
if (stderrTail) {
|
|
85
|
+
lines.push(` Stderr (last 15 lines):`);
|
|
86
|
+
for (const l of stderrTail.split('\n')) lines.push(` ${l}`);
|
|
87
|
+
}
|
|
88
|
+
if (stdoutTail) {
|
|
89
|
+
lines.push(` Stdout (last 15 lines):`);
|
|
90
|
+
for (const l of stdoutTail.split('\n')) lines.push(` ${l}`);
|
|
91
|
+
}
|
|
92
|
+
if (!stderrTail && !stdoutTail && message) {
|
|
93
|
+
lines.push(` Error: ${message}`);
|
|
94
|
+
}
|
|
95
|
+
lines.push(` Fix: ${hintFor(cmd, args)}`);
|
|
96
|
+
if (logPath) {
|
|
97
|
+
lines.push(` Log: ${logPath}`);
|
|
98
|
+
}
|
|
99
|
+
lines.push('Exit 1.');
|
|
100
|
+
process.stderr.write(lines.join('\n') + '\n');
|
|
101
|
+
|
|
102
|
+
await traceStep({
|
|
103
|
+
step, cmd, args,
|
|
104
|
+
stdout, stderr,
|
|
105
|
+
exitCode: err?.exitCode || 1,
|
|
106
|
+
durationMs: duration,
|
|
107
|
+
});
|
|
108
|
+
|
|
72
109
|
const e = new Error(`step ${step} failed`);
|
|
73
110
|
e.code = 'STEP_FAILED';
|
|
74
111
|
e.step = step;
|
package/src/tracer.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// tracer.mjs: structured per-run log file. Writes every step + output to ~/.ostup/logs/<run-id>.log.
|
|
2
|
+
// Auto-enabled on every ostup init / brief / export-pro run unless --no-log is passed.
|
|
3
|
+
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { mkdir, appendFile } from 'node:fs/promises';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
let _enabled = true;
|
|
10
|
+
let _logPath = null;
|
|
11
|
+
let _runId = null;
|
|
12
|
+
|
|
13
|
+
export function disableTracer() {
|
|
14
|
+
_enabled = false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function enableTracer() {
|
|
18
|
+
_enabled = true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isTracerEnabled() {
|
|
22
|
+
return _enabled;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function startRun({ label = 'run' } = {}) {
|
|
26
|
+
if (!_enabled) return null;
|
|
27
|
+
_runId = `${label}-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`;
|
|
28
|
+
const dir = join(homedir(), '.ostup', 'logs');
|
|
29
|
+
await mkdir(dir, { recursive: true });
|
|
30
|
+
_logPath = join(dir, `${_runId}.log`);
|
|
31
|
+
await appendFile(_logPath, `# ostup run ${_runId}\n# started: ${new Date().toISOString()}\n# cwd: ${process.cwd()}\n# argv: ${process.argv.slice(2).join(' ')}\n\n`);
|
|
32
|
+
return _logPath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function trace(category, message) {
|
|
36
|
+
if (!_enabled || !_logPath) return;
|
|
37
|
+
const stamp = new Date().toISOString();
|
|
38
|
+
const line = `[${stamp}] [${category}] ${message}\n`;
|
|
39
|
+
try {
|
|
40
|
+
await appendFile(_logPath, line);
|
|
41
|
+
} catch {
|
|
42
|
+
// Logging must never crash the main flow.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function traceStep({ step, cmd, args = [], stdout = '', stderr = '', exitCode = 0, durationMs = 0 }) {
|
|
47
|
+
if (!_enabled || !_logPath) return;
|
|
48
|
+
const display = `${cmd} ${(args || []).join(' ')}`.trim();
|
|
49
|
+
const stdoutTail = (stdout || '').toString().split('\n').slice(-30).join('\n');
|
|
50
|
+
const stderrTail = (stderr || '').toString().split('\n').slice(-30).join('\n');
|
|
51
|
+
const lines = [
|
|
52
|
+
`## step ${step} | exit=${exitCode} | ${durationMs}ms`,
|
|
53
|
+
` cmd: ${display}`,
|
|
54
|
+
];
|
|
55
|
+
if (stdoutTail.trim()) {
|
|
56
|
+
lines.push(` stdout (last 30 lines):`);
|
|
57
|
+
for (const l of stdoutTail.split('\n')) lines.push(` ${l}`);
|
|
58
|
+
}
|
|
59
|
+
if (stderrTail.trim()) {
|
|
60
|
+
lines.push(` stderr (last 30 lines):`);
|
|
61
|
+
for (const l of stderrTail.split('\n')) lines.push(` ${l}`);
|
|
62
|
+
}
|
|
63
|
+
lines.push('');
|
|
64
|
+
try {
|
|
65
|
+
await appendFile(_logPath, lines.join('\n'));
|
|
66
|
+
} catch {
|
|
67
|
+
// ignore
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function traceError(category, err) {
|
|
72
|
+
if (!_enabled || !_logPath) return;
|
|
73
|
+
const stamp = new Date().toISOString();
|
|
74
|
+
const lines = [
|
|
75
|
+
`## ERROR [${stamp}] [${category}]`,
|
|
76
|
+
` message: ${err?.message || err}`,
|
|
77
|
+
` code: ${err?.code || '(none)'}`,
|
|
78
|
+
];
|
|
79
|
+
if (err?.cause) {
|
|
80
|
+
lines.push(` cause:`);
|
|
81
|
+
const causeStr = (err.cause.message || String(err.cause)).split('\n');
|
|
82
|
+
for (const l of causeStr.slice(0, 20)) lines.push(` ${l}`);
|
|
83
|
+
}
|
|
84
|
+
if (err?.stack) {
|
|
85
|
+
lines.push(` stack (first 10 lines):`);
|
|
86
|
+
for (const l of err.stack.split('\n').slice(0, 10)) lines.push(` ${l}`);
|
|
87
|
+
}
|
|
88
|
+
lines.push('');
|
|
89
|
+
try {
|
|
90
|
+
await appendFile(_logPath, lines.join('\n'));
|
|
91
|
+
} catch {
|
|
92
|
+
// ignore
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function endRun({ ok = true } = {}) {
|
|
97
|
+
if (!_enabled || !_logPath) return null;
|
|
98
|
+
const stamp = new Date().toISOString();
|
|
99
|
+
try {
|
|
100
|
+
await appendFile(_logPath, `\n# ended: ${stamp}\n# status: ${ok ? 'ok' : 'failed'}\n`);
|
|
101
|
+
} catch {
|
|
102
|
+
// ignore
|
|
103
|
+
}
|
|
104
|
+
const path = _logPath;
|
|
105
|
+
_logPath = null;
|
|
106
|
+
_runId = null;
|
|
107
|
+
return path;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function getLogPath() {
|
|
111
|
+
return _logPath;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function getRunId() {
|
|
115
|
+
return _runId;
|
|
116
|
+
}
|