@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
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=bin.d.ts.map
package/dist/bin.js ADDED
@@ -0,0 +1,11 @@
1
+ import { buildProgram } from './program.js';
2
+ import { renderError } from './ui.js';
3
+ const program = buildProgram();
4
+ try {
5
+ await program.parseAsync(process.argv);
6
+ }
7
+ catch (e) {
8
+ const code = renderError(e, program.opts().json === true);
9
+ process.exitCode = code;
10
+ }
11
+ //# sourceMappingURL=bin.js.map
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ export declare function register(program: Command): void;
3
+ //# sourceMappingURL=agent.d.ts.map
@@ -0,0 +1,219 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import pc from 'picocolors';
3
+ import { SdodsError } from '@sdods/core';
4
+ import { createContext } from '../context.js';
5
+ import { collect, json, ok, out, table } from '../ui.js';
6
+ const ROLES = ['plan', 'generate', 'heal', 'upgrade', 'review'];
7
+ const ROLE_MAP = {
8
+ plan: 'planner',
9
+ generate: 'generator',
10
+ heal: 'healer',
11
+ upgrade: 'upgrader',
12
+ review: 'reviewer',
13
+ };
14
+ export function register(program) {
15
+ const agent = program
16
+ .command('agent')
17
+ .description('AI agents that plan, generate, heal, upgrade and review tests (they only write proposals)');
18
+ for (const verb of ROLES) {
19
+ agent
20
+ .command(verb)
21
+ .description(verb === 'plan'
22
+ ? 'Explore the app or its source and write a tagged test plan'
23
+ : verb === 'generate'
24
+ ? 'Turn a plan, goal or recorded spec into features, steps and page objects'
25
+ : verb === 'heal'
26
+ ? 'Diagnose a failing scenario and propose the smallest fix'
27
+ : verb === 'upgrade'
28
+ ? 'Map a code/API change to affected scenarios and propose updates'
29
+ : 'Review feature files for tagging, reuse and best practices')
30
+ .requiredOption('-p, --project <slug>', 'project slug')
31
+ .option('-e, --env <name>', 'environment')
32
+ .option('--goal <text>', 'what to do')
33
+ .option('--plan <file>', 'plan markdown (generate)')
34
+ .option('--spec <file>', 'recorded spec to convert (generate)')
35
+ .option('--scenario <fingerprint>', 'scenario fingerprint or name (heal)')
36
+ .option('--run-id <id>', 'run id (heal), default last', 'last')
37
+ .option('--diff <range>', 'git range (upgrade), e.g. main..HEAD')
38
+ .option('--files <list>', 'feature files (review)', collect, [])
39
+ .option('--adapter <name>', 'claude (API key) | claude-code (logged-in Claude Code CLI) | codex (logged-in Codex CLI) | openai | ollama (a model on this machine) | fake; default: auto-detect')
40
+ .option('--model <id>', 'model override')
41
+ .option('--profile <name>', 'auto | full | small — how many tools and how much prompt the model is given')
42
+ .option('--browser', 'attach the browser MCP server even under the small profile (which omits it)')
43
+ .option('--no-browser', 'never attach the browser MCP server')
44
+ .option('--base-url <url>', 'model server endpoint (ollama, vLLM, LM Studio, Azure)')
45
+ .option('--context-tokens <n>', 'context window to load a local model with (num_ctx)')
46
+ .option('--max-turns <n>', 'turn budget')
47
+ .option('--budget-usd <n>', 'cost budget in USD')
48
+ .option('--dry-run', 'use the fake adapter and print the prompt, tools and budget without calling an LLM')
49
+ .option('--events', 'print agent events as they arrive')
50
+ .action(async (opts, cmd) => {
51
+ const ctx = createContext(cmd);
52
+ ctx.registry.entry(opts.project);
53
+ const { prepareJob, runJob } = await import('@sdods/agents');
54
+ const role = ROLE_MAP[verb];
55
+ const input = {
56
+ project: opts.project,
57
+ env: opts.env,
58
+ goal: opts.goal,
59
+ plan: opts.plan ? readFileSync(opts.plan, 'utf8') : undefined,
60
+ spec: opts.spec ? readFileSync(opts.spec, 'utf8') : undefined,
61
+ scenario: opts.scenario,
62
+ runId: opts.runId,
63
+ diff: opts.diff,
64
+ files: opts.files,
65
+ };
66
+ const common = {
67
+ role,
68
+ input,
69
+ rootDir: ctx.rootDir,
70
+ provider: opts.dryRun ? 'fake' : opts.adapter,
71
+ model: opts.model,
72
+ profile: opts.profile,
73
+ browser: opts.browser,
74
+ baseUrl: opts.baseUrl,
75
+ contextTokens: opts.contextTokens ? Number(opts.contextTokens) : undefined,
76
+ maxTurns: opts.maxTurns ? Number(opts.maxTurns) : undefined,
77
+ budgetUsd: opts.budgetUsd ? Number(opts.budgetUsd) : undefined,
78
+ dryRun: Boolean(opts.dryRun),
79
+ };
80
+ if (opts.dryRun) {
81
+ const prep = await prepareJob(common);
82
+ const view = {
83
+ job: {
84
+ id: prep.job.id,
85
+ role: prep.job.role,
86
+ provider: prep.job.provider,
87
+ model: prep.job.model,
88
+ budgetUsd: prep.job.budgetUsd,
89
+ maxTurns: prep.job.maxTurns,
90
+ },
91
+ profile: { name: prep.profile.profile, reason: prep.profile.reason },
92
+ tools: prep.tools.map((t) => t.name),
93
+ mcpServers: Object.keys(prep.mcpServers),
94
+ system: prep.system,
95
+ prompt: prep.prompt,
96
+ };
97
+ if (ctx.opts.json)
98
+ return json(view);
99
+ out(pc.bold(`Dry run: sdods agent ${verb} (${role})`));
100
+ out(`${pc.dim('adapter:')} fake ${pc.dim('budget:')} $${view.job.budgetUsd} ${pc.dim('max turns:')} ${view.job.maxTurns} ${pc.dim('profile:')} ${view.profile.name} (${view.profile.reason})`);
101
+ out(`${pc.dim('tools:')} ${view.tools.join(', ')}`);
102
+ if (view.mcpServers.length)
103
+ out(`${pc.dim('external MCP servers:')} ${view.mcpServers.join(', ')}`);
104
+ out(pc.dim('\n--- system prompt ---'));
105
+ out(view.system);
106
+ out(pc.dim('\n--- user prompt ---'));
107
+ out(view.prompt);
108
+ out(pc.dim('\nNo files were written. Drop --dry-run (and set ANTHROPIC_API_KEY or OPENAI_API_KEY) to run for real.'));
109
+ return;
110
+ }
111
+ const job = await runJob({
112
+ ...common,
113
+ onEvent: opts.events || !ctx.opts.json
114
+ ? (e) => {
115
+ if (ctx.opts.json)
116
+ return;
117
+ if (e.type === 'text')
118
+ process.stdout.write(e.text);
119
+ else if (e.type === 'tool_call')
120
+ out(pc.cyan(`\n▶ ${e.name} ${JSON.stringify(e.input).slice(0, 160)}`));
121
+ else if (e.type === 'tool_result')
122
+ out(pc.dim(` ↳ ${e.isError ? 'error' : 'ok'}`));
123
+ else if (e.type === 'status')
124
+ out(pc.dim(` · ${e.message}`));
125
+ }
126
+ : undefined,
127
+ });
128
+ if (ctx.opts.json)
129
+ return json(job);
130
+ out('');
131
+ const line = `${job.status} · turns ${job.turns ?? 0} · tool calls ${job.toolCalls ?? 0} · cost $${(job.costUsd ?? 0).toFixed(3)} · model ${job.model}`;
132
+ if (job.status === 'failed')
133
+ throw new SdodsError('RUN_FAILED', `Agent job ${job.id} failed: ${job.error ?? 'unknown'}`, { details: { job: job.id } });
134
+ ok(`Job ${job.id}: ${line}`);
135
+ if (job.proposalIds.length) {
136
+ out(`Proposals: ${job.proposalIds.join(', ')}`);
137
+ out(pc.dim(`Review with: sdods proposals show ${job.proposalIds[0]}`));
138
+ }
139
+ });
140
+ }
141
+ agent
142
+ .command('jobs')
143
+ .description('List recent agent jobs')
144
+ .option('--limit <n>', 'max rows', '20')
145
+ .action(async (opts, cmd) => {
146
+ const ctx = createContext(cmd);
147
+ const { JobJournal } = await import('@sdods/agents');
148
+ const rows = new JobJournal(ctx.rootDir).list(Number(opts.limit)).map((j) => ({
149
+ id: j.id,
150
+ role: j.role,
151
+ project: j.project ?? '',
152
+ status: j.status,
153
+ cost: `$${(j.costUsd ?? 0).toFixed(3)}`,
154
+ turns: j.turns ?? 0,
155
+ proposals: j.proposalIds.length,
156
+ started: j.startedAt,
157
+ }));
158
+ if (ctx.opts.json)
159
+ return json(rows);
160
+ table(rows);
161
+ });
162
+ const installAction = async (opts, cmd, forced) => {
163
+ const ctx = createContext(cmd);
164
+ const target = (forced ?? opts.for ?? 'all');
165
+ if (!['claude', 'codex', 'all'].includes(target)) {
166
+ throw new SdodsError('NOT_SUPPORTED', `Unknown target "${target}".`, {
167
+ hint: 'Use --for claude, --for codex or --for all.',
168
+ exitCode: 2,
169
+ });
170
+ }
171
+ const { installCodingAgents } = await import('@sdods/agents');
172
+ const written = installCodingAgents(ctx.rootDir, {
173
+ for: target,
174
+ project: opts.project,
175
+ env: opts.env,
176
+ force: opts.force,
177
+ });
178
+ if (opts.mcp !== false) {
179
+ const { execa } = await import('execa');
180
+ const clients = target === 'all' ? ['claude', 'codex'] : [target];
181
+ for (const client of clients) {
182
+ const args = ['mcp', 'install', client];
183
+ if (opts.project)
184
+ args.push('-p', opts.project);
185
+ if (opts.env)
186
+ args.push('-e', opts.env);
187
+ const r = await execa(process.execPath, [...process.execArgv, process.argv[1], ...args], {
188
+ cwd: ctx.rootDir,
189
+ reject: false,
190
+ });
191
+ written.push(`mcp:${client} → ${r.exitCode === 0 ? 'registered' : `failed (${(r.stderr || r.stdout).trim().split('\n').pop()})`}`);
192
+ }
193
+ }
194
+ if (ctx.opts.json)
195
+ return json({ target, written });
196
+ for (const w of written)
197
+ ok(w.replace(ctx.rootDir + '/', ''));
198
+ if (!written.length)
199
+ out(pc.dim('Nothing written (files exist; use --force).'));
200
+ };
201
+ agent
202
+ .command('install')
203
+ .description('Set up coding-agent CLIs: --for claude writes .claude/agents/sdods-*.md + CLAUDE.md, --for codex writes AGENTS.md; both get AGENT.md/SKILL.md and the MCP registration')
204
+ .option('--for <client>', 'claude | codex | all', 'all')
205
+ .option('-p, --project <slug>')
206
+ .option('-e, --env <name>')
207
+ .option('--force', 'overwrite existing files')
208
+ .option('--no-mcp', 'skip `sdods mcp install <client>`')
209
+ .action((opts, cmd) => installAction(opts, cmd));
210
+ agent
211
+ .command('install-claude')
212
+ .description('Alias of `agent install --for claude`')
213
+ .option('-p, --project <slug>')
214
+ .option('-e, --env <name>')
215
+ .option('--force', 'overwrite existing files')
216
+ .option('--no-mcp', 'skip `sdods mcp install claude`')
217
+ .action((opts, cmd) => installAction(opts, cmd, 'claude'));
218
+ }
219
+ //# sourceMappingURL=agent.js.map
@@ -0,0 +1,20 @@
1
+ import type { Command } from 'commander';
2
+ import type { AnalysisReport, ProjectProposal } from '@sdods/contracts';
3
+ export interface InitFromAppOptions {
4
+ appPath: string;
5
+ rootDir: string;
6
+ slug?: string;
7
+ name?: string;
8
+ openapi?: string;
9
+ force?: boolean;
10
+ importSpecs?: boolean;
11
+ browsers?: string[];
12
+ }
13
+ /** Programmatic entry used by `sdods init --from <app>`: analyze → propose → apply. */
14
+ export declare function initFromApp(opts: InitFromAppOptions): Promise<{
15
+ report: AnalysisReport;
16
+ proposal: ProjectProposal;
17
+ result: import("@sdods/core/analyze").ApplyResult;
18
+ }>;
19
+ export declare function register(program: Command): void;
20
+ //# sourceMappingURL=analyze.d.ts.map
@@ -0,0 +1,216 @@
1
+ import { resolve as resolvePath } from 'node:path';
2
+ import pc from 'picocolors';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { ProjectConfigSchema } from '@sdods/contracts';
5
+ import { createContext } from '../context.js';
6
+ import { heading, json, ok, out, table, warn } from '../ui.js';
7
+ import { projectTemplateFiles } from '../templates/project.js';
8
+ /** Programmatic entry used by `sdods init --from <app>`: analyze → propose → apply. */
9
+ export async function initFromApp(opts) {
10
+ const { analyzeProject, proposeProject, applyProposal } = await import('@sdods/core/analyze');
11
+ const report = analyzeProject(opts.appPath, { openapi: opts.openapi });
12
+ const proposal = proposeProject(report, {
13
+ slug: opts.slug,
14
+ name: opts.name,
15
+ browsers: opts.browsers,
16
+ });
17
+ const result = applyProposal(proposal, {
18
+ rootDir: opts.rootDir,
19
+ scaffold: scaffoldFor(proposal),
20
+ force: opts.force,
21
+ appPath: opts.appPath,
22
+ importSpecs: opts.importSpecs ?? true,
23
+ });
24
+ return { report, proposal, result };
25
+ }
26
+ /** Code/data scaffolding from the project template; the proposal owns yaml, envs and features. */
27
+ function scaffoldFor(proposal) {
28
+ const config = ProjectConfigSchema.parse(parseYaml(proposal.projectYaml));
29
+ const files = projectTemplateFiles({
30
+ config,
31
+ envName: config.envs.default,
32
+ uiUrl: '',
33
+ apiUrl: '',
34
+ });
35
+ for (const key of Object.keys(files)) {
36
+ if (key.startsWith('envs/') || key.startsWith('features/') || key === '.env.example') {
37
+ delete files[key];
38
+ }
39
+ }
40
+ return files;
41
+ }
42
+ export function register(program) {
43
+ program
44
+ .command('analyze [path]')
45
+ .description('Analyze an application repository and propose an SDODS project (read-only unless --apply)')
46
+ .option('-p, --project <slug>', 'slug for the proposed project (default: from the app directory)')
47
+ .option('--slug <slug>', 'alias of --project')
48
+ .option('--name <name>', 'display name')
49
+ .option('--openapi <file>', 'OpenAPI spec path relative to the app (auto-detected otherwise)')
50
+ .option('--browsers <list>', 'comma list of browsers for the proposal', (v) => v.split(',').map((s) => s.trim()))
51
+ .option('--apply', 'write the proposed project under projects/<slug>')
52
+ .option('--force', 'overwrite an existing project when applying')
53
+ .option('--no-import-specs', 'do not copy existing Playwright specs into recorded/imported')
54
+ .option('--report-only', 'print the analysis, skip the proposal')
55
+ .action(async (path, opts, cmd) => {
56
+ const ctx = createContext(cmd);
57
+ // the app path is relative to where the command was typed, not to --cwd (the SDODS repo)
58
+ const appPath = resolvePath(process.cwd(), path ?? '.');
59
+ const { analyzeProject, proposeProject, applyProposal } = await import('@sdods/core/analyze');
60
+ const report = analyzeProject(appPath, { openapi: opts.openapi });
61
+ const slug = opts.project ?? opts.slug;
62
+ const proposal = opts.reportOnly
63
+ ? undefined
64
+ : proposeProject(report, { slug, name: opts.name, browsers: opts.browsers });
65
+ let applied;
66
+ if (opts.apply && proposal) {
67
+ applied = applyProposal(proposal, {
68
+ rootDir: ctx.rootDir,
69
+ scaffold: scaffoldFor(proposal),
70
+ force: opts.force,
71
+ appPath,
72
+ importSpecs: opts.importSpecs !== false,
73
+ });
74
+ }
75
+ if (ctx.opts.json) {
76
+ return json({
77
+ report,
78
+ proposal: proposal ? { ...proposal, files: undefined } : undefined,
79
+ applied,
80
+ });
81
+ }
82
+ printReport(report);
83
+ if (proposal)
84
+ printProposal(proposal);
85
+ if (applied && proposal) {
86
+ heading('Applied');
87
+ ok(`Project ${proposal.slug} written to ${applied.root}`);
88
+ for (const f of applied.written)
89
+ out(` ${f}`);
90
+ if (applied.skipped.length)
91
+ warn(`Skipped existing files: ${applied.skipped.join(', ')}`);
92
+ if (applied.importedSpecs.length) {
93
+ out(pc.dim(` imported ${applied.importedSpecs.length} Playwright spec(s) into recorded/imported/`));
94
+ }
95
+ const envName = Object.keys(proposal.envYamls)[0] ?? 'local';
96
+ out(`\nNext:\n sdods lint -p ${proposal.slug}\n sdods run -p ${proposal.slug} -e ${envName} -l api\n sdods run -p ${proposal.slug} -e ${envName} -l ui -b chromium -t @smoke`);
97
+ }
98
+ else if (proposal) {
99
+ out(pc.dim('\nRun again with --apply to write the project.'));
100
+ }
101
+ });
102
+ }
103
+ function printReport(r) {
104
+ heading(`Analysis of ${r.appPath}`);
105
+ out(pc.dim(`${r.filesScanned} files scanned · ${r.analyzedAt}`));
106
+ const mono = r.packageManager.monorepo
107
+ ? ` (monorepo: ${r.packageManager.workspaces.join(', ') || 'yes'})`
108
+ : '';
109
+ out(`${pc.bold('Package manager')} ${r.packageManager.name}${mono}`);
110
+ out(`${pc.bold('Frameworks')} ${r.frameworks
111
+ .map((f) => `${f.name}${f.version ? ` ${f.version}` : ''} (${Math.round(f.confidence * 100)}%)`)
112
+ .join(', ') || pc.dim('none detected')}`);
113
+ out(`${pc.bold('Test-id attribute')} ${r.testIds.attribute ?? pc.dim('none')}${r.testIds.attribute ? ` (${r.testIds.counts[r.testIds.attribute]} uses)` : ''}`);
114
+ out(`${pc.bold('Auth')} ${r.auth.strategyGuess}${r.auth.libraries.length ? ` via ${r.auth.libraries.join(', ')}` : ''}${r.auth.pages.length ? `; pages: ${r.auth.pages.join(', ')}` : ''}`);
115
+ out(`${pc.bold('Base URLs')} ui ${r.baseUrls.ui ?? '?'} · api ${r.baseUrls.api ?? '?'}`);
116
+ out(`${pc.bold('CI')} ${r.ci.provider}${r.ci.files.length ? ` (${r.ci.files.length} file(s))` : ''}`);
117
+ if (r.i18n.libraries.length || r.i18n.locales.length) {
118
+ out(`${pc.bold('i18n')} ${r.i18n.libraries.join(', ')}${r.i18n.locales.length ? ` locales ${r.i18n.locales.join(', ')}` : ''}`);
119
+ }
120
+ if (r.a11y.tooling.length)
121
+ out(`${pc.bold('a11y tooling')} ${r.a11y.tooling.join(', ')}`);
122
+ const pages = r.routes.filter((x) => x.kind === 'page');
123
+ const apis = r.routes.filter((x) => x.kind === 'api');
124
+ heading(`Routes (${pages.length} pages, ${apis.length} API)`);
125
+ table(r.routes.slice(0, 40).map((x) => ({
126
+ kind: x.kind,
127
+ method: x.method ?? '',
128
+ path: x.path,
129
+ source: x.source,
130
+ file: `${x.file}${x.line ? `:${x.line}` : ''}`,
131
+ })), ['kind', 'method', 'path', 'source', 'file']);
132
+ if (r.routes.length > 40)
133
+ out(pc.dim(` … ${r.routes.length - 40} more`));
134
+ if (r.openapi.length) {
135
+ heading('OpenAPI');
136
+ for (const o of r.openapi) {
137
+ out(` ${o.file} — ${o.title ?? ''} ${o.version ?? ''} · ${o.endpoints.length} operations`);
138
+ }
139
+ }
140
+ if (r.existingTests.length) {
141
+ heading('Existing tests');
142
+ table(r.existingTests.map((t) => ({
143
+ framework: t.framework,
144
+ files: t.files,
145
+ css: t.locators.css,
146
+ xpath: t.locators.xpath,
147
+ role: t.locators.role,
148
+ testId: t.locators.testId,
149
+ text: t.locators.text,
150
+ })));
151
+ }
152
+ if (r.envs.length) {
153
+ heading('Environment files');
154
+ table(r.envs.map((e) => ({
155
+ env: e.name,
156
+ file: e.file,
157
+ ui: e.uiBaseUrl ?? '',
158
+ api: e.apiBaseUrl ?? '',
159
+ vars: e.vars.length,
160
+ })));
161
+ }
162
+ heading('Checklist');
163
+ for (const c of r.checklist) {
164
+ const icon = c.severity === 'error'
165
+ ? pc.red('✖')
166
+ : c.severity === 'warning'
167
+ ? pc.yellow('⚠')
168
+ : pc.cyan('ℹ');
169
+ out(`${icon} ${pc.bold(c.title)} — ${c.detail}${c.fix ? pc.dim(`\n → ${c.fix}`) : ''}`);
170
+ }
171
+ }
172
+ /**
173
+ * Why each module exists. Modules are inferred by fusing several signals - the file that defines
174
+ * a route, the monorepo workspace, a domain directory, an OpenAPI tag - so the winning signal and
175
+ * its confidence are the part a reviewer actually needs to judge the proposal.
176
+ */
177
+ function printModules(p) {
178
+ const mods = p.detectedModules ?? [];
179
+ if (!mods.length)
180
+ return;
181
+ out(pc.dim('--- modules ---'));
182
+ const w = Math.max(6, ...mods.map((m) => m.name.length));
183
+ for (const m of mods) {
184
+ const targets = [
185
+ m.routes ? `${m.routes} route${m.routes === 1 ? '' : 's'}` : '',
186
+ m.endpoints ? `${m.endpoints} endpoint${m.endpoints === 1 ? '' : 's'}` : '',
187
+ ]
188
+ .filter(Boolean)
189
+ .join(', ');
190
+ const from = m.evidence[0]?.file;
191
+ out(` ${m.name.padEnd(w)} ${pc.dim(m.confidence.toFixed(2))} ${targets.padEnd(22)}` +
192
+ `${pc.dim(m.signals.join('+'))}${from ? pc.dim(` ← ${from}`) : ''}`);
193
+ }
194
+ const ignored = p.ignoredTargets ?? [];
195
+ if (ignored.length) {
196
+ out(pc.dim(`--- skipped (${ignored.length}) ---`));
197
+ for (const i of ignored)
198
+ out(pc.dim(` ${i.target.padEnd(20)} ${i.reason}`));
199
+ }
200
+ }
201
+ function printProposal(p) {
202
+ heading(`Proposed project: ${p.slug}`);
203
+ out(pc.dim(`envs: ${Object.keys(p.envYamls).join(', ')} · starter features: ${Object.keys(p.starterFeatures).length} · coverage targets: ${p.coverageMap.length}`));
204
+ for (const n of p.notes)
205
+ warn(n);
206
+ printModules(p);
207
+ out(pc.dim('--- sdods.project.yaml ---'));
208
+ const lines = p.projectYaml.split('\n');
209
+ out(lines.slice(0, 60).join('\n'));
210
+ if (lines.length > 60)
211
+ out(pc.dim(' …'));
212
+ out(pc.dim('--- starter features ---'));
213
+ for (const f of Object.keys(p.starterFeatures))
214
+ out(` ${f}`);
215
+ }
216
+ //# sourceMappingURL=analyze.js.map
@@ -0,0 +1,7 @@
1
+ import type { Command } from 'commander';
2
+ /**
3
+ * Login-state commands. Capture/list are thin wrappers over `@sdods/core/auth/capture`
4
+ * (the same library `sdods record --user` uses) so there is a single implementation.
5
+ */
6
+ export declare function register(program: Command): void;
7
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1,97 @@
1
+ import { execa } from 'execa';
2
+ import { SdodsError } from '@sdods/core';
3
+ import { createContext } from '../context.js';
4
+ import { json, ok, table } from '../ui.js';
5
+ /**
6
+ * Login-state commands. Capture/list are thin wrappers over `@sdods/core/auth/capture`
7
+ * (the same library `sdods record --user` uses) so there is a single implementation.
8
+ */
9
+ export function register(program) {
10
+ const auth = program
11
+ .command('auth')
12
+ .description('Capture and inspect login state for pool users');
13
+ auth
14
+ .command('capture')
15
+ .description('Log in as pool user(s) and store storageState under projects/<slug>/.auth/<env>/')
16
+ .requiredOption('-p, --project <slug>', 'project slug')
17
+ .option('-e, --env <name>', 'environment')
18
+ .option('-u, --user <role>', 'pool role to capture (default: every role)')
19
+ .option('--index <n>', 'pool index within the role', '0')
20
+ .option('--all', 'every user of the role, not only --index')
21
+ .option('--interactive', 'open a headed browser (SSO/MFA): finish the login by hand, then close the window')
22
+ .option('-b, --browser <name>', 'chromium | firefox | webkit', 'chromium')
23
+ .option('--headed', 'run the login with a visible browser')
24
+ .option('--force', 'recapture even when a fresh state exists')
25
+ .action(async (opts, cmd) => {
26
+ const ctx = createContext(cmd);
27
+ const cfg = ctx.registry.resolve(opts.project, opts.env);
28
+ const { captureAuth, poolUsers } = await import('@sdods/core/auth/capture');
29
+ const roles = opts.user
30
+ ? [opts.user]
31
+ : [...new Set((await poolUsers(cfg)).map((u) => u.role))];
32
+ const results = [];
33
+ for (const role of roles) {
34
+ const captured = await captureAuth({
35
+ config: cfg,
36
+ role,
37
+ index: Number(opts.index),
38
+ all: Boolean(opts.all),
39
+ interactive: Boolean(opts.interactive),
40
+ browserName: opts.browser,
41
+ headed: Boolean(opts.headed || opts.interactive),
42
+ force: Boolean(opts.force),
43
+ });
44
+ for (const r of captured) {
45
+ results.push({
46
+ user: r.user,
47
+ role: r.role,
48
+ index: r.index,
49
+ strategy: r.strategy,
50
+ file: r.file,
51
+ tokenFile: r.tokenFile,
52
+ status: r.skipped ? `skipped: ${r.skipped}` : r.file ? 'captured' : 'no state',
53
+ });
54
+ }
55
+ }
56
+ if (ctx.opts.json)
57
+ return json(results);
58
+ table(results, ['user', 'role', 'index', 'strategy', 'status', 'file']);
59
+ ok(`states under ${cfg.project.root}/.auth/${cfg.env.name}`);
60
+ });
61
+ auth
62
+ .command('list')
63
+ .description('Show cached login states and their age')
64
+ .requiredOption('-p, --project <slug>', 'project slug')
65
+ .option('-e, --env <name>', 'environment')
66
+ .action(async (opts, cmd) => {
67
+ const ctx = createContext(cmd);
68
+ const cfg = ctx.registry.resolve(opts.project, opts.env);
69
+ const { listAuthStates } = await import('@sdods/core/auth/capture');
70
+ const rows = listAuthStates(cfg).map((s) => ({
71
+ user: s.user,
72
+ role: s.role,
73
+ ageMinutes: s.ageMinutes,
74
+ fresh: s.fresh ? 'yes' : 'no',
75
+ file: s.file,
76
+ tokenFile: s.tokenFile ?? '',
77
+ }));
78
+ if (ctx.opts.json)
79
+ return json(rows);
80
+ table(rows, ['user', 'role', 'ageMinutes', 'fresh', 'file']);
81
+ });
82
+ auth
83
+ .command('token')
84
+ .description('Manage API tokens (server feature; delegates to `sdods tokens`)')
85
+ .allowUnknownOption()
86
+ .action(async () => {
87
+ const r = await execa('sdods', ['tokens', ...process.argv.slice(4)], {
88
+ stdio: 'inherit',
89
+ reject: false,
90
+ });
91
+ if (r.exitCode)
92
+ throw new SdodsError('NOT_SUPPORTED', '`sdods tokens` is provided by the server package.', {
93
+ exitCode: 2,
94
+ });
95
+ });
96
+ }
97
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1,17 @@
1
+ import type { Command } from 'commander';
2
+ export interface BrowserStatus {
3
+ name: string;
4
+ engine: 'chromium' | 'firefox' | 'webkit';
5
+ installed: boolean;
6
+ executable: string | null;
7
+ playwrightVersion: string;
8
+ }
9
+ export declare function browserStatuses(names?: string[]): Promise<BrowserStatus[]>;
10
+ /** Install browser engines (used by `browsers install`, `doctor --fix`, `init`). */
11
+ export declare function installBrowsers(opts?: {
12
+ browsers?: string[];
13
+ withDeps?: boolean;
14
+ cwd?: string;
15
+ }): Promise<void>;
16
+ export declare function register(program: Command): void;
17
+ //# sourceMappingURL=browsers.d.ts.map