@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.
Files changed (124) hide show
  1. package/LICENSE +17 -0
  2. package/bin/sdods.js +19 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/bin.d.ts +2 -0
  5. package/dist/bin.js +11 -0
  6. package/dist/commands/agent.d.ts +3 -0
  7. package/dist/commands/agent.js +219 -0
  8. package/dist/commands/analyze.d.ts +20 -0
  9. package/dist/commands/analyze.js +216 -0
  10. package/dist/commands/auth.d.ts +7 -0
  11. package/dist/commands/auth.js +97 -0
  12. package/dist/commands/browsers.d.ts +17 -0
  13. package/dist/commands/browsers.js +103 -0
  14. package/dist/commands/completion.d.ts +17 -0
  15. package/dist/commands/completion.js +143 -0
  16. package/dist/commands/config.d.ts +3 -0
  17. package/dist/commands/config.js +79 -0
  18. package/dist/commands/coverage.d.ts +3 -0
  19. package/dist/commands/coverage.js +65 -0
  20. package/dist/commands/data.d.ts +7 -0
  21. package/dist/commands/data.js +188 -0
  22. package/dist/commands/db.d.ts +3 -0
  23. package/dist/commands/db.js +241 -0
  24. package/dist/commands/doctor.d.ts +3 -0
  25. package/dist/commands/doctor.js +323 -0
  26. package/dist/commands/env.d.ts +3 -0
  27. package/dist/commands/env.js +65 -0
  28. package/dist/commands/features.d.ts +3 -0
  29. package/dist/commands/features.js +57 -0
  30. package/dist/commands/feedback.d.ts +7 -0
  31. package/dist/commands/feedback.js +96 -0
  32. package/dist/commands/har.d.ts +3 -0
  33. package/dist/commands/har.js +124 -0
  34. package/dist/commands/heal.d.ts +4 -0
  35. package/dist/commands/heal.js +63 -0
  36. package/dist/commands/init.d.ts +38 -0
  37. package/dist/commands/init.js +363 -0
  38. package/dist/commands/insights.d.ts +7 -0
  39. package/dist/commands/insights.js +127 -0
  40. package/dist/commands/integrations.d.ts +14 -0
  41. package/dist/commands/integrations.js +306 -0
  42. package/dist/commands/lint.d.ts +3 -0
  43. package/dist/commands/lint.js +74 -0
  44. package/dist/commands/mcp.d.ts +3 -0
  45. package/dist/commands/mcp.js +190 -0
  46. package/dist/commands/project.d.ts +3 -0
  47. package/dist/commands/project.js +133 -0
  48. package/dist/commands/proposals.d.ts +3 -0
  49. package/dist/commands/proposals.js +106 -0
  50. package/dist/commands/record.d.ts +3 -0
  51. package/dist/commands/record.js +174 -0
  52. package/dist/commands/report.d.ts +3 -0
  53. package/dist/commands/report.js +255 -0
  54. package/dist/commands/run.d.ts +41 -0
  55. package/dist/commands/run.js +369 -0
  56. package/dist/commands/schedule.d.ts +3 -0
  57. package/dist/commands/schedule.js +337 -0
  58. package/dist/commands/serve.d.ts +3 -0
  59. package/dist/commands/serve.js +38 -0
  60. package/dist/commands/steps.d.ts +8 -0
  61. package/dist/commands/steps.js +67 -0
  62. package/dist/commands/tokens.d.ts +3 -0
  63. package/dist/commands/tokens.js +110 -0
  64. package/dist/commands/trace.d.ts +10 -0
  65. package/dist/commands/trace.js +104 -0
  66. package/dist/commands/upgrade.d.ts +14 -0
  67. package/dist/commands/upgrade.js +115 -0
  68. package/dist/commands/users.d.ts +3 -0
  69. package/dist/commands/users.js +183 -0
  70. package/dist/commands/watch.d.ts +7 -0
  71. package/dist/commands/watch.js +116 -0
  72. package/dist/commands/workspace.d.ts +8 -0
  73. package/dist/commands/workspace.js +83 -0
  74. package/dist/context.d.ts +17 -0
  75. package/dist/context.js +22 -0
  76. package/dist/index.d.ts +3 -0
  77. package/dist/index.js +3 -0
  78. package/dist/program.d.ts +7 -0
  79. package/dist/program.js +90 -0
  80. package/dist/templates/project.d.ts +10 -0
  81. package/dist/templates/project.js +115 -0
  82. package/dist/ui.d.ts +16 -0
  83. package/dist/ui.js +67 -0
  84. package/package.json +61 -0
  85. package/templates/.claude/skills/sdods-record/SKILL.md +75 -0
  86. package/templates/.claude/skills/sdods-run/SKILL.md +54 -0
  87. package/templates/.env.example +62 -0
  88. package/templates/docker-compose.yml +40 -0
  89. package/templates/projects/demo-shop/.env.example +4 -0
  90. package/templates/projects/demo-shop/data/common/posts.yaml +11 -0
  91. package/templates/projects/demo-shop/data/common/products.json +8 -0
  92. package/templates/projects/demo-shop/data/common/users.csv +7 -0
  93. package/templates/projects/demo-shop/data/factories.ts +16 -0
  94. package/templates/projects/demo-shop/data/staging/users.csv +6 -0
  95. package/templates/projects/demo-shop/envs/local.yaml +17 -0
  96. package/templates/projects/demo-shop/envs/staging.yaml +23 -0
  97. package/templates/projects/demo-shop/features/__screenshots__/demo-shop--ui--chromium/darwin/inventory.png +0 -0
  98. package/templates/projects/demo-shop/features/api/posts.feature +90 -0
  99. package/templates/projects/demo-shop/features/auth/data-driven-login.feature +17 -0
  100. package/templates/projects/demo-shop/features/auth/login.feature +32 -0
  101. package/templates/projects/demo-shop/features/auth/user-pool.feature +20 -0
  102. package/templates/projects/demo-shop/features/cart/cart.feature +33 -0
  103. package/templates/projects/demo-shop/features/hybrid/seed-then-verify.feature +28 -0
  104. package/templates/projects/demo-shop/features/inventory/inventory.feature +38 -0
  105. package/templates/projects/demo-shop/features/inventory/network-intercept.feature +29 -0
  106. package/templates/projects/demo-shop/features/inventory/visual.feature +10 -0
  107. package/templates/projects/demo-shop/har/staging/cart.har +1 -0
  108. package/templates/projects/demo-shop/har/staging/cart.har.json +11 -0
  109. package/templates/projects/demo-shop/har/staging/login.har +1 -0
  110. package/templates/projects/demo-shop/har/staging/login.har.json +11 -0
  111. package/templates/projects/demo-shop/har/staging/posts.api.har +2061 -0
  112. package/templates/projects/demo-shop/har/staging/posts.har.json +8 -0
  113. package/templates/projects/demo-shop/har/staging/products.har +1 -0
  114. package/templates/projects/demo-shop/har/staging/products.har.json +11 -0
  115. package/templates/projects/demo-shop/har/staging/site.api.har +148 -0
  116. package/templates/projects/demo-shop/har/staging/site.har +1 -0
  117. package/templates/projects/demo-shop/har/staging/site.har.json +11 -0
  118. package/templates/projects/demo-shop/pages/CartPage.ts +20 -0
  119. package/templates/projects/demo-shop/pages/InventoryPage.ts +103 -0
  120. package/templates/projects/demo-shop/pages/LoginPage.ts +42 -0
  121. package/templates/projects/demo-shop/schemas/post.schema.json +13 -0
  122. package/templates/projects/demo-shop/sdods.project.yaml +185 -0
  123. package/templates/projects/demo-shop/steps/auth.ts +7 -0
  124. package/templates/projects/demo-shop/steps/fixtures.ts +39 -0
