qactl 0.1.0-alpha.1

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 (3) hide show
  1. package/README.md +11 -0
  2. package/dist/index.js +161 -0
  3. package/package.json +11 -0
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # qactl (alpha)
2
+
3
+ Agentic QA CLI for the private pilot. This release supports the commands printed by `qa --help`; the full v1 command catalog is still in development.
4
+
5
+ Requires Node.js 22 or newer. Install a pinned version with `npm install --global qactl@0.1.0-alpha.1`, then run `qactl --help` or `qa --help`. Both names run the same executable.
6
+
7
+ Set `QA_API_URL` to the Agentic QA API and `QA_API_TOKEN` to a token created in your web account. Run `qa doctor --json` to check connectivity and authentication. Tokens are never passed as command arguments.
8
+
9
+ The current GKE pilot has no public endpoint. You can use the private port-forward described in the repository's `HANDOFF.md` while authorized to access that environment.
10
+
11
+ Package source: `https://github.com/SicoseSA/agentic-qa` (private repository). Package contents are the compiled CLI and this README; no browser or runner is installed.
package/dist/index.js ADDED
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ const base = process.env.QA_API_URL || 'http://127.0.0.1:5010';
4
+ const token = process.env.QA_API_TOKEN;
5
+ const args = process.argv.slice(2);
6
+ const json = args.includes('--json');
7
+ const cliVersion = '0.1.0-alpha.1';
8
+ const schemaVersion = '1.0';
9
+ function value(flag) { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; }
10
+ function output(data) { process.stdout.write(JSON.stringify(data) + '\n'); }
11
+ function error(code, message, exit = 2) { output({ schema_version: '1.0', error: { code, message } }); process.exit(exit); }
12
+ async function request(route, options = {}) {
13
+ if (!token)
14
+ error('AUTH_REQUIRED', 'Set QA_API_TOKEN from the web account screen', 3);
15
+ const response = await fetch(base + route, { ...options, headers: { Authorization: `Bearer ${token}`, ...options.headers } });
16
+ const body = await response.json();
17
+ if (body.schema_version !== schemaVersion)
18
+ error('API_SCHEMA_INCOMPATIBLE', `This CLI supports API schema ${schemaVersion}; the server returned ${String(body.schema_version ?? 'none')}`, 5);
19
+ if (!response.ok)
20
+ error(body.error?.code || 'HTTP_ERROR', body.error?.message || `HTTP ${response.status}`, response.status === 401 || response.status === 403 ? 3 : response.status === 409 ? 8 : 2);
21
+ return body;
22
+ }
23
+ function input() {
24
+ const file = value('--file');
25
+ if (!file)
26
+ error('FILE_REQUIRED', 'Use --file path.json or --file -');
27
+ return JSON.parse(readFileSync(file === '-' ? 0 : file, 'utf8'));
28
+ }
29
+ async function main() {
30
+ const [group, action] = args;
31
+ if (!group || args.includes('--help') || group === 'help') {
32
+ const commands = ['qa version --json', 'qa doctor --json', 'qa schema --json', 'qa auth whoami --json', 'qa project list --json', 'qa deployment register --project ID --file deployment.json --json', 'qa verify --project ID --deployment ID --scenario SCN-FIXTURE-TOAST --idempotency-key KEY --deadline-seconds 1800 --wait --json', 'qa run get ID --json', 'qa run cancel ID --json', 'qa operation get ID --json', 'qa operation wait ID --timeout-ms 120000 --json', 'qa operation cancel ID --json', 'qa finding list --project ID --json'];
33
+ if (json)
34
+ output({ schema_version: '1.0', data: { commands } });
35
+ else
36
+ process.stdout.write(commands.join('\n') + '\n');
37
+ return;
38
+ }
39
+ if (group === 'version')
40
+ return output({ schema_version: schemaVersion, data: { version: cliVersion, api: 'v1', package: 'qactl' } });
41
+ if (group === 'doctor') {
42
+ const checks = [];
43
+ checks.push({ name: 'node', status: Number(process.versions.node.split('.')[0]) >= 22 ? 'ok' : 'fail', detail: `Node ${process.versions.node}; required >=22` });
44
+ let endpoint;
45
+ try {
46
+ endpoint = new URL(base);
47
+ if (!['https:', 'http:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.search || endpoint.hash)
48
+ throw new Error();
49
+ checks.push({ name: 'api_url', status: 'ok' });
50
+ }
51
+ catch {
52
+ checks.push({ name: 'api_url', status: 'fail', detail: 'Set QA_API_URL to an HTTP(S) API origin without embedded credentials' });
53
+ }
54
+ if (endpoint) {
55
+ try {
56
+ const response = await fetch(new URL('/healthz', endpoint), { signal: AbortSignal.timeout(5000) });
57
+ checks.push({ name: 'api_health', status: response.ok ? 'ok' : 'fail', detail: response.ok ? undefined : `HTTP ${response.status}` });
58
+ }
59
+ catch {
60
+ checks.push({ name: 'api_health', status: 'fail', detail: 'API health endpoint unavailable' });
61
+ }
62
+ }
63
+ checks.push({ name: 'api_token', status: token ? 'ok' : 'fail', detail: token ? undefined : 'Set QA_API_TOKEN from the web account screen' });
64
+ if (endpoint && token) {
65
+ try {
66
+ const response = await fetch(new URL('/v1/me', endpoint), { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(5000) });
67
+ const body = await response.json();
68
+ checks.push({ name: 'api_auth', status: response.ok && body.schema_version === schemaVersion ? 'ok' : 'fail',
69
+ detail: response.ok && body.schema_version === schemaVersion ? undefined : body.schema_version !== schemaVersion ? 'API schema incompatible' : `HTTP ${response.status}` });
70
+ }
71
+ catch {
72
+ checks.push({ name: 'api_auth', status: 'fail', detail: 'Token could not be verified' });
73
+ }
74
+ }
75
+ const ok = checks.every(check => check.status === 'ok');
76
+ output({ schema_version: schemaVersion, data: { status: ok ? 'ok' : 'needs_configuration', checks } });
77
+ if (!ok)
78
+ process.exitCode = 5;
79
+ return;
80
+ }
81
+ if (group === 'schema')
82
+ return output({ schema_version: schemaVersion, data: { commands: ['auth whoami', 'project list', 'deployment register', 'verify', 'run get', 'run cancel', 'operation get', 'operation wait', 'operation cancel', 'finding list'], scenarioIds: ['SCN-FIXTURE-TOAST', 'SCN-FIXTURE-PERSISTENCE'] } });
83
+ if (group === 'auth' && action === 'whoami')
84
+ return output(await request('/v1/me'));
85
+ if (group === 'project' && action === 'list')
86
+ return output(await request('/v1/projects'));
87
+ if (group === 'deployment' && action === 'register') {
88
+ const project = value('--project');
89
+ if (!project)
90
+ error('PROJECT_REQUIRED', 'Use --project ID');
91
+ return output(await request(`/v1/projects/${encodeURIComponent(project)}/deployments`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input()) }));
92
+ }
93
+ if (group === 'run' && action === 'get') {
94
+ if (!args[2])
95
+ error('RUN_REQUIRED', 'Use qa run get ID');
96
+ return output(await request(`/v1/runs/${encodeURIComponent(args[2])}`));
97
+ }
98
+ if (group === 'run' && action === 'cancel') {
99
+ if (!args[2])
100
+ error('RUN_REQUIRED', 'Use qa run cancel ID');
101
+ return output(await request(`/v1/runs/${encodeURIComponent(args[2])}/cancel`, { method: 'POST' }));
102
+ }
103
+ if (group === 'operation' && action === 'get') {
104
+ if (!args[2])
105
+ error('OPERATION_REQUIRED', 'Use qa operation get ID');
106
+ return output(await request(`/v1/operations/${encodeURIComponent(args[2])}`));
107
+ }
108
+ if (group === 'operation' && action === 'cancel') {
109
+ if (!args[2])
110
+ error('OPERATION_REQUIRED', 'Use qa operation cancel ID');
111
+ return output(await request(`/v1/operations/${encodeURIComponent(args[2])}/cancel`, { method: 'POST' }));
112
+ }
113
+ if (group === 'operation' && action === 'wait') {
114
+ if (!args[2])
115
+ error('OPERATION_REQUIRED', 'Use qa operation wait ID');
116
+ const deadline = Date.now() + Number(value('--timeout-ms') || 120000);
117
+ while (Date.now() < deadline) {
118
+ const current = await request(`/v1/operations/${encodeURIComponent(args[2])}`);
119
+ if (['succeeded', 'failed', 'cancelled', 'timed_out'].includes(current.data.status)) {
120
+ output(current);
121
+ process.exit(current.data.status === 'succeeded' ? 0 : current.data.status === 'cancelled' || current.data.status === 'timed_out' ? 6 : 5);
122
+ }
123
+ await new Promise(resolve => setTimeout(resolve, 1500));
124
+ }
125
+ error('WAIT_TIMEOUT', `Operation ${args[2]} may still be running`, 6);
126
+ }
127
+ if (group === 'finding' && action === 'list') {
128
+ const project = value('--project');
129
+ if (!project)
130
+ error('PROJECT_REQUIRED', 'Use --project ID');
131
+ return output(await request(`/v1/projects/${encodeURIComponent(project)}/findings`));
132
+ }
133
+ if (group === 'verify') {
134
+ const project = value('--project'), deployment = value('--deployment'), scenario = value('--scenario') || 'SCN-FIXTURE-TOAST';
135
+ if (!project || !deployment)
136
+ error('INPUT_REQUIRED', 'Use --project and --deployment');
137
+ const key = value('--idempotency-key');
138
+ const deadlineSeconds = value('--deadline-seconds');
139
+ const queued = await request('/v1/runs', { method: 'POST', headers: { 'content-type': 'application/json', ...(key ? { 'Idempotency-Key': key } : {}) }, body: JSON.stringify({ projectId: project, deploymentId: deployment, scenarioId: scenario, ...(deadlineSeconds ? { deadlineSeconds: Number(deadlineSeconds) } : {}) }) });
140
+ if (!args.includes('--wait'))
141
+ return output(queued);
142
+ const runId = queued.data.runId;
143
+ const deadline = Date.now() + Number(value('--timeout-ms') || 120000);
144
+ while (Date.now() < deadline) {
145
+ const current = await request(`/v1/runs/${runId}`);
146
+ if (current.data.run.status === 'aborted') {
147
+ output(current);
148
+ process.exit(6);
149
+ }
150
+ if (current.data.run.status === 'completed') {
151
+ output(current);
152
+ const gate = current.data.run.gate_status;
153
+ process.exit(gate === 'pass' ? 0 : gate === 'needs_review' ? 7 : current.data.run.automatic_verdict === 'failed' ? 1 : 5);
154
+ }
155
+ await new Promise(resolve => setTimeout(resolve, 1500));
156
+ }
157
+ error('WAIT_TIMEOUT', `Run ${runId} may still be running`, 6);
158
+ }
159
+ error('UNKNOWN_COMMAND', 'Run qa --help');
160
+ }
161
+ main().catch(e => error('CLI_ERROR', e instanceof Error ? e.message : String(e), 5));
package/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "qactl",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Agentic QA command-line client (early private-pilot alpha)",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "repository": {"type":"git","url":"git+https://github.com/SicoseSA/agentic-qa.git","directory":"packages/cli"},
8
+ "engines": {"node": ">=22"},
9
+ "bin": { "qa": "dist/index.js", "qactl": "dist/index.js" },
10
+ "files": ["dist", "README.md"]
11
+ }