@sdods/cli 0.13.2 → 0.14.1

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.
@@ -1,3 +1,8 @@
1
1
  import type { Command } from 'commander';
2
+ /** `.sdods/runs` under the workspace, or SDODS_ARTIFACTS_DIR (absolute, or relative to it). */
3
+ export declare function artifactsRootOf(rootDir: string): string;
4
+ /** `.sdods/backups`, next to the artifacts (SDODS_BACKUPS_DIR overrides). */
5
+ export declare function backupsRootOf(rootDir: string): string;
6
+ export declare function formatBytes(n: number | null | undefined): string;
2
7
  export declare function register(program: Command): void;
3
8
  //# sourceMappingURL=db.d.ts.map
@@ -1,14 +1,36 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
2
+ import { dirname, join, resolve } from 'node:path';
3
3
  import pc from 'picocolors';
4
4
  import { parse as parseYaml } from 'yaml';
5
5
  import { WorkspaceFileSchema } from '@sdods/contracts';
6
6
  import { SdodsError } from '@sdods/core';
7
7
  import { createContext } from '../context.js';
8
- import { json, ok, out, table, warn } from '../ui.js';
8
+ import { json, ok, out, parseIntFlag, table, warn } from '../ui.js';
9
9
  async function loadDb() {
10
10
  return import('@sdods/db');
11
11
  }
