feat(setup): three-level output (clack UI / progression log / raw per-step)
Documents and implements the output contract from docs/setup-flow.md:
Level 1: clack UI — branded, concise, product content
Level 2: logs/setup.log — append-only, linear, structured entries for
humans + AI agents reviewing a run
Level 3: logs/setup-steps/NN-name.log — full raw stdout+stderr per step
Every scripted sub-step, including bootstrap, emits at all three levels.
Bootstrap now runs under a bash-side clack-alike spinner with live elapsed
time; its apt/pnpm output is captured to 01-bootstrap.log and summarised
as a progression entry. setup.sh's legacy log() routes to the raw log
instead of contaminating the progression log.
Telegram install becomes fully branded: setup/auto.ts owns the BotFather
instructions (clack note), token paste (clack password with format
validation), and getMe check (clack spinner). add-telegram.sh drops to a
non-interactive installer that reads TELEGRAM_BOT_TOKEN from env, logs to
stderr, and emits a single ADD_TELEGRAM status block on stdout.
The Anthropic credential flow is the one intentional break — register-
claude-token.sh still inherits the TTY for claude setup-token's browser
dance; it logs as an 'interactive' progression entry with the method.
setup/logs.ts centralises the level 2/3 formatting: reset, header, step,
userInput, complete, abort, stepRawLog. User answers (display name, agent
name, channel choice, telegram_token preview) log as their own entries so
the setup path is reconstructable from the progression log alone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
130
setup/logs.ts
Normal file
130
setup/logs.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Three-level setup logging primitives. See docs/setup-flow.md for the
|
||||
* contract and design rationale.
|
||||
*
|
||||
* Level 1: clack UI in setup/auto.ts (not here)
|
||||
* Level 2: logs/setup.log — structured, append-only progression log
|
||||
* Level 3: logs/setup-steps/NN-name.log — raw stdout+stderr per step
|
||||
*
|
||||
* Usage from auto.ts:
|
||||
*
|
||||
* import * as setupLog from './logs.js';
|
||||
*
|
||||
* const rawLog = setupLog.stepRawLog('container');
|
||||
* const { ok, durationMs, terminal } =
|
||||
* await spawnIntoRawLog('...', rawLog);
|
||||
* setupLog.step('container', ok ? 'success' : 'failed', durationMs,
|
||||
* { RUNTIME: 'docker', BUILD_OK: terminal.fields.BUILD_OK },
|
||||
* rawLog);
|
||||
*
|
||||
* nanoclaw.sh emits the bootstrap entry directly via a bash helper so
|
||||
* the format stays consistent without needing IPC between bash and tsx.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const LOGS_DIR = 'logs';
|
||||
const STEPS_DIR = path.join(LOGS_DIR, 'setup-steps');
|
||||
const PROGRESS_LOG = path.join(LOGS_DIR, 'setup.log');
|
||||
|
||||
export const progressLogPath = PROGRESS_LOG;
|
||||
export const stepsDir = STEPS_DIR;
|
||||
|
||||
/** Wipe prior logs and write a header. Called once per fresh run (by nanoclaw.sh or as a fallback by auto.ts if invoked standalone). */
|
||||
export function reset(meta: Record<string, string>): void {
|
||||
if (fs.existsSync(STEPS_DIR)) {
|
||||
fs.rmSync(STEPS_DIR, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(STEPS_DIR, { recursive: true });
|
||||
if (fs.existsSync(PROGRESS_LOG)) fs.unlinkSync(PROGRESS_LOG);
|
||||
header(meta);
|
||||
}
|
||||
|
||||
/** Append a run-start header to the progression log. Idempotent: creates the file if missing. */
|
||||
export function header(meta: Record<string, string>): void {
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
const ts = new Date().toISOString();
|
||||
const lines = [`## ${ts} · setup:auto started`];
|
||||
for (const [k, v] of Object.entries(meta)) {
|
||||
lines.push(` ${k}: ${v}`);
|
||||
}
|
||||
lines.push('');
|
||||
fs.appendFileSync(PROGRESS_LOG, lines.join('\n') + '\n');
|
||||
}
|
||||
|
||||
/** Append one step entry to the progression log. */
|
||||
export function step(
|
||||
name: string,
|
||||
status: 'success' | 'skipped' | 'failed' | 'aborted' | 'interactive',
|
||||
durationMs: number,
|
||||
fields: Record<string, string | number | boolean | undefined>,
|
||||
rawRel?: string,
|
||||
): void {
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
const ts = new Date().toISOString();
|
||||
const dur = formatDuration(durationMs);
|
||||
const lines = [`=== [${ts}] ${name} [${dur}] → ${status} ===`];
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
lines.push(` ${k.toLowerCase()}: ${String(v)}`);
|
||||
}
|
||||
if (rawRel) lines.push(` raw: ${rawRel}`);
|
||||
lines.push('');
|
||||
fs.appendFileSync(PROGRESS_LOG, lines.join('\n') + '\n');
|
||||
}
|
||||
|
||||
/** A user answered a prompt. Logs as its own entry because the setup path depends on it. */
|
||||
export function userInput(key: string, value: string): void {
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
const ts = new Date().toISOString();
|
||||
fs.appendFileSync(
|
||||
PROGRESS_LOG,
|
||||
`=== [${ts}] user-input → ${key} ===\n value: ${value}\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Append the success footer. */
|
||||
export function complete(totalMs: number): void {
|
||||
const ts = new Date().toISOString();
|
||||
fs.appendFileSync(
|
||||
PROGRESS_LOG,
|
||||
`## ${ts} · completed (total ${formatDurationTotal(totalMs)})\n`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Append the failure footer. Keep error short — full context lives in the failing step's raw log. */
|
||||
export function abort(stepName: string, error: string): void {
|
||||
const ts = new Date().toISOString();
|
||||
fs.appendFileSync(
|
||||
PROGRESS_LOG,
|
||||
`## ${ts} · aborted at ${stepName} (${error})\n`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the next raw-log path for a given step name. Numbering is derived
|
||||
* from the count of existing NN-*.log files in STEPS_DIR, so bootstrap's
|
||||
* pre-existing 01-bootstrap.log (written by nanoclaw.sh before this module
|
||||
* is loaded) counts toward the sequence.
|
||||
*/
|
||||
export function stepRawLog(name: string): string {
|
||||
fs.mkdirSync(STEPS_DIR, { recursive: true });
|
||||
const existing = fs
|
||||
.readdirSync(STEPS_DIR)
|
||||
.filter((n) => /^\d+-.+\.log$/.test(n));
|
||||
const nextIdx = existing.length + 1;
|
||||
const num = String(nextIdx).padStart(2, '0');
|
||||
const safeName = name.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
|
||||
return path.join(STEPS_DIR, `${num}-${safeName}.log`);
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function formatDurationTotal(ms: number): string {
|
||||
const mins = Math.floor(ms / 60000);
|
||||
const secs = Math.round((ms % 60000) / 1000);
|
||||
return mins > 0 ? `${mins}m${secs}s` : `${secs}s`;
|
||||
}
|
||||
Reference in New Issue
Block a user