runspec-node 0.7.0 → 0.9.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/cli.d.ts.map +1 -1
- package/dist/cli.js +100 -9
- package/dist/cli.js.map +1 -1
- 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/inference.d.ts.map +1 -1
- package/dist/inference.js +7 -1
- package/dist/inference.js.map +1 -1
- package/dist/jump.d.ts +7 -0
- package/dist/jump.d.ts.map +1 -0
- package/dist/jump.js +246 -0
- package/dist/jump.js.map +1 -0
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +34 -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 +298 -0
- package/dist/logging_setup.js.map +1 -0
- package/dist/models.d.ts +26 -6
- package/dist/models.d.ts.map +1 -1
- package/dist/parser.d.ts +1 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +68 -12
- package/dist/parser.js.map +1 -1
- package/dist/runspec.toml +81 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.ts +116 -10
- package/src/index.ts +2 -1
- package/src/inference.ts +4 -1
- package/src/jump.ts +243 -0
- package/src/loader.ts +37 -1
- package/src/logging_setup.ts +291 -0
- package/src/models.ts +28 -6
- package/src/parser.ts +73 -12
- package/src/runspec.toml +81 -0
- package/src/types.ts +1 -0
- package/tests/test_cli_init.test.ts +5 -5
- package/tests/test_loader.test.ts +53 -0
- package/tests/test_logging_setup.test.ts +313 -0
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 } 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');
|
|
@@ -20,10 +20,45 @@ export function loadRaw(configPath: string): RawSpec {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function normaliseConfig(raw: Record<string, unknown>): RawConfig {
|
|
23
|
+
const rawHosts = (raw['jump-hosts'] ?? {}) as Record<string, Record<string, unknown>>;
|
|
24
|
+
const jumpHosts: Record<string, JumpHostConfig> = {};
|
|
25
|
+
for (const [name, cfg] of Object.entries(rawHosts)) {
|
|
26
|
+
jumpHosts[name] = normaliseJumpHost(cfg);
|
|
27
|
+
}
|
|
23
28
|
return {
|
|
24
29
|
autonomyDefault: (raw['autonomy-default'] as string | undefined) ?? 'confirm',
|
|
25
30
|
lang: raw['lang'] as string | undefined,
|
|
26
31
|
version: String(raw['version'] ?? '1'),
|
|
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),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normaliseJumpHost(raw: Record<string, unknown>): JumpHostConfig {
|
|
54
|
+
return {
|
|
55
|
+
host: raw['host'] as string,
|
|
56
|
+
user: raw['user'] as string | undefined,
|
|
57
|
+
port: raw['port'] as number | undefined,
|
|
58
|
+
sshKey: raw['ssh-key'] as string | undefined,
|
|
59
|
+
bin: raw['bin'] as string | undefined,
|
|
60
|
+
useSshConfig: raw['use-ssh-config'] as boolean | undefined,
|
|
61
|
+
sshOptions: raw['ssh-options'] as string[] | undefined,
|
|
27
62
|
};
|
|
28
63
|
}
|
|
29
64
|
|
|
@@ -67,6 +102,7 @@ function normaliseArg(name: string, raw: Record<string, unknown>): ArgSpec {
|
|
|
67
102
|
multiple: (raw['multiple'] as boolean | undefined) ?? false,
|
|
68
103
|
delimiter: raw['delimiter'] as string | undefined,
|
|
69
104
|
short: raw['short'] as string | undefined,
|
|
105
|
+
position: raw['position'] as number | undefined,
|
|
70
106
|
env: raw['env'] as string | undefined,
|
|
71
107
|
deprecated: raw['deprecated'] as string | undefined,
|
|
72
108
|
autonomy: raw['autonomy'] as string | undefined,
|
|
@@ -0,0 +1,291 @@
|
|
|
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: Array<[RegExp, string]> = [
|
|
31
|
+
[/(password|passwd|pwd)\s*[=:]\s*\S+/gi, '$1=[REDACTED]'],
|
|
32
|
+
[/(token|api[_-]?key|secret)\s*[=:]\s*\S+/gi, '$1=[REDACTED]'],
|
|
33
|
+
[/Authorization:\s*(Bearer|Basic)\s+\S+/gi, 'Authorization: $1 [REDACTED]'],
|
|
34
|
+
[/https?:\/\/[^:@\s]+:[^@\s]+@/g, 'https://[REDACTED]@'],
|
|
35
|
+
[/"(password|token|api_key|secret)"\s*:\s*"[^"]*"/gi, '"$1": "[REDACTED]"'],
|
|
36
|
+
[/(password|passwd|token)=([^&\s"]+)/gi, '$1=[REDACTED]'],
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function redact(msg: string): string {
|
|
40
|
+
try {
|
|
41
|
+
for (const [pattern, replacement] of SENSITIVE) {
|
|
42
|
+
msg = msg.replace(pattern, replacement);
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// never disrupt logging on redaction errors
|
|
46
|
+
}
|
|
47
|
+
return msg;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── log record & handler ──────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
interface LogRecord {
|
|
53
|
+
ts: Date;
|
|
54
|
+
levelNum: number;
|
|
55
|
+
loggerName: string;
|
|
56
|
+
message: string;
|
|
57
|
+
error?: Error;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Handler {
|
|
61
|
+
level: number;
|
|
62
|
+
emit(record: LogRecord): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Logger ────────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export class Logger {
|
|
68
|
+
constructor(private readonly name: string) {}
|
|
69
|
+
|
|
70
|
+
debug(msg: string, error?: Error): void { this._emit(10, msg, error); }
|
|
71
|
+
info(msg: string, error?: Error): void { this._emit(20, msg, error); }
|
|
72
|
+
warning(msg: string, error?: Error): void { this._emit(30, msg, error); }
|
|
73
|
+
warn(msg: string, error?: Error): void { this._emit(30, msg, error); }
|
|
74
|
+
error(msg: string, error?: Error): void { this._emit(40, msg, error); }
|
|
75
|
+
critical(msg: string, error?: Error): void { this._emit(50, msg, error); }
|
|
76
|
+
|
|
77
|
+
private _emit(levelNum: number, message: string, error?: Error): void {
|
|
78
|
+
if (_handlers.length === 0) return;
|
|
79
|
+
const record: LogRecord = { ts: new Date(), levelNum, loggerName: this.name, message: redact(message), error };
|
|
80
|
+
for (const h of _handlers) {
|
|
81
|
+
if (levelNum >= h.level) h.emit(record);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getLogger(name: string): Logger {
|
|
87
|
+
let logger = _loggers.get(name);
|
|
88
|
+
if (!logger) {
|
|
89
|
+
logger = new Logger(name);
|
|
90
|
+
_loggers.set(name, logger);
|
|
91
|
+
}
|
|
92
|
+
return logger;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── formatters ────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function formatJson(record: LogRecord): string {
|
|
98
|
+
const obj: Record<string, unknown> = {
|
|
99
|
+
ts: record.ts.toISOString(),
|
|
100
|
+
level: LEVEL_LABEL[record.levelNum] ?? String(record.levelNum),
|
|
101
|
+
logger: record.loggerName,
|
|
102
|
+
message: record.message,
|
|
103
|
+
};
|
|
104
|
+
if (record.error) obj['exc'] = record.error.stack ?? record.error.message;
|
|
105
|
+
return JSON.stringify(obj);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatHuman(record: LogRecord, showTracebacks: boolean): string {
|
|
109
|
+
const hh = String(record.ts.getHours()).padStart(2, '0');
|
|
110
|
+
const mm = String(record.ts.getMinutes()).padStart(2, '0');
|
|
111
|
+
const ss = String(record.ts.getSeconds()).padStart(2, '0');
|
|
112
|
+
const time = `${hh}:${mm}:${ss}`;
|
|
113
|
+
const label = (LEVEL_LABEL[record.levelNum] ?? String(record.levelNum)).padEnd(8);
|
|
114
|
+
let line = `${time} ${label} ${record.loggerName}: ${record.message}`;
|
|
115
|
+
if (showTracebacks && record.error) line += `\n${record.error.stack ?? record.error.message}`;
|
|
116
|
+
return line;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── console handler ───────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
class ConsoleHandler implements Handler {
|
|
122
|
+
constructor(public readonly level: number, private readonly showTracebacks: boolean) {}
|
|
123
|
+
|
|
124
|
+
emit(record: LogRecord): void {
|
|
125
|
+
try {
|
|
126
|
+
process.stderr.write(formatHuman(record, this.showTracebacks) + '\n');
|
|
127
|
+
} catch {
|
|
128
|
+
// never disrupt
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── rotating file handlers ────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
function doRotate(logPath: string, keep: number): void {
|
|
136
|
+
for (let i = keep; i >= 1; i--) {
|
|
137
|
+
const src = `${logPath}.${i}`;
|
|
138
|
+
if (i === keep) {
|
|
139
|
+
try { fs.unlinkSync(src); } catch { /* already gone */ }
|
|
140
|
+
} else {
|
|
141
|
+
try { fs.renameSync(src, `${logPath}.${i + 1}`); } catch { /* missing backup */ }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
try { fs.renameSync(logPath, `${logPath}.1`); } catch { /* current file missing */ }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
class SizeRotatingFileHandler implements Handler {
|
|
148
|
+
readonly level = 10; // always DEBUG
|
|
149
|
+
|
|
150
|
+
constructor(
|
|
151
|
+
private readonly logPath: string,
|
|
152
|
+
private readonly maxBytes: number,
|
|
153
|
+
private readonly keep: number,
|
|
154
|
+
) {}
|
|
155
|
+
|
|
156
|
+
emit(record: LogRecord): void {
|
|
157
|
+
try {
|
|
158
|
+
this._rotateIfNeeded();
|
|
159
|
+
fs.appendFileSync(this.logPath, formatJson(record) + '\n', 'utf-8');
|
|
160
|
+
} catch {
|
|
161
|
+
// never disrupt
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private _rotateIfNeeded(): void {
|
|
166
|
+
try {
|
|
167
|
+
if (fs.statSync(this.logPath).size < this.maxBytes) return;
|
|
168
|
+
} catch {
|
|
169
|
+
return; // file doesn't exist yet
|
|
170
|
+
}
|
|
171
|
+
doRotate(this.logPath, this.keep);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function _periodForDate(d: Date, when: 'daily' | 'midnight' | 'weekly'): string {
|
|
176
|
+
if (when === 'weekly') {
|
|
177
|
+
const tmp = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
178
|
+
const day = tmp.getUTCDay() || 7;
|
|
179
|
+
tmp.setUTCDate(tmp.getUTCDate() + 4 - day);
|
|
180
|
+
const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
|
|
181
|
+
const week = Math.ceil(((tmp.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
|
182
|
+
return `${tmp.getUTCFullYear()}-W${week}`;
|
|
183
|
+
}
|
|
184
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
class TimedRotatingFileHandler implements Handler {
|
|
188
|
+
readonly level = 10;
|
|
189
|
+
|
|
190
|
+
constructor(
|
|
191
|
+
private readonly logPath: string,
|
|
192
|
+
private readonly when: 'daily' | 'midnight' | 'weekly',
|
|
193
|
+
private readonly keep: number,
|
|
194
|
+
) {}
|
|
195
|
+
|
|
196
|
+
emit(record: LogRecord): void {
|
|
197
|
+
try {
|
|
198
|
+
this._rotateIfNeeded();
|
|
199
|
+
fs.appendFileSync(this.logPath, formatJson(record) + '\n', 'utf-8');
|
|
200
|
+
} catch {
|
|
201
|
+
// never disrupt
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private _rotateIfNeeded(): void {
|
|
206
|
+
let filePeriod: string;
|
|
207
|
+
try {
|
|
208
|
+
filePeriod = _periodForDate(fs.statSync(this.logPath).mtime, this.when);
|
|
209
|
+
} catch {
|
|
210
|
+
return; // file doesn't exist yet
|
|
211
|
+
}
|
|
212
|
+
if (filePeriod === _periodForDate(new Date(), this.when)) return;
|
|
213
|
+
doRotate(this.logPath, this.keep);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── size/rotate parser ────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
const SIZE_RE = /^(\d+(?:\.\d+)?)\s*(KB|MB|GB)$/i;
|
|
220
|
+
const SIZE_MULT: Record<string, number> = { KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
|
|
221
|
+
const TIMED_KEYS = new Set(['daily', 'midnight', 'weekly']);
|
|
222
|
+
|
|
223
|
+
function makeFileHandler(logPath: string, rotate: string, keep: number): Handler {
|
|
224
|
+
const sizeMatch = SIZE_RE.exec(rotate);
|
|
225
|
+
if (sizeMatch) {
|
|
226
|
+
const maxBytes = Math.round(parseFloat(sizeMatch[1]) * SIZE_MULT[sizeMatch[2].toUpperCase()]);
|
|
227
|
+
return new SizeRotatingFileHandler(logPath, maxBytes, keep);
|
|
228
|
+
}
|
|
229
|
+
const when = rotate.toLowerCase();
|
|
230
|
+
if (TIMED_KEYS.has(when)) {
|
|
231
|
+
return new TimedRotatingFileHandler(logPath, when as 'daily' | 'midnight' | 'weekly', keep);
|
|
232
|
+
}
|
|
233
|
+
throw new Error(
|
|
234
|
+
`✗ [config.logging] rotate ${JSON.stringify(rotate)} not recognised.\n` +
|
|
235
|
+
` Valid: '10 MB', '100 KB', '1 GB', 'daily', 'midnight', 'weekly'`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── log dir resolution ────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
function resolveLogDir(configPath: string): string {
|
|
242
|
+
const candidate = path.join(path.dirname(configPath), 'logs');
|
|
243
|
+
try {
|
|
244
|
+
fs.mkdirSync(candidate, { recursive: true });
|
|
245
|
+
const probe = path.join(candidate, '.wtest');
|
|
246
|
+
fs.writeFileSync(probe, '');
|
|
247
|
+
fs.unlinkSync(probe);
|
|
248
|
+
return candidate;
|
|
249
|
+
} catch {
|
|
250
|
+
const fallback = path.join(os.homedir(), 'logs');
|
|
251
|
+
fs.mkdirSync(fallback, { recursive: true });
|
|
252
|
+
return fallback;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── public: configureLogging ──────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
export interface ConfigureLoggingOptions {
|
|
259
|
+
logCfg: LoggingConfig | undefined;
|
|
260
|
+
agent: boolean;
|
|
261
|
+
runnableName: string;
|
|
262
|
+
configPath: string;
|
|
263
|
+
logLevelOverride?: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function configureLogging(opts: ConfigureLoggingOptions): void {
|
|
267
|
+
if (!opts.logCfg || _configured) return;
|
|
268
|
+
|
|
269
|
+
const effectiveLevelName = opts.logLevelOverride ?? opts.logCfg.level;
|
|
270
|
+
const effectiveLevel = LEVEL_NUM[effectiveLevelName] ?? LEVEL_NUM['info'];
|
|
271
|
+
|
|
272
|
+
if (!opts.agent) {
|
|
273
|
+
_handlers.push(new ConsoleHandler(effectiveLevel, effectiveLevelName === 'debug'));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const logDir = resolveLogDir(opts.configPath);
|
|
277
|
+
const logPath = path.join(logDir, `${opts.runnableName}.log`);
|
|
278
|
+
_handlers.push(makeFileHandler(logPath, opts.logCfg.rotate, opts.logCfg.keep));
|
|
279
|
+
|
|
280
|
+
_configured = true;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── test helper ───────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
export function _resetForTest(): void {
|
|
286
|
+
_configured = false;
|
|
287
|
+
_loggers.clear();
|
|
288
|
+
_handlers.length = 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export { _periodForDate };
|
package/src/models.ts
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
|
+
export interface JumpHostConfig {
|
|
2
|
+
host: string;
|
|
3
|
+
user?: string;
|
|
4
|
+
port?: number;
|
|
5
|
+
sshKey?: string;
|
|
6
|
+
bin?: string;
|
|
7
|
+
useSshConfig?: boolean;
|
|
8
|
+
sshOptions?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LoggingConfig {
|
|
12
|
+
level: string;
|
|
13
|
+
rotate: string;
|
|
14
|
+
keep: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
1
17
|
export interface RawConfig {
|
|
2
18
|
autonomyDefault: string;
|
|
3
19
|
lang?: string;
|
|
4
20
|
version: string;
|
|
21
|
+
jumpHosts: Record<string, JumpHostConfig>;
|
|
22
|
+
logging?: LoggingConfig;
|
|
5
23
|
}
|
|
6
24
|
|
|
7
25
|
export interface ArgSpec {
|
|
@@ -15,6 +33,7 @@ export interface ArgSpec {
|
|
|
15
33
|
multiple?: boolean;
|
|
16
34
|
delimiter?: string;
|
|
17
35
|
short?: string;
|
|
36
|
+
position?: number;
|
|
18
37
|
env?: string;
|
|
19
38
|
deprecated?: string;
|
|
20
39
|
autonomy?: string;
|
|
@@ -51,10 +70,13 @@ export interface RawSpec {
|
|
|
51
70
|
|
|
52
71
|
export interface ParsedArgs {
|
|
53
72
|
[key: string]: unknown;
|
|
54
|
-
readonly
|
|
55
|
-
readonly
|
|
56
|
-
readonly
|
|
57
|
-
readonly
|
|
58
|
-
readonly
|
|
59
|
-
readonly
|
|
73
|
+
readonly __runspec_agent__: boolean;
|
|
74
|
+
readonly __runspec_script__: string;
|
|
75
|
+
readonly __runspec_command_path__: string[];
|
|
76
|
+
readonly __runspec_autonomy__: string;
|
|
77
|
+
readonly __runspec_source__: string;
|
|
78
|
+
readonly __runspec_spec__: ScriptSpec;
|
|
79
|
+
readonly runspec_command: string | undefined;
|
|
80
|
+
readonly runspec_command_path: string[];
|
|
81
|
+
readonly runspec_prefix: string;
|
|
60
82
|
}
|
package/src/parser.ts
CHANGED
|
@@ -5,18 +5,20 @@ 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 {
|
|
11
12
|
scriptName?: string;
|
|
12
13
|
argv?: string[];
|
|
13
14
|
cwd?: string;
|
|
15
|
+
configPath?: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function parse(opts: ParseOptions = {}): ParsedArgs {
|
|
17
|
-
const { scriptName, argv: argvOverride, cwd } = opts;
|
|
19
|
+
const { scriptName, argv: argvOverride, cwd, configPath: configPathOverride } = opts;
|
|
18
20
|
|
|
19
|
-
const { configPath } = findConfig(cwd);
|
|
21
|
+
const { configPath } = configPathOverride ? { configPath: configPathOverride } : findConfig(cwd);
|
|
20
22
|
const raw = loadRaw(configPath);
|
|
21
23
|
const config = raw.config;
|
|
22
24
|
|
|
@@ -29,21 +31,41 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
|
|
|
29
31
|
throw new RunSpecError(`✗ Runnable '${name}' not found.\n Available: ${available}\n Config: ${configPath}`);
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
|
|
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
|
+
}
|
|
33
55
|
|
|
34
56
|
let argv = argvOverride ?? process.argv.slice(2);
|
|
35
57
|
let activeScript = rawScript;
|
|
36
|
-
let
|
|
58
|
+
let commandPath: string[] = [];
|
|
37
59
|
|
|
38
60
|
const commands = rawScript.commands ?? {};
|
|
39
61
|
if (Object.keys(commands).length > 0 && argv.length > 0 && argv[0] in commands) {
|
|
40
|
-
|
|
62
|
+
commandPath = [argv[0]];
|
|
41
63
|
activeScript = commands[argv[0]];
|
|
42
64
|
argv = argv.slice(1);
|
|
43
65
|
}
|
|
44
66
|
|
|
45
67
|
if (argv.includes('--help') || argv.includes('-h')) {
|
|
46
|
-
printHelp(name, activeScript,
|
|
68
|
+
printHelp(name, activeScript, commandPath.length > 0 ? commandPath[commandPath.length - 1] : undefined);
|
|
47
69
|
process.exit(0);
|
|
48
70
|
}
|
|
49
71
|
|
|
@@ -64,14 +86,33 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
|
|
|
64
86
|
|
|
65
87
|
const agent = ['1', 'true', 'yes'].includes((process.env['RUNSPEC_AGENT'] ?? '').toLowerCase());
|
|
66
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
|
+
|
|
67
105
|
return {
|
|
68
106
|
...coercedValues,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
107
|
+
__runspec_agent__: agent,
|
|
108
|
+
__runspec_script__: name,
|
|
109
|
+
__runspec_command_path__: commandPath,
|
|
110
|
+
__runspec_autonomy__: autonomy,
|
|
111
|
+
__runspec_source__: configPath,
|
|
112
|
+
__runspec_spec__: activeScript,
|
|
113
|
+
get runspec_command() { return commandPath.length > 0 ? commandPath[commandPath.length - 1] : undefined; },
|
|
114
|
+
get runspec_command_path() { return commandPath; },
|
|
115
|
+
get runspec_prefix() { return path.dirname(configPath); },
|
|
75
116
|
} as ParsedArgs;
|
|
76
117
|
}
|
|
77
118
|
|
|
@@ -95,15 +136,29 @@ function parseArgv(argv: string[], argSpecs: Record<string, ArgSpec>): Record<st
|
|
|
95
136
|
if (spec.short) shortMap[spec.short] = norm;
|
|
96
137
|
}
|
|
97
138
|
|
|
139
|
+
// Positional args sorted by position index; rest arg (type='rest') collects post-'--' tokens
|
|
140
|
+
const positionalArgs = Object.entries(argSpecs)
|
|
141
|
+
.filter(([, s]) => s.position !== undefined)
|
|
142
|
+
.sort(([, a], [, b]) => (a.position ?? 0) - (b.position ?? 0))
|
|
143
|
+
.map(([name]) => name.replace(/-/g, '_'));
|
|
144
|
+
const restArgNorm = Object.entries(argSpecs).find(([, s]) => s.type === 'rest')?.[0]?.replace(/-/g, '_');
|
|
145
|
+
|
|
98
146
|
const result: Record<string, unknown> = {};
|
|
99
147
|
for (const name of Object.keys(argSpecs)) {
|
|
100
148
|
result[name.replace(/-/g, '_')] = undefined;
|
|
101
149
|
}
|
|
102
150
|
|
|
151
|
+
let positionalIndex = 0;
|
|
103
152
|
let i = 0;
|
|
104
153
|
while (i < argv.length) {
|
|
105
154
|
const token = argv[i];
|
|
106
155
|
|
|
156
|
+
// '--' separator: remaining tokens go to the rest arg
|
|
157
|
+
if (token === '--') {
|
|
158
|
+
if (restArgNorm !== undefined) result[restArgNorm] = argv.slice(i + 1);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
|
|
107
162
|
if (token.startsWith('--') && token.includes('=')) {
|
|
108
163
|
const eqIdx = token.indexOf('=');
|
|
109
164
|
const key = token.slice(0, eqIdx);
|
|
@@ -140,6 +195,12 @@ function parseArgv(argv: string[], argSpecs: Record<string, ArgSpec>): Record<st
|
|
|
140
195
|
continue;
|
|
141
196
|
}
|
|
142
197
|
|
|
198
|
+
// Unrecognized non-flag token: assign to next positional arg
|
|
199
|
+
if (!token.startsWith('-') && positionalIndex < positionalArgs.length) {
|
|
200
|
+
result[positionalArgs[positionalIndex]] = token;
|
|
201
|
+
positionalIndex++;
|
|
202
|
+
}
|
|
203
|
+
|
|
143
204
|
i++;
|
|
144
205
|
}
|
|
145
206
|
|
package/src/runspec.toml
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#:schema https://raw.githubusercontent.com/JasonFinestone/runspec/main/schema/runspec.schema.json
|
|
2
|
+
|
|
3
|
+
# runspec itself is a developer CLI, not an agent tool. Suppress autonomy
|
|
4
|
+
# inference so --help doesn't display a meaningless autonomy level on the
|
|
5
|
+
# top-level menu or on `serve` (which is a server, not an action).
|
|
6
|
+
[config]
|
|
7
|
+
autonomy-default = ""
|
|
8
|
+
|
|
9
|
+
[runspec]
|
|
10
|
+
description = "Interface specification for anything runnable"
|
|
11
|
+
|
|
12
|
+
examples = [
|
|
13
|
+
{cmd = "runspec init", description = "Scaffold a new runnable in this directory"},
|
|
14
|
+
{cmd = "runspec local", description = "Discover and validate runnables"},
|
|
15
|
+
{cmd = "runspec serve", description = "Start the MCP stdio server"},
|
|
16
|
+
{cmd = "runspec jump --list-jump-hosts", description = "List configured jump hosts"},
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[runspec.commands.init]
|
|
20
|
+
description = "Scaffold a new runnable — creates runspec.toml and a code stub"
|
|
21
|
+
autonomy = "confirm"
|
|
22
|
+
|
|
23
|
+
examples = [
|
|
24
|
+
{cmd = "runspec init", description = "Scaffold using current directory name"},
|
|
25
|
+
{cmd = "runspec init --name deploy", description = "Scaffold a runnable called 'deploy'"},
|
|
26
|
+
{cmd = "runspec init --example", description = "Generate worked example (clean + scan)"},
|
|
27
|
+
{cmd = "runspec init --example --write-project", description = "Also generate pyproject.toml in parent dir"},
|
|
28
|
+
{cmd = "runspec init --write-project --project-dir /tmp/myproject", description = "Write project files to a specific path"},
|
|
29
|
+
{cmd = "runspec init --name myapp --lang typescript", description = "Use TypeScript code stub"},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[runspec.commands.init.args]
|
|
33
|
+
name = {type = "str", description = "Runnable name (defaults to package directory name)", short = "-n", required = false}
|
|
34
|
+
lang = {type = "choice", description = "Language for the generated code stub", options = ["python", "typescript", "javascript"], default = "python"}
|
|
35
|
+
example = {type = "flag", description = "Generate a worked example (clean + scan runnables)", short = "-e", default = false}
|
|
36
|
+
write-project = {type = "flag", description = "Generate pyproject.toml, __init__.py, .gitignore, and CLAUDE.md", short = "-w", default = false}
|
|
37
|
+
project-dir = {type = "str", description = "Where to write project files (default: parent directory)", short = "-d", required = false}
|
|
38
|
+
force = {type = "flag", description = "Override the cwd safety check (don't refuse when pyproject.toml is present in cwd)", default = false}
|
|
39
|
+
|
|
40
|
+
[runspec.commands.local]
|
|
41
|
+
description = "List installed runnables or emit their tool schemas"
|
|
42
|
+
autonomy = "autonomous"
|
|
43
|
+
output = "json"
|
|
44
|
+
|
|
45
|
+
examples = [
|
|
46
|
+
{cmd = "runspec local", description = "Discover runnables and validate setup"},
|
|
47
|
+
{cmd = "runspec local --format mcp", description = "Emit MCP tool schemas"},
|
|
48
|
+
{cmd = "runspec local --format mcp --runnable deploy", description = "Emit schema for one runnable"},
|
|
49
|
+
{cmd = "runspec local --format json", description = "Full spec as JSON"},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[runspec.commands.local.args]
|
|
53
|
+
format = {type = "choice", description = "Output format", options = ["text", "json", "mcp", "openai", "anthropic"], short = "-f", default = "text"}
|
|
54
|
+
runnable = {type = "str", description = "Filter output to a single runnable by name", short = "-r", required = false}
|
|
55
|
+
|
|
56
|
+
[runspec.commands.serve]
|
|
57
|
+
description = "Start an MCP stdio server exposing all installed runnables as tools"
|
|
58
|
+
|
|
59
|
+
examples = [
|
|
60
|
+
{cmd = "runspec serve", description = "Serve all runspec-aware runnables installed in this venv"},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
# No args — runnables are discovered via importlib.metadata. Install with
|
|
64
|
+
# `pip install -e .` to make a package visible.
|
|
65
|
+
|
|
66
|
+
[runspec.commands.jump]
|
|
67
|
+
description = "List jump hosts, list tools on a jump host, or run a tool via SSH+MCP"
|
|
68
|
+
autonomy = "confirm"
|
|
69
|
+
|
|
70
|
+
examples = [
|
|
71
|
+
{cmd = "runspec jump --list-jump-hosts", description = "List configured jump hosts"},
|
|
72
|
+
{cmd = "runspec jump myserver", description = "List tools available on myserver"},
|
|
73
|
+
{cmd = "runspec jump myserver deploy -- --env prod", description = "Run 'deploy' on myserver with --env prod"},
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
[runspec.commands.jump.args]
|
|
77
|
+
list-jump-hosts = {type = "flag", description = "List configured jump hosts from runspec.toml", short = "-l", default = false}
|
|
78
|
+
format = {type = "choice", description = "Output format when listing", options = ["text", "json"], short = "-f", default = "text"}
|
|
79
|
+
jump-host = {type = "str", description = "Jump host alias from [config.jump-hosts]", position = 1, required = false}
|
|
80
|
+
tool = {type = "str", description = "Tool to run on the jump host", position = 2, required = false}
|
|
81
|
+
tool-args = {type = "rest", description = "Args passed to the remote tool"}
|
package/src/types.ts
CHANGED
|
@@ -107,11 +107,11 @@ test('--example creates clean.ts and scan.ts by default', () => {
|
|
|
107
107
|
expect(fs.existsSync(path.join(dir, 'scan.ts'))).toBe(true);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
test('--example clean stub uses
|
|
110
|
+
test('--example clean stub uses __runspec_agent__ check', () => {
|
|
111
111
|
const dir = tmpDir();
|
|
112
112
|
runInit(dir, ['--example']);
|
|
113
113
|
const ts = fs.readFileSync(path.join(dir, 'clean.ts'), 'utf-8');
|
|
114
|
-
expect(ts).toContain('
|
|
114
|
+
expect(ts).toContain('__runspec_agent__');
|
|
115
115
|
expect(ts).toContain('delete');
|
|
116
116
|
});
|
|
117
117
|
|
|
@@ -186,10 +186,10 @@ test('emit command is no longer available', () => {
|
|
|
186
186
|
expect(stdout).toContain('Unknown command');
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
test('jump command
|
|
189
|
+
test('jump command requires a host name', () => {
|
|
190
190
|
const dir = tmpDir();
|
|
191
|
-
const {
|
|
192
|
-
expect(
|
|
191
|
+
const { stderr } = runCLI(dir, ['jump']);
|
|
192
|
+
expect(stderr).toContain('jump host name is required');
|
|
193
193
|
});
|
|
194
194
|
|
|
195
195
|
// ── help ──────────────────────────────────────────────────────────────────────
|