aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
492 lines • 18.7 kB
JavaScript
/**
* AIWG CLI Structured Logger
*
* Two public surfaces:
*
* debug(scope, msg, ...args) — cheap, env-gated, stderr-only diagnostic log.
* Unchanged from Phase 4 (#921). Use this for
* ad-hoc troubleshooting prints that no-op
* unless AIWG_DEBUG is set.
*
* getLogger(scope) — structured Logger (this file's main surface).
* Every record carries full provenance
* metadata (ts, invocation_id, command, user,
* cwd, aiwg_version, git_sha, channel, node,
* platform, tty, ci) and is written to both
* stderr (pretty) and ~/.aiwg/logs/aiwg-YYYY-
* MM-DD.jsonl (structured).
*
* Phase 4.5 of the CLI Stabilization Epic (#925) extends the Phase 4 debug()
* helper into a full structured-logging stack.
*
* To enable debug-level output:
* AIWG_DEBUG=1 aiwg use all # enable everything (debug)
* AIWG_LOG_LEVEL=debug aiwg use all # same
* AIWG_LOG_LEVEL=info aiwg use all # info and above
* aiwg use all -v # info (verbose)
* aiwg use all -vv # debug
* aiwg use all --quiet # error only
* AIWG_LOG_FILE=/tmp/aiwg.jsonl aiwg use all # override JSONL path
* AIWG_LOG_DISABLE=1 aiwg --version # skip all logging
*
* Scope syntax (passed to both the debug() filter AND the Logger):
* 'cli:*' — any cli:* scope
* 'cli:use:*,net:*' — multiple globs
* 'cli:*,-cli:use:deploy' — include/exclude
*/
import { hostname, platform, arch, userInfo, homedir, release } from 'os';
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
import path from 'path';
const LEVEL_ORDER = {
debug: 10,
info: 20,
warn: 30,
error: 40,
silent: 50,
};
function coerceLevel(raw) {
if (!raw)
return null;
const lower = raw.toLowerCase();
if (lower === 'debug' || lower === 'info' || lower === 'warn' || lower === 'error' || lower === 'silent') {
return lower;
}
if (lower === '1' || lower === 'true' || lower === 'all')
return 'debug';
if (lower === '0' || lower === 'false' || lower === 'off')
return 'silent';
return null;
}
function compileScopeFilter(raw) {
if (!raw || raw === '0' || raw.toLowerCase() === 'false') {
return { includes: [], excludes: [], any: false };
}
if (raw === '1' || raw.toLowerCase() === 'true' || raw === '*') {
return { includes: [/.*/], excludes: [], any: true };
}
const parts = raw.split(',').map(s => s.trim()).filter(Boolean);
const includes = [];
const excludes = [];
for (const part of parts) {
const negated = part.startsWith('-');
const pattern = negated ? part.slice(1) : part;
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp('^' + escaped.replace(/\*/g, '[^:]*') + '$');
(negated ? excludes : includes).push(regex);
}
return { includes, excludes, any: includes.length > 0 };
}
function scopeMatches(filter, scope) {
if (!filter.any)
return false;
for (const ex of filter.excludes) {
if (ex.test(scope))
return false;
}
for (const inc of filter.includes) {
if (inc.test(scope))
return true;
}
return false;
}
let cachedProvenance = null;
let invocationIdOverride = null;
/**
* Override the invocation ID discovered by the logger. Called from the
* top-level entry (bin/aiwg.mjs) once the ID has been minted or inherited
* from `AIWG_INVOCATION_ID`. Safe to call before the logger is used.
*/
export function setInvocationId(id) {
invocationIdOverride = id;
if (cachedProvenance)
cachedProvenance.invocation_id = id;
}
export function getInvocationId() {
if (invocationIdOverride)
return invocationIdOverride;
if (cachedProvenance)
return cachedProvenance.invocation_id;
// Fallback: a synthesized id if the entry never called setInvocationId
// (typically only happens in unit tests that import the logger directly).
return process.env['AIWG_INVOCATION_ID'] ?? 'unknown';
}
function detectCI() {
for (const k of ['CI', 'CONTINUOUS_INTEGRATION', 'GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'TRAVIS', 'BUILDKITE', 'JENKINS_URL', 'TF_BUILD', 'TEAMCITY_VERSION']) {
const v = process.env[k];
if (v && v !== '0' && v.toLowerCase() !== 'false')
return true;
}
return false;
}
function detectVersion() {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const require = (0, eval)('require');
const fs = require('fs');
let dir = __dirname;
for (let i = 0; i < 10; i++) {
const pkgPath = path.join(dir, 'package.json');
if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
if (pkg.name === 'aiwg')
return pkg.version;
}
catch { /* keep walking */ }
}
const parent = path.dirname(dir);
if (parent === dir)
break;
dir = parent;
}
}
catch { /* fall through */ }
return 'unknown';
}
function detectGitSha() {
// Lightweight git detection: look for aiwg repo's .git/HEAD. No git CLI spawn.
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const require = (0, eval)('require');
const fs = require('fs');
let dir = __dirname;
for (let i = 0; i < 10; i++) {
const gitDir = path.join(dir, '.git');
if (fs.existsSync(gitDir)) {
const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf-8').trim();
if (head.startsWith('ref:')) {
const refPath = path.join(gitDir, head.slice(5).trim());
if (fs.existsSync(refPath)) {
return fs.readFileSync(refPath, 'utf-8').trim().slice(0, 8);
}
}
return head.slice(0, 8);
}
const parent = path.dirname(dir);
if (parent === dir)
break;
dir = parent;
}
}
catch { /* non-git installs are fine */ }
return undefined;
}
function detectChannel() {
// Minimal detection without loading the full channel manager (circular risk).
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const require = (0, eval)('require');
const fs = require('fs');
const channelFile = path.join(homedir(), '.aiwg', 'channel.json');
if (fs.existsSync(channelFile)) {
const json = JSON.parse(fs.readFileSync(channelFile, 'utf-8'));
if (json.devMode)
return 'dev';
if (json.channel)
return json.channel;
}
}
catch { /* ignore */ }
return 'stable';
}
function getProvenance() {
if (cachedProvenance)
return cachedProvenance;
const id = invocationIdOverride ?? process.env['AIWG_INVOCATION_ID'] ?? 'unknown';
cachedProvenance = {
pid: process.pid,
ppid: process.ppid,
user: userInfo().username,
host: hostname(),
cwd: process.cwd(),
aiwg_version: detectVersion(),
git_sha: detectGitSha(),
channel: detectChannel(),
node_version: process.version,
platform: platform(),
arch: arch(),
os_release: release(),
tty: process.stderr.isTTY ?? false,
ci: detectCI(),
invocation_id: id,
};
return cachedProvenance;
}
let cachedConfig = null;
let currentLevelOverride = null;
function resolveConfig() {
if (cachedConfig)
return cachedConfig;
const disabled = process.env['AIWG_LOG_DISABLE'] === '1' ||
process.env['AIWG_LOG_DISABLE']?.toLowerCase() === 'true';
// Precedence: AIWG_LOG_LEVEL > AIWG_DEBUG (implies debug) > default info.
// When AIWG_LOG_DISABLE is set, silent wins.
let level = 'info';
if (disabled) {
level = 'silent';
}
else {
const envLevel = coerceLevel(process.env['AIWG_LOG_LEVEL']);
if (envLevel)
level = envLevel;
else if (process.env['AIWG_DEBUG'])
level = 'debug';
}
// AIWG_LOG_LEVEL may carry either a bare level ('debug') or a scope filter
// ('cli:use:*=debug,net:*=info'). The scope-level form is richer; keep both
// paths for backward compat. Today the simple form is supported.
const scopeFilter = compileScopeFilter(process.env['AIWG_DEBUG']);
// Log file: env override wins; default is ~/.aiwg/logs/aiwg-YYYY-MM-DD.jsonl.
let logFile = null;
if (!disabled) {
if (process.env['AIWG_LOG_FILE']) {
logFile = process.env['AIWG_LOG_FILE'];
}
else {
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
logFile = path.join(homedir(), '.aiwg', 'logs', `aiwg-${today}.jsonl`);
}
}
const retentionDays = (() => {
const raw = process.env['AIWG_LOG_RETENTION_DAYS'];
const n = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : 30;
})();
cachedConfig = { level, scopeFilter, logFile, disabled, retentionDays };
return cachedConfig;
}
/**
* Override the log level from code (typically from bin/aiwg.mjs after
* parsing -v / -vv / --quiet flags). Must be called before the first
* Logger method to affect records.
*/
export function setLogLevel(level) {
currentLevelOverride = level;
if (cachedConfig)
cachedConfig.level = level;
}
function effectiveLevel() {
if (currentLevelOverride)
return currentLevelOverride;
return resolveConfig().level;
}
// ── JSONL sink ────────────────────────────────────────────────────────────────
let logFileEnsured = false;
function ensureLogDir(logFile) {
if (logFileEnsured)
return;
try {
mkdirSync(path.dirname(logFile), { recursive: true });
logFileEnsured = true;
}
catch {
// Disk full / permission denied — downgrade to stderr-only silently.
logFileEnsured = true; // don't retry per-record
}
}
/**
* Prune daily log files older than `retentionDays`. Run once at startup
* (bounded work: one directory listing). Silent on failure.
*/
export function pruneOldLogs() {
const cfg = resolveConfig();
if (cfg.disabled || !cfg.logFile)
return;
const dir = path.dirname(cfg.logFile);
if (!existsSync(dir))
return;
const cutoff = Date.now() - cfg.retentionDays * 24 * 60 * 60 * 1000;
try {
const entries = readdirSync(dir);
for (const name of entries) {
if (!/^aiwg-\d{4}-\d{2}-\d{2}\.jsonl$/.test(name))
continue;
const full = path.join(dir, name);
try {
const s = statSync(full);
if (s.mtimeMs < cutoff)
unlinkSync(full);
}
catch { /* ignore per-file errors */ }
}
}
catch { /* ignore listing errors */ }
}
function writeJsonl(record) {
const cfg = resolveConfig();
if (cfg.disabled || !cfg.logFile)
return;
try {
ensureLogDir(cfg.logFile);
// Sync append — cheap for typical record sizes, simpler than backpressure
// management. Can move to async append with a tiny queue if we see perf
// impact on hot paths.
appendFileSync(cfg.logFile, JSON.stringify(record) + '\n', 'utf-8');
}
catch {
// Never fail the command because logging failed.
}
}
// ── Span support ──────────────────────────────────────────────────────────────
function shortSpanId() {
// 6 base32 chars = ~30 bits of entropy — enough for per-invocation uniqueness.
const buf = new Uint8Array(4);
if (typeof globalThis.crypto?.getRandomValues === 'function') {
globalThis.crypto.getRandomValues(buf);
}
else {
for (let i = 0; i < 4; i++)
buf[i] = Math.floor(Math.random() * 256);
}
const base32 = '0123456789abcdefghjkmnpqrstvwxyz';
let out = '';
let acc = 0;
let bits = 0;
for (const byte of buf) {
acc = (acc << 8) | byte;
bits += 8;
while (bits >= 5) {
out += base32[(acc >> (bits - 5)) & 0x1f];
bits -= 5;
}
}
return out.slice(0, 6);
}
function shouldLog(level, scope) {
const cfg = resolveConfig();
if (cfg.disabled)
return false;
if (LEVEL_ORDER[level] < LEVEL_ORDER[effectiveLevel()])
return false;
// AIWG_DEBUG scope filter, when set, acts as an allow-list for debug-level
// records only. Higher levels always pass.
if (level === 'debug' && cfg.scopeFilter.any) {
return scopeMatches(cfg.scopeFilter, scope);
}
return true;
}
function emit(level, scope, msg, ctx, spanId, extraFields) {
if (!shouldLog(level, scope))
return;
const now = new Date();
const prov = getProvenance();
const record = {
ts: now.toISOString(),
ts_unix_ms: now.getTime(),
invocation_id: prov.invocation_id,
span_id: spanId,
parent_span_id: ctx.parentSpanId,
scope,
level,
msg,
pid: prov.pid,
ppid: prov.ppid,
user: prov.user,
host: prov.host,
cwd: prov.cwd,
aiwg_version: prov.aiwg_version,
channel: prov.channel,
node_version: prov.node_version,
platform: prov.platform,
arch: prov.arch,
os_release: prov.os_release,
tty: prov.tty,
ci: prov.ci,
...(prov.git_sha ? { git_sha: prov.git_sha } : {}),
...ctx.fields,
...(extraFields ?? {}),
};
// Pretty output to stderr (human-readable). Gated by effective level.
// File records always carry full provenance; terminal output is condensed
// so operators aren't drowned in metadata.
const colorReset = '\x1b[0m';
const color = level === 'error' ? '\x1b[31m' :
level === 'warn' ? '\x1b[33m' :
level === 'info' ? '\x1b[36m' :
'\x1b[2m';
const useColor = prov.tty && !process.env['NO_COLOR'];
const prefix = useColor ? `${color}[${level}]${colorReset}` : `[${level}]`;
const line = `${prefix} ${scope ? scope + ' ' : ''}${msg}`;
// eslint-disable-next-line no-console
console.error(line);
// JSONL sink (structured, always full provenance).
writeJsonl(record);
}
export function getLogger(scope = 'cli', extraFields = {}) {
return makeLogger({ scope, parentSpanId: '0', fields: extraFields });
}
function makeLogger(ctx) {
const currentSpanId = ctx.parentSpanId === '0' ? '0' : ctx.parentSpanId;
return {
debug(msg, fields) { emit('debug', ctx.scope, msg, ctx, currentSpanId, fields); },
info(msg, fields) { emit('info', ctx.scope, msg, ctx, currentSpanId, fields); },
warn(msg, fields) { emit('warn', ctx.scope, msg, ctx, currentSpanId, fields); },
error(msg, fields) { emit('error', ctx.scope, msg, ctx, currentSpanId, fields); },
child(opts) {
return makeLogger({
scope: opts.scope ?? ctx.scope,
parentSpanId: ctx.parentSpanId,
fields: { ...ctx.fields, ...(opts.fields ?? {}) },
});
},
span(name, fields) {
const spanId = shortSpanId();
const started = Date.now();
const spanCtx = {
scope: ctx.scope,
parentSpanId: spanId,
fields: { ...ctx.fields, ...(fields ?? {}) },
};
emit('debug', ctx.scope, `span:begin:${name}`, spanCtx, spanId, { span_name: name });
return {
end(endMsg, endFields) {
const duration_ms = Date.now() - started;
emit('debug', ctx.scope, endMsg ?? `span:end:${name}`, spanCtx, spanId, {
span_name: name,
duration_ms,
...(endFields ?? {}),
});
},
debug(msg, f) { emit('debug', ctx.scope, msg, spanCtx, spanId, f); },
info(msg, f) { emit('info', ctx.scope, msg, spanCtx, spanId, f); },
warn(msg, f) { emit('warn', ctx.scope, msg, spanCtx, spanId, f); },
error(msg, f) { emit('error', ctx.scope, msg, spanCtx, spanId, f); },
};
},
};
}
// ── Phase 4 debug() helper (unchanged, kept for backward compat) ──────────────
const DEBUG_FILTER = compileScopeFilter(process.env['AIWG_DEBUG']);
/**
* Emit a debug log record to stderr. No-op when AIWG_DEBUG is unset or
* the scope does not match.
*
* Backward-compatible with the Phase 4 API. New code should prefer
* getLogger(scope).debug() which also writes to the JSONL sink.
*/
export function debug(scope, ...args) {
if (!scopeMatches(DEBUG_FILTER, scope))
return;
const ts = new Date().toISOString();
// eslint-disable-next-line no-console
console.error(`[${ts}] [${scope}]`, ...args);
}
export function isDebugEnabled(scope) {
return scopeMatches(DEBUG_FILTER, scope);
}
// ── Diagnostic helpers for 'aiwg diagnose' ────────────────────────────────────
/**
* Return the effective logger configuration — used by `aiwg version --verbose`
* and `aiwg diagnose` to surface where logs go and what level is in effect.
*/
export function getLoggerInfo() {
const cfg = resolveConfig();
return {
level: effectiveLevel(),
disabled: cfg.disabled,
logFile: cfg.logFile,
retentionDays: cfg.retentionDays,
provenance: getProvenance(),
};
}
//# sourceMappingURL=log.js.map