runspec-node 0.8.0 → 0.10.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/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +16 -0
- package/dist/loader.js.map +1 -1
- package/dist/logging_setup.d.ts +30 -0
- package/dist/logging_setup.d.ts.map +1 -0
- package/dist/logging_setup.js +321 -0
- package/dist/logging_setup.js.map +1 -0
- package/dist/models.d.ts +7 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +37 -1
- package/dist/parser.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +2 -1
- package/src/loader.ts +18 -1
- package/src/logging_setup.ts +314 -0
- package/src/models.ts +8 -0
- package/src/parser.ts +39 -1
- package/tests/test_loader.test.ts +53 -0
- package/tests/test_logging_setup.test.ts +388 -0
package/src/index.ts
CHANGED
|
@@ -3,4 +3,5 @@ export { registerType, listTypes } from './types';
|
|
|
3
3
|
export { RunSpecError, MissingRequiredArg, InvalidChoice, OutOfRange, UnknownArg, GroupViolation, AutonomyViolation } from './errors';
|
|
4
4
|
export { findConfig } from './finder';
|
|
5
5
|
export { loadRaw } from './loader';
|
|
6
|
-
export
|
|
6
|
+
export { getLogger } from './logging_setup';
|
|
7
|
+
export type { ParsedArgs, ScriptSpec, ArgSpec, GroupSpec, RawSpec, RawConfig, LoggingConfig } from './models';
|
package/src/loader.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import { parse as parseTOML } from 'smol-toml';
|
|
3
|
-
import type { RawConfig, RawSpec, ScriptSpec, ArgSpec, GroupSpec, JumpHostConfig } from './models';
|
|
3
|
+
import type { RawConfig, RawSpec, ScriptSpec, ArgSpec, GroupSpec, JumpHostConfig, LoggingConfig } from './models';
|
|
4
4
|
|
|
5
5
|
export function loadRaw(configPath: string): RawSpec {
|
|
6
6
|
const content = fs.readFileSync(configPath, 'utf-8');
|
|
@@ -30,6 +30,23 @@ function normaliseConfig(raw: Record<string, unknown>): RawConfig {
|
|
|
30
30
|
lang: raw['lang'] as string | undefined,
|
|
31
31
|
version: String(raw['version'] ?? '1'),
|
|
32
32
|
jumpHosts,
|
|
33
|
+
logging: normaliseLogging(raw['logging'] as Record<string, unknown> | undefined),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const VALID_LOG_LEVELS = new Set(['debug', 'info', 'warning', 'error', 'critical']);
|
|
38
|
+
|
|
39
|
+
function normaliseLogging(raw: Record<string, unknown> | undefined): LoggingConfig | undefined {
|
|
40
|
+
if (raw === undefined) return undefined;
|
|
41
|
+
const level = String(raw['level'] ?? 'info').toLowerCase();
|
|
42
|
+
if (!VALID_LOG_LEVELS.has(level)) {
|
|
43
|
+
const sorted = [...VALID_LOG_LEVELS].sort().join(', ');
|
|
44
|
+
throw new Error(`✗ [config.logging] level must be one of: ${sorted}. Got: ${JSON.stringify(level)}`);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
level,
|
|
48
|
+
rotate: String(raw['rotate'] ?? 'midnight'),
|
|
49
|
+
keep: Number(raw['keep'] ?? 7),
|
|
33
50
|
};
|
|
34
51
|
}
|
|
35
52
|
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configure a lightweight logger from [config.logging]. Zero new deps — uses
|
|
3
|
+
* only Node stdlib (fs, path, os). Call configureLogging() once from parse();
|
|
4
|
+
* runnables call getLogger(name) to obtain a named Logger.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import type { LoggingConfig } from './models';
|
|
11
|
+
|
|
12
|
+
// ── internal state ────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
let _configured = false;
|
|
15
|
+
const _loggers = new Map<string, Logger>();
|
|
16
|
+
const _handlers: Handler[] = [];
|
|
17
|
+
|
|
18
|
+
// ── level map ─────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const LEVEL_NUM: Record<string, number> = {
|
|
21
|
+
debug: 10, info: 20, warning: 30, error: 40, critical: 50,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const LEVEL_LABEL: Record<number, string> = {
|
|
25
|
+
10: 'DEBUG', 20: 'INFO', 30: 'WARNING', 40: 'ERROR', 50: 'CRITICAL',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ── sensitive data redaction ──────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const SENSITIVE_KEY_RE = /^(password|passwd|pwd|token|api[_-]?key|secret)$/i;
|
|
31
|
+
|
|
32
|
+
const SENSITIVE: Array<[RegExp, string]> = [
|
|
33
|
+
[/(password|passwd|pwd)\s*[=:]\s*\S+/gi, '$1=[REDACTED]'],
|
|
34
|
+
[/(token|api[_-]?key|secret)\s*[=:]\s*\S+/gi, '$1=[REDACTED]'],
|
|
35
|
+
[/Authorization:\s*(Bearer|Basic)\s+\S+/gi, 'Authorization: $1 [REDACTED]'],
|
|
36
|
+
[/https?:\/\/[^:@\s]+:[^@\s]+@/g, 'https://[REDACTED]@'],
|
|
37
|
+
[/"(password|token|api_key|secret)"\s*:\s*"[^"]*"/gi, '"$1": "[REDACTED]"'],
|
|
38
|
+
[/(password|passwd|token)=([^&\s"]+)/gi, '$1=[REDACTED]'],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
function redact(msg: string): string {
|
|
42
|
+
try {
|
|
43
|
+
for (const [pattern, replacement] of SENSITIVE) {
|
|
44
|
+
msg = msg.replace(pattern, replacement);
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// never disrupt logging on redaction errors
|
|
48
|
+
}
|
|
49
|
+
return msg;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── log record & handler ──────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
interface LogRecord {
|
|
55
|
+
ts: Date;
|
|
56
|
+
levelNum: number;
|
|
57
|
+
loggerName: string;
|
|
58
|
+
message: string;
|
|
59
|
+
error?: Error;
|
|
60
|
+
extra?: Record<string, unknown>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Handler {
|
|
64
|
+
level: number;
|
|
65
|
+
emit(record: LogRecord): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Logger ────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export class Logger {
|
|
71
|
+
constructor(private readonly name: string) {}
|
|
72
|
+
|
|
73
|
+
debug(msg: string, fields?: Record<string, unknown>): void { this._emit(10, msg, fields); }
|
|
74
|
+
info(msg: string, fields?: Record<string, unknown>): void { this._emit(20, msg, fields); }
|
|
75
|
+
warning(msg: string, fields?: Record<string, unknown>): void { this._emit(30, msg, fields); }
|
|
76
|
+
warn(msg: string, fields?: Record<string, unknown>): void { this._emit(30, msg, fields); }
|
|
77
|
+
error(msg: string, fields?: Record<string, unknown>): void { this._emit(40, msg, fields); }
|
|
78
|
+
critical(msg: string, fields?: Record<string, unknown>): void { this._emit(50, msg, fields); }
|
|
79
|
+
|
|
80
|
+
private _emit(levelNum: number, message: string, fields?: Record<string, unknown>): void {
|
|
81
|
+
if (_handlers.length === 0) return;
|
|
82
|
+
const error = fields?.['error'] instanceof Error ? (fields['error'] as Error) : undefined;
|
|
83
|
+
const extra = fields ? _extractExtra(fields) : undefined;
|
|
84
|
+
const record: LogRecord = { ts: new Date(), levelNum, loggerName: this.name, message: redact(message), error, extra };
|
|
85
|
+
for (const h of _handlers) {
|
|
86
|
+
if (levelNum >= h.level) h.emit(record);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _extractExtra(fields: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
92
|
+
const result: Record<string, unknown> = {};
|
|
93
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
94
|
+
if (k === 'error') continue;
|
|
95
|
+
if (typeof v === 'string') {
|
|
96
|
+
result[k] = SENSITIVE_KEY_RE.test(k) ? '[REDACTED]' : redact(v);
|
|
97
|
+
} else {
|
|
98
|
+
result[k] = v;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getLogger(name: string): Logger {
|
|
105
|
+
let logger = _loggers.get(name);
|
|
106
|
+
if (!logger) {
|
|
107
|
+
logger = new Logger(name);
|
|
108
|
+
_loggers.set(name, logger);
|
|
109
|
+
}
|
|
110
|
+
return logger;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── formatters ────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
function formatJson(record: LogRecord): string {
|
|
116
|
+
const obj: Record<string, unknown> = {
|
|
117
|
+
ts: record.ts.toISOString(),
|
|
118
|
+
level: LEVEL_LABEL[record.levelNum] ?? String(record.levelNum),
|
|
119
|
+
logger: record.loggerName,
|
|
120
|
+
message: record.message,
|
|
121
|
+
};
|
|
122
|
+
if (record.error) obj['exc'] = record.error.stack ?? record.error.message;
|
|
123
|
+
if (record.extra) obj['extra'] = record.extra;
|
|
124
|
+
return JSON.stringify(obj);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function formatHuman(record: LogRecord, showTracebacks: boolean): string {
|
|
128
|
+
const hh = String(record.ts.getHours()).padStart(2, '0');
|
|
129
|
+
const mm = String(record.ts.getMinutes()).padStart(2, '0');
|
|
130
|
+
const ss = String(record.ts.getSeconds()).padStart(2, '0');
|
|
131
|
+
const time = `${hh}:${mm}:${ss}`;
|
|
132
|
+
const label = (LEVEL_LABEL[record.levelNum] ?? String(record.levelNum)).padEnd(8);
|
|
133
|
+
let line = `${time} ${label} ${record.loggerName}: ${record.message}`;
|
|
134
|
+
if (record.extra) {
|
|
135
|
+
const pairs = Object.entries(record.extra).map(([k, v]) => `${k}=${v}`).join(' ');
|
|
136
|
+
line += ` {${pairs}}`;
|
|
137
|
+
}
|
|
138
|
+
if (showTracebacks && record.error) line += `\n${record.error.stack ?? record.error.message}`;
|
|
139
|
+
return line;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── console handler ───────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
class ConsoleHandler implements Handler {
|
|
145
|
+
constructor(public readonly level: number, private readonly showTracebacks: boolean) {}
|
|
146
|
+
|
|
147
|
+
emit(record: LogRecord): void {
|
|
148
|
+
try {
|
|
149
|
+
process.stderr.write(formatHuman(record, this.showTracebacks) + '\n');
|
|
150
|
+
} catch {
|
|
151
|
+
// never disrupt
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── rotating file handlers ────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
function doRotate(logPath: string, keep: number): void {
|
|
159
|
+
for (let i = keep; i >= 1; i--) {
|
|
160
|
+
const src = `${logPath}.${i}`;
|
|
161
|
+
if (i === keep) {
|
|
162
|
+
try { fs.unlinkSync(src); } catch { /* already gone */ }
|
|
163
|
+
} else {
|
|
164
|
+
try { fs.renameSync(src, `${logPath}.${i + 1}`); } catch { /* missing backup */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
try { fs.renameSync(logPath, `${logPath}.1`); } catch { /* current file missing */ }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
class SizeRotatingFileHandler implements Handler {
|
|
171
|
+
readonly level = 10; // always DEBUG
|
|
172
|
+
|
|
173
|
+
constructor(
|
|
174
|
+
private readonly logPath: string,
|
|
175
|
+
private readonly maxBytes: number,
|
|
176
|
+
private readonly keep: number,
|
|
177
|
+
) {}
|
|
178
|
+
|
|
179
|
+
emit(record: LogRecord): void {
|
|
180
|
+
try {
|
|
181
|
+
this._rotateIfNeeded();
|
|
182
|
+
fs.appendFileSync(this.logPath, formatJson(record) + '\n', 'utf-8');
|
|
183
|
+
} catch {
|
|
184
|
+
// never disrupt
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private _rotateIfNeeded(): void {
|
|
189
|
+
try {
|
|
190
|
+
if (fs.statSync(this.logPath).size < this.maxBytes) return;
|
|
191
|
+
} catch {
|
|
192
|
+
return; // file doesn't exist yet
|
|
193
|
+
}
|
|
194
|
+
doRotate(this.logPath, this.keep);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function _periodForDate(d: Date, when: 'daily' | 'midnight' | 'weekly'): string {
|
|
199
|
+
if (when === 'weekly') {
|
|
200
|
+
const tmp = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
201
|
+
const day = tmp.getUTCDay() || 7;
|
|
202
|
+
tmp.setUTCDate(tmp.getUTCDate() + 4 - day);
|
|
203
|
+
const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
|
|
204
|
+
const week = Math.ceil(((tmp.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
|
205
|
+
return `${tmp.getUTCFullYear()}-W${week}`;
|
|
206
|
+
}
|
|
207
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
class TimedRotatingFileHandler implements Handler {
|
|
211
|
+
readonly level = 10;
|
|
212
|
+
|
|
213
|
+
constructor(
|
|
214
|
+
private readonly logPath: string,
|
|
215
|
+
private readonly when: 'daily' | 'midnight' | 'weekly',
|
|
216
|
+
private readonly keep: number,
|
|
217
|
+
) {}
|
|
218
|
+
|
|
219
|
+
emit(record: LogRecord): void {
|
|
220
|
+
try {
|
|
221
|
+
this._rotateIfNeeded();
|
|
222
|
+
fs.appendFileSync(this.logPath, formatJson(record) + '\n', 'utf-8');
|
|
223
|
+
} catch {
|
|
224
|
+
// never disrupt
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private _rotateIfNeeded(): void {
|
|
229
|
+
let filePeriod: string;
|
|
230
|
+
try {
|
|
231
|
+
filePeriod = _periodForDate(fs.statSync(this.logPath).mtime, this.when);
|
|
232
|
+
} catch {
|
|
233
|
+
return; // file doesn't exist yet
|
|
234
|
+
}
|
|
235
|
+
if (filePeriod === _periodForDate(new Date(), this.when)) return;
|
|
236
|
+
doRotate(this.logPath, this.keep);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── size/rotate parser ────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
const SIZE_RE = /^(\d+(?:\.\d+)?)\s*(KB|MB|GB)$/i;
|
|
243
|
+
const SIZE_MULT: Record<string, number> = { KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
|
|
244
|
+
const TIMED_KEYS = new Set(['daily', 'midnight', 'weekly']);
|
|
245
|
+
|
|
246
|
+
function makeFileHandler(logPath: string, rotate: string, keep: number): Handler {
|
|
247
|
+
const sizeMatch = SIZE_RE.exec(rotate);
|
|
248
|
+
if (sizeMatch) {
|
|
249
|
+
const maxBytes = Math.round(parseFloat(sizeMatch[1]) * SIZE_MULT[sizeMatch[2].toUpperCase()]);
|
|
250
|
+
return new SizeRotatingFileHandler(logPath, maxBytes, keep);
|
|
251
|
+
}
|
|
252
|
+
const when = rotate.toLowerCase();
|
|
253
|
+
if (TIMED_KEYS.has(when)) {
|
|
254
|
+
return new TimedRotatingFileHandler(logPath, when as 'daily' | 'midnight' | 'weekly', keep);
|
|
255
|
+
}
|
|
256
|
+
throw new Error(
|
|
257
|
+
`✗ [config.logging] rotate ${JSON.stringify(rotate)} not recognised.\n` +
|
|
258
|
+
` Valid: '10 MB', '100 KB', '1 GB', 'daily', 'midnight', 'weekly'`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── log dir resolution ────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
function resolveLogDir(configPath: string): string {
|
|
265
|
+
const candidate = path.join(path.dirname(configPath), 'logs');
|
|
266
|
+
try {
|
|
267
|
+
fs.mkdirSync(candidate, { recursive: true });
|
|
268
|
+
const probe = path.join(candidate, '.wtest');
|
|
269
|
+
fs.writeFileSync(probe, '');
|
|
270
|
+
fs.unlinkSync(probe);
|
|
271
|
+
return candidate;
|
|
272
|
+
} catch {
|
|
273
|
+
const fallback = path.join(os.homedir(), 'logs');
|
|
274
|
+
fs.mkdirSync(fallback, { recursive: true });
|
|
275
|
+
return fallback;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── public: configureLogging ──────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
export interface ConfigureLoggingOptions {
|
|
282
|
+
logCfg: LoggingConfig | undefined;
|
|
283
|
+
agent: boolean;
|
|
284
|
+
runnableName: string;
|
|
285
|
+
configPath: string;
|
|
286
|
+
logLevelOverride?: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function configureLogging(opts: ConfigureLoggingOptions): void {
|
|
290
|
+
if (!opts.logCfg || _configured) return;
|
|
291
|
+
|
|
292
|
+
const effectiveLevelName = opts.logLevelOverride ?? opts.logCfg.level;
|
|
293
|
+
const effectiveLevel = LEVEL_NUM[effectiveLevelName] ?? LEVEL_NUM['info'];
|
|
294
|
+
|
|
295
|
+
if (!opts.agent) {
|
|
296
|
+
_handlers.push(new ConsoleHandler(effectiveLevel, effectiveLevelName === 'debug'));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const logDir = resolveLogDir(opts.configPath);
|
|
300
|
+
const logPath = path.join(logDir, `${opts.runnableName}.log`);
|
|
301
|
+
_handlers.push(makeFileHandler(logPath, opts.logCfg.rotate, opts.logCfg.keep));
|
|
302
|
+
|
|
303
|
+
_configured = true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── test helper ───────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
export function _resetForTest(): void {
|
|
309
|
+
_configured = false;
|
|
310
|
+
_loggers.clear();
|
|
311
|
+
_handlers.length = 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export { _periodForDate };
|
package/src/models.ts
CHANGED
|
@@ -8,11 +8,18 @@ export interface JumpHostConfig {
|
|
|
8
8
|
sshOptions?: string[];
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export interface LoggingConfig {
|
|
12
|
+
level: string;
|
|
13
|
+
rotate: string;
|
|
14
|
+
keep: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
export interface RawConfig {
|
|
12
18
|
autonomyDefault: string;
|
|
13
19
|
lang?: string;
|
|
14
20
|
version: string;
|
|
15
21
|
jumpHosts: Record<string, JumpHostConfig>;
|
|
22
|
+
logging?: LoggingConfig;
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
export interface ArgSpec {
|
|
@@ -71,4 +78,5 @@ export interface ParsedArgs {
|
|
|
71
78
|
readonly __runspec_spec__: ScriptSpec;
|
|
72
79
|
readonly runspec_command: string | undefined;
|
|
73
80
|
readonly runspec_command_path: string[];
|
|
81
|
+
readonly runspec_prefix: string;
|
|
74
82
|
}
|
package/src/parser.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { inferScript, effectiveAutonomy } from './inference';
|
|
|
5
5
|
import { coerce } from './types';
|
|
6
6
|
import { validateArgs, validateGroups, raiseIfErrors } from './validator';
|
|
7
7
|
import { RunSpecError } from './errors';
|
|
8
|
+
import { configureLogging } from './logging_setup';
|
|
8
9
|
import type { ParsedArgs, ScriptSpec, ArgSpec } from './models';
|
|
9
10
|
|
|
10
11
|
export interface ParseOptions {
|
|
@@ -30,7 +31,27 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
|
|
|
30
31
|
throw new RunSpecError(`✗ Runnable '${name}' not found.\n Available: ${available}\n Config: ${configPath}`);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
let rawScript = inferScript(raw.runnables[name], config.autonomyDefault);
|
|
35
|
+
|
|
36
|
+
// Auto-inject --log-level when [config.logging] is present
|
|
37
|
+
if (config.logging && !('log-level' in rawScript.args)) {
|
|
38
|
+
rawScript = {
|
|
39
|
+
...rawScript,
|
|
40
|
+
args: {
|
|
41
|
+
...rawScript.args,
|
|
42
|
+
'log-level': {
|
|
43
|
+
name: 'log-level',
|
|
44
|
+
type: 'choice',
|
|
45
|
+
options: ['debug', 'info', 'warning', 'error', 'critical'],
|
|
46
|
+
default: config.logging.level,
|
|
47
|
+
required: false,
|
|
48
|
+
description: 'Override the console log level for this invocation.',
|
|
49
|
+
multiple: false,
|
|
50
|
+
env: 'RUNSPEC_LOG_LEVEL',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
34
55
|
|
|
35
56
|
let argv = argvOverride ?? process.argv.slice(2);
|
|
36
57
|
let activeScript = rawScript;
|
|
@@ -65,6 +86,22 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
|
|
|
65
86
|
|
|
66
87
|
const agent = ['1', 'true', 'yes'].includes((process.env['RUNSPEC_AGENT'] ?? '').toLowerCase());
|
|
67
88
|
|
|
89
|
+
const logLevelOverride = config.logging
|
|
90
|
+
? (coercedValues['log_level'] as string | undefined) ?? undefined
|
|
91
|
+
: undefined;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
configureLogging({
|
|
95
|
+
logCfg: config.logging,
|
|
96
|
+
agent,
|
|
97
|
+
runnableName: name,
|
|
98
|
+
configPath,
|
|
99
|
+
logLevelOverride,
|
|
100
|
+
});
|
|
101
|
+
} catch (e) {
|
|
102
|
+
throw new RunSpecError((e as Error).message);
|
|
103
|
+
}
|
|
104
|
+
|
|
68
105
|
return {
|
|
69
106
|
...coercedValues,
|
|
70
107
|
__runspec_agent__: agent,
|
|
@@ -75,6 +112,7 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
|
|
|
75
112
|
__runspec_spec__: activeScript,
|
|
76
113
|
get runspec_command() { return commandPath.length > 0 ? commandPath[commandPath.length - 1] : undefined; },
|
|
77
114
|
get runspec_command_path() { return commandPath; },
|
|
115
|
+
get runspec_prefix() { return path.dirname(configPath); },
|
|
78
116
|
} as ParsedArgs;
|
|
79
117
|
}
|
|
80
118
|
|
|
@@ -132,3 +132,56 @@ test('autonomy-default falls back to confirm', () => {
|
|
|
132
132
|
const raw = loadRaw(file);
|
|
133
133
|
expect(raw.config.autonomyDefault).toBe('confirm');
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
// ── [config.logging] ──────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
test('normalises [config.logging] with defaults', () => {
|
|
139
|
+
const dir = tmpDir();
|
|
140
|
+
const file = path.join(dir, 'runspec.toml');
|
|
141
|
+
fs.writeFileSync(file, `
|
|
142
|
+
[config.logging]
|
|
143
|
+
level = "info"
|
|
144
|
+
|
|
145
|
+
[greet]
|
|
146
|
+
description = "hi"
|
|
147
|
+
`);
|
|
148
|
+
const raw = loadRaw(file);
|
|
149
|
+
expect(raw.config.logging).toEqual({ level: 'info', rotate: 'midnight', keep: 7 });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('normalises [config.logging] all fields', () => {
|
|
153
|
+
const dir = tmpDir();
|
|
154
|
+
const file = path.join(dir, 'runspec.toml');
|
|
155
|
+
fs.writeFileSync(file, `
|
|
156
|
+
[config.logging]
|
|
157
|
+
level = "debug"
|
|
158
|
+
rotate = "10 MB"
|
|
159
|
+
keep = 3
|
|
160
|
+
|
|
161
|
+
[greet]
|
|
162
|
+
description = "hi"
|
|
163
|
+
`);
|
|
164
|
+
const raw = loadRaw(file);
|
|
165
|
+
expect(raw.config.logging).toEqual({ level: 'debug', rotate: '10 MB', keep: 3 });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('logging is undefined when section absent', () => {
|
|
169
|
+
const dir = tmpDir();
|
|
170
|
+
const file = path.join(dir, 'runspec.toml');
|
|
171
|
+
fs.writeFileSync(file, `[greet]\ndescription = "hi"\n`);
|
|
172
|
+
const raw = loadRaw(file);
|
|
173
|
+
expect(raw.config.logging).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('throws on invalid log level', () => {
|
|
177
|
+
const dir = tmpDir();
|
|
178
|
+
const file = path.join(dir, 'runspec.toml');
|
|
179
|
+
fs.writeFileSync(file, `
|
|
180
|
+
[config.logging]
|
|
181
|
+
level = "verbose"
|
|
182
|
+
|
|
183
|
+
[greet]
|
|
184
|
+
description = "hi"
|
|
185
|
+
`);
|
|
186
|
+
expect(() => loadRaw(file)).toThrow('[config.logging]');
|
|
187
|
+
});
|