12
+ /** `.sdods/runs` under the workspace, or SDODS_ARTIFACTS_DIR (absolute, or relative to it). */
13
+ export function artifactsRootOf(rootDir) {
14
+ return resolve(rootDir, process.env.SDODS_ARTIFACTS_DIR ?? '.sdods/runs');
15
+ }
16
+ /** `.sdods/backups`, next to the artifacts (SDODS_BACKUPS_DIR overrides). */
17
+ export function backupsRootOf(rootDir) {
18
+ return process.env.SDODS_BACKUPS_DIR
19
+ ? resolve(rootDir, process.env.SDODS_BACKUPS_DIR)
20
+ : join(dirname(artifactsRootOf(rootDir)), 'backups');
21
+ }
22
+ export function formatBytes(n) {
23
+ if (n == null)
24
+ return 'unknown';
25
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
26
+ let v = n;
27
+ let i = 0;
28
+ while (v >= 1024 && i < units.length - 1) {
29
+ v /= 1024;
30
+ i++;
31
+ }
32
+ return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
33
+ }
12
34
  export function register(program) {
13
35
  const db = program
14
36
  .command('db')
@@ -55,13 +77,13 @@ export function register(program) {
55
77
  if (ctx.opts.json)
56
78
  return json({
57
79
  driver: adb.driver,
58
- config: { ...adb.config, databaseUrl: redactUrl(adb.config.databaseUrl) },
80
+ config: { ...adb.config, databaseUrl: m.redactUrl(adb.config.databaseUrl) },
59
81
  migrations: status,
60
82
  });
61
83
  out(pc.bold(`driver: ${adb.driver}`) +
62
84
  pc.dim(adb.driver === 'sqlite'
63
85
  ? ` ${adb.config.sqlitePath}`
64
- : ` ${redactUrl(adb.config.databaseUrl)}`));
86
+ : ` ${m.redactUrl(adb.config.databaseUrl)}`));
65
87
  table(status.map((s) => ({
66
88
  migration: s.name,
67
89
  applied: s.executedAt ? s.executedAt : pc.yellow('pending'),
@@ -196,13 +218,156 @@ export function register(program) {
196
218
  keepRuns: opts.keepRuns ? Number(opts.keepRuns) : undefined,
197
219
  keepDays: opts.keepDays ? Number(opts.keepDays) : undefined,
198
220
  projectSlug: opts.project,
199
- artifactsRoot: join(ctx.rootDir, process.env.SDODS_ARTIFACTS_DIR ?? '.sdods/runs'),
221
+ artifactsRoot: artifactsRootOf(ctx.rootDir),
200
222
  dryRun: Boolean(opts.dryRun),
223
+ actor: { type: 'cli' },
201
224
  });
202
225
  if (ctx.opts.json)
203
226
  return json(res);
204
227
  table(res.deleted.map((d) => ({ run: d.id, project: d.projectSlug, created: d.createdAt })));
205
- ok(`${opts.dryRun ? 'Would delete' : 'Deleted'} ${res.deleted.length} run(s); kept ${res.kept}`);
228
+ for (const s of res.skipped)
229
+ warn(`kept ${s.id}: ${s.reason === 'live' ? 'still running' : s.reason}`);
230
+ ok(`${opts.dryRun ? 'Would delete' : 'Deleted'} ${res.deleted.length} run(s), ${opts.dryRun ? 'freeing' : 'freed'} ${formatBytes(res.freedBytes)}; kept ${res.kept}`);
231
+ }
232
+ finally {
233
+ await adb.close();
234
+ }
235
+ });
236
+ db.command('stats')
237
+ .description('Show which database is in use, how big it is, and what it holds')
238
+ .action(async (_opts, cmd) => {
239
+ const ctx = createContext(cmd);
240
+ const m = await loadDb();
241
+ const adb = m.createDb();
242
+ try {
243
+ await m.migrateToLatest(adb);
244
+ const s = await m.storageStats(adb, { artifactsRoot: artifactsRootOf(ctx.rootDir) });
245
+ const backups = m.listBackups(backupsRootOf(ctx.rootDir));
246
+ if (ctx.opts.json)
247
+ return json({ ...s, backupsDir: backupsRootOf(ctx.rootDir), backups: backups.length });
248
+ out(pc.bold(`driver: ${s.driver}`));
249
+ out(`location: ${s.location}`);
250
+ out(`database: ${formatBytes(s.dbBytes)}`);
251
+ out(`artifacts: ${formatBytes(s.artifactsBytes)} ${pc.dim(s.artifactsDir ?? '')}`);
252
+ out(`backups: ${backups.length} ${pc.dim(backupsRootOf(ctx.rootDir))}`);
253
+ out(`runs: ${s.counts.runs}${s.oldestRunAt ? pc.dim(` ${s.oldestRunAt.slice(0, 10)} → ${s.newestRunAt?.slice(0, 10)}`) : ''}`);
254
+ out(`rows: ${s.counts.scenarios} scenarios, ${s.counts.artifacts} artifacts, ${s.counts.auditLog} audit entries, ${s.counts.users} users`);
255
+ out(`migrations: ${s.migrations.pending ? pc.yellow(`${s.migrations.pending} pending`) : pc.green('up to date')} ${pc.dim(s.migrations.latest ?? '')}`);
256
+ }
257
+ finally {
258
+ await adb.close();
259
+ }
260
+ });
261
+ db.command('retention')
262
+ .description('Show or set how much run history to keep (applied daily by `sdods serve` when --auto on)')
263
+ .option('--keep-runs <n>', 'runs to keep per project (0 = no limit)', parseIntFlag('keep-runs'))
264
+ .option('--keep-days <n>', 'delete runs older than n days (0 = no limit)', parseIntFlag('keep-days'))
265
+ .option('--auto <on|off>', 'clean up automatically once a day')
266
+ .action(async (opts, cmd) => {
267
+ const ctx = createContext(cmd);
268
+ const m = await loadDb();
269
+ const adb = m.createDb();
270
+ try {
271
+ await m.migrateToLatest(adb);
272
+ const patch = {};
273
+ if (opts.keepRuns !== undefined)
274
+ patch.keepRuns = opts.keepRuns || null;
275
+ if (opts.keepDays !== undefined)
276
+ patch.keepDays = opts.keepDays || null;
277
+ if (opts.auto !== undefined) {
278
+ if (!['on', 'off'].includes(opts.auto))
279
+ throw new SdodsError('NOT_SUPPORTED', '--auto expects on or off.', { exitCode: 2 });
280
+ patch.auto = opts.auto === 'on';
281
+ }
282
+ const r = Object.keys(patch).length
283
+ ? await m.setRetentionSettings(adb.db, patch)
284
+ : await m.getRetentionSettings(adb.db);
285
+ if (Object.keys(patch).length)
286
+ await m.audit(adb.db, {
287
+ actorType: 'cli',
288
+ action: 'retention.update',
289
+ targetType: 'settings',
290
+ targetId: 'retention',
291
+ details: { ...r },
292
+ });
293
+ if (ctx.opts.json)
294
+ return json(r);
295
+ out(`keep runs per project: ${r.keepRuns ?? 'no limit'}`);
296
+ out(`keep days: ${r.keepDays ?? 'no limit'}`);
297
+ out(`auto clean-up: ${r.auto ? 'on (daily)' : 'off'}`);
298
+ }
299
+ finally {
300
+ await adb.close();
301
+ }
302
+ });
303
+ db.command('backup')
304
+ .description('Back up the database to .sdods/backups/<timestamp>/ (JSONL + manifest)')
305
+ .option('--out <dir>', 'backups folder (default .sdods/backups)')
306
+ .action(async (opts, cmd) => {
307
+ const ctx = createContext(cmd);
308
+ const m = await loadDb();
309
+ const adb = m.createDb();
310
+ try {
311
+ await m.migrateToLatest(adb);
312
+ const { VERSION } = await import('@sdods/core');
313
+ const b = await m.createBackup(adb, opts.out ?? backupsRootOf(ctx.rootDir), {
314
+ label: 'manual',
315
+ sdodsVersion: VERSION,
316
+ });
317
+ if (ctx.opts.json)
318
+ return json(b);
319
+ ok(`Backed up ${b.rows} row(s), ${formatBytes(b.bytes)} → ${b.dir}`);
320
+ }
321
+ finally {
322
+ await adb.close();
323
+ }
324
+ });
325
+ db.command('backups')
326
+ .description('List backups, newest first')
327
+ .option('--dir <dir>', 'backups folder (default .sdods/backups)')
328
+ .action(async (opts, cmd) => {
329
+ const ctx = createContext(cmd);
330
+ const m = await loadDb();
331
+ const list = m.listBackups(opts.dir ?? backupsRootOf(ctx.rootDir));
332
+ if (ctx.opts.json)
333
+ return json(list);
334
+ if (!list.length)
335
+ return out(pc.dim('No backups yet. Create one with `sdods db backup`.'));
336
+ table(list.map((b) => ({
337
+ id: b.id,
338
+ label: b.label,
339
+ rows: b.rows,
340
+ size: formatBytes(b.bytes),
341
+ dir: b.dir,
342
+ })));
343
+ });
344
+ db.command('restore <dir>')
345
+ .description('Replace the database contents with a backup (a pre-restore backup is taken first)')
346
+ .option('--yes', 'confirm: the current data is replaced')
347
+ .action(async (dir, opts, cmd) => {
348
+ const ctx = createContext(cmd);
349
+ if (!opts.yes)
350
+ throw new SdodsError('NOT_SUPPORTED', 'restore replaces every row in the database; pass --yes to confirm.', { exitCode: 2 });
351
+ const m = await loadDb();
352
+ const adb = m.createDb();
353
+ try {
354
+ await m.migrateToLatest(adb);
355
+ const { VERSION } = await import('@sdods/core');
356
+ const res = await m.restoreBackup(adb, resolve(dir), {
357
+ safetyRoot: backupsRootOf(ctx.rootDir),
358
+ sdodsVersion: VERSION,
359
+ });
360
+ await m.audit(adb.db, {
361
+ actorType: 'cli',
362
+ action: 'backup.restore',
363
+ targetType: 'backup',
364
+ targetId: resolve(dir),
365
+ details: { safety: res.safety?.id ?? null },
366
+ });
367
+ if (ctx.opts.json)
368
+ return json(res);
369
+ table(res.imported.tables.filter((x) => x.rows).map((x) => ({ table: x.table, rows: x.rows })));
370
+ ok(`Restored from ${resolve(dir)}${res.safety ? `. The previous data is in ${res.safety.dir}` : ''}`);
206
371
  }
207
372
  finally {
208
373
  await adb.close();
@@ -233,9 +398,4 @@ export function register(program) {
233
398
  }
234
399
  });
235
400
  }
