runspec-node 0.24.0 → 0.26.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 +4 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +100 -2
- package/dist/cli.js.map +1 -1
- package/dist/logging_setup.d.ts +10 -1
- package/dist/logging_setup.d.ts.map +1 -1
- package/dist/logging_setup.js +86 -3
- package/dist/logging_setup.js.map +1 -1
- package/dist/logs.d.ts +85 -0
- package/dist/logs.d.ts.map +1 -0
- package/dist/logs.js +504 -0
- package/dist/logs.js.map +1 -0
- package/package.json +1 -1
- package/src/cli.ts +99 -2
- package/src/logging_setup.ts +88 -4
- package/src/logs.ts +507 -0
- package/tests/test_cli_bin.test.ts +15 -1
- package/tests/test_logs.test.ts +209 -0
- package/tests/test_print_capture.test.ts +82 -0
package/src/logs.ts
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runspec logs — view / status / prune / compact per-invocation audit logs.
|
|
3
|
+
*
|
|
4
|
+
* The Node port of the Python `runspec/logs.py` engine. The per-run store
|
|
5
|
+
* (`[config.logging] store = "per-run"`) writes one
|
|
6
|
+
* `{runnable}.{utc-ts}.{run_id}.log` per invocation with no in-process rotation;
|
|
7
|
+
* this is the read + maintenance side. Pure functions (collectRecords,
|
|
8
|
+
* inventory, planPrune, planCompact) are kept separate from I/O so they can be
|
|
9
|
+
* unit-tested without a filesystem fixture.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import * as os from 'os';
|
|
15
|
+
import * as zlib from 'zlib';
|
|
16
|
+
import { findConfig } from './finder';
|
|
17
|
+
import { findProjectRoot } from './logging_setup';
|
|
18
|
+
|
|
19
|
+
// Filename grammar (under {logs}/), matching the Node + Python write side:
|
|
20
|
+
// {runnable}.log single-mode active file
|
|
21
|
+
// {runnable}.{YYYYMMDDThhmmssZ}.{run_id}.log per-run invocation file
|
|
22
|
+
// {runnable}.archive.{YYYYMMDD}.log[.gz] compacted archive
|
|
23
|
+
const PER_RUN_RE = /\.\d{8}T\d{6}Z\.[0-9a-fA-F-]{8,}\.log$/;
|
|
24
|
+
const ARCHIVE_RE = /\.archive\.\d{8}\.log(?:\.gz)?$/;
|
|
25
|
+
|
|
26
|
+
const DUR_MS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
|
|
27
|
+
const SIZE_MULT: Record<string, number> = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
|
|
28
|
+
|
|
29
|
+
// ── parsing helpers ────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** '30m' / '24h' / '7d' / '2w' → milliseconds. Throws on a bad value. */
|
|
32
|
+
export function parseDuration(s: string): number {
|
|
33
|
+
const m = /^\s*(\d+)\s*([smhdw])\s*$/i.exec(s);
|
|
34
|
+
if (!m) throw new Error(`invalid duration ${JSON.stringify(s)} — use e.g. 30m, 24h, 7d, 2w`);
|
|
35
|
+
return parseInt(m[1], 10) * DUR_MS[m[2].toLowerCase()];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** '500KB' / '10MB' / '5GB' → bytes. Throws on a bad value. */
|
|
39
|
+
export function parseSize(s: string): number {
|
|
40
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?\s*$/i.exec(s);
|
|
41
|
+
if (!m) throw new Error(`invalid size ${JSON.stringify(s)} — use e.g. 500KB, 10MB, 5GB`);
|
|
42
|
+
return Math.round(parseFloat(m[1]) * SIZE_MULT[(m[2] ?? 'B').toUpperCase()]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function iso(mtimeMs: number | null): string | null {
|
|
46
|
+
if (mtimeMs === null) return null;
|
|
47
|
+
return new Date(mtimeMs).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function humanSize(n: number): string {
|
|
51
|
+
let size = n;
|
|
52
|
+
for (const unit of ['B', 'KB', 'MB', 'GB', 'TB']) {
|
|
53
|
+
if (size < 1024 || unit === 'TB') return unit === 'B' ? `${Math.round(size)} B` : `${size.toFixed(1)} ${unit}`;
|
|
54
|
+
size /= 1024;
|
|
55
|
+
}
|
|
56
|
+
return `${size.toFixed(1)} TB`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── discovery ──────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/** Existing logs directories, in priority order: the project's logs/, then ~/logs. */
|
|
62
|
+
export function logDirs(): string[] {
|
|
63
|
+
const candidates: string[] = [];
|
|
64
|
+
try {
|
|
65
|
+
const { configPath } = findConfig();
|
|
66
|
+
const root = findProjectRoot(path.dirname(configPath)) ?? path.dirname(configPath);
|
|
67
|
+
candidates.push(path.join(root, 'logs'));
|
|
68
|
+
} catch {
|
|
69
|
+
// no project context (no runspec.toml) — fall back to ~/logs only
|
|
70
|
+
}
|
|
71
|
+
candidates.push(path.join(os.homedir(), 'logs'));
|
|
72
|
+
|
|
73
|
+
const seen = new Set<string>();
|
|
74
|
+
const out: string[] = [];
|
|
75
|
+
for (const d of candidates) {
|
|
76
|
+
const r = path.resolve(d);
|
|
77
|
+
if (seen.has(r)) continue;
|
|
78
|
+
seen.add(r);
|
|
79
|
+
try {
|
|
80
|
+
if (fs.statSync(d).isDirectory()) out.push(d);
|
|
81
|
+
} catch {
|
|
82
|
+
// not present — skip
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function matchesRunnable(name: string, runnable: string | null): boolean {
|
|
89
|
+
if (!(name.endsWith('.log') || name.endsWith('.log.gz'))) return false;
|
|
90
|
+
if (!runnable) return true;
|
|
91
|
+
return name === `${runnable}.log` || name.startsWith(`${runnable}.`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function discover(dirs: string[], runnable: string | null): string[] {
|
|
95
|
+
const seen = new Set<string>();
|
|
96
|
+
const files: string[] = [];
|
|
97
|
+
for (const d of dirs) {
|
|
98
|
+
let entries: string[];
|
|
99
|
+
try {
|
|
100
|
+
entries = fs.readdirSync(d);
|
|
101
|
+
} catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
for (const name of entries) {
|
|
105
|
+
if (!matchesRunnable(name, runnable)) continue;
|
|
106
|
+
const full = path.join(d, name);
|
|
107
|
+
if (seen.has(full)) continue;
|
|
108
|
+
try {
|
|
109
|
+
if (!fs.statSync(full).isFile()) continue;
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
seen.add(full);
|
|
114
|
+
files.push(full);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return files;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True only for per-run files and archives — never a single {runnable}.log. */
|
|
121
|
+
export function isManaged(p: string): boolean {
|
|
122
|
+
const n = path.basename(p);
|
|
123
|
+
return PER_RUN_RE.test(n) || ARCHIVE_RE.test(n);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isArchive(p: string): boolean {
|
|
127
|
+
return ARCHIVE_RE.test(path.basename(p));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function runnableOf(p: string): string {
|
|
131
|
+
const n = path.basename(p);
|
|
132
|
+
for (const rx of [PER_RUN_RE, ARCHIVE_RE]) {
|
|
133
|
+
const m = rx.exec(n);
|
|
134
|
+
if (m) return n.slice(0, m.index);
|
|
135
|
+
}
|
|
136
|
+
return n.endsWith('.log.gz') ? n.slice(0, -7) : n.slice(0, -4);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readLines(p: string): string[] {
|
|
140
|
+
let text: string;
|
|
141
|
+
try {
|
|
142
|
+
text = p.endsWith('.gz') ? zlib.gunzipSync(fs.readFileSync(p)).toString('utf-8') : fs.readFileSync(p, 'utf-8');
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
return text.split('\n').filter((l) => l.trim() !== '');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── view ────────────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export interface RecPair {
|
|
152
|
+
rec: Record<string, any>;
|
|
153
|
+
raw: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface CollectOpts {
|
|
157
|
+
since?: number | null; // milliseconds
|
|
158
|
+
run?: string | null;
|
|
159
|
+
user?: string | null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Merge a runnable's files into one timestamp-sorted list of {rec, raw}. Pure. */
|
|
163
|
+
export function collectRecords(dirs: string[], runnable: string, opts: CollectOpts = {}): RecPair[] {
|
|
164
|
+
const raws: string[] = [];
|
|
165
|
+
for (const f of discover(dirs, runnable)) raws.push(...readLines(f));
|
|
166
|
+
|
|
167
|
+
const parsed: RecPair[] = [];
|
|
168
|
+
const userOfRun: Record<string, string> = {};
|
|
169
|
+
for (const raw of raws) {
|
|
170
|
+
let rec: Record<string, any>;
|
|
171
|
+
try {
|
|
172
|
+
rec = JSON.parse(raw);
|
|
173
|
+
} catch {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const extra = rec && typeof rec.extra === 'object' && rec.extra ? rec.extra : {};
|
|
177
|
+
if (extra.event === 'run_summary' && extra.run_id) userOfRun[extra.run_id] = extra.user ?? '';
|
|
178
|
+
parsed.push({ rec, raw });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const cutoff = opts.since != null ? Date.now() - opts.since : null;
|
|
182
|
+
const keep = (rec: Record<string, any>): boolean => {
|
|
183
|
+
const extra = rec.extra && typeof rec.extra === 'object' ? rec.extra : {};
|
|
184
|
+
const rid = extra.run_id ?? '';
|
|
185
|
+
if (opts.run && rid !== opts.run) return false;
|
|
186
|
+
if (opts.user && (userOfRun[rid] ?? extra.user ?? '') !== opts.user) return false;
|
|
187
|
+
if (cutoff != null) {
|
|
188
|
+
const t = Date.parse(rec.ts ?? '');
|
|
189
|
+
if (Number.isNaN(t) || t < cutoff) return false;
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return parsed.filter((p) => keep(p.rec)).sort((a, b) => String(a.rec.ts ?? '').localeCompare(String(b.rec.ts ?? '')));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function formatLine(rec: Record<string, any>, userOfRun: Record<string, string>): string {
|
|
198
|
+
const extra = rec.extra && typeof rec.extra === 'object' ? rec.extra : {};
|
|
199
|
+
const rid = extra.run_id ?? '';
|
|
200
|
+
const user = extra.user || userOfRun[rid] || '';
|
|
201
|
+
const ts = rec.ts ?? '';
|
|
202
|
+
const level = String(rec.level ?? '').padEnd(8);
|
|
203
|
+
const msg = rec.message ?? '';
|
|
204
|
+
return `${ts} ${level} run=${(rid || '-').slice(0, 8)} user=${user || '-'} ${msg}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
type Writer = (s: string) => void;
|
|
208
|
+
|
|
209
|
+
function emit(records: RecPair[], asJson: boolean, out: Writer): void {
|
|
210
|
+
const userOfRun: Record<string, string> = {};
|
|
211
|
+
for (const { rec } of records) {
|
|
212
|
+
const extra = rec.extra && typeof rec.extra === 'object' ? rec.extra : {};
|
|
213
|
+
if (extra.event === 'run_summary' && extra.run_id) userOfRun[extra.run_id] = extra.user ?? '';
|
|
214
|
+
}
|
|
215
|
+
for (const { rec, raw } of records) {
|
|
216
|
+
out(asJson ? raw + '\n' : formatLine(rec, userOfRun) + '\n');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface ViewOpts extends CollectOpts {
|
|
221
|
+
dirs?: string[];
|
|
222
|
+
asJson?: boolean;
|
|
223
|
+
follow?: boolean;
|
|
224
|
+
out?: Writer;
|
|
225
|
+
pollMs?: number;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Write the merged stream to `out` (default stdout). Supports `--follow` (poll). */
|
|
229
|
+
export function view(runnable: string, opts: ViewOpts = {}): void {
|
|
230
|
+
const dirs = opts.dirs ?? logDirs();
|
|
231
|
+
const out = opts.out ?? ((s: string) => process.stdout.write(s));
|
|
232
|
+
const co: CollectOpts = { since: opts.since, run: opts.run, user: opts.user };
|
|
233
|
+
const records = collectRecords(dirs, runnable, co);
|
|
234
|
+
emit(records, !!opts.asJson, out);
|
|
235
|
+
|
|
236
|
+
if (!opts.follow) return;
|
|
237
|
+
const key = (r: Record<string, any>) => `${r.ts ?? ''}|${(r.extra && r.extra.run_id) ?? ''}|${r.message ?? ''}`;
|
|
238
|
+
const seen = new Set(records.map((p) => key(p.rec)));
|
|
239
|
+
const interval = setInterval(() => {
|
|
240
|
+
const fresh = collectRecords(dirs, runnable, co).filter((p) => !seen.has(key(p.rec)));
|
|
241
|
+
if (fresh.length) {
|
|
242
|
+
emit(fresh, !!opts.asJson, out);
|
|
243
|
+
for (const p of fresh) seen.add(key(p.rec));
|
|
244
|
+
}
|
|
245
|
+
}, opts.pollMs ?? 1000);
|
|
246
|
+
// Don't keep the event loop alive solely for the poll if the process is done.
|
|
247
|
+
if (typeof interval.unref === 'function') interval.unref();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── status ───────────────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
export interface InvRow {
|
|
253
|
+
runnable: string;
|
|
254
|
+
per_run_files: number;
|
|
255
|
+
archives: number;
|
|
256
|
+
total_bytes: number;
|
|
257
|
+
oldest: number | null; // mtime epoch ms
|
|
258
|
+
newest: number | null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Per-runnable inventory of managed files. Pure (stat only). */
|
|
262
|
+
export function inventory(dirs: string[], runnable: string | null = null): Record<string, InvRow> {
|
|
263
|
+
const per: Record<string, InvRow> = {};
|
|
264
|
+
for (const f of discover(dirs, runnable)) {
|
|
265
|
+
if (!isManaged(f)) continue;
|
|
266
|
+
let st: fs.Stats;
|
|
267
|
+
try {
|
|
268
|
+
st = fs.statSync(f);
|
|
269
|
+
} catch {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const name = runnableOf(f);
|
|
273
|
+
const row = (per[name] ??= { runnable: name, per_run_files: 0, archives: 0, total_bytes: 0, oldest: null, newest: null });
|
|
274
|
+
if (isArchive(f)) row.archives++;
|
|
275
|
+
else row.per_run_files++;
|
|
276
|
+
row.total_bytes += st.size;
|
|
277
|
+
if (row.oldest === null || st.mtimeMs < row.oldest) row.oldest = st.mtimeMs;
|
|
278
|
+
if (row.newest === null || st.mtimeMs > row.newest) row.newest = st.mtimeMs;
|
|
279
|
+
}
|
|
280
|
+
return per;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface StatusOpts {
|
|
284
|
+
dirs?: string[];
|
|
285
|
+
asJson?: boolean;
|
|
286
|
+
out?: Writer;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function status(runnable: string | null = null, opts: StatusOpts = {}): void {
|
|
290
|
+
const dirs = opts.dirs ?? logDirs();
|
|
291
|
+
const out = opts.out ?? ((s: string) => process.stdout.write(s));
|
|
292
|
+
const rows = Object.values(inventory(dirs, runnable)).sort((a, b) => a.runnable.localeCompare(b.runnable));
|
|
293
|
+
const totalBytes = rows.reduce((s, r) => s + r.total_bytes, 0);
|
|
294
|
+
const totalFiles = rows.reduce((s, r) => s + r.per_run_files + r.archives, 0);
|
|
295
|
+
|
|
296
|
+
if (opts.asJson) {
|
|
297
|
+
out(
|
|
298
|
+
JSON.stringify({
|
|
299
|
+
dirs,
|
|
300
|
+
runnables: rows.map((r) => ({
|
|
301
|
+
runnable: r.runnable,
|
|
302
|
+
per_run_files: r.per_run_files,
|
|
303
|
+
archives: r.archives,
|
|
304
|
+
total_bytes: r.total_bytes,
|
|
305
|
+
oldest: iso(r.oldest),
|
|
306
|
+
newest: iso(r.newest),
|
|
307
|
+
})),
|
|
308
|
+
total_bytes: totalBytes,
|
|
309
|
+
total_files: totalFiles,
|
|
310
|
+
}) + '\n',
|
|
311
|
+
);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (rows.length === 0) {
|
|
316
|
+
out('No per-invocation logs found.\n');
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const nameW = Math.max('RUNNABLE'.length, ...rows.map((r) => r.runnable.length));
|
|
320
|
+
out(`${'RUNNABLE'.padEnd(nameW)} ${'RUNS'.padStart(6)} ${'ARCHIVES'.padStart(8)} ${'SIZE'.padStart(10)} NEWEST\n`);
|
|
321
|
+
for (const r of rows) {
|
|
322
|
+
out(`${r.runnable.padEnd(nameW)} ${String(r.per_run_files).padStart(6)} ${String(r.archives).padStart(8)} ${humanSize(r.total_bytes).padStart(10)} ${iso(r.newest) ?? '-'}\n`);
|
|
323
|
+
}
|
|
324
|
+
out(`\n${totalFiles} file(s), ${humanSize(totalBytes)} total across ${rows.length} runnable(s).\n`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── prune ──────────────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
export interface PrunePolicy {
|
|
330
|
+
olderThan?: number | null; // ms
|
|
331
|
+
maxFiles?: number | null;
|
|
332
|
+
maxTotalSize?: number | null; // bytes
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Return the managed files to delete. Pure — touches nothing. Throws if no policy. */
|
|
336
|
+
export function planPrune(dirs: string[], runnable: string | null, policy: PrunePolicy, now: number = Date.now()): string[] {
|
|
337
|
+
const { olderThan = null, maxFiles = null, maxTotalSize = null } = policy;
|
|
338
|
+
if (olderThan == null && maxFiles == null && maxTotalSize == null) {
|
|
339
|
+
throw new Error('prune needs at least one of --older-than / --max-files / --max-total-size');
|
|
340
|
+
}
|
|
341
|
+
const files = discover(dirs, runnable).filter(isManaged);
|
|
342
|
+
const stat = new Map<string, fs.Stats>();
|
|
343
|
+
for (const f of files) {
|
|
344
|
+
try {
|
|
345
|
+
stat.set(f, fs.statSync(f));
|
|
346
|
+
} catch {
|
|
347
|
+
// skip vanished file
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const live = files.filter((f) => stat.has(f));
|
|
351
|
+
const doomed = new Set<string>();
|
|
352
|
+
|
|
353
|
+
if (olderThan != null) {
|
|
354
|
+
const cut = now - olderThan;
|
|
355
|
+
for (const f of live) if (stat.get(f)!.mtimeMs < cut) doomed.add(f);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (maxFiles != null) {
|
|
359
|
+
const per: Record<string, string[]> = {};
|
|
360
|
+
for (const f of live) (per[runnableOf(f)] ??= []).push(f);
|
|
361
|
+
for (const group of Object.values(per)) {
|
|
362
|
+
group.sort((a, b) => stat.get(b)!.mtimeMs - stat.get(a)!.mtimeMs); // newest first
|
|
363
|
+
for (const f of group.slice(maxFiles)) doomed.add(f);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (maxTotalSize != null) {
|
|
368
|
+
const ordered = [...live].sort((a, b) => stat.get(b)!.mtimeMs - stat.get(a)!.mtimeMs);
|
|
369
|
+
let total = 0;
|
|
370
|
+
for (const f of ordered) {
|
|
371
|
+
total += stat.get(f)!.size;
|
|
372
|
+
if (total > maxTotalSize) doomed.add(f);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return [...doomed].sort((a, b) => stat.get(a)!.mtimeMs - stat.get(b)!.mtimeMs);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export interface PruneOpts extends PrunePolicy {
|
|
380
|
+
dirs?: string[];
|
|
381
|
+
dryRun?: boolean;
|
|
382
|
+
asJson?: boolean;
|
|
383
|
+
out?: Writer;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function prune(runnable: string | null, opts: PruneOpts = {}): { count: number; freed: number } {
|
|
387
|
+
const dirs = opts.dirs ?? logDirs();
|
|
388
|
+
const out = opts.out ?? ((s: string) => process.stdout.write(s));
|
|
389
|
+
const targets = planPrune(dirs, runnable, opts);
|
|
390
|
+
let freed = 0;
|
|
391
|
+
const deleted: Array<{ path: string; bytes: number }> = [];
|
|
392
|
+
for (const f of targets) {
|
|
393
|
+
let size = 0;
|
|
394
|
+
try {
|
|
395
|
+
size = fs.statSync(f).size;
|
|
396
|
+
} catch {
|
|
397
|
+
// already gone
|
|
398
|
+
}
|
|
399
|
+
if (!opts.dryRun) {
|
|
400
|
+
try {
|
|
401
|
+
fs.unlinkSync(f);
|
|
402
|
+
} catch {
|
|
403
|
+
// ignore
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
freed += size;
|
|
407
|
+
deleted.push({ path: f, bytes: size });
|
|
408
|
+
if (!opts.asJson) out(`${opts.dryRun ? 'would delete' : 'deleted'} ${f} (${size} bytes)\n`);
|
|
409
|
+
}
|
|
410
|
+
if (opts.asJson) {
|
|
411
|
+
out(JSON.stringify({ dry_run: !!opts.dryRun, count: targets.length, freed_bytes: freed, deleted }) + '\n');
|
|
412
|
+
} else {
|
|
413
|
+
out(`${opts.dryRun ? 'Would free' : 'Freed'} ${freed} bytes across ${targets.length} file(s).\n`);
|
|
414
|
+
}
|
|
415
|
+
return { count: targets.length, freed };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── compact ──────────────────────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
function lineTs(line: string): string {
|
|
421
|
+
try {
|
|
422
|
+
return String(JSON.parse(line).ts ?? '');
|
|
423
|
+
} catch {
|
|
424
|
+
return '';
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Map each per-runnable archive target → the per-run files to fold in. Pure. */
|
|
429
|
+
export function planCompact(dirs: string[], runnable: string | null, olderThan: number, now: number = Date.now()): Map<string, string[]> {
|
|
430
|
+
const cut = now - olderThan;
|
|
431
|
+
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
432
|
+
const plan = new Map<string, string[]>();
|
|
433
|
+
for (const f of discover(dirs, runnable)) {
|
|
434
|
+
if (!isManaged(f) || isArchive(f)) continue;
|
|
435
|
+
let st: fs.Stats;
|
|
436
|
+
try {
|
|
437
|
+
st = fs.statSync(f);
|
|
438
|
+
} catch {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (st.mtimeMs >= cut) continue;
|
|
442
|
+
const archive = path.join(path.dirname(f), `${runnableOf(f)}.archive.${today}.log`);
|
|
443
|
+
(plan.get(archive) ?? plan.set(archive, []).get(archive)!).push(f);
|
|
444
|
+
}
|
|
445
|
+
return plan;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export interface CompactOpts {
|
|
449
|
+
dirs?: string[];
|
|
450
|
+
gzip?: boolean;
|
|
451
|
+
dryRun?: boolean;
|
|
452
|
+
asJson?: boolean;
|
|
453
|
+
out?: Writer;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function compact(runnable: string | null, olderThan: number, opts: CompactOpts = {}): number {
|
|
457
|
+
const dirs = opts.dirs ?? logDirs();
|
|
458
|
+
const out = opts.out ?? ((s: string) => process.stdout.write(s));
|
|
459
|
+
const plan = planCompact(dirs, runnable, olderThan);
|
|
460
|
+
let compacted = 0;
|
|
461
|
+
const archives: Array<{ archive: string; count: number; sources: string[] }> = [];
|
|
462
|
+
|
|
463
|
+
for (const [archive, sources] of plan) {
|
|
464
|
+
const target = opts.gzip ? archive + '.gz' : archive;
|
|
465
|
+
if (opts.dryRun) {
|
|
466
|
+
archives.push({ archive: target, count: sources.length, sources });
|
|
467
|
+
compacted += sources.length;
|
|
468
|
+
if (!opts.asJson) out(`would compact ${sources.length} file(s) → ${target}\n`);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const lines: string[] = [];
|
|
473
|
+
for (const existing of [archive, archive + '.gz']) {
|
|
474
|
+
if (fs.existsSync(existing)) lines.push(...readLines(existing));
|
|
475
|
+
}
|
|
476
|
+
for (const s of sources) lines.push(...readLines(s));
|
|
477
|
+
lines.sort((a, b) => lineTs(a).localeCompare(lineTs(b)));
|
|
478
|
+
|
|
479
|
+
const body = lines.join('\n') + '\n';
|
|
480
|
+
if (opts.gzip) {
|
|
481
|
+
fs.writeFileSync(target, zlib.gzipSync(Buffer.from(body, 'utf-8')));
|
|
482
|
+
if (fs.existsSync(archive) && archive !== target) {
|
|
483
|
+
try {
|
|
484
|
+
fs.unlinkSync(archive);
|
|
485
|
+
} catch {
|
|
486
|
+
// ignore
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
490
|
+
fs.writeFileSync(target, body, 'utf-8');
|
|
491
|
+
}
|
|
492
|
+
for (const s of sources) {
|
|
493
|
+
try {
|
|
494
|
+
fs.unlinkSync(s);
|
|
495
|
+
} catch {
|
|
496
|
+
// ignore
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
archives.push({ archive: target, count: sources.length, sources });
|
|
500
|
+
compacted += sources.length;
|
|
501
|
+
if (!opts.asJson) out(`compacted ${sources.length} file(s) → ${target}\n`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (opts.asJson) out(JSON.stringify({ dry_run: !!opts.dryRun, compacted, archives }) + '\n');
|
|
505
|
+
else if (plan.size === 0) out('nothing to compact.\n');
|
|
506
|
+
return compacted;
|
|
507
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as os from 'os';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
-
import { scaffoldBin, resolveScript, readBinMap, shimContent } from '../src/cli';
|
|
4
|
+
import { scaffoldBin, resolveScript, readBinMap, shimContent, cmdShimContent } from '../src/cli';
|
|
5
5
|
|
|
6
6
|
function tmp(): string {
|
|
7
7
|
return fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'runspec-bin-'));
|
|
@@ -35,6 +35,15 @@ describe('shimContent', () => {
|
|
|
35
35
|
});
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
describe('cmdShimContent', () => {
|
|
39
|
+
it('is a Windows batch shim that resolves its own root and runs node', () => {
|
|
40
|
+
const s = cmdShimContent('cmd/greet.js');
|
|
41
|
+
expect(s.startsWith('@echo off')).toBe(true);
|
|
42
|
+
expect(s).toContain('node "%~dp0..\\cmd\\greet.js" %*'); // forward slashes → backslashes
|
|
43
|
+
expect(s).toContain('\r\n'); // CRLF line endings
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
38
47
|
describe('readBinMap', () => {
|
|
39
48
|
it('reads an object bin map', () => {
|
|
40
49
|
const dir = track(project({ 'package.json': JSON.stringify({ name: 'x', bin: { greet: './g.js' } }) }));
|
|
@@ -98,6 +107,11 @@ describe('scaffoldBin', () => {
|
|
|
98
107
|
// bin/runspec points at the installed CLI; bin/greet at the runnable script.
|
|
99
108
|
expect(fs.readFileSync(path.join(dir, 'bin', 'runspec'), 'utf-8')).toContain('node_modules/runspec-node/bin/runspec.js');
|
|
100
109
|
expect(fs.readFileSync(path.join(dir, 'bin', 'greet'), 'utf-8')).toContain('exec node "$DIR/greet.js"');
|
|
110
|
+
// Windows .cmd shims sit alongside each POSIX shim.
|
|
111
|
+
for (const name of ['runspec', 'greet', 'backup']) {
|
|
112
|
+
expect(fs.existsSync(path.join(dir, 'bin', name + '.cmd'))).toBe(true);
|
|
113
|
+
}
|
|
114
|
+
expect(fs.readFileSync(path.join(dir, 'bin', 'greet.cmd'), 'utf-8')).toContain('node "%~dp0..\\greet.js" %*');
|
|
101
115
|
// venv shape includes logs/
|
|
102
116
|
expect(fs.existsSync(path.join(dir, 'logs'))).toBe(true);
|
|
103
117
|
});
|