@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,103 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { BrowserSchema } from '@sdods/contracts';
|
|
6
|
+
import { SdodsError } from '@sdods/core';
|
|
7
|
+
import { createContext } from '../context.js';
|
|
8
|
+
import { collect, json, ok, table } from '../ui.js';
|
|
9
|
+
const ENGINE_OF = {
|
|
10
|
+
chromium: 'chromium',
|
|
11
|
+
firefox: 'firefox',
|
|
12
|
+
webkit: 'webkit',
|
|
13
|
+
'mobile-chrome': 'chromium',
|
|
14
|
+
'mobile-safari': 'webkit',
|
|
15
|
+
};
|
|
16
|
+
export async function browserStatuses(names = Object.keys(ENGINE_OF)) {
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
let pw;
|
|
19
|
+
let version = 'unknown';
|
|
20
|
+
try {
|
|
21
|
+
pw = await import('playwright-core');
|
|
22
|
+
version = require('playwright-core/package.json').version;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return names.map((name) => ({
|
|
26
|
+
name,
|
|
27
|
+
engine: ENGINE_OF[name] ?? 'chromium',
|
|
28
|
+
installed: false,
|
|
29
|
+
executable: null,
|
|
30
|
+
playwrightVersion: version,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
return names.map((name) => {
|
|
34
|
+
const engine = ENGINE_OF[name] ?? 'chromium';
|
|
35
|
+
let executable = null;
|
|
36
|
+
try {
|
|
37
|
+
executable = pw[engine].executablePath();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
executable = null;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
name,
|
|
44
|
+
engine,
|
|
45
|
+
installed: Boolean(executable && existsSync(executable)),
|
|
46
|
+
executable,
|
|
47
|
+
playwrightVersion: version,
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** Install browser engines (used by `browsers install`, `doctor --fix`, `init`). */
|
|
52
|
+
export async function installBrowsers(opts = {}) {
|
|
53
|
+
const engines = [
|
|
54
|
+
...new Set((opts.browsers?.length ? opts.browsers : Object.keys(ENGINE_OF)).map((b) => ENGINE_OF[b] ?? b)),
|
|
55
|
+
];
|
|
56
|
+
const args = ['playwright', 'install', ...(opts.withDeps ? ['--with-deps'] : []), ...engines];
|
|
57
|
+
await execa('npx', args, { stdio: 'inherit', cwd: opts.cwd });
|
|
58
|
+
}
|
|
59
|
+
export function register(program) {
|
|
60
|
+
const browsers = program.command('browsers').description('Install and list browser engines');
|
|
61
|
+
browsers
|
|
62
|
+
.command('install')
|
|
63
|
+
.description('Install browser engines (default: chromium, firefox, webkit)')
|
|
64
|
+
.option('-b, --browser <name>', 'browser to install (repeatable)', collect, [])
|
|
65
|
+
.option('--with-deps', 'also install OS dependencies (Linux CI)')
|
|
66
|
+
.option('-p, --project <slug>', 'install the browsers declared by a project')
|
|
67
|
+
.action(async (opts, cmd) => {
|
|
68
|
+
const ctx = createContext(cmd);
|
|
69
|
+
let list = opts.browser;
|
|
70
|
+
if (opts.project)
|
|
71
|
+
list = ctx.registry.get(opts.project).browsers;
|
|
72
|
+
for (const b of list)
|
|
73
|
+
BrowserSchema.parse(b);
|
|
74
|
+
await installBrowsers({ browsers: list, withDeps: opts.withDeps, cwd: ctx.rootDir });
|
|
75
|
+
ok(`Installed ${list.length ? list.join(', ') : 'chromium, firefox, webkit'}`);
|
|
76
|
+
});
|
|
77
|
+
browsers
|
|
78
|
+
.command('list')
|
|
79
|
+
.description('Show which browsers are installed and where')
|
|
80
|
+
.option('-p, --project <slug>', 'limit to the browsers declared by a project')
|
|
81
|
+
.action(async (opts, cmd) => {
|
|
82
|
+
const ctx = createContext(cmd);
|
|
83
|
+
const names = opts.project ? ctx.registry.get(opts.project).browsers : Object.keys(ENGINE_OF);
|
|
84
|
+
const statuses = await browserStatuses(names);
|
|
85
|
+
if (ctx.opts.json)
|
|
86
|
+
return json(statuses);
|
|
87
|
+
table(statuses.map((s) => ({
|
|
88
|
+
browser: s.name,
|
|
89
|
+
engine: s.engine,
|
|
90
|
+
installed: s.installed ? pc.green('yes') : pc.red('no'),
|
|
91
|
+
playwright: s.playwrightVersion,
|
|
92
|
+
executable: s.executable ?? '',
|
|
93
|
+
})));
|
|
94
|
+
if (statuses.some((s) => !s.installed)) {
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
throw new SdodsError('NOT_SUPPORTED', 'Some browsers are not installed.', {
|
|
97
|
+
hint: 'Run `sdods browsers install --with-deps`.',
|
|
98
|
+
exitCode: 1,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=browsers.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
type Shell = 'bash' | 'zsh' | 'fish';
|
|
3
|
+
interface Node {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
options: Array<{
|
|
7
|
+
flags: string[];
|
|
8
|
+
description: string;
|
|
9
|
+
}>;
|
|
10
|
+
children: Node[];
|
|
11
|
+
}
|
|
12
|
+
/** Walk commander metadata into a plain tree (no actions executed). */
|
|
13
|
+
export declare function commandTree(cmd: Command): Node;
|
|
14
|
+
export declare function renderCompletion(shell: Shell, root: Node): string;
|
|
15
|
+
export declare function register(program: Command): void;
|
|
16
|
+
export {};
|
|
17
|
+
//# sourceMappingURL=completion.d.ts.map
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { SdodsError } from '@sdods/core';
|
|
2
|
+
/** Walk commander metadata into a plain tree (no actions executed). */
|
|
3
|
+
export function commandTree(cmd) {
|
|
4
|
+
return {
|
|
5
|
+
name: cmd.name(),
|
|
6
|
+
description: cmd.description(),
|
|
7
|
+
options: cmd.options.map((o) => ({
|
|
8
|
+
flags: [o.short, o.long].filter((f) => Boolean(f)),
|
|
9
|
+
description: o.description,
|
|
10
|
+
})),
|
|
11
|
+
children: cmd.commands
|
|
12
|
+
.filter((c) => !c.name().startsWith('_'))
|
|
13
|
+
.map((c) => commandTree(c))
|
|
14
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function renderCompletion(shell, root) {
|
|
18
|
+
switch (shell) {
|
|
19
|
+
case 'bash':
|
|
20
|
+
return bash(root);
|
|
21
|
+
case 'zsh':
|
|
22
|
+
return zsh(root);
|
|
23
|
+
case 'fish':
|
|
24
|
+
return fish(root);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function q(s) {
|
|
28
|
+
return s.replace(/'/g, "'\\''");
|
|
29
|
+
}
|
|
30
|
+
function words(n) {
|
|
31
|
+
return [...n.children.map((c) => c.name), ...n.options.flatMap((o) => o.flags)].join(' ');
|
|
32
|
+
}
|
|
33
|
+
function bash(root) {
|
|
34
|
+
const cases = root.children
|
|
35
|
+
.map((c) => {
|
|
36
|
+
const sub = c.children.length
|
|
37
|
+
? c.children
|
|
38
|
+
.map((s) => ` ${s.name}) COMPREPLY=( $(compgen -W '${q(words(s))} ${q(root.options.flatMap((o) => o.flags).join(' '))}' -- "$cur") ); return 0 ;;`)
|
|
39
|
+
.join('\n')
|
|
40
|
+
: '';
|
|
41
|
+
return ` ${c.name})
|
|
42
|
+
case "\${COMP_WORDS[2]}" in
|
|
43
|
+
${sub}
|
|
44
|
+
*) COMPREPLY=( $(compgen -W '${q(words(c))}' -- "$cur") ); return 0 ;;
|
|
45
|
+
esac ;;`;
|
|
46
|
+
})
|
|
47
|
+
.join('\n');
|
|
48
|
+
return `# bash completion for sdods — generated by \`sdods completion bash\`
|
|
49
|
+
# Install: sdods completion bash > ~/.sdods-completion.bash && echo 'source ~/.sdods-completion.bash' >> ~/.bashrc
|
|
50
|
+
_sdods() {
|
|
51
|
+
local cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
52
|
+
if [[ $COMP_CWORD -eq 1 ]]; then
|
|
53
|
+
COMPREPLY=( $(compgen -W '${q(words(root))}' -- "$cur") ); return 0
|
|
54
|
+
fi
|
|
55
|
+
case "\${COMP_WORDS[1]}" in
|
|
56
|
+
${cases}
|
|
57
|
+
*) COMPREPLY=( $(compgen -W '${q(root.options.flatMap((o) => o.flags).join(' '))}' -- "$cur") ) ;;
|
|
58
|
+
esac
|
|
59
|
+
}
|
|
60
|
+
complete -F _sdods sdods
|
|
61
|
+
`;
|
|
62
|
+
}
|
|
63
|
+
function zsh(root) {
|
|
64
|
+
const describe = (n, indent) => n.children.map((c) => `${indent}'${q(c.name)}:${q(c.description || c.name)}'`).join('\n');
|
|
65
|
+
const optSpecs = (n) => n.options.flatMap((o) => o.flags.map((f) => `'${q(f)}[${q(o.description || f)}]'`)).join(' ');
|
|
66
|
+
const level2 = root.children
|
|
67
|
+
.map((c) => ` ${c.name})
|
|
68
|
+
if (( CURRENT == 3 )) && [[ -n "${c.children.length ? 'x' : ''}" ]]; then
|
|
69
|
+
local -a subcmds; subcmds=(
|
|
70
|
+
${describe(c, ' ')}
|
|
71
|
+
)
|
|
72
|
+
_describe 'subcommand' subcmds
|
|
73
|
+
else
|
|
74
|
+
_arguments -s ${optSpecs(c)} ${optSpecs(root)} '*:file:_files'
|
|
75
|
+
fi ;;`)
|
|
76
|
+
.join('\n');
|
|
77
|
+
return `#compdef sdods
|
|
78
|
+
# zsh completion for sdods — generated by \`sdods completion zsh\`
|
|
79
|
+
# Install: sdods completion zsh > "\${fpath[1]}/_sdods" && autoload -Uz compinit && compinit
|
|
80
|
+
_sdods() {
|
|
81
|
+
local -a cmds
|
|
82
|
+
cmds=(
|
|
83
|
+
${describe(root, ' ')}
|
|
84
|
+
)
|
|
85
|
+
if (( CURRENT == 2 )); then
|
|
86
|
+
_describe 'command' cmds
|
|
87
|
+
_arguments -s ${optSpecs(root)}
|
|
88
|
+
return
|
|
89
|
+
fi
|
|
90
|
+
case "\${words[2]}" in
|
|
91
|
+
${level2}
|
|
92
|
+
*) _arguments -s ${optSpecs(root)} ;;
|
|
93
|
+
esac
|
|
94
|
+
}
|
|
95
|
+
_sdods "$@"
|
|
96
|
+
`;
|
|
97
|
+
}
|
|
98
|
+
function fish(root) {
|
|
99
|
+
const lines = [
|
|
100
|
+
'# fish completion for sdods — generated by `sdods completion fish`',
|
|
101
|
+
'# Install: sdods completion fish > ~/.config/fish/completions/sdods.fish',
|
|
102
|
+
`complete -c sdods -f`,
|
|
103
|
+
];
|
|
104
|
+
for (const o of root.options) {
|
|
105
|
+
for (const f of o.flags) {
|
|
106
|
+
lines.push(`complete -c sdods ${f.startsWith('--') ? `-l ${f.slice(2)}` : `-s ${f.slice(1)}`} -d '${q(o.description)}'`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const c of root.children) {
|
|
110
|
+
lines.push(`complete -c sdods -n '__fish_use_subcommand' -a ${c.name} -d '${q(c.description)}'`);
|
|
111
|
+
for (const s of c.children) {
|
|
112
|
+
lines.push(`complete -c sdods -n '__fish_seen_subcommand_from ${c.name}' -a ${s.name} -d '${q(s.description)}'`);
|
|
113
|
+
for (const o of s.options) {
|
|
114
|
+
for (const f of o.flags) {
|
|
115
|
+
lines.push(`complete -c sdods -n '__fish_seen_subcommand_from ${s.name}' ${f.startsWith('--') ? `-l ${f.slice(2)}` : `-s ${f.slice(1)}`} -d '${q(o.description)}'`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const o of c.options) {
|
|
120
|
+
for (const f of o.flags) {
|
|
121
|
+
lines.push(`complete -c sdods -n '__fish_seen_subcommand_from ${c.name}' ${f.startsWith('--') ? `-l ${f.slice(2)}` : `-s ${f.slice(1)}`} -d '${q(o.description)}'`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return lines.join('\n') + '\n';
|
|
126
|
+
}
|
|
127
|
+
export function register(program) {
|
|
128
|
+
program
|
|
129
|
+
.command('completion <shell>')
|
|
130
|
+
.description('Print a shell completion script (bash | zsh | fish)')
|
|
131
|
+
.action((shell) => {
|
|
132
|
+
if (!['bash', 'zsh', 'fish'].includes(shell)) {
|
|
133
|
+
throw new SdodsError('CONFIG_INVALID', `Unknown shell "${shell}". Use bash, zsh or fish.`, {
|
|
134
|
+
exitCode: 2,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
let root = program;
|
|
138
|
+
while (root.parent)
|
|
139
|
+
root = root.parent;
|
|
140
|
+
process.stdout.write(renderCompletion(shell, commandTree(root)));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=completion.js.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { explainConfig, redact } from '@sdods/core';
|
|
3
|
+
import { createContext } from '../context.js';
|
|
4
|
+
import { json, out, table } from '../ui.js';
|
|
5
|
+
export function registerConfigCommands(program) {
|
|
6
|
+
const config = program.command('config').description('Inspect resolved configuration');
|
|
7
|
+
config
|
|
8
|
+
.command('show')
|
|
9
|
+
.description('Print the merged config for a project/env with the winning layer per key')
|
|
10
|
+
.requiredOption('-p, --project <slug>', 'project slug')
|
|
11
|
+
.option('-e, --env <name>', 'environment name (default: project envs.default)')
|
|
12
|
+
.option('--explain', 'show provenance table instead of the tree')
|
|
13
|
+
.option('--show-secrets', 'do not redact secret-looking values')
|
|
14
|
+
.action((opts, cmd) => {
|
|
15
|
+
const ctx = createContext(cmd);
|
|
16
|
+
const cfg = ctx.registry.resolve(opts.project, opts.env);
|
|
17
|
+
const view = opts.showSecrets ? cfg : redact(cfg);
|
|
18
|
+
if (opts.explain) {
|
|
19
|
+
const rows = explainConfig(cfg).map((r) => ({
|
|
20
|
+
path: r.path,
|
|
21
|
+
layer: r.layer,
|
|
22
|
+
value: typeof r.value === 'object'
|
|
23
|
+
? JSON.stringify(r.value)
|
|
24
|
+
: String(opts.showSecrets ? r.value : redactLeaf(r.path, r.value)),
|
|
25
|
+
}));
|
|
26
|
+
if (ctx.opts.json)
|
|
27
|
+
return json(rows);
|
|
28
|
+
return table(rows, ['path', 'layer', 'value']);
|
|
29
|
+
}
|
|
30
|
+
if (ctx.opts.json)
|
|
31
|
+
return json({
|
|
32
|
+
project: view.project,
|
|
33
|
+
env: view.env,
|
|
34
|
+
runtime: view.runtime,
|
|
35
|
+
sources: view.sources,
|
|
36
|
+
});
|
|
37
|
+
out(pc.bold(`Project ${cfg.project.slug} · env ${cfg.env.name}`));
|
|
38
|
+
out(pc.dim(`dotenv files: ${cfg.sources.dotenvFiles.length ? cfg.sources.dotenvFiles.join(', ') : '(none)'}`));
|
|
39
|
+
out(JSON.stringify({ project: view.project, env: view.env, runtime: view.runtime }, null, 2));
|
|
40
|
+
});
|
|
41
|
+
config
|
|
42
|
+
.command('validate')
|
|
43
|
+
.description('Validate every project and environment file without running anything')
|
|
44
|
+
.action((_opts, cmd) => {
|
|
45
|
+
const ctx = createContext(cmd);
|
|
46
|
+
const results = [];
|
|
47
|
+
for (const e of ctx.registry.entriesList()) {
|
|
48
|
+
for (const env of e.config.envs.available) {
|
|
49
|
+
try {
|
|
50
|
+
ctx.registry.resolve(e.slug, env, {}, { ...process.env });
|
|
51
|
+
results.push({ project: e.slug, env, ok: true });
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
results.push({
|
|
55
|
+
project: e.slug,
|
|
56
|
+
env,
|
|
57
|
+
ok: false,
|
|
58
|
+
error: err.message.split('\n')[0],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (ctx.opts.json)
|
|
64
|
+
return json(results);
|
|
65
|
+
table(results.map((r) => ({
|
|
66
|
+
...r,
|
|
67
|
+
ok: r.ok ? pc.green('ok') : pc.red('FAIL'),
|
|
68
|
+
error: r.error ?? '',
|
|
69
|
+
})));
|
|
70
|
+
if (results.some((r) => !r.ok))
|
|
71
|
+
process.exitCode = 2;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function redactLeaf(path, value) {
|
|
75
|
+
return /(password|secret|token|apikey|api_key)/i.test(path) && typeof value === 'string' && value
|
|
76
|
+
? '***'
|
|
77
|
+
: value;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { createContext } from '../context.js';
|
|
3
|
+
import { heading, json, out, table } from '../ui.js';
|
|
4
|
+
export function register(program) {
|
|
5
|
+
program
|
|
6
|
+
.command('coverage')
|
|
7
|
+
.description('Route, endpoint and role coverage by scenarios, split by suite tag')
|
|
8
|
+
.requiredOption('-p, --project <slug>', 'project slug')
|
|
9
|
+
.option('-e, --env <name>', 'environment (for the OpenAPI spec and API base URL)')
|
|
10
|
+
.option('--openapi [file]', 'include endpoints from the OpenAPI spec (env api.openapi or a file)')
|
|
11
|
+
.option('--routes', 'only routes')
|
|
12
|
+
.option('--endpoints', 'only endpoints')
|
|
13
|
+
.option('--roles', 'only roles')
|
|
14
|
+
.option('--uncovered', 'only rows without scenarios')
|
|
15
|
+
.action(async (opts, cmd) => {
|
|
16
|
+
const ctx = createContext(cmd);
|
|
17
|
+
const { computeCoverage } = await import('@sdods/core/analyze');
|
|
18
|
+
const report = computeCoverage(ctx.registry, opts.project, {
|
|
19
|
+
env: opts.env,
|
|
20
|
+
openapi: opts.openapi === true ? true : (opts.openapi ?? false),
|
|
21
|
+
});
|
|
22
|
+
if (ctx.opts.json)
|
|
23
|
+
return json(report);
|
|
24
|
+
printCoverage(report, opts);
|
|
25
|
+
if (report.summary.uncoveredModules.length)
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function rows(list, suites, onlyUncovered) {
|
|
30
|
+
return list
|
|
31
|
+
.filter((r) => !onlyUncovered || !r.covered)
|
|
32
|
+
.map((r) => ({
|
|
33
|
+
name: r.name,
|
|
34
|
+
target: r.target,
|
|
35
|
+
module: r.module ?? '',
|
|
36
|
+
covered: r.covered ? pc.green('yes') : pc.red('no'),
|
|
37
|
+
...Object.fromEntries(suites.map((s) => [s, r.bySuite[s] ?? 0])),
|
|
38
|
+
scenarios: r.scenarios.length,
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
function printCoverage(report, opts) {
|
|
42
|
+
const all = !opts.routes && !opts.endpoints && !opts.roles;
|
|
43
|
+
const s = report.summary;
|
|
44
|
+
heading(`Coverage for ${report.project}`);
|
|
45
|
+
out(`${pc.bold('routes')} ${s.routes.covered}/${s.routes.total} ${pc.bold('endpoints')} ${s.endpoints.covered}/${s.endpoints.total} ${pc.bold('roles')} ${s.roles.covered}/${s.roles.total} ${pc.bold('scenarios')} ${s.scenarios} ${pc.dim(Object.entries(s.bySuite)
|
|
46
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
47
|
+
.join(' '))}`);
|
|
48
|
+
const cols = ['name', 'target', 'module', 'covered', ...report.suites, 'scenarios'];
|
|
49
|
+
if (all || opts.routes) {
|
|
50
|
+
heading('Routes');
|
|
51
|
+
table(rows(report.routes, report.suites, Boolean(opts.uncovered)), cols);
|
|
52
|
+
}
|
|
53
|
+
if (all || opts.endpoints) {
|
|
54
|
+
heading('Endpoints');
|
|
55
|
+
table(rows(report.endpoints, report.suites, Boolean(opts.uncovered)), cols);
|
|
56
|
+
}
|
|
57
|
+
if (all || opts.roles) {
|
|
58
|
+
heading('Roles');
|
|
59
|
+
table(rows(report.roles, report.suites, Boolean(opts.uncovered)), cols);
|
|
60
|
+
}
|
|
61
|
+
if (s.uncoveredModules.length) {
|
|
62
|
+
out(`\n${pc.yellow('⚠')} Modules without any covered target: ${s.uncoveredModules.join(', ')}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=coverage.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
type Row = Record<string, unknown>;
|
|
3
|
+
/** CSV/JSON/YAML → rows (CSV via csv-parse, loaded lazily). */
|
|
4
|
+
export declare function readRows(file: string): Promise<Row[]>;
|
|
5
|
+
export declare function register(program: Command): void;
|
|
6
|
+
export {};
|
|
7
|
+
//# sourceMappingURL=data.d.ts.map
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { extname, resolve } from 'node:path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
|
+
import { SdodsError } from '@sdods/core';
|
|
6
|
+
import { createContext } from '../context.js';
|
|
7
|
+
import { json, ok, out, table } from '../ui.js';
|
|
8
|
+
/** CSV/JSON/YAML → rows (CSV via csv-parse, loaded lazily). */
|
|
9
|
+
export async function readRows(file) {
|
|
10
|
+
const abs = resolve(file);
|
|
11
|
+
const text = readFileSync(abs, 'utf8');
|
|
12
|
+
const ext = extname(abs).toLowerCase();
|
|
13
|
+
if (ext === '.json') {
|
|
14
|
+
const data = JSON.parse(text);
|
|
15
|
+
return Array.isArray(data) ? data : Array.isArray(data?.rows) ? data.rows : [data];
|
|
16
|
+
}
|
|
17
|
+
if (ext === '.yaml' || ext === '.yml') {
|
|
18
|
+
const data = parseYaml(text);
|
|
19
|
+
return Array.isArray(data) ? data : Array.isArray(data?.rows) ? data.rows : [data];
|
|
20
|
+
}
|
|
21
|
+
const { parse } = await import('csv-parse/sync');
|
|
22
|
+
return parse(text, {
|
|
23
|
+
columns: true,
|
|
24
|
+
skip_empty_lines: true,
|
|
25
|
+
trim: true,
|
|
26
|
+
bom: true,
|
|
27
|
+
cast: true,
|
|
28
|
+
cast_date: false,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export function register(program) {
|
|
32
|
+
const data = program
|
|
33
|
+
.command('data')
|
|
34
|
+
.description('Import, preview and seed test data (CSV, JSON, YAML)');
|
|
35
|
+
data
|
|
36
|
+
.command('preview <file>')
|
|
37
|
+
.description('Show the first rows and inferred columns of a data file')
|
|
38
|
+
.option('--limit <n>', 'rows to show', '10')
|
|
39
|
+
.action(async (file, opts, cmd) => {
|
|
40
|
+
const ctx = createContext(cmd);
|
|
41
|
+
const rows = await readRows(file);
|
|
42
|
+
const columns = [...new Set(rows.flatMap((r) => Object.keys(r)))];
|
|
43
|
+
const head = rows.slice(0, Number(opts.limit));
|
|
44
|
+
if (ctx.opts.json)
|
|
45
|
+
return json({ file, rows: rows.length, columns, sample: head });
|
|
46
|
+
out(pc.dim(`${rows.length} row(s), columns: ${columns.join(', ')}`));
|
|
47
|
+
table(head.map((r) => Object.fromEntries(columns.map((c) => [c, String(r[c] ?? '')]))));
|
|
48
|
+
});
|
|
49
|
+
data
|
|
50
|
+
.command('import <dataset> <file>')
|
|
51
|
+
.description('Import a data file into the database (datasets table or a td_<project>_<dataset> table)')
|
|
52
|
+
.requiredOption('-p, --project <slug>', 'project slug')
|
|
53
|
+
.option('-e, --env <name>', 'environment key (default: * = every environment)')
|
|
54
|
+
.option('--to <target>', 'db (datasets/dataset_rows) or table (td_* table)', 'db')
|
|
55
|
+
.option('--truncate', 'replace existing rows for this env when --to table')
|
|
56
|
+
.action(async (dataset, file, opts, cmd) => {
|
|
57
|
+
const ctx = createContext(cmd);
|
|
58
|
+
const entry = ctx.registry.entry(opts.project);
|
|
59
|
+
const rows = await readRows(file);
|
|
60
|
+
const m = await import('@sdods/db');
|
|
61
|
+
const adb = m.createDb();
|
|
62
|
+
try {
|
|
63
|
+
await m.migrateToLatest(adb);
|
|
64
|
+
const projectId = await m.ensureProject(adb.db, adb.driver, entry.slug, entry.config.name);
|
|
65
|
+
if (opts.to === 'table') {
|
|
66
|
+
const res = await m.importRowsToTable(adb.db, adb.driver, {
|
|
67
|
+
projectSlug: entry.slug,
|
|
68
|
+
dataset,
|
|
69
|
+
env: opts.env ?? '*',
|
|
70
|
+
rows,
|
|
71
|
+
truncate: Boolean(opts.truncate),
|
|
72
|
+
});
|
|
73
|
+
if (ctx.opts.json)
|
|
74
|
+
return json(res);
|
|
75
|
+
ok(`Imported ${res.inserted} row(s) into ${res.table} (${res.columns.join(', ')})`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (opts.to !== 'db')
|
|
79
|
+
throw new SdodsError('NOT_SUPPORTED', `--to must be db or table (got ${opts.to}).`, {
|
|
80
|
+
exitCode: 2,
|
|
81
|
+
});
|
|
82
|
+
const res = await m.upsertDataset(adb.db, adb.driver, {
|
|
83
|
+
projectId,
|
|
84
|
+
envKey: opts.env ?? '*',
|
|
85
|
+
name: dataset,
|
|
86
|
+
kind: extname(file).slice(1) || 'json',
|
|
87
|
+
storage: 'db',
|
|
88
|
+
sourcePath: resolve(file),
|
|
89
|
+
rows,
|
|
90
|
+
});
|
|
91
|
+
if (ctx.opts.json)
|
|
92
|
+
return json(res);
|
|
93
|
+
ok(`Imported dataset ${dataset} (${res.rowCount} row(s)) for ${entry.slug} [${opts.env ?? '*'}]`);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await adb.close();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
data
|
|
100
|
+
.command('list')
|
|
101
|
+
.description('List datasets stored in the database for a project')
|
|
102
|
+
.requiredOption('-p, --project <slug>', 'project slug')
|
|
103
|
+
.action(async (opts, cmd) => {
|
|
104
|
+
const ctx = createContext(cmd);
|
|
105
|
+
const m = await import('@sdods/db');
|
|
106
|
+
const adb = m.createDb();
|
|
107
|
+
try {
|
|
108
|
+
await m.migrateToLatest(adb);
|
|
109
|
+
const project = await m.getProjectBySlug(adb.db, opts.project);
|
|
110
|
+
const rows = project ? await m.listDatasets(adb.db, project.id) : [];
|
|
111
|
+
const tables = (await m.listTdTables(adb.db, adb.driver)).filter((t) => t.startsWith(`td_${opts.project.replace(/[^a-z0-9]+/g, '_')}_`));
|
|
112
|
+
if (ctx.opts.json)
|
|
113
|
+
return json({ datasets: rows, tables });
|
|
114
|
+
table(rows.map((d) => ({
|
|
115
|
+
name: d.name,
|
|
116
|
+
env: d.envKey,
|
|
117
|
+
kind: d.kind,
|
|
118
|
+
rows: d.rowCount,
|
|
119
|
+
columns: d.columns.join(','),
|
|
120
|
+
})));
|
|
121
|
+
if (tables.length)
|
|
122
|
+
out(pc.dim(`td tables: ${tables.join(', ')}`));
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await adb.close();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
data
|
|
129
|
+
.command('seed')
|
|
130
|
+
.description('Import every file under projects/<slug>/data/<env>/ and data/common/ into the database')
|
|
131
|
+
.requiredOption('-p, --project <slug>', 'project slug')
|
|
132
|
+
.option('-e, --env <name>', 'environment (default: project default)')
|
|
133
|
+
.option('--to <target>', 'db or table', 'db')
|
|
134
|
+
.action(async (opts, cmd) => {
|
|
135
|
+
const ctx = createContext(cmd);
|
|
136
|
+
const entry = ctx.registry.entry(opts.project);
|
|
137
|
+
const env = opts.env ?? entry.config.envs.default;
|
|
138
|
+
const { existsSync, readdirSync } = await import('node:fs');
|
|
139
|
+
const { join } = await import('node:path');
|
|
140
|
+
const m = await import('@sdods/db');
|
|
141
|
+
const adb = m.createDb();
|
|
142
|
+
const imported = [];
|
|
143
|
+
try {
|
|
144
|
+
await m.migrateToLatest(adb);
|
|
145
|
+
const projectId = await m.ensureProject(adb.db, adb.driver, entry.slug, entry.config.name);
|
|
146
|
+
for (const [envKey, dir] of [
|
|
147
|
+
['*', join(entry.root, 'data', 'common')],
|
|
148
|
+
[env, join(entry.root, 'data', env)],
|
|
149
|
+
]) {
|
|
150
|
+
if (!existsSync(dir))
|
|
151
|
+
continue;
|
|
152
|
+
for (const f of readdirSync(dir)) {
|
|
153
|
+
if (!/\.(csv|json|ya?ml)$/i.test(f))
|
|
154
|
+
continue;
|
|
155
|
+
const dataset = f.replace(/\.(csv|json|ya?ml)$/i, '');
|
|
156
|
+
const rows = await readRows(join(dir, f));
|
|
157
|
+
if (opts.to === 'table')
|
|
158
|
+
await m.importRowsToTable(adb.db, adb.driver, {
|
|
159
|
+
projectSlug: entry.slug,
|
|
160
|
+
dataset,
|
|
161
|
+
env: envKey,
|
|
162
|
+
rows,
|
|
163
|
+
truncate: true,
|
|
164
|
+
});
|
|
165
|
+
else
|
|
166
|
+
await m.upsertDataset(adb.db, adb.driver, {
|
|
167
|
+
projectId,
|
|
168
|
+
envKey,
|
|
169
|
+
name: dataset,
|
|
170
|
+
kind: f.split('.').pop(),
|
|
171
|
+
storage: 'db',
|
|
172
|
+
sourcePath: join(dir, f),
|
|
173
|
+
rows,
|
|
174
|
+
});
|
|
175
|
+
imported.push({ dataset, env: envKey, rows: rows.length });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (ctx.opts.json)
|
|
179
|
+
return json(imported);
|
|
180
|
+
table(imported);
|
|
181
|
+
ok(`Seeded ${imported.length} dataset(s) for ${entry.slug}`);
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
await adb.close();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=data.js.map
|