236
- function redactUrl(url) {
237
- if (!url)
238
- return '';
239
- return url.replace(/\/\/([^:]+):[^@]+@/, '//$1:***@');
240
- }
241
401
  //# sourceMappingURL=db.js.map
@@ -241,11 +241,6 @@ function tokenMatrix(cliStatus, localLlm) {
241
241
  requirement: 'optional',
242
242
  present: has('SDODS_TOKEN'),
243
243
  detail: 'self-issued, free: MCP over HTTP and CI result ingest (`sdods tokens create`)',
244
- }, {
245
- name: 'NPM_TOKEN',
246
- requirement: 'ci-only',
247
- present: has('NPM_TOKEN'),
248
- detail: 'GitHub secret for publishing @sdods/* packages',
249
244
  });
250
245
  return rows;
251
246
  }
@@ -0,0 +1,4 @@
1
+ import type { Command } from 'commander';
2
+ /** `sdods runs …`: manage stored run history (`sdods run` starts a run). */
3
+ export declare function register(program: Command): void;
4
+ //# sourceMappingURL=runs.d.ts.map
@@ -0,0 +1,49 @@
1
+ import pc from 'picocolors';
2
+ import { SdodsError } from '@sdods/core';
3
+ import { createContext } from '../context.js';
4
+ import { json, ok, out, table, warn } from '../ui.js';
5
+ import { artifactsRootOf, formatBytes } from './db.js';
6
+ /** `sdods runs …`: manage stored run history (`sdods run` starts a run). */
7
+ export function register(program) {
8
+ const runs = program.command('runs').description('Manage stored run history');
9
+ runs
10
+ .command('rm <ids...>')
11
+ .alias('delete')
12
+ .description('Delete runs, their results and artifact folders (running runs are skipped)')
13
+ .option('--dry-run', 'show what would be deleted')
14
+ .option('--yes', 'confirm the delete')
15
+ .action(async (ids, opts, cmd) => {
16
+ const ctx = createContext(cmd);
17
+ if (!opts.dryRun && !opts.yes)
18
+ throw new SdodsError('NOT_SUPPORTED', 'Deleting runs cannot be undone; pass --yes to confirm (or --dry-run to preview).', { exitCode: 2 });
19
+ const m = await import('@sdods/db');
20
+ const adb = m.createDb();
21
+ try {
22
+ await m.migrateToLatest(adb);
23
+ const res = await m.deleteRuns(adb, ids, {
24
+ artifactsRoot: artifactsRootOf(ctx.rootDir),
25
+ actor: { type: 'cli' },
26
+ reason: 'manual',
27
+ dryRun: Boolean(opts.dryRun),
28
+ });
29
+ if (ctx.opts.json)
30
+ return json(res);
31
+ if (res.deleted.length)
32
+ table(res.deleted.map((d) => ({
33
+ run: d.id,
34
+ project: d.projectSlug,
35
+ created: d.createdAt,
36
+ size: formatBytes(d.bytes),
37
+ })));
38
+ for (const s of res.skipped)
39
+ warn(`${s.id}: ${s.reason === 'live' ? 'still running, stop it first' : 'not found'}`);
40
+ if (!res.deleted.length)
41
+ return out(pc.dim('Nothing to delete.'));
42
+ ok(`${opts.dryRun ? 'Would delete' : 'Deleted'} ${res.deleted.length} run(s), ${opts.dryRun ? 'freeing' : 'freed'} ${formatBytes(res.freedBytes)}`);
43
+ }
44
+ finally {
45
+ await adb.close();
46
+ }
47
+ });
48
+ }
49
+ //# sourceMappingURL=runs.js.map
package/dist/program.js CHANGED
@@ -15,6 +15,7 @@ import { register as registerHar } from './commands/har.js';
15
15
  import { register as registerAuth } from './commands/auth.js';