@@ -0,0 +1,241 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import pc from 'picocolors';
4
+ import { parse as parseYaml } from 'yaml';
5
+ import { WorkspaceFileSchema } from '@sdods/contracts';
6
+ import { SdodsError } from '@sdods/core';
7
+ import { createContext } from '../context.js';
8
+ import { json, ok, out, table, warn } from '../ui.js';
9
+ async function loadDb() {
10
+ return import('@sdods/db');
11
+ }
12
+ export function register(program) {
13
+ const db = program
14
+ .command('db')
15
+ .description('Migrate, inspect and switch the platform database (DB_DRIVER=sqlite|postgres)');
16
+ db.command('migrate')
17
+ .description('Apply pending migrations (creates the SQLite file on first use)')
18
+ .option('--to <name>', 'migrate up/down to a specific migration')
19
+ .option('--down', 'roll back one migration')
20
+ .action(async (opts, cmd) => {
21
+ const ctx = createContext(cmd);
22
+ const m = await loadDb();
23
+ const adb = m.createDb();
24
+ try {
25
+ const results = opts.down
26
+ ? await m.migrateDown(adb)
27
+ : opts.to
28
+ ? await m.migrateTo(adb, opts.to)
29
+ : await m.migrateToLatest(adb);
30
+ const rows = results.map((r) => ({
31
+ migration: r.migrationName,
32
+ direction: r.direction,
33
+ status: r.status,
34
+ }));
35
+ if (ctx.opts.json)
36
+ return json({ driver: adb.driver, results: rows });
37
+ out(pc.dim(`driver: ${adb.driver}${adb.driver === 'sqlite' ? ` (${adb.config.sqlitePath})` : ''}`));
38
+ if (rows.length === 0)
39
+ ok('Database is up to date');
40
+ else
41
+ table(rows);
42
+ }
43
+ finally {
44
+ await adb.close();
45
+ }
46
+ });
47
+ db.command('status')
48
+ .description('Show applied and pending migrations')
49
+ .action(async (_opts, cmd) => {
50
+ const ctx = createContext(cmd);
51
+ const m = await loadDb();
52
+ const adb = m.createDb();
53
+ try {
54
+ const status = await m.migrationStatus(adb);
55
+ if (ctx.opts.json)
56
+ return json({
57
+ driver: adb.driver,
58
+ config: { ...adb.config, databaseUrl: redactUrl(adb.config.databaseUrl) },
59
+ migrations: status,
60
+ });
61
+ out(pc.bold(`driver: ${adb.driver}`) +
62
+ pc.dim(adb.driver === 'sqlite'
63
+ ? ` ${adb.config.sqlitePath}`
64
+ : ` ${redactUrl(adb.config.databaseUrl)}`));
65
+ table(status.map((s) => ({
66
+ migration: s.name,
67
+ applied: s.executedAt ? s.executedAt : pc.yellow('pending'),
68
+ })));
69
+ }
70
+ finally {
71
+ await adb.close();
72
+ }
73
+ });
74
+ db.command('reset')
75
+ .description('Drop every SDODS table (sqlite only)')
76
+ .option('--yes', 'confirm')
77
+ .action(async (opts, cmd) => {
78
+ const ctx = createContext(cmd);
79
+ if (!opts.yes)
80
+ throw new SdodsError('NOT_SUPPORTED', 'db reset is destructive; pass --yes to confirm.', {
81
+ exitCode: 2,
82
+ });
83
+ const m = await loadDb();
84
+ const adb = m.createDb();
85
+ try {
86
+ await m.resetDatabase(adb);
87
+ if (ctx.opts.json)
88
+ return json({ ok: true });
89
+ ok('Database reset');
90
+ }
91
+ finally {
92
+ await adb.close();
93
+ }
94
+ });
95
+ db.command('switch <target>')
96
+ .description('Copy the database to sqlite|postgres, verify counts and update .env')
97
+ .option('--target-url <url>', 'Postgres connection string for the target')
98
+ .option('--target-path <path>', 'SQLite file for the target')
99
+ .option('--dry-run', 'verify the target and report row counts without writing anything')
100
+ .option('--truncate', 'clear the target platform tables first (required when the target already has data)')
101
+ .option('--env-file <file>', 'dotenv file to update', '.env')
102
+ .option('--yes', 'skip confirmation')
103
+ .action(async (target, opts, cmd) => {
104
+ const ctx = createContext(cmd);
105
+ if (target !== 'sqlite' && target !== 'postgres') {
106
+ throw new SdodsError('NOT_SUPPORTED', `Unknown target "${target}"; use sqlite or postgres.`, { exitCode: 2 });
107
+ }
108
+ if (!opts.dryRun && !opts.yes) {
109
+ throw new SdodsError('NOT_SUPPORTED', 'db switch rewrites .env; pass --yes (or --dry-run to preview).', { exitCode: 2 });
110
+ }
111
+ const m = await loadDb();
112
+ const source = m.createDb();
113
+ try {
114
+ const result = await m.switchDriver({
115
+ source,
116
+ target,
117
+ targetUrl: opts.targetUrl,
118
+ targetPath: opts.targetPath,
119
+ dryRun: Boolean(opts.dryRun),
120
+ truncate: Boolean(opts.truncate),
121
+ envFile: resolve(ctx.rootDir, opts.envFile),
122
+ });
123
+ if (ctx.opts.json)
124
+ return json(result);
125
+ table(result.tables.map((t) => ({
126
+ table: t.table,
127
+ source: t.source,
128
+ target: t.target,
129
+ ok: t.ok ? pc.green('ok') : pc.red('MISMATCH'),
130
+ })));
131
+ if (!result.ok) {
132
+ warn('Row counts do not match; .env was not changed.');
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+ if (result.dryRun)
137
+ ok(`Dry run: ${target} copy verified. Re-run with --yes to switch.`);
138
+ else
139
+ ok(`Switched to ${target}. Updated ${result.envFile}: ${result.envChanges?.join(', ')}`);
140
+ }
141
+ finally {
142
+ await source.close();
143
+ }
144
+ });
145
+ db.command('export <dir>')
146
+ .description('Export every table to <dir>/<table>.jsonl')
147
+ .action(async (dir, _opts, cmd) => {
148
+ const ctx = createContext(cmd);
149
+ const m = await loadDb();
150
+ const adb = m.createDb();
151
+ try {
152
+ const res = await m.exportAll(adb, dir);
153
+ if (ctx.opts.json)
154
+ return json(res);
155
+ table(res.tables.map((t) => ({ table: t.table, rows: t.rows })));
156
+ ok(`Exported to ${res.dir}`);
157
+ }
158
+ finally {
159
+ await adb.close();
160
+ }
161
+ });
162
+ db.command('import <dir>')
163
+ .description('Import <dir>/<table>.jsonl files (ON CONFLICT DO NOTHING)')
164
+ .action(async (dir, _opts, cmd) => {
165
+ const ctx = createContext(cmd);
166
+ const m = await loadDb();
167
+ const adb = m.createDb();
168
+ try {
169
+ await m.migrateToLatest(adb);
170
+ const res = await m.importAll(adb, dir);
171
+ if (ctx.opts.json)
172
+ return json(res);
173
+ table(res.tables.map((t) => ({ table: t.table, inserted: t.rows, skipped: t.skipped })));
174
+ }
175
+ finally {
176
+ await adb.close();
177
+ }
178
+ });
179
+ db.command('prune')
180
+ .description('Delete old runs and their artifact directories')
181
+ .option('--keep-runs <n>', 'runs to keep per project')
182
+ .option('--keep-days <n>', 'delete runs older than n days')
183
+ .option('-p, --project <slug>', 'limit to one project')
184
+ .option('--dry-run', 'list what would be deleted')
185
+ .action(async (opts, cmd) => {
186
+ const ctx = createContext(cmd);
187
+ if (!opts.keepRuns && !opts.keepDays) {
188
+ throw new SdodsError('NOT_SUPPORTED', 'Pass --keep-runs and/or --keep-days.', {
189
+ exitCode: 2,
190
+ });
191
+ }
192
+ const m = await loadDb();
193
+ const adb = m.createDb();
194
+ try {
195
+ const res = await m.prune(adb, {
196
+ keepRuns: opts.keepRuns ? Number(opts.keepRuns) : undefined,
197
+ keepDays: opts.keepDays ? Number(opts.keepDays) : undefined,
198
+ projectSlug: opts.project,
199
+ artifactsRoot: join(ctx.rootDir, process.env.SDODS_ARTIFACTS_DIR ?? '.sdods/runs'),
200
+ dryRun: Boolean(opts.dryRun),
201
+ });
202
+ if (ctx.opts.json)
203
+ return json(res);
204
+ 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}`);
206
+ }
207
+ finally {
208
+ await adb.close();
209
+ }
210
+ });
211
+ db.command('sync')
212
+ .description('Upsert organization, workspaces, projects, modules and processes from the yaml files')
213
+ .action(async (_opts, cmd) => {
214
+ const ctx = createContext(cmd);
215
+ const m = await loadDb();
216
+ const adb = m.createDb();
217
+ try {
218
+ await m.migrateToLatest(adb);
219
+ const wsFile = join(ctx.rootDir, 'sdods.workspace.yaml');
220
+ const workspaceFile = existsSync(wsFile)
221
+ ? WorkspaceFileSchema.parse(parseYaml(readFileSync(wsFile, 'utf8')))
222
+ : null;
223
+ const projects = ctx.registry
224
+ .entriesList()
225
+ .map((e) => ({ config: e.config, root: e.root }));
226
+ const res = await m.syncHierarchy(adb.db, adb.driver, { workspaceFile, projects });
227
+ if (ctx.opts.json)
228
+ return json(res);
229
+ ok(`Synced ${workspaceFile ? `organization ${workspaceFile.organization.slug}, ${Object.keys(res.workspaces).length} workspace(s), ` : ''}${Object.keys(res.projects).length} project(s), ${res.modules} module(s), ${res.processes} process(es)`);
230
+ }
231
+ finally {
232
+ await adb.close();
233
+ }
234
+ });
235
+ }
236
+ function redactUrl(url) {
237
+ if (!url)
238
+ return '';
239
+ return url.replace(/\/\/([^:]+):[^@]+@/, '//$1:***@');
240
+ }
241
+ //# sourceMappingURL=db.js.map
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerDoctorCommand(program: Command): void;
3
+ //# sourceMappingURL=doctor.d.ts.map
@@ -0,0 +1,323 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { execa } from 'execa';
4
+ import pc from 'picocolors';
5
+ import { collectVarRefs, loadDotEnvLayer, loadEnvFile } from '@sdods/core';
6
+ import { createContext } from '../context.js';
7
+ import { json, out } from '../ui.js';
8
+ export function registerDoctorCommand(program) {
9
+ program
10
+ .command('doctor')
11
+ .description('Check Node, Bun, browsers, projects, env vars and database reachability')
12
+ .option('-p, --project <slug>', 'limit env-var checks to one project')
13
+ .option('--fix', 'install missing browsers')
14
+ .action(async (opts, cmd) => {
15
+ const ctx = createContext(cmd);
16
+ const checks = [];
17
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
18
+ checks.push({
19
+ name: 'node',
20
+ ok: nodeMajor >= 22,
21
+ detail: `v${process.versions.node}`,
22
+ fix: 'Install Node 22 LTS (nvm use 22).',
23
+ });
24
+ checks.push(await versionCheck('bun', ['--version'], 'optional: bun install/build are faster'));
25
+ checks.push(await versionCheck('runner', ['playwright', '--version'], 'install workspace dependencies: bun install (or npm install)', 'npx'));
26
+ const browsers = await browserCheck();
27
+ checks.push(...browsers);
28
+ if (opts.fix && browsers.some((b) => !b.ok)) {
29
+ out(pc.cyan('Installing browsers…'));
30
+ await execa('npx', ['playwright', 'install', '--with-deps'], {
31
+ stdio: 'inherit',
32
+ cwd: ctx.rootDir,
33
+ }).catch(() => undefined);
34
+ }
35
+ const entries = opts.project
36
+ ? [ctx.registry.entry(opts.project)]
37
+ : ctx.registry.entriesList();
38
+ checks.push({
39
+ name: 'projects',
40
+ ok: entries.length > 0,
41
+ detail: entries.map((e) => e.slug).join(', ') || 'none',
42
+ fix: 'sdods project create <slug>',
43
+ });
44
+ for (const e of entries) {
45
+ for (const envName of e.config.envs.available) {
46
+ const file = join(e.root, 'envs', `${envName}.yaml`);
47
+ if (!existsSync(file)) {
48
+ checks.push({
49
+ name: `${e.slug}/${envName}`,
50
+ ok: false,
51
+ detail: 'env yaml missing',
52
+ fix: `sdods env add ${envName} -p ${e.slug} --ui-url ... --api-url ...`,
53
+ });
54
+ continue;
55
+ }
56
+ const envCfg = loadEnvFile(e.root, envName);
57
+ const refs = collectVarRefs(envCfg);
58
+ const dotenv = loadDotEnvLayer(ctx.rootDir, e.root, envName).values;
59
+ const missing = [...refs].filter((v) => dotenv[v] === undefined && process.env[v] === undefined);
60
+ checks.push({
61
+ name: `${e.slug}/${envName} vars`,
62
+ ok: missing.length === 0,
63
+ detail: missing.length
64
+ ? `missing: ${missing.join(', ')}`
65
+ : `${refs.size} var(s) resolved`,
66
+ fix: missing.length
67
+ ? `Add ${missing.join(', ')} to ${join(e.root, `.env.${envName}`)}`
68
+ : undefined,
69
+ });
70
+ }
71
+ }
72
+ const driver = process.env.DB_DRIVER ?? 'sqlite';
73
+ checks.push({
74
+ name: 'database',
75
+ ok: driver === 'sqlite' || Boolean(process.env.DATABASE_URL),
76
+ detail: driver === 'sqlite'
77
+ ? `sqlite (${process.env.SQLITE_PATH ?? '.sdods/sdods.db'})`
78
+ : `postgres ${process.env.DATABASE_URL ? '(url set)' : '(DATABASE_URL missing)'}`,
79
+ fix: 'Set DB_DRIVER=sqlite or provide DATABASE_URL.',
80
+ });
81
+ // Coding-agent CLIs (optional: they let agents run on your existing login instead of an API key)
82
+ const cliStatus = await codingCliStatus();
83
+ for (const c of cliStatus)
84
+ checks.push(c);
85
+ // A model server on this machine is the fourth way to reach a model, and the only free one.
86
+ const localLlm = await localModelStatus();
87
+ checks.push(localLlm);
88
+ const tokens = tokenMatrix(cliStatus, localLlm);
89
+ if (ctx.opts.json)
90
+ return json({ checks, tokens });
91
+ for (const c of checks) {
92
+ out(`${c.ok ? pc.green('✔') : pc.red('✖')} ${c.name.padEnd(28)} ${c.detail}${!c.ok && c.fix ? pc.dim(` → ${c.fix}`) : ''}`);
93
+ }
94
+ out(pc.bold('\nTokens and keys'));
95
+ for (const t of tokens) {
96
+ const icon = t.present
97
+ ? pc.green('✔')
98
+ : t.requirement === 'mandatory'
99
+ ? pc.red('✖')
100
+ : t.requirement === 'one-of' && !t.groupSatisfied
101
+ ? pc.yellow('!')
102
+ : pc.dim('·');
103
+ out(`${icon} ${t.name.padEnd(36)} ${pc.dim(t.requirement.padEnd(10))} ${t.detail}${t.hint && !t.present ? pc.dim(` → ${t.hint}`) : ''}`);
104
+ }
105
+ out(pc.dim('\nNothing is mandatory for the platform itself (SDODS API tokens are self-issued and free). Agents need one of the "one-of" rows.'));
106
+ const blocking = checks.some((c) => !c.ok && !c.optional);
107
+ if (blocking)
108
+ process.exitCode = 1;
109
+ });
110
+ }
111
+ /**
112
+ * The token matrix. "one-of" rows form a group: agents need ANY one of them.
113
+ * Platform features never need an external token.
114
+ */
115
+ function tokenMatrix(cliStatus, localLlm) {
116
+ const has = (k) => Boolean(process.env[k]);
117
+ const claudeOk = cliStatus.find((c) => c.name === 'cli:claude')?.ok ?? false;
118
+ const codexOk = cliStatus.find((c) => c.name === 'cli:codex')?.ok ?? false;
119
+ const agentsGroup = [
120
+ {
121
+ name: 'ANTHROPIC_API_KEY',
122
+ present: has('ANTHROPIC_API_KEY'),
123
+ detail: 'Claude via the Agent SDK / Messages API (billed by Anthropic)',
124
+ hint: 'or log in to Claude Code (`claude login`) — no key needed',
125
+ },
126
+ {
127
+ name: 'claude CLI login',
128
+ present: claudeOk,
129
+ detail: 'adapter claude-code: uses your Claude Code login (subscription or key)',
130
+ hint: '`npm i -g @anthropic-ai/claude-code && claude login`',
131
+ },
132
+ {
133
+ name: 'codex CLI login',
134
+ present: codexOk,
135
+ detail: 'adapter codex: uses your Codex login (ChatGPT account)',
136
+ hint: '`npm i -g @openai/codex && codex login`',
137
+ },
138
+ {
139
+ name: 'OPENAI_API_KEY (+OPENAI_BASE_URL)',
140
+ present: has('OPENAI_API_KEY'),
141
+ detail: 'any OpenAI-compatible chat-completions endpoint',
142
+ hint: 'optional OPENAI_BASE_URL for Azure, vLLM, LM Studio',
143
+ },
144
+ {
145
+ name: 'local models (ollama)',
146
+ present: localLlm?.ok ?? false,
147
+ detail: localLlm?.ok
148
+ ? `adapter ollama: ${localLlm.detail}`
149
+ : 'adapter ollama: a model on this machine — no key, no per-token cost',
150
+ hint: localLlm?.fix ?? 'ollama serve && ollama pull qwen2.5-coder:7b',
151
+ },
152
+ ];
153
+ const groupSatisfied = agentsGroup.some((g) => g.present);
154
+ const rows = agentsGroup.map((g) => ({
155
+ ...g,
156
+ requirement: 'one-of',
157
+ group: 'agents',
158
+ groupSatisfied,
159
+ }));
160
+ const serve = process.argv.includes('serve');
161
+ rows.push({
162
+ name: 'SESSION_SECRET',
163
+ requirement: serve ? 'mandatory' : 'optional',
164
+ present: has('SESSION_SECRET'),
165
+ detail: 'web server session signing (≥32 chars); needed only for `sdods serve`',
166
+ hint: 'generate: `openssl rand -hex 32`',
167
+ }, {
168
+ name: 'DATABASE_URL',
169
+ requirement: process.env.DB_DRIVER === 'postgres' ? 'mandatory' : 'optional',
170
+ present: has('DATABASE_URL'),
171
+ detail: 'only when DB_DRIVER=postgres (SQLite needs nothing)',
172
+ }, {
173
+ name: 'GITHUB_TOKEN',
174
+ requirement: 'optional',
175
+ present: has('GITHUB_TOKEN'),
176
+ detail: 'GitHub check runs, PR comments, issues (integrations.github)',
177
+ }, {
178
+ name: 'JIRA_EMAIL + JIRA_API_TOKEN',
179
+ requirement: 'optional',
180
+ present: has('JIRA_EMAIL') && has('JIRA_API_TOKEN'),
181
+ detail: 'Jira issues, links, transitions (integrations.jira)',
182
+ }, {
183
+ name: 'SDODS_TOKEN (+SDODS_SERVER_URL)',
184
+ requirement: 'optional',
185
+ present: has('SDODS_TOKEN'),
186
+ detail: 'self-issued, free: MCP over HTTP and CI result ingest (`sdods tokens create`)',
187
+ }, {
188
+ name: 'FIREBASE_SERVICE_ACCOUNT_AUTOMAX_DOCS',
189
+ requirement: 'ci-only',
190
+ present: has('FIREBASE_SERVICE_ACCOUNT_AUTOMAX_DOCS'),
191
+ detail: 'GitHub secret for the docs deploy workflow',
192
+ }, {
193
+ name: 'NPM_TOKEN',
194
+ requirement: 'ci-only',
195
+ present: has('NPM_TOKEN'),
196
+ detail: 'GitHub secret for publishing @sdods/* packages',
197
+ });
198
+ return rows;
199
+ }
200
+ /**
201
+ * `llm:local` row: is a model server answering, and is the model it would use pulled?
202
+ *
203
+ * Optional, and quick — a laptop without Ollama must not pay for this check. The context the
204
+ * models are loaded with is reported because that, not the model, is what usually makes a local
205
+ * agent run produce nonsense.
206
+ */
207
+ async function localModelStatus() {
208
+ const name = 'llm:local';
209
+ try {
210
+ const { OllamaAdapter } = await import('@sdods/agents');
211
+ if (!(await OllamaAdapter.reachable()))
212
+ return {
213
+ name,
214
+ ok: false,
215
+ optional: true,
216
+ detail: 'no model server answering (optional)',
217
+ fix: 'ollama serve && ollama pull qwen2.5-coder:7b',
218
+ };
219
+ const adapter = new OllamaAdapter();
220
+ const [version, models] = await Promise.all([
221
+ adapter.version().catch(() => 'unknown'),
222
+ adapter.models().catch(() => []),
223
+ ]);
224
+ if (!models.length)
225
+ return {
226
+ name,
227
+ ok: false,
228
+ optional: true,
229
+ detail: `ollama ${version} · no models pulled`,
230
+ fix: 'ollama pull qwen2.5-coder:7b',
231
+ };
232
+ const configured = models.find((m) => m.name === adapter.defaultModel);
233
+ const preferred = configured ?? models[0];
234
+ const loaded = await adapter.loadedContext(preferred.name).catch(() => undefined);
235
+ const tight = loaded !== undefined && loaded < 8192;
236
+ return {
237
+ name,
238
+ ok: true,
239
+ optional: true,
240
+ detail: `ollama ${version} · ${models.length} model(s) · ${preferred.name}${loaded ? ` · loaded ctx ${loaded}` : ''}${tight ? ' (small)' : ''}${configured ? '' : ` · default ${adapter.defaultModel} not pulled`}`,
241
+ fix: !configured
242
+ ? `ollama pull ${adapter.defaultModel}, or set agents.models.default to one you have`
243
+ : tight
244
+ ? 'agents.local.contextTokens: 16384, or OLLAMA_CONTEXT_LENGTH=16384 ollama serve'
245
+ : undefined,
246
+ };
247
+ }
248
+ catch {
249
+ return { name, ok: false, optional: true, detail: 'agents package unavailable' };
250
+ }
251
+ }
252
+ /** `cli:claude` / `cli:codex` rows: installed + logged in. Missing CLIs are not failures. */
253
+ async function codingCliStatus() {
254
+ const rows = [];
255
+ try {
256
+ const { ClaudeCodeCliAdapter, CodexCliAdapter } = await import('@sdods/agents');
257
+ const c = ClaudeCodeCliAdapter.loginStatus();
258
+ rows.push({
259
+ name: 'cli:claude',
260
+ ok: c.installed && c.loggedIn !== false,
261
+ optional: true,
262
+ detail: c.detail,
263
+ fix: c.installed ? 'claude login' : 'npm i -g @anthropic-ai/claude-code (optional)',
264
+ });
265
+ const x = CodexCliAdapter.loginStatus();
266
+ rows.push({
267
+ name: 'cli:codex',
268
+ ok: x.installed && x.loggedIn !== false,
269
+ optional: true,
270
+ detail: x.detail,
271
+ fix: x.installed ? 'codex login' : 'npm i -g @openai/codex (optional)',
272
+ });
273
+ }
274
+ catch {
275
+ rows.push({
276
+ name: 'cli:claude',
277
+ ok: false,
278
+ optional: true,
279
+ detail: 'agents package unavailable',
280
+ });
281
+ rows.push({
282
+ name: 'cli:codex',
283
+ ok: false,
284
+ optional: true,
285
+ detail: 'agents package unavailable',
286
+ });
287
+ }
288
+ return rows;
289
+ }
290
+ async function versionCheck(name, args, fix, bin = name) {
291
+ try {
292
+ const { stdout } = await execa(bin, args, { timeout: 20_000 });
293
+ return { name, ok: true, detail: stdout.trim().split('\n')[0] ?? '' };
294
+ }
295
+ catch {
296
+ return { name, ok: false, detail: 'not found', fix };
297
+ }
298
+ }
299
+ async function browserCheck() {
300
+ const out = [];
301
+ for (const b of ['chromium', 'firefox', 'webkit']) {
302
+ try {
303
+ const mod = await import('playwright-core');
304
+ const path = mod[b].executablePath();
305
+ out.push({
306
+ name: `browser:${b}`,
307
+ ok: existsSync(path),
308
+ detail: existsSync(path) ? path : 'not installed',
309
+ fix: `sdods browsers install -b ${b}`,
310
+ });
311
+ }
312
+ catch {
313
+ out.push({
314
+ name: `browser:${b}`,
315
+ ok: false,
316
+ detail: 'browser runner not resolvable',
317
+ fix: 'install workspace dependencies: bun install (or npm install)',
318
+ });
319
+ }
320
+ }
321
+ return out;
322
+ }
323
+ //# sourceMappingURL=doctor.js.map
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerEnvCommands(program: Command): void;
3
+ //# sourceMappingURL=env.d.ts.map
@@ -0,0 +1,65 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parseDocument } from 'yaml';
4
+ import { SdodsError, PROJECT_FILE } from '@sdods/core';
5
+ import { createContext } from '../context.js';
6
+ import { json, ok, table } from '../ui.js';
7
+ export function registerEnvCommands(program) {
8
+ const env = program.command('env').description('Manage project environments');
9
+ env
10
+ .command('list')
11
+ .description('List environments of a project')
12
+ .requiredOption('-p, --project <slug>', 'project slug')
13
+ .action((opts, cmd) => {
14
+ const ctx = createContext(cmd);
15
+ const e = ctx.registry.entry(opts.project);
16
+ const rows = e.config.envs.available.map((name) => {
17
+ let ui = '';
18
+ let api = '';
19
+ let status = 'ok';
20
+ try {
21
+ const cfg = ctx.registry.resolve(e.slug, name, {}, { ...process.env });
22
+ ui = cfg.env.ui.baseUrl;
23
+ api = cfg.env.api.baseUrl;
24
+ }
25
+ catch (err) {
26
+ status = err.message.split('\n')[0];
27
+ }
28
+ return { name, default: name === e.config.envs.default ? '*' : '', ui, api, status };
29
+ });
30
+ if (ctx.opts.json)
31
+ return json(rows);
32
+ table(rows);
33
+ });
34
+ env
35
+ .command('add <name>')
36
+ .description('Create envs/<name>.yaml and register it in envs.available')
37
+ .requiredOption('-p, --project <slug>', 'project slug')
38
+ .requiredOption('--ui-url <url>', 'UI base URL')
39
+ .requiredOption('--api-url <url>', 'API base URL')
40
+ .option('--default', 'make it the default environment')
41
+ .option('--pool-size <n>', 'user pool size', '2')
42
+ .action((name, opts, cmd) => {
43
+ const ctx = createContext(cmd);
44
+ const e = ctx.registry.entry(opts.project);
45
+ const file = join(e.root, 'envs', `${name}.yaml`);
46
+ if (existsSync(file))
47
+ throw new SdodsError('CONFIG_INVALID', `${file} already exists.`, { exitCode: 2 });
48
+ writeFileSync(file, `name: ${name}\nui:\n baseUrl: ${opts.uiUrl}\napi:\n baseUrl: ${opts.apiUrl}\n headers: { Accept: application/json }\n auth: { type: none }\nusers:\n poolSize: ${Number(opts.poolSize)}\nvars: {}\n`);
49
+ const projectFile = join(e.root, PROJECT_FILE);
50
+ const doc = parseDocument(readFileSync(projectFile, 'utf8'));
51
+ const available = doc.getIn(['envs', 'available']);
52
+ const list = Array.isArray(available?.items)
53
+ ? available.items.map((i) => String(i.value ?? i))
54
+ : [];
55
+ if (!list.includes(name))
56
+ doc.setIn(['envs', 'available'], [...list, name]);
57
+ if (opts.default)
58
+ doc.setIn(['envs', 'default'], name);
59
+ writeFileSync(projectFile, doc.toString({ lineWidth: 100 }));
60
+ if (ctx.opts.json)
61
+ return json({ project: e.slug, env: name, file });
62
+ ok(`Added environment ${name} → ${file}`);
63
+ });
64
+ }
65
+ //# sourceMappingURL=env.js.map
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ export declare function register(program: Command): void;
3
+ //# sourceMappingURL=features.d.ts.map