@sdods/cli 0.2.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/LICENSE +17 -0
- package/bin/sdods.js +19 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +11 -0
- package/dist/commands/agent.d.ts +3 -0
- package/dist/commands/agent.js +219 -0
- package/dist/commands/analyze.d.ts +20 -0
- package/dist/commands/analyze.js +216 -0
- package/dist/commands/auth.d.ts +7 -0
- package/dist/commands/auth.js +97 -0
- package/dist/commands/browsers.d.ts +17 -0
- package/dist/commands/browsers.js +103 -0
- package/dist/commands/completion.d.ts +17 -0
- package/dist/commands/completion.js +143 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.js +79 -0
- package/dist/commands/coverage.d.ts +3 -0
- package/dist/commands/coverage.js +65 -0
- package/dist/commands/data.d.ts +7 -0
- package/dist/commands/data.js +188 -0
- package/dist/commands/db.d.ts +3 -0
- package/dist/commands/db.js +241 -0
- package/dist/commands/doctor.d.ts +3 -0
- package/dist/commands/doctor.js +323 -0
- package/dist/commands/env.d.ts +3 -0
- package/dist/commands/env.js +65 -0
- package/dist/commands/features.d.ts +3 -0
- package/dist/commands/features.js +57 -0
- package/dist/commands/feedback.d.ts +7 -0
- package/dist/commands/feedback.js +96 -0
- package/dist/commands/har.d.ts +3 -0
- package/dist/commands/har.js +124 -0
- package/dist/commands/heal.d.ts +4 -0
- package/dist/commands/heal.js +63 -0
- package/dist/commands/init.d.ts +38 -0
- package/dist/commands/init.js +363 -0
- package/dist/commands/insights.d.ts +7 -0
- package/dist/commands/insights.js +127 -0
- package/dist/commands/integrations.d.ts +14 -0
- package/dist/commands/integrations.js +306 -0
- package/dist/commands/lint.d.ts +3 -0
- package/dist/commands/lint.js +74 -0
- package/dist/commands/mcp.d.ts +3 -0
- package/dist/commands/mcp.js +190 -0
- package/dist/commands/project.d.ts +3 -0
- package/dist/commands/project.js +133 -0
- package/dist/commands/proposals.d.ts +3 -0
- package/dist/commands/proposals.js +106 -0
- package/dist/commands/record.d.ts +3 -0
- package/dist/commands/record.js +174 -0
- package/dist/commands/report.d.ts +3 -0
- package/dist/commands/report.js +255 -0
- package/dist/commands/run.d.ts +41 -0
- package/dist/commands/run.js +369 -0
- package/dist/commands/schedule.d.ts +3 -0
- package/dist/commands/schedule.js +337 -0
- package/dist/commands/serve.d.ts +3 -0
- package/dist/commands/serve.js +38 -0
- package/dist/commands/steps.d.ts +8 -0
- package/dist/commands/steps.js +67 -0
- package/dist/commands/tokens.d.ts +3 -0
- package/dist/commands/tokens.js +110 -0
- package/dist/commands/trace.d.ts +10 -0
- package/dist/commands/trace.js +104 -0
- package/dist/commands/upgrade.d.ts +14 -0
- package/dist/commands/upgrade.js +115 -0
- package/dist/commands/users.d.ts +3 -0
- package/dist/commands/users.js +183 -0
- package/dist/commands/watch.d.ts +7 -0
- package/dist/commands/watch.js +116 -0
- package/dist/commands/workspace.d.ts +8 -0
- package/dist/commands/workspace.js +83 -0
- package/dist/context.d.ts +17 -0
- package/dist/context.js +22 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/program.d.ts +7 -0
- package/dist/program.js +90 -0
- package/dist/templates/project.d.ts +10 -0
- package/dist/templates/project.js +115 -0
- package/dist/ui.d.ts +16 -0
- package/dist/ui.js +67 -0
- package/package.json +61 -0
- package/templates/.claude/skills/sdods-record/SKILL.md +75 -0
- package/templates/.claude/skills/sdods-run/SKILL.md +54 -0
- package/templates/.env.example +62 -0
- package/templates/docker-compose.yml +40 -0
- package/templates/projects/demo-shop/.env.example +4 -0
- package/templates/projects/demo-shop/data/common/posts.yaml +11 -0
- package/templates/projects/demo-shop/data/common/products.json +8 -0
- package/templates/projects/demo-shop/data/common/users.csv +7 -0
- package/templates/projects/demo-shop/data/factories.ts +16 -0
- package/templates/projects/demo-shop/data/staging/users.csv +6 -0
- package/templates/projects/demo-shop/envs/local.yaml +17 -0
- package/templates/projects/demo-shop/envs/staging.yaml +23 -0
- package/templates/projects/demo-shop/features/__screenshots__/demo-shop--ui--chromium/darwin/inventory.png +0 -0
- package/templates/projects/demo-shop/features/api/posts.feature +90 -0
- package/templates/projects/demo-shop/features/auth/data-driven-login.feature +17 -0
- package/templates/projects/demo-shop/features/auth/login.feature +32 -0
- package/templates/projects/demo-shop/features/auth/user-pool.feature +20 -0
- package/templates/projects/demo-shop/features/cart/cart.feature +33 -0
- package/templates/projects/demo-shop/features/hybrid/seed-then-verify.feature +28 -0
- package/templates/projects/demo-shop/features/inventory/inventory.feature +38 -0
- package/templates/projects/demo-shop/features/inventory/network-intercept.feature +29 -0
- package/templates/projects/demo-shop/features/inventory/visual.feature +10 -0
- package/templates/projects/demo-shop/har/staging/cart.har +1 -0
- package/templates/projects/demo-shop/har/staging/cart.har.json +11 -0
- package/templates/projects/demo-shop/har/staging/login.har +1 -0
- package/templates/projects/demo-shop/har/staging/login.har.json +11 -0
- package/templates/projects/demo-shop/har/staging/posts.api.har +2061 -0
- package/templates/projects/demo-shop/har/staging/posts.har.json +8 -0
- package/templates/projects/demo-shop/har/staging/products.har +1 -0
- package/templates/projects/demo-shop/har/staging/products.har.json +11 -0
- package/templates/projects/demo-shop/har/staging/site.api.har +148 -0
- package/templates/projects/demo-shop/har/staging/site.har +1 -0
- package/templates/projects/demo-shop/har/staging/site.har.json +11 -0
- package/templates/projects/demo-shop/pages/CartPage.ts +20 -0
- package/templates/projects/demo-shop/pages/InventoryPage.ts +103 -0
- package/templates/projects/demo-shop/pages/LoginPage.ts +42 -0
- package/templates/projects/demo-shop/schemas/post.schema.json +13 -0
- package/templates/projects/demo-shop/sdods.project.yaml +185 -0
- package/templates/projects/demo-shop/steps/auth.ts +7 -0
- package/templates/projects/demo-shop/steps/fixtures.ts +39 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { SdodsError } from '@sdods/core';
|
|
4
|
+
import { createContext } from '../context.js';
|
|
5
|
+
import { collect, info, json, ok, out, table } from '../ui.js';
|
|
6
|
+
async function withServices(rootDir) {
|
|
7
|
+
const db = await import('@sdods/db');
|
|
8
|
+
const srv = await import('@sdods/server');
|
|
9
|
+
const adb = await db.openDb();
|
|
10
|
+
const config = srv.loadServerConfig({ rootDir });
|
|
11
|
+
const runManager = new srv.RunManager(config, adb);
|
|
12
|
+
const scheduler = new srv.Scheduler(adb, runManager, {
|
|
13
|
+
info: () => undefined,
|
|
14
|
+
warn: (m) => console.error(m),
|
|
15
|
+
});
|
|
16
|
+
return { db, srv, adb, config, runManager, scheduler };
|
|
17
|
+
}
|
|
18
|
+
export function register(program) {
|
|
19
|
+
const schedule = program
|
|
20
|
+
.command('schedule')
|
|
21
|
+
.description('Cron schedules for runs (project yaml + database; the server executes them)');
|
|
22
|
+
schedule
|
|
23
|
+
.command('add')
|
|
24
|
+
.description('Create or update a schedule')
|
|
25
|
+
.requiredOption('-p, --project <slug>', 'project')
|
|
26
|
+
.requiredOption('--name <name>', 'schedule name')
|
|
27
|
+
.requiredOption('--cron <expr>', 'cron expression, e.g. "0 2 * * *"')
|
|
28
|
+
.option('--tz <timezone>', 'IANA timezone', 'UTC')
|
|
29
|
+
.option('-e, --env <name>', 'environment')
|
|
30
|
+
.option('-t, --tags <expr>', 'tag expression')
|
|
31
|
+
.option('-l, --layer <layer>', 'layer (repeatable)', collect, [])
|
|
32
|
+
.option('-b, --browser <name>', 'browser (repeatable)', collect, [])
|
|
33
|
+
.option('--process <name>', 'run a named process')
|
|
34
|
+
.option('--overlap <policy>', 'skip | queue | cancel-previous', 'skip')
|
|
35
|
+
.option('--jitter <seconds>', 'random start delay', '0')
|
|
36
|
+
.option('--notify <list>', 'github,jira,webhook', collect, [])
|
|
37
|
+
.option('--disabled', 'create disabled')
|
|
38
|
+
.action(async (opts, cmd) => {
|
|
39
|
+
const ctx = createContext(cmd);
|
|
40
|
+
const project = ctx.registry.entry(opts.project);
|
|
41
|
+
const s = await withServices(ctx.rootDir);
|
|
42
|
+
try {
|
|
43
|
+
const { HierarchyService } = s.srv;
|
|
44
|
+
await new HierarchyService(s.adb).sync(ctx.registry);
|
|
45
|
+
const row = await s.db.getProjectBySlug(s.adb.db, project.slug);
|
|
46
|
+
if (!row)
|
|
47
|
+
throw new SdodsError('CONFIG_NOT_FOUND', `Project row for ${project.slug} missing.`, {
|
|
48
|
+
exitCode: 2,
|
|
49
|
+
});
|
|
50
|
+
const next = s.srv.nextTimes(opts.cron, opts.tz, 5);
|
|
51
|
+
if (!next.length)
|
|
52
|
+
throw new SdodsError('CONFIG_INVALID', `Invalid cron "${opts.cron}".`, { exitCode: 2 });
|
|
53
|
+
const id = await s.db.upsertSchedule(s.adb.db, s.adb.driver, {
|
|
54
|
+
projectId: row.id,
|
|
55
|
+
name: opts.name,
|
|
56
|
+
cronExpr: opts.cron,
|
|
57
|
+
timezone: opts.tz,
|
|
58
|
+
runInput: {
|
|
59
|
+
env: opts.env,
|
|
60
|
+
tags: opts.tags,
|
|
61
|
+
layers: opts.layer.length ? opts.layer : undefined,
|
|
62
|
+
browsers: opts.browser.length ? opts.browser : undefined,
|
|
63
|
+
process: opts.process,
|
|
64
|
+
},
|
|
65
|
+
overlapPolicy: opts.overlap,
|
|
66
|
+
jitterSeconds: Number(opts.jitter),
|
|
67
|
+
enabled: !opts.disabled,
|
|
68
|
+
notify: opts.notify,
|
|
69
|
+
nextRunAt: next[0],
|
|
70
|
+
});
|
|
71
|
+
if (ctx.opts.json)
|
|
72
|
+
return json({ id, nextFireTimes: next });
|
|
73
|
+
ok(`Schedule ${opts.name} (${opts.cron} ${opts.tz}) saved`);
|
|
74
|
+
info(`Next: ${next.slice(0, 3).join(', ')}`);
|
|
75
|
+
info('The server runs schedules (`sdods serve`); for server-less setups use `sdods schedule install`.');
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await s.adb.close();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
schedule
|
|
82
|
+
.command('list')
|
|
83
|
+
.description('List schedules with next fire times')
|
|
84
|
+
.option('-p, --project <slug>', 'project')
|
|
85
|
+
.action(async (opts, cmd) => {
|
|
86
|
+
const ctx = createContext(cmd);
|
|
87
|
+
const s = await withServices(ctx.rootDir);
|
|
88
|
+
try {
|
|
89
|
+
const row = opts.project ? await s.db.getProjectBySlug(s.adb.db, opts.project) : null;
|
|
90
|
+
const list = await s.scheduler.list(row?.id);
|
|
91
|
+
const rows = list.map((x) => ({
|
|
92
|
+
id: x.id,
|
|
93
|
+
project: x.projectSlug ?? '',
|
|
94
|
+
name: x.name,
|
|
95
|
+
cron: x.cronExpr,
|
|
96
|
+
tz: x.timezone,
|
|
97
|
+
enabled: x.enabled ? 'yes' : 'no',
|
|
98
|
+
next: x.nextFireTimes?.[0] ?? '',
|
|
99
|
+
last: x.lastStatus ?? '',
|
|
100
|
+
}));
|
|
101
|
+
if (ctx.opts.json)
|
|
102
|
+
return json(list);
|
|
103
|
+
table(rows);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
await s.adb.close();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
for (const action of ['pause', 'resume', 'remove']) {
|
|
110
|
+
schedule
|
|
111
|
+
.command(`${action} <name>`)
|
|
112
|
+
.description(`${action[0].toUpperCase()}${action.slice(1)} a schedule by name`)
|
|
113
|
+
.requiredOption('-p, --project <slug>', 'project')
|
|
114
|
+
.action(async (name, opts, cmd) => {
|
|
115
|
+
const ctx = createContext(cmd);
|
|
116
|
+
const s = await withServices(ctx.rootDir);
|
|
117
|
+
try {
|
|
118
|
+
const row = await s.db.getProjectBySlug(s.adb.db, opts.project);
|
|
119
|
+
const target = (await s.scheduler.list(row?.id)).find((x) => x.name === name);
|
|
120
|
+
if (!target)
|
|
121
|
+
throw new SdodsError('CONFIG_NOT_FOUND', `Schedule ${name} not found.`, {
|
|
122
|
+
exitCode: 2,
|
|
123
|
+
});
|
|
124
|
+
if (action === 'remove')
|
|
125
|
+
await s.db.deleteSchedule(s.adb.db, target.id);
|
|
126
|
+
else
|
|
127
|
+
await s.db.updateScheduleState(s.adb.db, target.id, { enabled: action === 'resume' }, s.adb.driver);
|
|
128
|
+
if (ctx.opts.json)
|
|
129
|
+
return json({ id: target.id, action });
|
|
130
|
+
ok(`${name}: ${action}d`);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
await s.adb.close();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
schedule
|
|
138
|
+
.command('run-now <name>')
|
|
139
|
+
.description('Fire a schedule immediately (waits for the run to finish)')
|
|
140
|
+
.requiredOption('-p, --project <slug>', 'project')
|
|
141
|
+
.action(async (name, opts, cmd) => {
|
|
142
|
+
const ctx = createContext(cmd);
|
|
143
|
+
const s = await withServices(ctx.rootDir);
|
|
144
|
+
try {
|
|
145
|
+
const row = await s.db.getProjectBySlug(s.adb.db, opts.project);
|
|
146
|
+
const target = (await s.scheduler.list(row?.id)).find((x) => x.name === name);
|
|
147
|
+
if (!target)
|
|
148
|
+
throw new SdodsError('CONFIG_NOT_FOUND', `Schedule ${name} not found.`, {
|
|
149
|
+
exitCode: 2,
|
|
150
|
+
});
|
|
151
|
+
const r = await s.scheduler.fire(target.id, { force: true });
|
|
152
|
+
if (!r.runId)
|
|
153
|
+
throw new SdodsError('RUN_FAILED', `Schedule did not start: ${r.skipped}`);
|
|
154
|
+
info(`Started run ${r.runId}; streaming log…`);
|
|
155
|
+
const job = s.runManager.get(r.runId);
|
|
156
|
+
for await (const line of job.log.stream(0))
|
|
157
|
+
if (!ctx.opts.quiet)
|
|
158
|
+
out(line.line);
|
|
159
|
+
if (ctx.opts.json)
|
|
160
|
+
return json({ runId: r.runId, status: job.status, exitCode: job.exitCode });
|
|
161
|
+
process.exitCode = job.exitCode ?? 0;
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
await s.adb.close();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
schedule
|
|
168
|
+
.command('next')
|
|
169
|
+
.description('Preview upcoming fire times for a cron expression')
|
|
170
|
+
.requiredOption('--cron <expr>', 'cron expression')
|
|
171
|
+
.option('--tz <timezone>', 'IANA timezone', 'UTC')
|
|
172
|
+
.option('--count <n>', 'how many', '5')
|
|
173
|
+
.action(async (opts, cmd) => {
|
|
174
|
+
const ctx = createContext(cmd);
|
|
175
|
+
const srv = await import('@sdods/server');
|
|
176
|
+
const times = srv.nextTimes(opts.cron, opts.tz, Number(opts.count));
|
|
177
|
+
if (!times.length)
|
|
178
|
+
throw new SdodsError('CONFIG_INVALID', `Invalid cron "${opts.cron}".`, { exitCode: 2 });
|
|
179
|
+
if (ctx.opts.json)
|
|
180
|
+
return json({ cron: opts.cron, timezone: opts.tz, times });
|
|
181
|
+
for (const t of times)
|
|
182
|
+
out(t);
|
|
183
|
+
});
|
|
184
|
+
schedule
|
|
185
|
+
.command('history <name>')
|
|
186
|
+
.description('Show recent firings of a schedule')
|
|
187
|
+
.requiredOption('-p, --project <slug>', 'project')
|
|
188
|
+
.action(async (name, opts, cmd) => {
|
|
189
|
+
const ctx = createContext(cmd);
|
|
190
|
+
const s = await withServices(ctx.rootDir);
|
|
191
|
+
try {
|
|
192
|
+
const row = await s.db.getProjectBySlug(s.adb.db, opts.project);
|
|
193
|
+
const target = (await s.scheduler.list(row?.id)).find((x) => x.name === name);
|
|
194
|
+
if (!target)
|
|
195
|
+
throw new SdodsError('CONFIG_NOT_FOUND', `Schedule ${name} not found.`, {
|
|
196
|
+
exitCode: 2,
|
|
197
|
+
});
|
|
198
|
+
const rows = await s.db.listScheduleRuns(s.adb.db, target.id);
|
|
199
|
+
if (ctx.opts.json)
|
|
200
|
+
return json(rows);
|
|
201
|
+
table(rows.map((r) => ({
|
|
202
|
+
firedAt: String(r.fired_at),
|
|
203
|
+
status: r.status,
|
|
204
|
+
runId: r.run_id ?? '',
|
|
205
|
+
note: r.note ?? '',
|
|
206
|
+
})));
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
await s.adb.close();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
schedule
|
|
213
|
+
.command('sync')
|
|
214
|
+
.description('Mirror yaml schedules (project schedules[] and processes with a schedule) into the database')
|
|
215
|
+
.action(async (_opts, cmd) => {
|
|
216
|
+
const ctx = createContext(cmd);
|
|
217
|
+
const s = await withServices(ctx.rootDir);
|
|
218
|
+
try {
|
|
219
|
+
await new s.srv.HierarchyService(s.adb).sync(ctx.registry);
|
|
220
|
+
const n = await s.scheduler.syncFromRegistry(ctx.registry);
|
|
221
|
+
if (ctx.opts.json)
|
|
222
|
+
return json({ synced: n });
|
|
223
|
+
ok(`Synced ${n} schedule(s) from yaml`);
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
await s.adb.close();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
schedule
|
|
230
|
+
.command('install')
|
|
231
|
+
.description('Write OS/CI scheduler entries for server-less setups')
|
|
232
|
+
.requiredOption('--target <kind>', 'crontab | launchd | systemd | github')
|
|
233
|
+
.option('-p, --project <slug>', 'project (default: all)')
|
|
234
|
+
.option('--out <dir>', 'output directory for launchd/systemd/github files')
|
|
235
|
+
.action(async (opts, cmd) => {
|
|
236
|
+
const ctx = createContext(cmd);
|
|
237
|
+
const entries = [];
|
|
238
|
+
for (const e of ctx.registry.entriesList()) {
|
|
239
|
+
if (opts.project && e.slug !== opts.project)
|
|
240
|
+
continue;
|
|
241
|
+
for (const s of e.config.schedules) {
|
|
242
|
+
const args = [
|
|
243
|
+
'-p',
|
|
244
|
+
e.slug,
|
|
245
|
+
...(s.env ? ['-e', s.env] : []),
|
|
246
|
+
...(s.tags ? ['-t', s.tags] : []),
|
|
247
|
+
...(s.layers ?? []).flatMap((l) => ['-l', l]),
|
|
248
|
+
...(s.browsers ?? []).flatMap((b) => ['-b', b]),
|
|
249
|
+
...(s.harMode === 'replay' ? ['--har-replay'] : []),
|
|
250
|
+
];
|
|
251
|
+
entries.push({ project: e.slug, name: s.name, cron: s.cron, tz: s.timezone, args });
|
|
252
|
+
}
|
|
253
|
+
for (const p of ctx.registry.processesOf(e.slug))
|
|
254
|
+
if (p.schedule)
|
|
255
|
+
entries.push({
|
|
256
|
+
project: e.slug,
|
|
257
|
+
name: `process-${p.name}`,
|
|
258
|
+
cron: p.schedule,
|
|
259
|
+
tz: 'UTC',
|
|
260
|
+
args: ['-p', e.slug, '--process', p.name],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
if (!entries.length)
|
|
264
|
+
throw new SdodsError('CONFIG_NOT_FOUND', 'No schedules found in project yaml files.', {
|
|
265
|
+
exitCode: 2,
|
|
266
|
+
});
|
|
267
|
+
const bin = 'bun run sdods';
|
|
268
|
+
const cwd = ctx.rootDir;
|
|
269
|
+
const outDir = opts.out ?? join(ctx.rootDir, '.sdods', 'schedules');
|
|
270
|
+
const written = [];
|
|
271
|
+
if (opts.target === 'crontab') {
|
|
272
|
+
const lines = entries.map((e) => `# sdods ${e.project}/${e.name} (${e.tz})\n${e.cron} cd ${cwd} && ${bin} run ${e.args.join(' ')} --trigger schedule >> .sdods/cron-${e.project}-${e.name}.log 2>&1`);
|
|
273
|
+
out(lines.join('\n'));
|
|
274
|
+
info('\nAppend with: (crontab -l; sdods schedule install --target crontab) | crontab -');
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
mkdirSync(outDir, { recursive: true });
|
|
278
|
+
for (const e of entries) {
|
|
279
|
+
if (opts.target === 'github') {
|
|
280
|
+
const file = join(ctx.rootDir, '.github', 'workflows', `scheduled-${e.project}-${e.name}.yml`);
|
|
281
|
+
mkdirSync(join(ctx.rootDir, '.github', 'workflows'), { recursive: true });
|
|
282
|
+
writeFileSync(file, `name: scheduled ${e.project} ${e.name}\non:\n schedule:\n - cron: '${e.cron}'\n workflow_dispatch: {}\njobs:\n run:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: oven-sh/setup-bun@v2\n - uses: actions/setup-node@v4\n with: { node-version: 22 }\n - run: bun install\n - run: bun run sdods browsers install --with-deps\n - run: bun run sdods run ${e.args.join(' ')} --trigger schedule --run-id sched-${e.name}-\${{ github.run_id }}\n env:\n SDODS_SERVER_URL: \${{ secrets.SDODS_SERVER_URL }}\n SDODS_TOKEN: \${{ secrets.SDODS_TOKEN }}\n - uses: actions/upload-artifact@v4\n if: always()\n with: { name: sdods-${e.project}-${e.name}, path: .sdods/runs }\n`);
|
|
283
|
+
written.push(file);
|
|
284
|
+
}
|
|
285
|
+
else if (opts.target === 'launchd') {
|
|
286
|
+
const file = join(outDir, `com.sdods.${e.project}.${e.name}.plist`);
|
|
287
|
+
const [min, hour] = e.cron.split(' ');
|
|
288
|
+
writeFileSync(file, `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n <key>Label</key><string>com.sdods.${e.project}.${e.name}</string>\n <key>ProgramArguments</key><array><string>/bin/sh</string><string>-c</string><string>cd ${cwd} && ${bin} run ${e.args.join(' ')} --trigger schedule</string></array>\n <key>StartCalendarInterval</key><dict>${/^\d+$/.test(min ?? '') ? `<key>Minute</key><integer>${min}</integer>` : ''}${/^\d+$/.test(hour ?? '') ? `<key>Hour</key><integer>${hour}</integer>` : ''}</dict>\n <key>StandardOutPath</key><string>${cwd}/.sdods/launchd-${e.name}.log</string>\n <key>StandardErrorPath</key><string>${cwd}/.sdods/launchd-${e.name}.log</string>\n</dict></plist>\n`);
|
|
289
|
+
written.push(file);
|
|
290
|
+
}
|
|
291
|
+
else if (opts.target === 'systemd') {
|
|
292
|
+
const base = join(outDir, `sdods-${e.project}-${e.name}`);
|
|
293
|
+
writeFileSync(`${base}.service`, `[Unit]\nDescription=SDODS ${e.project} ${e.name}\n\n[Service]\nType=oneshot\nWorkingDirectory=${cwd}\nExecStart=/bin/sh -c '${bin} run ${e.args.join(' ')} --trigger schedule'\n`);
|
|
294
|
+
writeFileSync(`${base}.timer`, `[Unit]\nDescription=SDODS ${e.project} ${e.name} timer\n\n[Timer]\nOnCalendar=${cronToOnCalendar(e.cron)}\nPersistent=false\n\n[Install]\nWantedBy=timers.target\n`);
|
|
295
|
+
written.push(`${base}.service`, `${base}.timer`);
|
|
296
|
+
}
|
|
297
|
+
else
|
|
298
|
+
throw new SdodsError('CONFIG_INVALID', `Unknown target ${opts.target}.`, {
|
|
299
|
+
exitCode: 2,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
if (ctx.opts.json)
|
|
303
|
+
return json({ written });
|
|
304
|
+
for (const f of written)
|
|
305
|
+
ok(`wrote ${f}`);
|
|
306
|
+
if (opts.target === 'launchd')
|
|
307
|
+
info(`Load with: launchctl load ${outDir}/*.plist`);
|
|
308
|
+
if (opts.target === 'systemd')
|
|
309
|
+
info(`Install with: sudo cp ${outDir}/* /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now <timer>`);
|
|
310
|
+
if (!existsSync(join(ctx.rootDir, '.sdods')))
|
|
311
|
+
mkdirSync(join(ctx.rootDir, '.sdods'), { recursive: true });
|
|
312
|
+
});
|
|
313
|
+
schedule
|
|
314
|
+
.command('export')
|
|
315
|
+
.description('Print schedules from yaml as JSON')
|
|
316
|
+
.action((_opts, cmd) => {
|
|
317
|
+
const ctx = createContext(cmd);
|
|
318
|
+
json(ctx.registry.entriesList().map((e) => ({
|
|
319
|
+
project: e.slug,
|
|
320
|
+
schedules: e.config.schedules,
|
|
321
|
+
processes: ctx.registry.processesOf(e.slug).filter((p) => p.schedule),
|
|
322
|
+
})));
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
/** Best-effort cron → systemd OnCalendar (minute hour dom month dow). */
|
|
326
|
+
function cronToOnCalendar(cron) {
|
|
327
|
+
const [min = '*', hour = '*', dom = '*', mon = '*', dow = '*'] = cron.split(/\s+/);
|
|
328
|
+
const days = dow === '*'
|
|
329
|
+
? ''
|
|
330
|
+
: dow
|
|
331
|
+
.split(',')
|
|
332
|
+
.map((d) => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][Number(d) % 7])
|
|
333
|
+
.join(',') + ' ';
|
|
334
|
+
const f = (v) => (v === '*' ? '*' : v.padStart(2, '0'));
|
|
335
|
+
return `${days}*-${mon === '*' ? '*' : mon.padStart(2, '0')}-${dom === '*' ? '*' : dom.padStart(2, '0')} ${f(hour)}:${f(min)}:00`;
|
|
336
|
+
}
|
|
337
|
+
//# sourceMappingURL=schedule.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createContext } from '../context.js';
|
|
2
|
+
import { info, ok, parseIntFlag } from '../ui.js';
|
|
3
|
+
export function register(program) {
|
|
4
|
+
program
|
|
5
|
+
.command('serve')
|
|
6
|
+
.description('Start the SDODS web server (REST API, SSE, web UI, MCP over HTTP, scheduler)')
|
|
7
|
+
.option('--port <n>', 'port (default: PORT env or 4444)', parseIntFlag('port'))
|
|
8
|
+
.option('--host <host>', 'bind address (default: HOST env or 127.0.0.1)')
|
|
9
|
+
.option('--open', 'open the browser after start')
|
|
10
|
+
.option('--no-scheduler', 'do not start the cron scheduler')
|
|
11
|
+
.option('--auth-disabled', 'development only: every request is a local admin')
|
|
12
|
+
.action(async (opts, cmd) => {
|
|
13
|
+
const ctx = createContext(cmd);
|
|
14
|
+
const { startServer } = await import('@sdods/server');
|
|
15
|
+
const { app, url } = await startServer({
|
|
16
|
+
config: {
|
|
17
|
+
rootDir: ctx.rootDir,
|
|
18
|
+
port: opts.port,
|
|
19
|
+
host: opts.host,
|
|
20
|
+
authDisabled: opts.authDisabled ? true : undefined,
|
|
21
|
+
},
|
|
22
|
+
scheduler: opts.scheduler !== false,
|
|
23
|
+
open: Boolean(opts.open),
|
|
24
|
+
});
|
|
25
|
+
ok(`SDODS server listening on ${url}`);
|
|
26
|
+
if (app.setupState.token)
|
|
27
|
+
info(`First run: create the admin at ${url}/setup?token=${app.setupState.token} (or: sdods users create --admin --username <name> --password <pw>)`);
|
|
28
|
+
info('Press Ctrl+C to stop.');
|
|
29
|
+
const stop = async () => {
|
|
30
|
+
await app.close();
|
|
31
|
+
process.exit(0);
|
|
32
|
+
};
|
|
33
|
+
process.on('SIGINT', stop);
|
|
34
|
+
process.on('SIGTERM', stop);
|
|
35
|
+
await new Promise(() => undefined);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=serve.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import type { StepDef } from '@sdods/contracts';
|
|
3
|
+
/** Parse `bddgen export` output (`* Given ...` lines) into StepDef[]. */
|
|
4
|
+
export declare function listSteps(rootDir: string, slug: string, opts?: {
|
|
5
|
+
unused?: boolean;
|
|
6
|
+
}): Promise<StepDef[]>;
|
|
7
|
+
export declare function register(program: Command): void;
|
|
8
|
+
//# sourceMappingURL=steps.d.ts.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
import { SdodsError, coreStepsDir } from '@sdods/core';
|
|
4
|
+
import { createContext } from '../context.js';
|
|
5
|
+
import { json, table } from '../ui.js';
|
|
6
|
+
/** Parse `bddgen export` output (`* Given ...` lines) into StepDef[]. */
|
|
7
|
+
export async function listSteps(rootDir, slug, opts = {}) {
|
|
8
|
+
const env = {
|
|
9
|
+
...process.env,
|
|
10
|
+
SDODS_PROJECT: slug,
|
|
11
|
+
SDODS_ARTIFACTS_DIR: join(rootDir, '.sdods', 'lint'),
|
|
12
|
+
SDODS_LAYERS: '',
|
|
13
|
+
};
|
|
14
|
+
const args = ['bddgen', 'export', '-c', join(rootDir, 'sdods.runner.config.ts')];
|
|
15
|
+
if (opts.unused)
|
|
16
|
+
args.push('--unused-steps');
|
|
17
|
+
const result = await execa('npx', args, { cwd: rootDir, env, reject: false, all: true });
|
|
18
|
+
if (result.exitCode !== 0) {
|
|
19
|
+
throw new SdodsError('RUN_FAILED', `bddgen export failed for ${slug}.`, {
|
|
20
|
+
details: { output: result.all?.slice(-2000) },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const steps = [];
|
|
24
|
+
for (const raw of (result.all ?? '').split('\n')) {
|
|
25
|
+
const line = raw.replace(new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g'), '').trim();
|
|
26
|
+
const m = /^\*\s+(Given|When|Then)\s+(.+)$/.exec(line);
|
|
27
|
+
if (m)
|
|
28
|
+
steps.push({ keyword: m[1], pattern: m[2].trim(), source: 'project' });
|
|
29
|
+
else if (opts.unused) {
|
|
30
|
+
const u = /^\|\s*(.+?)\s*\|\s*(.+?):(\d+)\s*\|$/.exec(line);
|
|
31
|
+
if (u)
|
|
32
|
+
steps.push({
|
|
33
|
+
keyword: 'Unknown',
|
|
34
|
+
pattern: u[1],
|
|
35
|
+
file: u[2],
|
|
36
|
+
line: Number(u[3]),
|
|
37
|
+
source: u[2].includes(coreStepsDir()) ? 'core' : 'project',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return steps;
|
|
42
|
+
}
|
|
43
|
+
export function register(program) {
|
|
44
|
+
const steps = program
|
|
45
|
+
.command('steps')
|
|
46
|
+
.description('Inspect step definitions available to a project');
|
|
47
|
+
steps
|
|
48
|
+
.command('list')
|
|
49
|
+
.description('List all step definitions (core library + project) as seen by bddgen')
|
|
50
|
+
.requiredOption('-p, --project <slug>', 'project slug')
|
|
51
|
+
.option('--unused', 'only steps not used by any feature')
|
|
52
|
+
.option('--grep <text>', 'filter by substring')
|
|
53
|
+
.action(async (opts, cmd) => {
|
|
54
|
+
const ctx = createContext(cmd);
|
|
55
|
+
let result = await listSteps(ctx.rootDir, opts.project, { unused: opts.unused });
|
|
56
|
+
if (opts.grep)
|
|
57
|
+
result = result.filter((s) => s.pattern.toLowerCase().includes(String(opts.grep).toLowerCase()));
|
|
58
|
+
if (ctx.opts.json)
|
|
59
|
+
return json(result);
|
|
60
|
+
table(result.map((s) => ({
|
|
61
|
+
keyword: s.keyword,
|
|
62
|
+
pattern: s.pattern,
|
|
63
|
+
...(s.file ? { location: `${s.file}:${s.line}` } : {}),
|
|
64
|
+
})));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=steps.js.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { SdodsError } from '@sdods/core';
|
|
2
|
+
import { createContext } from '../context.js';
|
|
3
|
+
import { collect, json, ok, table, warn } from '../ui.js';
|
|
4
|
+
export function register(program) {
|
|
5
|
+
const tokens = program
|
|
6
|
+
.command('tokens')
|
|
7
|
+
.description('API tokens: free, scoped, revocable (used by MCP over HTTP and CI ingest)');
|
|
8
|
+
tokens
|
|
9
|
+
.command('create')
|
|
10
|
+
.description('Create a token for a user; the value is printed once')
|
|
11
|
+
.requiredOption('--user <username>', 'owner')
|
|
12
|
+
.requiredOption('--name <name>', 'label, e.g. ci or mcp-laptop')
|
|
13
|
+
.option('--scopes <list>', 'comma list (default: all scopes of the owner role)', collect, [])
|
|
14
|
+
.option('--expires <days>', 'expiry in days (default: never)')
|
|
15
|
+
.action(async (opts, cmd) => {
|
|
16
|
+
const ctx = createContext(cmd);
|
|
17
|
+
const db = await import('@sdods/db');
|
|
18
|
+
const adb = await db.openDb();
|
|
19
|
+
try {
|
|
20
|
+
const u = await db.getUserByUsername(adb.db, opts.user);
|
|
21
|
+
if (!u)
|
|
22
|
+
throw new SdodsError('CONFIG_NOT_FOUND', `User ${opts.user} not found.`, {
|
|
23
|
+
exitCode: 2,
|
|
24
|
+
});
|
|
25
|
+
const scopes = opts.scopes.length ? opts.scopes : db.scopesForRole(u.role);
|
|
26
|
+
const created = await db.createApiToken(adb.db, {
|
|
27
|
+
userId: u.id,
|
|
28
|
+
name: opts.name,
|
|
29
|
+
scopes,
|
|
30
|
+
expiresInDays: opts.expires ? Number(opts.expires) : null,
|
|
31
|
+
ownerRole: u.role,
|
|
32
|
+
});
|
|
33
|
+
await db.audit(adb.db, {
|
|
34
|
+
actorType: 'cli',
|
|
35
|
+
action: 'token.create',
|
|
36
|
+
targetType: 'api_token',
|
|
37
|
+
targetId: created.id,
|
|
38
|
+
details: { name: opts.name, scopes: created.scopes },
|
|
39
|
+
});
|
|
40
|
+
if (ctx.opts.json)
|
|
41
|
+
return json(created);
|
|
42
|
+
ok(`Token ${opts.name} for ${opts.user} (scopes: ${created.scopes.join(', ')})`);
|
|
43
|
+
console.log(`\n ${created.token}\n`);
|
|
44
|
+
warn('Store it now; it is not shown again. Tokens are free and unlimited.');
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await adb.close();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
tokens
|
|
51
|
+
.command('list')
|
|
52
|
+
.description('List tokens (prefix, scopes, expiry, last use)')
|
|
53
|
+
.option('--user <username>', 'only this user')
|
|
54
|
+
.action(async (opts, cmd) => {
|
|
55
|
+
const ctx = createContext(cmd);
|
|
56
|
+
const db = await import('@sdods/db');
|
|
57
|
+
const adb = await db.openDb();
|
|
58
|
+
try {
|
|
59
|
+
let userId;
|
|
60
|
+
if (opts.user) {
|
|
61
|
+
const u = await db.getUserByUsername(adb.db, opts.user);
|
|
62
|
+
if (!u)
|
|
63
|
+
throw new SdodsError('CONFIG_NOT_FOUND', `User ${opts.user} not found.`, {
|
|
64
|
+
exitCode: 2,
|
|
65
|
+
});
|
|
66
|
+
userId = u.id;
|
|
67
|
+
}
|
|
68
|
+
const rows = (await db.listApiTokens(adb.db, userId)).map((t) => ({
|
|
69
|
+
id: t.id,
|
|
70
|
+
user: t.username,
|
|
71
|
+
name: t.name,
|
|
72
|
+
prefix: t.prefix,
|
|
73
|
+
scopes: t.scopes.join(','),
|
|
74
|
+
expires: t.expiresAt ?? 'never',
|
|
75
|
+
lastUsed: t.lastUsedAt ?? '',
|
|
76
|
+
revoked: t.revokedAt ? 'yes' : '',
|
|
77
|
+
}));
|
|
78
|
+
if (ctx.opts.json)
|
|
79
|
+
return json(rows);
|
|
80
|
+
table(rows);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await adb.close();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
tokens
|
|
87
|
+
.command('revoke <id>')
|
|
88
|
+
.description('Revoke a token by id')
|
|
89
|
+
.action(async (id, cmd) => {
|
|
90
|
+
const ctx = createContext(cmd);
|
|
91
|
+
const db = await import('@sdods/db');
|
|
92
|
+
const adb = await db.openDb();
|
|
93
|
+
try {
|
|
94
|
+
await db.revokeApiToken(adb.db, id);
|
|
95
|
+
await db.audit(adb.db, {
|
|
96
|
+
actorType: 'cli',
|
|
97
|
+
action: 'token.revoke',
|
|
98
|
+
targetType: 'api_token',
|
|
99
|
+
targetId: id,
|
|
100
|
+
});
|
|
101
|
+
if (ctx.opts.json)
|
|
102
|
+
return json({ id, revoked: true });
|
|
103
|
+
ok(`Revoked ${id}`);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
await adb.close();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=tokens.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
/** Recursively find `trace.zip` files (runner output) below a directory. */
|
|
3
|
+
export declare function findTraceZips(dir: string, depth?: number): string[];
|
|
4
|
+
export declare function listRuns(artifactsDir: string): Array<{
|
|
5
|
+
runId: string;
|
|
6
|
+
dir: string;
|
|
7
|
+
mtime: number;
|
|
8
|
+
}>;
|
|
9
|
+
export declare function register(program: Command): void;
|
|
10
|
+
//# sourceMappingURL=trace.d.ts.map
|