16
16
  import { register as registerData } from './commands/data.js';
17
17
  import { register as registerDb } from './commands/db.js';
18
+ import { register as registerRuns } from './commands/runs.js';
18
19
  import { register as registerReport } from './commands/report.js';
19
20
  import { register as registerHeal } from './commands/heal.js';
20
21
  import { register as registerInsights } from './commands/insights.js';
@@ -75,6 +76,7 @@ export function buildProgram() {
75
76
  registerAuth(program);
76
77
  registerData(program);
77
78
  registerDb(program);
79
+ registerRuns(program);
78
80
  registerReport(program);
79
81
  registerHeal(program);
80
82
  registerInsights(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/cli",
3
- "version": "0.13.2",
3
+ "version": "0.14.1",
4
4
  "description": "SDODS command line: run, record, analyze, agents, MCP server, scheduler and the web server.",
5
5
  "license": "Apache-2.0",
6
6
  "funding": "https://sdods.com/sponsor/",
@@ -26,8 +26,8 @@
26
26
  "node": ">=22"
27
27
  },
28
28
  "dependencies": {
29
- "@sdods/contracts": "0.13.2",
30
- "@sdods/core": "0.13.2",
29
+ "@sdods/contracts": "0.14.1",
30
+ "@sdods/core": "0.14.1",
31
31
  "commander": "^15.0.0",
32
32
  "execa": "^10.1.0",
33
33
  "picocolors": "^1.1.1",
@@ -36,12 +36,12 @@
36
36
  "zod": "^4.6.5",
37
37
  "@playwright/test": "^1.64.0",
38
38
  "playwright-core": "^1.64.0",
39
- "@sdods/integrations": "0.13.2",
40
- "@sdods/mcp": "0.13.2",
41
- "@sdods/agents": "0.13.2",
42
- "@sdods/db": "0.13.2",
39
+ "@sdods/integrations": "0.14.1",
40
+ "@sdods/mcp": "0.14.1",
41
+ "@sdods/agents": "0.14.1",
42
+ "@sdods/db": "0.14.1",
43
43
  "csv-parse": "^7.0.3",
44
- "@sdods/server": "0.13.2"
44
+ "@sdods/server": "0.14.1"
45
45
  },
46
46
  "homepage": "https://sdods.com",
47
47
  "bugs": {
@@ -295,8 +295,8 @@ sdods browsers install --with-deps # refresh engines afterwards (Linux needs ro
295
295
  sdods doctor # confirm
296
296
  ```
297
297
 
298
- CI does this on a schedule: `.github/workflows/dependencies.yml` reports freshness into one rolling
299
- issue every morning, and on the 1st of the month upgrades `@sdods/*` and Playwright, runs the suite,
298
+ CI does this on a schedule: `.github/workflows/dependencies.yml` posts a freshness report to Yarlis
299
+ Dev (Google Chat, through Yarlis Relay) on the 15th, and on the 1st of the month upgrades `@sdods/*` and Playwright, runs the suite,
300
300
  and opens a PR only if it passes.
301
301
 
302
302
  After any upgrade, restart `serve` — a running server keeps the old bundle in memory.
@@ -5,7 +5,6 @@
5
5
  # mandatory only when the feature next to it is used
6
6
  # one-of agents need ANY ONE of the four options in the Agents block
7
7
  # optional turns on an integration
8
- # ci-only GitHub Actions secrets, never needed locally
9
8
 
10
9
  # ── Database (mandatory: none; DATABASE_URL only when DB_DRIVER=postgres) ─────
11
10
  DB_DRIVER=sqlite
@@ -57,7 +56,3 @@ SESSION_SECRET=change-me-32-chars-minimum-please # openssl rand -hex 32
57
56
  # ── SDODS's own token (optional, free): MCP over HTTP and CI ingest ─────────
58
57
  # SDODS_SERVER_URL=http://127.0.0.1:4444
59
58
  # SDODS_TOKEN=amx_... # sdods tokens create --user <name> --scopes runs:ingest,runs:read
60
-
61
- # ── CI-only GitHub secrets (never needed locally) ────────────────────────────
62
- # FIREBASE_SERVICE_ACCOUNT_AUTOMAX_DOCS docs deploy (Firebase Hosting)
63
- # NPM_TOKEN publishing @sdods/* packages