@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,363 @@
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from 'node:fs';
2
+ import { basename, dirname, join, relative, resolve as resolvePath } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { execa } from 'execa';
5
+ import pc from 'picocolors';
6
+ import { OrganizationSchema, SlugSchema, WorkspaceSchema } from '@sdods/contracts';
7
+ import { SdodsError, VERSION, WORKSPACE_FILE } from '@sdods/core';
8
+ import { globalOptions } from '../context.js';
9
+ import { json, ok, out, warn } from '../ui.js';
10
+ /** Root of the SDODS monorepo this CLI runs from (used by --link, which needs a real checkout). */
11
+ export function sourceRepoRoot() {
12
+ // packages/cli/{src,dist}/commands/init.{ts,js} → repo root (same depth either way)
13
+ return resolvePath(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
14
+ }
15
+ /**
16
+ * Where `init` reads the assets it copies into a new workspace. A real checkout wins, so working
17
+ * on SDODS scaffolds from the live `projects/demo-shop` rather than a staged copy. A published
18
+ * install has no checkout above it and falls back to `templates/`, put into the tarball at pack
19
+ * time by scripts/build-publish-assets.ts. The staged layout mirrors the repo root, so the callers
20
+ * below resolve the same relative paths either way.
21
+ */
22
+ export function templateRoot() {
23
+ const repo = sourceRepoRoot();
24
+ if (existsSync(join(repo, 'projects', 'demo-shop')))
25
+ return repo;
26
+ // packages/cli/{src,dist}/commands → packages/cli
27
+ const packaged = resolvePath(dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
28
+ return existsSync(packaged) ? packaged : repo;
29
+ }
30
+ export function register(program) {
31
+ program
32
+ .command('init [dir]')
33
+ .description('Scaffold a new SDODS workspace: workspace yaml, runner config, demo project, skills')
34
+ .option('--db <driver>', 'sqlite | postgres (written to .env.example)', 'sqlite')
35
+ .option('--pm <manager>', 'bun | pnpm', 'bun')
36
+ .option('--no-demo', 'skip projects/demo-shop')
37
+ .option('--claude', 'also write .claude/agents and .mcp.json via `sdods agent install-claude`')
38
+ .option('--from <app>', 'analyze an application repo and create a project from it')
39
+ .option('--git', 'git init and make an initial commit')
40
+ .option('--link', 'depend on the local SDODS packages (development) instead of the npm registry')
41
+ .option('--no-install', 'skip the package manager install step')
42
+ .option('--no-browsers', 'skip downloading the chromium engine')
43
+ .option('--org <slug>', 'organization slug', 'default')
44
+ .option('--org-name <name>', 'organization display name')
45
+ .option('--workspace <slug>', 'workspace slug', 'default')
46
+ .option('--workspace-name <name>', 'workspace display name')
47
+ .option('--force', 'write into a non-empty directory')
48
+ .action(async (dir, flags, cmd) => {
49
+ const opts = globalOptions(cmd);
50
+ const target = resolvePath(opts.cwd ?? process.cwd(), dir ?? '.');
51
+ const result = await initWorkspace(target, flags);
52
+ if (opts.json)
53
+ return json(result);
54
+ const run = flags.pm === 'bun' ? 'bun run' : 'pnpm';
55
+ ok(`SDODS workspace ready at ${target}`);
56
+ for (const f of result.files)
57
+ out(` ${f}`);
58
+ out('');
59
+ out(pc.bold('Next steps'));
60
+ if (dir)
61
+ out(` cd ${dir}`);
62
+ if (!flags.install)
63
+ out(` ${flags.pm} install && ${flags.pm} run sdods browsers install --with-deps`);
64
+ out(` ${run} sdods doctor`);
65
+ out(` ${run} sdods workspace tree`);
66
+ if (result.demo)
67
+ out(` ${run} sdods run -p demo-shop -e staging -l api`);
68
+ else
69
+ out(` ${run} sdods project create my-app --ui-url http://localhost:3000 --api-url http://localhost:3000/api`);
70
+ });
71
+ }
72
+ export async function initWorkspace(target, flags) {
73
+ if (!['sqlite', 'postgres'].includes(flags.db)) {
74
+ throw new SdodsError('CONFIG_INVALID', `--db must be sqlite or postgres, got "${flags.db}".`, {
75
+ exitCode: 2,
76
+ });
77
+ }
78
+ if (!['bun', 'pnpm'].includes(flags.pm)) {
79
+ throw new SdodsError('CONFIG_INVALID', `--pm must be bun or pnpm, got "${flags.pm}".`, {
80
+ exitCode: 2,
81
+ });
82
+ }
83
+ const org = OrganizationSchema.parse({
84
+ slug: SlugSchema.parse(flags.org),
85
+ name: flags.orgName ?? titleCase(flags.org),
86
+ });
87
+ const workspace = WorkspaceSchema.parse({
88
+ slug: SlugSchema.parse(flags.workspace),
89
+ name: flags.workspaceName ?? titleCase(flags.workspace),
90
+ organization: org.slug,
91
+ });
92
+ mkdirSync(target, { recursive: true });
93
+ const existing = readdirSync(target).filter((f) => f !== '.git' && f !== '.DS_Store');
94
+ if (existing.length && !flags.force) {
95
+ throw new SdodsError('CONFIG_INVALID', `Directory ${target} is not empty.`, {
96
+ hint: 'Pass --force to write into it anyway.',
97
+ exitCode: 2,
98
+ });
99
+ }
100
+ const src = templateRoot();
101
+ const files = [];
102
+ const write = (rel, content) => {
103
+ const file = join(target, rel);
104
+ mkdirSync(dirname(file), { recursive: true });
105
+ writeFileSync(file, content);
106
+ files.push(rel);
107
+ };
108
+ const name = basename(target)
109
+ .replace(/[^a-zA-Z0-9-_]/g, '-')
110
+ .toLowerCase() || 'sdods-tests';
111
+ // --link: bun's `link:` protocol expects a package registered with `bun link` (done below);
112
+ // pnpm accepts a path. Transitive workspace deps resolve from the symlink's real location.
113
+ const dep = (pkg) => flags.link
114
+ ? flags.pm === 'bun'
115
+ ? `link:${pkg}`
116
+ : `link:${join(sourceRepoRoot(), 'packages', pkg.replace('@sdods/', ''))}`
117
+ : `^${VERSION}`;
118
+ const run = flags.pm === 'bun' ? 'bun run' : 'pnpm';
119
+ write('package.json', JSON.stringify({
120
+ name,
121
+ private: true,
122
+ type: 'module',
123
+ version: '0.1.0',
124
+ description: `${name} — SDODS automation workspace`,
125
+ engines: { node: '>=22' },
126
+ scripts: {
127
+ sdods: 'sdods',
128
+ test: 'sdods run',
129
+ 'test:api': 'sdods run -l api',
130
+ 'test:ui': 'sdods run -l ui -b chromium -t @smoke',
131
+ lint: 'sdods lint',
132
+ serve: 'sdods serve',
133
+ doctor: 'sdods doctor',
134
+ },
135
+ dependencies: {
136
+ '@sdods/cli': dep('@sdods/cli'),
137
+ '@sdods/core': dep('@sdods/core'),
138
+ '@sdods/contracts': dep('@sdods/contracts'),
139
+ '@playwright/test': flags.link && flags.pm === 'bun' ? 'link:@playwright/test' : '^1.62.1',
140
+ 'playwright-bdd': flags.link && flags.pm === 'bun' ? 'link:playwright-bdd' : '^9.2.0',
141
+ },
142
+ devDependencies: {
143
+ '@types/node': '^22.15.0',
144
+ typescript: '~5.9.3',
145
+ tsx: '^4.23.0',
146
+ },
147
+ trustedDependencies: [
148
+ '@playwright/test',
149
+ 'playwright',
150
+ 'esbuild',
151
+ 'better-sqlite3',
152
+ 'argon2',
153
+ ],
154
+ }, null, 2) + '\n');
155
+ write(WORKSPACE_FILE, `# Hierarchy: organization → workspace → project (projects/<slug>) → module (features/<module>).
156
+ organization:
157
+ slug: ${org.slug}
158
+ name: ${org.name}
159
+ workspaces:
160
+ - slug: ${workspace.slug}
161
+ name: ${workspace.name}
162
+ organization: ${org.slug}
163
+ defaultWorkspace: ${workspace.slug}
164
+ defaults:
165
+ browsers: [chromium, firefox, webkit]
166
+ suites: [smoke, regression, sanity]
167
+ testIdAttribute: data-testid
168
+ processes:
169
+ - { name: pr-check, title: Pull request check, trigger: pr, tags: '@smoke', browsers: [chromium], harMode: replay, gates: { minPassRate: 100 } }
170
+ - { name: nightly-regression, title: Nightly regression, trigger: nightly, tags: '@regression', browsers: [chromium, firefox, webkit], schedule: '0 2 * * *' }
171
+ - { name: release-gate, title: Release gate, trigger: release, tags: '@smoke or @regression', failOnFlaky: true, gates: { minPassRate: 100, maxFlaky: 0 } }
172
+ `);
173
+ write('sdods.runner.config.ts', `/**
174
+ * SDODS runner config. Generated from the project registry:
175
+ * one run target per (project × layer × browser), driven by SDODS_* env vars set by \`sdods run\`.
176
+ */
177
+ import { defineConfig } from '@playwright/test';
178
+ import { ProjectRegistry, buildRunnerConfig, selectionFromEnv } from '@sdods/core/config';
179
+
180
+ const rootDir = process.env.SDODS_ROOT ?? ProjectRegistry.findRepoRoot(import.meta.dirname);
181
+ const registry = ProjectRegistry.discover(rootDir);
182
+
183
+ export default defineConfig(buildRunnerConfig(registry, selectionFromEnv()));
184
+ `);
185
+ write('tsconfig.json', JSON.stringify({
186
+ compilerOptions: {
187
+ target: 'ES2022',
188
+ module: 'NodeNext',
189
+ moduleResolution: 'NodeNext',
190
+ lib: ['ES2023', 'DOM', 'DOM.Iterable'],
191
+ strict: true,
192
+ skipLibCheck: true,
193
+ esModuleInterop: true,
194
+ resolveJsonModule: true,
195
+ noEmit: true,
196
+ types: ['node'],
197
+ },
198
+ include: ['sdods.runner.config.ts', 'projects/**/*.ts'],
199
+ exclude: ['node_modules', '.features-gen', '.sdods'],
200
+ }, null, 2) + '\n');
201
+ const envExample = existsSync(join(src, '.env.example'))
202
+ ? readFileSync(join(src, '.env.example'), 'utf8')
203
+ : '';
204
+ write('.env.example', (envExample || '# SDODS platform\nDB_DRIVER=sqlite\nSQLITE_PATH=.sdods/sdods.db\n')
205
+ .replace(/^DB_DRIVER=.*$/m, `DB_DRIVER=${flags.db}`)
206
+ .replace(/^# DATABASE_URL=.*$/m, flags.db === 'postgres'
207
+ ? 'DATABASE_URL=postgres://sdods:sdods@localhost:5432/sdods'
208
+ : '# DATABASE_URL=postgres://sdods:sdods@localhost:5432/sdods'));
209
+ write('.gitignore', `node_modules/
210
+ dist/
211
+ .sdods/
212
+ test-results/
213
+ html-report/
214
+ shard-reports/
215
+ *.log
216
+ .env
217
+ .env.*
218
+ !.env.example
219
+ projects/*/.auth/
220
+ projects/*/.env.*
221
+ !projects/*/.env.example
222
+ proposals/*/files/
223
+ .DS_Store
224
+ `);
225
+ const composeSrc = join(src, 'docker-compose.yml');
226
+ write('docker-compose.yml', existsSync(composeSrc)
227
+ ? readFileSync(composeSrc, 'utf8')
228
+ : `services:
229
+ postgres:
230
+ image: postgres:16
231
+ environment: { POSTGRES_USER: sdods, POSTGRES_PASSWORD: sdods, POSTGRES_DB: sdods }
232
+ ports: ['5432:5432']
233
+ volumes: [sdods-pgdata:/var/lib/postgresql/data]
234
+ volumes:
235
+ sdods-pgdata:
236
+ `);
237
+ const demoSrc = join(src, 'projects', 'demo-shop');
238
+ const withDemo = flags.demo && existsSync(demoSrc);
239
+ write('README.md', `# ${name}
240
+
241
+ SDODS automation workspace (organization **${org.name}**, workspace **${workspace.name}**).
242
+
243
+ \`\`\`bash
244
+ ${flags.pm} install && ${flags.pm} run sdods browsers install --with-deps
245
+ ${run} sdods doctor
246
+ ${run} sdods workspace tree
247
+ ${withDemo ? `${run} sdods run -p demo-shop -e staging -l api` : `${run} sdods project create my-app --ui-url http://localhost:3000 --api-url http://localhost:3000/api`}
248
+ \`\`\`
249
+
250
+ Docs: https://docs.sdods.com
251
+ `);
252
+ if (withDemo) {
253
+ cpSync(demoSrc, join(target, 'projects', 'demo-shop'), {
254
+ recursive: true,
255
+ // Match on the path *within* the demo project, never the absolute path: on a published
256
+ // install the template source itself lives under node_modules, and an absolute-path test
257
+ // would reject its own root and copy nothing.
258
+ filter: (p) => {
259
+ const rel = relative(demoSrc, p).replace(/\\/g, '/');
260
+ if (!rel)
261
+ return true;
262
+ return (!/(^|\/)(\.auth|node_modules|\.features-gen)(\/|$)/.test(rel) &&
263
+ !/(^|\/)\.env\.(?!example)[^/]*$/.test(rel));
264
+ },
265
+ });
266
+ // the demo declares the SDODS org/workspace; re-home it into the new workspace file
267
+ const demoYaml = join(target, 'projects', 'demo-shop', 'sdods.project.yaml');
268
+ if (existsSync(demoYaml)) {
269
+ const rehomed = readFileSync(demoYaml, 'utf8')
270
+ .replace(/^organization:.*$/m, `organization: ${org.slug}`)
271
+ .replace(/^workspace:.*$/m, `workspace: ${workspace.slug}`);
272
+ writeFileSync(demoYaml, rehomed);
273
+ }
274
+ files.push('projects/demo-shop/');
275
+ }
276
+ else {
277
+ write('projects/.gitkeep', '');
278
+ }
279
+ const skillsSrc = join(src, '.claude', 'skills');
280
+ if (existsSync(skillsSrc)) {
281
+ cpSync(skillsSrc, join(target, '.claude', 'skills'), { recursive: true });
282
+ files.push('.claude/skills/');
283
+ }
284
+ if (flags.from) {
285
+ try {
286
+ const analyze = (await import('@sdods/core/analyze'));
287
+ if (analyze.initFromApp) {
288
+ const created = await analyze.initFromApp({
289
+ appPath: resolvePath(flags.from),
290
+ rootDir: target,
291
+ });
292
+ files.push(`projects/${created.slug}/`);
293
+ }
294
+ else {
295
+ warn('`sdods analyze` is not available in this build; skipping --from.');
296
+ }
297
+ }
298
+ catch (e) {
299
+ warn(`--from skipped: ${e.message}`);
300
+ }
301
+ }
302
+ if (flags.git) {
303
+ await execa('git', ['init', '-q'], { cwd: target, reject: false });
304
+ }
305
+ let installed = false;
306
+ if (flags.install && flags.link && flags.pm === 'bun') {
307
+ // Register the local packages so `link:@sdods/*` resolves. Playwright and playwright-bdd are
308
+ // linked from the monorepo too: a second @playwright/test copy in the consumer would make
309
+ // Playwright throw "Requiring @playwright/test second time".
310
+ const repo = sourceRepoRoot();
311
+ const linkDirs = [
312
+ ...['contracts', 'core', 'cli'].map((pkg) => join(repo, 'packages', pkg)),
313
+ ...['@playwright/test', 'playwright-bdd'].map((pkg) => join(repo, 'node_modules', pkg)),
314
+ ];
315
+ for (const dir of linkDirs) {
316
+ if (!existsSync(dir))
317
+ continue;
318
+ await execa('bun', ['link'], { cwd: dir, reject: false, stdio: 'ignore' });
319
+ }
320
+ }
321
+ if (flags.install) {
322
+ const res = await execa(flags.pm, ['install'], {
323
+ cwd: target,
324
+ stdio: 'inherit',
325
+ reject: false,
326
+ });
327
+ installed = res.exitCode === 0;
328
+ if (!installed)
329
+ warn(`${flags.pm} install exited with ${res.exitCode}; run it manually.`);
330
+ }
331
+ let browsersInstalled = false;
332
+ if (flags.install && flags.browsers && installed) {
333
+ const res = await execa('npx', ['playwright', 'install', 'chromium'], {
334
+ cwd: target,
335
+ stdio: 'inherit',
336
+ reject: false,
337
+ });
338
+ browsersInstalled = res.exitCode === 0;
339
+ }
340
+ if (flags.claude) {
341
+ const res = await execa('npx', ['sdods', 'agent', 'install-claude'], {
342
+ cwd: target,
343
+ reject: false,
344
+ stdio: 'pipe',
345
+ });
346
+ if (res.exitCode === 0)
347
+ files.push('.claude/agents/', '.mcp.json');
348
+ else
349
+ warn('`sdods agent install-claude` is not available yet; run it later.');
350
+ }
351
+ if (flags.git) {
352
+ await execa('git', ['add', '-A'], { cwd: target, reject: false });
353
+ await execa('git', ['-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'Initialize SDODS workspace'], { cwd: target, reject: false });
354
+ }
355
+ return { dir: target, files, installed, browsers: browsersInstalled, demo: withDemo };
356
+ }
357
+ function titleCase(slug) {
358
+ return slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
359
+ }
360
+ export function isDir(p) {
361
+ return existsSync(p) && statSync(p).isDirectory();
362
+ }
363
+ //# sourceMappingURL=init.js.map
@@ -0,0 +1,7 @@
1
+ import type { Command } from 'commander';
2
+ /**
3
+ * `sdods insights compute|show` — flakiness, locator fragility, environment stability and
4
+ * suite health over the last N ingested runs (needs a database with ingested results).
5
+ */
6
+ export declare function register(program: Command): void;
7
+ //# sourceMappingURL=insights.d.ts.map
@@ -0,0 +1,127 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import pc from 'picocolors';
4
+ import { SdodsError } from '@sdods/core';
5
+ import { createContext } from '../context.js';
6
+ import { json, ok, out, table } from '../ui.js';
7
+ /**
8
+ * `sdods insights compute|show` — flakiness, locator fragility, environment stability and
9
+ * suite health over the last N ingested runs (needs a database with ingested results).
10
+ */
11
+ export function register(program) {
12
+ const insights = program
13
+ .command('insights')
14
+ .description('Flakiness, locator fragility, environment stability and suite health');
15
+ insights
16
+ .command('compute')
17
+ .description('Compute insights from ingested runs and store the result under .sdods/insights/')
18
+ .requiredOption('-p, --project <slug>', 'project slug')
19
+ .option('--window <n>', 'number of most recent runs to consider', '30')
20
+ .option('--flaky-threshold <r>', 'quarantine candidate when flakiness ≥ r', '0.2')
21
+ .option('--fragility-threshold <r>', 'hot locator when fragility ≥ r', '0.1')
22
+ .option('--min-runs <n>', 'minimum runs before a scenario can be flagged', '10')
23
+ .action(async (opts, cmd) => {
24
+ const ctx = createContext(cmd);
25
+ ctx.registry.entry(opts.project);
26
+ const m = await loadDb();
27
+ const adb = m.createDb();
28
+ try {
29
+ await m.migrateToLatest(adb);
30
+ const result = await m.computeInsights(adb.db, {
31
+ projectSlug: opts.project,
32
+ window: Number(opts.window),
33
+ flakyThreshold: Number(opts.flakyThreshold),
34
+ fragilityThreshold: Number(opts.fragilityThreshold),
35
+ minRuns: Number(opts.minRuns),
36
+ });
37
+ const file = resultFile(ctx.rootDir, opts.project);
38
+ mkdirSync(join(file, '..'), { recursive: true });
39
+ writeFileSync(file, JSON.stringify(result, null, 2));
40
+ if (ctx.opts.json)
41
+ return json(result);
42
+ render(result);
43
+ ok(`Saved ${file}`);
44
+ }
45
+ finally {
46
+ await adb.close();
47
+ }
48
+ });
49
+ insights
50
+ .command('show')
51
+ .description('Show the last computed insights for a project')
52
+ .requiredOption('-p, --project <slug>', 'project slug')
53
+ .option('--top <n>', 'rows per table', '10')
54
+ .action((opts, cmd) => {
55
+ const ctx = createContext(cmd);
56
+ const file = resultFile(ctx.rootDir, opts.project);
57
+ if (!existsSync(file)) {
58
+ throw new SdodsError('CONFIG_NOT_FOUND', `No insights computed yet for ${opts.project}.`, {
59
+ hint: `Run \`sdods insights compute -p ${opts.project}\` after ingesting some runs.`,
60
+ exitCode: 2,
61
+ });
62
+ }
63
+ const result = JSON.parse(readFileSync(file, 'utf8'));
64
+ if (ctx.opts.json)
65
+ return json(result);
66
+ render(result, Number(opts.top));
67
+ });
68
+ }
69
+ function resultFile(rootDir, slug) {
70
+ return join(rootDir, '.sdods', 'insights', `${slug}.json`);
71
+ }
72
+ async function loadDb() {
73
+ try {
74
+ return await import('@sdods/db');
75
+ }
76
+ catch (e) {
77
+ throw new SdodsError('DB_REQUIRED', 'Insights need the @sdods/db package and a configured database.', {
78
+ hint: 'Set DB_DRIVER=sqlite (default), run `sdods db migrate`, and ingest runs with `sdods run --ingest`.',
79
+ cause: e,
80
+ });
81
+ }
82
+ }
83
+ function pct(n) {
84
+ return `${Math.round(n * 100)}%`;
85
+ }
86
+ function render(r, top = 10) {
87
+ out(`${pc.bold(`Insights for ${r.projectSlug}`)} · window ${r.window} · runs considered ${r.runsConsidered} · pass rate ${pct(r.passRate)} · suite health ${pc.bold(pct(r.suiteHealth))}`);
88
+ if (Array.isArray(r.suiteHealthTrend) && r.suiteHealthTrend.length) {
89
+ out(pc.dim(`trend: ${r.suiteHealthTrend.map((v) => pct(v)).join(' → ')}`));
90
+ }
91
+ out(pc.bold('\nScenarios by flakiness'));
92
+ table([...r.scenarios]
93
+ .sort((a, b) => b.flakinessScore - a.flakinessScore)
94
+ .slice(0, top)
95
+ .map((s) => ({
96
+ scenario: s.scenarioName,
97
+ module: s.module ?? '',
98
+ browser: String(s.runnerProject).split('--').pop(),
99
+ runs: s.runs,
100
+ failed: s.failed,
101
+ flaky: s.flaky,
102
+ flakiness: pct(s.flakinessScore),
103
+ p95ms: s.p95DurationMs ?? '',
104
+ quarantine: s.quarantineCandidate ? pc.yellow('candidate') : '',
105
+ })));
106
+ out(pc.bold('\nLocators by fragility'));
107
+ table([...r.locators]
108
+ .sort((a, b) => b.fragility - a.fragility)
109
+ .slice(0, top)
110
+ .map((l) => ({
111
+ selector: l.selector,
112
+ page: l.pageHint ?? '',
113
+ fails: l.fails,
114
+ heals: l.heals,
115
+ fragility: pct(l.fragility),
116
+ hot: l.hot ? pc.red('hot') : '',
117
+ suggestion: l.suggestedSelector ?? '',
118
+ })));
119
+ out(pc.bold('\nEnvironments'));
120
+ table(r.envs.map((e) => ({
121
+ env: e.env,
122
+ runs: e.runs,
123
+ envFailures: e.envAttributedFailures,
124
+ stability: pct(e.stability),
125
+ })));
126
+ }
127
+ //# sourceMappingURL=insights.js.map
@@ -0,0 +1,14 @@
1
+ import type { Command } from 'commander';
2
+ /** `sdods integrations test|notify|sync|links` — GitHub and Jira without the server or a database. */
3
+ export declare function register(program: Command): void;
4
+ interface TaggedScenario {
5
+ tag: 'jira' | 'github';
6
+ key: string;
7
+ featureUri: string;
8
+ scenarioName: string;
9
+ layer: string;
10
+ }
11
+ /** Lightweight feature scan for issue tags (feature-level tags inherit to scenarios). */
12
+ export declare function scanFeatureTags(featuresDir: string, projectRoot: string): TaggedScenario[];
13
+ export {};
14
+ //# sourceMappingURL=integrations.d.ts.map