@malmhq/intermesh-cli 0.1.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 +198 -0
- package/README.md +77 -0
- package/dist/generation/.opencode/AGENTS.md +38 -0
- package/dist/generation/.opencode/agents/generation-builder.md +9 -0
- package/dist/generation/.opencode/agents/generation-fidelity.md +9 -0
- package/dist/generation/.opencode/agents/generation-source.md +13 -0
- package/dist/generation/.opencode/prompts/analyze-capabilities.md +61 -0
- package/dist/generation/.opencode/prompts/analyze-source.md +41 -0
- package/dist/generation/.opencode/prompts/generate.md +9 -0
- package/dist/generation/.opencode/prompts/repair-capability-proposal.md +5 -0
- package/dist/generation/.opencode/skills/generation-building/SKILL.md +32 -0
- package/dist/generation/.opencode/skills/generation-package-review/SKILL.md +36 -0
- package/dist/generation/.opencode/skills/generation-source-analysis/SKILL.md +36 -0
- package/dist/generation/AGENTS.md +38 -0
- package/dist/generation/agent-skill-installer/index.ts +4 -0
- package/dist/generation/agent-skill-installer/install.ts +503 -0
- package/dist/generation/agent-skill-installer/lock.ts +128 -0
- package/dist/generation/agent-skill-installer/manifest.ts +271 -0
- package/dist/generation/agent-skill-installer/targets.ts +169 -0
- package/dist/generation/runtime-entrypoint.js +39980 -0
- package/dist/generation/scripts/check-package.mjs +45 -0
- package/dist/generation/scripts/installed-help.mjs +98 -0
- package/dist/generation/scripts/lead-input.mjs +584 -0
- package/dist/generation/scripts/prepare-opencode.mjs +33 -0
- package/dist/generation/scripts/prepared-work.mjs +197 -0
- package/dist/generation/scripts/repair-loop.mjs +519 -0
- package/dist/generation/scripts/run-reviewers.mjs +306 -0
- package/dist/generation/scripts/runtime-capture.mjs +119 -0
- package/dist/generation/scripts/stage-timings.mjs +53 -0
- package/dist/generation/scripts/validate-source-brief.mjs +220 -0
- package/dist/generation/scripts/verify-package.mjs +365 -0
- package/dist/generation/templates/npm-cli/CONTRIBUTING.md +20 -0
- package/dist/generation/templates/npm-cli/GENERATION-PATTERNS.md +143 -0
- package/dist/generation/templates/npm-cli/README.md +67 -0
- package/dist/generation/templates/npm-cli/capabilities.json +1 -0
- package/dist/generation/templates/npm-cli/mock/routes.ts +3 -0
- package/dist/generation/templates/npm-cli/mock/server.ts +15 -0
- package/dist/generation/templates/npm-cli/mock/types.ts +7 -0
- package/dist/generation/templates/npm-cli/package.json +36 -0
- package/dist/generation/templates/npm-cli/pnpm-lock.yaml +495 -0
- package/dist/generation/templates/npm-cli/pnpm-workspace.yaml +2 -0
- package/dist/generation/templates/npm-cli/skills/customer-cli-management/SKILL.md +22 -0
- package/dist/generation/templates/npm-cli/skills/customer-workflow/SKILL.md +22 -0
- package/dist/generation/templates/npm-cli/src/auth/commands.ts +77 -0
- package/dist/generation/templates/npm-cli/src/auth/credentials.ts +66 -0
- package/dist/generation/templates/npm-cli/src/auth/prompt.ts +27 -0
- package/dist/generation/templates/npm-cli/src/cli.ts +53 -0
- package/dist/generation/templates/npm-cli/src/commands.ts +6 -0
- package/dist/generation/templates/npm-cli/src/customer.ts +16 -0
- package/dist/generation/templates/npm-cli/src/index.ts +11 -0
- package/dist/generation/templates/npm-cli/src/lifecycle/commands.ts +344 -0
- package/dist/generation/templates/npm-cli/src/lifecycle/config.ts +32 -0
- package/dist/generation/templates/npm-cli/src/shared/dry-run.ts +32 -0
- package/dist/generation/templates/npm-cli/src/shared/errors.ts +162 -0
- package/dist/generation/templates/npm-cli/src/shared/files.ts +45 -0
- package/dist/generation/templates/npm-cli/src/shared/http.ts +125 -0
- package/dist/generation/templates/npm-cli/src/shared/output.ts +33 -0
- package/dist/generation/templates/npm-cli/test/auth.test.ts +113 -0
- package/dist/generation/templates/npm-cli/test/fixture-cli.ts +51 -0
- package/dist/generation/templates/npm-cli/test/foundation.test.ts +316 -0
- package/dist/generation/templates/npm-cli/test/helpers.ts +34 -0
- package/dist/generation/templates/npm-cli/test/mock-lifecycle.test.ts +86 -0
- package/dist/generation/templates/npm-cli/test/run-with-mock.ts +142 -0
- package/dist/generation/templates/npm-cli/tsconfig.build.json +5 -0
- package/dist/generation/templates/npm-cli/tsconfig.json +11 -0
- package/dist/generation/templates/opencode-bootstrap/package-lock.json +402 -0
- package/dist/generation/templates/opencode-bootstrap/package.json +7 -0
- package/dist/index.js +51965 -0
- package/dist/runtime/candidate-inspection/inspect.Dockerfile +4 -0
- package/dist/runtime/candidate-inspection/install.Dockerfile +5 -0
- package/dist/runtime/candidate-inspection/registry-proxy.mjs +37 -0
- package/dist/runtime/harbor/bootstrap/package-lock.json +402 -0
- package/dist/runtime/harbor/bootstrap/package.json +7 -0
- package/dist/runtime/harbor/evaluation.Dockerfile +31 -0
- package/dist/runtime/harbor/harbor_launcher.py +62 -0
- package/dist/runtime/harbor/intermesh_opencode.py +44 -0
- package/dist/runtime/harbor/model-forwarder.mjs +52 -0
- package/dist/runtime/live-task.md +13 -0
- package/package.json +44 -0
- package/skills/inter-cli-management/SKILL.md +38 -0
- package/skills/inter-cli-management/evals/evals.json +40 -0
- package/skills/inter-context/SKILL.md +72 -0
- package/skills/inter-context/evals/cases.json +82 -0
- package/skills/inter-eval-authoring/SKILL.md +70 -0
- package/skills/inter-eval-authoring/evals/evals.json +69 -0
- package/skills/inter-eval-authoring/evals/trigger-queries.json +34 -0
- package/skills/inter-eval-authoring/references/authoring-guide.md +70 -0
- package/skills/inter-eval-authoring/references/eval-plan.example.json +92 -0
- package/skills/inter-eval-authoring/references/eval-plan.schema.json +304 -0
- package/skills/inter-evaluation/SKILL.md +51 -0
- package/skills/inter-evaluation/evals/evals.json +49 -0
- package/skills/inter-evaluation/evals/trigger-queries.json +30 -0
- package/skills/inter-generation/SKILL.md +42 -0
- package/skills/inter-generation/evals/evals.json +81 -0
- package/skills/inter-generation/evals/trigger-queries.json +58 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { lstat, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { CliError, cliErrors } from './errors.js';
|
|
4
|
+
import { dryRun, DryRunComplete } from './dry-run.js';
|
|
5
|
+
import { printSuccess } from './output.js';
|
|
6
|
+
|
|
7
|
+
export async function saveFile(path: string, body: Uint8Array, mediaType: string) {
|
|
8
|
+
await validateOutputFile(path);
|
|
9
|
+
if (dryRun.getStore()) {
|
|
10
|
+
printSuccess(
|
|
11
|
+
{
|
|
12
|
+
dry_run: true,
|
|
13
|
+
summary: 'Would write a new output file. No file was written.',
|
|
14
|
+
effect: 'Write an output file',
|
|
15
|
+
bytes: body.byteLength,
|
|
16
|
+
checks: ['Output does not exist', 'Parent directory exists'],
|
|
17
|
+
not_checked: ['Write permissions and available disk space'],
|
|
18
|
+
},
|
|
19
|
+
'save-file',
|
|
20
|
+
);
|
|
21
|
+
throw new DryRunComplete();
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
await writeFile(path, body, { flag: 'wx' });
|
|
25
|
+
} catch {
|
|
26
|
+
throw new CliError(cliErrors.fileWriteFailed);
|
|
27
|
+
}
|
|
28
|
+
return { path, media_type: mediaType };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function validateOutputFile(path: string): Promise<void> {
|
|
32
|
+
try {
|
|
33
|
+
const exists = await lstat(path).then(
|
|
34
|
+
() => true,
|
|
35
|
+
(error: unknown) => {
|
|
36
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false;
|
|
37
|
+
throw error;
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
if (exists || !(await stat(dirname(path))).isDirectory())
|
|
41
|
+
throw new Error('Invalid destination');
|
|
42
|
+
} catch {
|
|
43
|
+
throw new CliError(cliErrors.fileWriteFailed);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { bearerToken } from '../auth/credentials.js';
|
|
2
|
+
import { dryRun, DryRunComplete, previewWorkflow, type WorkflowPreview } from './dry-run.js';
|
|
3
|
+
import { printSuccess } from './output.js';
|
|
4
|
+
import { customer } from '../customer.js';
|
|
5
|
+
import { CliError, cliErrors } from './errors.js';
|
|
6
|
+
|
|
7
|
+
type Method = 'DELETE' | 'GET' | 'HEAD' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT';
|
|
8
|
+
|
|
9
|
+
function loopbackUrl(value: string): URL {
|
|
10
|
+
let url: URL;
|
|
11
|
+
try {
|
|
12
|
+
url = new URL(value);
|
|
13
|
+
} catch {
|
|
14
|
+
throw new CliError(cliErrors.invalidLoopbackUrl);
|
|
15
|
+
}
|
|
16
|
+
if (
|
|
17
|
+
!['http:', 'https:'].includes(url.protocol) ||
|
|
18
|
+
!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) ||
|
|
19
|
+
url.username ||
|
|
20
|
+
url.password
|
|
21
|
+
) {
|
|
22
|
+
throw new CliError(cliErrors.loopbackUrlNotPermitted);
|
|
23
|
+
}
|
|
24
|
+
return url;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function productionUrl(): URL {
|
|
28
|
+
const url = new URL(customer.api.base_url);
|
|
29
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
30
|
+
throw new CliError(cliErrors.customerApiConfigurationInvalid);
|
|
31
|
+
return url;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function operationUrl(base: URL, path: string): URL {
|
|
35
|
+
if (!path.startsWith('/') || path.startsWith('//'))
|
|
36
|
+
throw new CliError(cliErrors.operationPathInvalid);
|
|
37
|
+
const directory = new URL(base);
|
|
38
|
+
if (!directory.pathname.endsWith('/')) directory.pathname += '/';
|
|
39
|
+
return new URL(path.slice(1), directory);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function request(options: {
|
|
43
|
+
baseUrl?: string;
|
|
44
|
+
method: Method;
|
|
45
|
+
path: string;
|
|
46
|
+
query?: URLSearchParams;
|
|
47
|
+
body?: Uint8Array;
|
|
48
|
+
mediaType?: string;
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
workflow?: WorkflowPreview;
|
|
51
|
+
}): Promise<{ body: Uint8Array; mediaType: string; status: number }> {
|
|
52
|
+
const mockBaseUrl = options.baseUrl;
|
|
53
|
+
const mock = mockBaseUrl !== undefined;
|
|
54
|
+
const base = mockBaseUrl === undefined ? productionUrl() : loopbackUrl(mockBaseUrl);
|
|
55
|
+
const resolved = operationUrl(base, options.path);
|
|
56
|
+
const url = mock ? loopbackUrl(resolved.href) : resolved;
|
|
57
|
+
if (url.origin !== base.origin) throw new CliError(cliErrors.operationOriginMismatch);
|
|
58
|
+
for (const [key, value] of options.query ?? []) url.searchParams.append(key, value);
|
|
59
|
+
if (dryRun.getStore()) {
|
|
60
|
+
if (options.workflow) previewWorkflow(options.workflow, 'request');
|
|
61
|
+
printSuccess(
|
|
62
|
+
{
|
|
63
|
+
dry_run: true,
|
|
64
|
+
summary: `Would send a ${options.method} request. No API request was sent.`,
|
|
65
|
+
steps: [{ summary: `Send ${options.method} request`, status: 'planned' }],
|
|
66
|
+
method: options.method,
|
|
67
|
+
origin: url.origin,
|
|
68
|
+
path: '[REDACTED]',
|
|
69
|
+
query_parameters: [...(options.query?.keys() ?? [])],
|
|
70
|
+
body_bytes: options.body?.byteLength ?? 0,
|
|
71
|
+
checks: ['Request URL and local command inputs'],
|
|
72
|
+
not_checked: [
|
|
73
|
+
'API permissions and server state',
|
|
74
|
+
'Execution results',
|
|
75
|
+
'Later workflow steps unless explicitly declared',
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
'request',
|
|
79
|
+
);
|
|
80
|
+
throw new DryRunComplete();
|
|
81
|
+
}
|
|
82
|
+
const signal = AbortSignal.timeout(options.timeoutMs ?? 30_000);
|
|
83
|
+
const headers = new Headers();
|
|
84
|
+
if (!mock) headers.set('authorization', `Bearer ${await bearerToken()}`);
|
|
85
|
+
if (options.mediaType) headers.set('content-type', options.mediaType);
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(url, {
|
|
88
|
+
method: options.method,
|
|
89
|
+
headers,
|
|
90
|
+
body: options.body ? Buffer.from(options.body) : undefined,
|
|
91
|
+
redirect: 'manual',
|
|
92
|
+
signal,
|
|
93
|
+
});
|
|
94
|
+
if (response.status >= 300 && response.status < 400) {
|
|
95
|
+
await response.body?.cancel();
|
|
96
|
+
throw new CliError(cliErrors.redirectNotAllowed);
|
|
97
|
+
}
|
|
98
|
+
if (response.status === 401 || response.status === 403) {
|
|
99
|
+
await response.body?.cancel();
|
|
100
|
+
throw new CliError(cliErrors.authFailed);
|
|
101
|
+
}
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
await response.body?.cancel();
|
|
104
|
+
throw new CliError(cliErrors.upstream(response.status));
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
body: new Uint8Array(await response.arrayBuffer()),
|
|
108
|
+
mediaType: response.headers.get('content-type') ?? 'application/octet-stream',
|
|
109
|
+
status: response.status,
|
|
110
|
+
};
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof CliError) throw error;
|
|
113
|
+
if (signal.aborted) throw new CliError(cliErrors.timeout);
|
|
114
|
+
throw new CliError(cliErrors.requestFailed);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function get(
|
|
119
|
+
baseUrl: string | undefined,
|
|
120
|
+
path: string,
|
|
121
|
+
query = new URLSearchParams(),
|
|
122
|
+
timeoutMs = 30_000,
|
|
123
|
+
) {
|
|
124
|
+
return request({ baseUrl, method: 'GET', path, query, timeoutMs });
|
|
125
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { parse, stringify } from 'lossless-json';
|
|
2
|
+
import { CliError, cliErrors } from './errors.js';
|
|
3
|
+
|
|
4
|
+
export function parseJson(body: Uint8Array): unknown {
|
|
5
|
+
try {
|
|
6
|
+
return parse(Buffer.from(body).toString('utf8'));
|
|
7
|
+
} catch {
|
|
8
|
+
throw new CliError(cliErrors.invalidJson);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function printSuccess(data: unknown, command: string): void {
|
|
13
|
+
process.stdout.write(
|
|
14
|
+
stringify({ schema_version: '1.0.0', ok: true, data, meta: { command } }) + '\n',
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function printError(error: CliError): void {
|
|
19
|
+
process.stdout.write(
|
|
20
|
+
JSON.stringify({
|
|
21
|
+
schema_version: '1.0.0',
|
|
22
|
+
ok: false,
|
|
23
|
+
error: {
|
|
24
|
+
code: error.code,
|
|
25
|
+
message: error.message,
|
|
26
|
+
retryable: error.retryable,
|
|
27
|
+
...(error.hint === undefined ? {} : { hint: error.hint }),
|
|
28
|
+
...(error.details === undefined ? {} : { details: error.details }),
|
|
29
|
+
},
|
|
30
|
+
}) + '\n',
|
|
31
|
+
);
|
|
32
|
+
process.exitCode = error.exitCode;
|
|
33
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { Readable, Writable } from 'node:stream';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import { login, logout, status, registerAuthCommands } from '../src/auth/commands.js';
|
|
5
|
+
import { createProgram, runCli } from '../src/cli.js';
|
|
6
|
+
import { bearerToken, credentialStatus, type CredentialStore } from '../src/auth/credentials.js';
|
|
7
|
+
import { readHiddenToken } from '../src/auth/prompt.js';
|
|
8
|
+
import { customer } from '../src/customer.js';
|
|
9
|
+
|
|
10
|
+
function memoryStore(initial?: string) {
|
|
11
|
+
let token = initial;
|
|
12
|
+
const store: CredentialStore = {
|
|
13
|
+
async get() {
|
|
14
|
+
return token;
|
|
15
|
+
},
|
|
16
|
+
async set(value) {
|
|
17
|
+
token = value;
|
|
18
|
+
},
|
|
19
|
+
async delete() {
|
|
20
|
+
const removed = token !== undefined;
|
|
21
|
+
token = undefined;
|
|
22
|
+
return removed;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
return { store, value: () => token };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
void test('JSON login uses environment input and never prompts; dry run never stores it', async (context) => {
|
|
29
|
+
const key = customer.api.auth.environment_variable;
|
|
30
|
+
const original = process.env[key];
|
|
31
|
+
let output = '';
|
|
32
|
+
context.mock.method(process.stdout, 'write', (chunk: string | Uint8Array) => {
|
|
33
|
+
output += String(chunk);
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
36
|
+
const invoke = async (args: string[], memory: ReturnType<typeof memoryStore>) => {
|
|
37
|
+
const program = createProgram();
|
|
38
|
+
registerAuthCommands(program, memory.store, async () => {
|
|
39
|
+
throw new Error('Unexpected prompt');
|
|
40
|
+
});
|
|
41
|
+
await runCli(program, args);
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
delete process.env[key];
|
|
45
|
+
const memory = memoryStore();
|
|
46
|
+
await invoke(['--json', 'login'], memory);
|
|
47
|
+
assert.equal(process.exitCode, 2);
|
|
48
|
+
assert.equal(JSON.parse(output).error.code, 'AUTH_REQUIRED');
|
|
49
|
+
process.exitCode = undefined;
|
|
50
|
+
output = '';
|
|
51
|
+
process.env[key] = 'private-token';
|
|
52
|
+
await invoke(['--json', '--dry-run', 'login'], memory);
|
|
53
|
+
assert.equal(memory.value(), undefined);
|
|
54
|
+
await invoke(['--json', 'login'], memory);
|
|
55
|
+
assert.equal(memory.value(), 'private-token');
|
|
56
|
+
assert.doesNotMatch(output, /private-token/);
|
|
57
|
+
} finally {
|
|
58
|
+
process.exitCode = undefined;
|
|
59
|
+
if (original === undefined) delete process.env[key];
|
|
60
|
+
else process.env[key] = original;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
void test('login, status and logout use a credential store without exposing the token', async () => {
|
|
65
|
+
const memory = memoryStore();
|
|
66
|
+
assert.deepEqual(await login(memory.store, async () => 'private-token'), {
|
|
67
|
+
authenticated: true,
|
|
68
|
+
storage: 'secure-store',
|
|
69
|
+
});
|
|
70
|
+
assert.equal(memory.value(), 'private-token');
|
|
71
|
+
assert.deepEqual(await status(memory.store), {
|
|
72
|
+
api: customer.api.base_url,
|
|
73
|
+
configured: true,
|
|
74
|
+
source: 'secure-store',
|
|
75
|
+
});
|
|
76
|
+
assert.deepEqual(await logout(memory.store), { removed: true });
|
|
77
|
+
assert.equal(memory.value(), undefined);
|
|
78
|
+
await assert.rejects(
|
|
79
|
+
login(memory.store, async () => ''),
|
|
80
|
+
{ code: 'INVALID_INPUT' },
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
void test('environment credentials override the secure store for CI', async () => {
|
|
85
|
+
const name = customer.api.auth.environment_variable;
|
|
86
|
+
const original = process.env[name];
|
|
87
|
+
process.env[name] = 'environment-token';
|
|
88
|
+
try {
|
|
89
|
+
assert.equal(await bearerToken(memoryStore('stored-token').store), 'environment-token');
|
|
90
|
+
assert.deepEqual(await credentialStatus(memoryStore().store), {
|
|
91
|
+
configured: true,
|
|
92
|
+
source: 'environment',
|
|
93
|
+
});
|
|
94
|
+
} finally {
|
|
95
|
+
if (original === undefined) delete process.env[name];
|
|
96
|
+
else process.env[name] = original;
|
|
97
|
+
}
|
|
98
|
+
await assert.rejects(bearerToken(memoryStore().store), { code: 'AUTH_REQUIRED' });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
void test('token prompt rejects non-interactive input without reading or echoing it', async () => {
|
|
102
|
+
let output = '';
|
|
103
|
+
const sink = new Writable({
|
|
104
|
+
write(chunk, _encoding, done) {
|
|
105
|
+
output += String(chunk);
|
|
106
|
+
done();
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
await assert.rejects(readHiddenToken('Paste token', Readable.from('private-token\n'), sink), {
|
|
110
|
+
code: 'INVALID_INPUT',
|
|
111
|
+
});
|
|
112
|
+
assert.equal(output, '');
|
|
113
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createProgram, runCli } from '../src/cli.js';
|
|
2
|
+
import { get, request } from '../src/shared/http.js';
|
|
3
|
+
import { CliError, cliErrors } from '../src/shared/errors.js';
|
|
4
|
+
import { parseJson, printSuccess } from '../src/shared/output.js';
|
|
5
|
+
|
|
6
|
+
const program = createProgram();
|
|
7
|
+
program
|
|
8
|
+
.command('example-command')
|
|
9
|
+
.description('Synthetic command for foundation tests only')
|
|
10
|
+
.requiredOption('--id <id>', 'Exact decimal identifier', (value: string) => {
|
|
11
|
+
if (!/^-?[0-9]+$/.test(value))
|
|
12
|
+
throw new CliError(cliErrors.invalidInput('ID must be an integer.'));
|
|
13
|
+
return value;
|
|
14
|
+
})
|
|
15
|
+
.allowExcessArguments(false)
|
|
16
|
+
.action(async (_options, command) => {
|
|
17
|
+
const options = command.optsWithGlobals() as { id: string; baseUrl: string };
|
|
18
|
+
const result = await get(options.baseUrl, '/records', new URLSearchParams({ id: options.id }));
|
|
19
|
+
printSuccess(parseJson(result.body), 'example-command');
|
|
20
|
+
});
|
|
21
|
+
program
|
|
22
|
+
.command('example-workflow')
|
|
23
|
+
.requiredOption('--id <id>', 'Customer identifier')
|
|
24
|
+
.action(async (_options, command) => {
|
|
25
|
+
const options = command.optsWithGlobals();
|
|
26
|
+
const found = await request({
|
|
27
|
+
baseUrl: options.baseUrl,
|
|
28
|
+
method: 'GET',
|
|
29
|
+
path: '/customers',
|
|
30
|
+
query: new URLSearchParams({ id: options.id }),
|
|
31
|
+
workflow: {
|
|
32
|
+
summary: 'Find the selected customer and create an invoice.',
|
|
33
|
+
steps: [
|
|
34
|
+
{ summary: 'Find the selected customer' },
|
|
35
|
+
{
|
|
36
|
+
summary: 'Create an invoice using the returned customer ID',
|
|
37
|
+
depends_on_response: true,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const customer = JSON.parse(Buffer.from(found.body).toString('utf8'));
|
|
43
|
+
const result = await request({
|
|
44
|
+
baseUrl: options.baseUrl,
|
|
45
|
+
method: 'POST',
|
|
46
|
+
path: '/invoices',
|
|
47
|
+
body: Buffer.from(JSON.stringify({ customer_id: customer.id })),
|
|
48
|
+
});
|
|
49
|
+
printSuccess(parseJson(result.body), 'example-workflow');
|
|
50
|
+
});
|
|
51
|
+
await runCli(program, process.argv.slice(2));
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import { customer } from '../src/customer.js';
|
|
7
|
+
import { get } from '../src/shared/http.js';
|
|
8
|
+
import { saveFile } from '../src/shared/files.js';
|
|
9
|
+
import { parseJson } from '../src/shared/output.js';
|
|
10
|
+
import { mockServer, runCli } from './helpers.js';
|
|
11
|
+
import { dryRun, DryRunComplete, previewWorkflow } from '../src/shared/dry-run.js';
|
|
12
|
+
import { request } from '../src/shared/http.js';
|
|
13
|
+
|
|
14
|
+
void test('workflow preview describes every step without executing response-dependent work', async (context) => {
|
|
15
|
+
let output = '';
|
|
16
|
+
context.mock.method(process.stdout, 'write', (chunk: string | Uint8Array) => {
|
|
17
|
+
output += String(chunk);
|
|
18
|
+
return true;
|
|
19
|
+
});
|
|
20
|
+
const workflow = {
|
|
21
|
+
summary: 'Find a customer and create an invoice.',
|
|
22
|
+
steps: [
|
|
23
|
+
{ summary: 'Find the customer' },
|
|
24
|
+
{ summary: 'Create an invoice', depends_on_response: true },
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
assert.throws(() => dryRun.run(true, () => previewWorkflow(workflow, 'invoice')), DryRunComplete);
|
|
28
|
+
const preview = JSON.parse(output).data;
|
|
29
|
+
assert.equal(preview.summary, workflow.summary);
|
|
30
|
+
assert.equal(preview.steps.length, 2);
|
|
31
|
+
assert.equal(preview.steps[1].status, 'resolved_during_execution');
|
|
32
|
+
assert.ok(preview.not_checked.includes('API permissions and server state'));
|
|
33
|
+
output = '';
|
|
34
|
+
dryRun.run(false, () => previewWorkflow(workflow, 'invoice'));
|
|
35
|
+
assert.equal(output, '');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
void test('dry run validates input and sends no HTTP request', async () => {
|
|
39
|
+
let requests = 0;
|
|
40
|
+
const server = await mockServer((_request, response) => {
|
|
41
|
+
requests++;
|
|
42
|
+
response.end('{}');
|
|
43
|
+
});
|
|
44
|
+
try {
|
|
45
|
+
const result = await runCli([
|
|
46
|
+
'--json',
|
|
47
|
+
'--dry-run',
|
|
48
|
+
'example-command',
|
|
49
|
+
'--id',
|
|
50
|
+
'123',
|
|
51
|
+
'--base-url',
|
|
52
|
+
server.url,
|
|
53
|
+
]);
|
|
54
|
+
assert.equal(result.code, 0, result.stdout);
|
|
55
|
+
assert.equal(result.stderr, '');
|
|
56
|
+
assert.equal(JSON.parse(result.stdout).data.dry_run, true);
|
|
57
|
+
const workflow = await runCli([
|
|
58
|
+
'example-workflow',
|
|
59
|
+
'--id',
|
|
60
|
+
'private-customer',
|
|
61
|
+
'--base-url',
|
|
62
|
+
server.url,
|
|
63
|
+
'--dry-run',
|
|
64
|
+
]);
|
|
65
|
+
assert.equal(workflow.code, 0);
|
|
66
|
+
assert.equal(JSON.parse(workflow.stdout).data.steps.length, 2);
|
|
67
|
+
assert.equal(JSON.parse(workflow.stdout).data.steps[1].status, 'resolved_during_execution');
|
|
68
|
+
assert.doesNotMatch(workflow.stdout, /private-customer/);
|
|
69
|
+
const invalid = await runCli([
|
|
70
|
+
'--dry-run',
|
|
71
|
+
'example-command',
|
|
72
|
+
'--id',
|
|
73
|
+
'invalid',
|
|
74
|
+
'--base-url',
|
|
75
|
+
server.url,
|
|
76
|
+
]);
|
|
77
|
+
assert.equal(invalid.code, 2);
|
|
78
|
+
assert.equal(JSON.parse(invalid.stdout).ok, false);
|
|
79
|
+
assert.equal(requests, 0);
|
|
80
|
+
} finally {
|
|
81
|
+
await server.close();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
void test('dry run blocks mutations and file writes without exposing request values', async (context) => {
|
|
86
|
+
let output = '';
|
|
87
|
+
context.mock.method(process.stdout, 'write', (chunk: string | Uint8Array) => {
|
|
88
|
+
output += String(chunk);
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
91
|
+
context.mock.method(globalThis, 'fetch', () => {
|
|
92
|
+
throw new Error('dry run must not fetch');
|
|
93
|
+
});
|
|
94
|
+
const dir = await mkdtemp(join(tmpdir(), 'cli-dry-file-'));
|
|
95
|
+
try {
|
|
96
|
+
for (const method of ['POST', 'PATCH', 'DELETE'] as const) {
|
|
97
|
+
await assert.rejects(
|
|
98
|
+
dryRun.run(true, () =>
|
|
99
|
+
request({
|
|
100
|
+
method,
|
|
101
|
+
path: '/private-value',
|
|
102
|
+
query: new URLSearchParams({ token: 'private-value' }),
|
|
103
|
+
body: Buffer.from('private-value'),
|
|
104
|
+
}),
|
|
105
|
+
),
|
|
106
|
+
DryRunComplete,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
await assert.rejects(
|
|
110
|
+
dryRun.run(true, () =>
|
|
111
|
+
saveFile(join(dir, 'result'), Buffer.from('private-value'), 'text/plain'),
|
|
112
|
+
),
|
|
113
|
+
DryRunComplete,
|
|
114
|
+
);
|
|
115
|
+
await assert.rejects(readFile(join(dir, 'result')), { code: 'ENOENT' });
|
|
116
|
+
assert.doesNotMatch(output, /private-value/);
|
|
117
|
+
} finally {
|
|
118
|
+
await rm(dir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
void test('root and command help work without configuration; version follows package metadata', async () => {
|
|
123
|
+
for (const args of [[], ['--help'], ['example-command', '--help']]) {
|
|
124
|
+
const result = await runCli(args);
|
|
125
|
+
assert.equal(result.code, 0, result.stderr);
|
|
126
|
+
assert.match(result.stdout, /Usage:/);
|
|
127
|
+
assert.doesNotMatch(result.stdout, /base-url/u);
|
|
128
|
+
assert.equal(result.stderr, '');
|
|
129
|
+
if (args[0] === 'example-command') assert.match(result.stdout, /--id <id>/);
|
|
130
|
+
}
|
|
131
|
+
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
|
|
132
|
+
assert.equal(JSON.parse((await runCli(['--version'])).stdout).data.help.trim(), pkg.version);
|
|
133
|
+
assert.doesNotMatch((await runCli(['--help'], 'src/index.ts')).stdout, /example-command/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
void test('CLI rejects bad input without requests and preserves int64 in requests and output', async () => {
|
|
137
|
+
const requests: string[] = [];
|
|
138
|
+
const server = await mockServer((request, response) => {
|
|
139
|
+
requests.push(request.url!);
|
|
140
|
+
response.end('{"id":9007199254740993,"quoted":"9007199254740993"}');
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
for (const args of [
|
|
144
|
+
[],
|
|
145
|
+
['--id', '1.5'],
|
|
146
|
+
['--id', '1junk'],
|
|
147
|
+
['--id', '1', '--bogus'],
|
|
148
|
+
['--id', '1', 'extra'],
|
|
149
|
+
]) {
|
|
150
|
+
const result = await runCli(['example-command', '--base-url', server.url, ...args]);
|
|
151
|
+
assert.equal(result.code, 2);
|
|
152
|
+
assert.equal(result.stderr, '');
|
|
153
|
+
const error = JSON.parse(result.stdout).error;
|
|
154
|
+
assert.equal(error.code, 'INVALID_INPUT');
|
|
155
|
+
assert.equal(error.hint, 'Run the command with --help to see valid inputs.');
|
|
156
|
+
assert.deepEqual(error.details, { reason: 'invalid_arguments' });
|
|
157
|
+
assert.equal(requests.length, 0);
|
|
158
|
+
}
|
|
159
|
+
const unknown = await runCli(['unknown']);
|
|
160
|
+
assert.equal(unknown.code, 2);
|
|
161
|
+
assert.equal(JSON.parse(unknown.stdout).error.code, 'UNKNOWN_COMMAND');
|
|
162
|
+
const result = await runCli([
|
|
163
|
+
'example-command',
|
|
164
|
+
'--id',
|
|
165
|
+
'9007199254740993',
|
|
166
|
+
'--base-url',
|
|
167
|
+
server.url,
|
|
168
|
+
]);
|
|
169
|
+
assert.equal(result.code, 0, result.stderr);
|
|
170
|
+
assert.equal(
|
|
171
|
+
result.stdout,
|
|
172
|
+
'{"schema_version":"1.0.0","ok":true,"data":{"id":9007199254740993,"quoted":"9007199254740993"},"meta":{"command":"example-command"}}\n',
|
|
173
|
+
);
|
|
174
|
+
assert.deepEqual(requests, ['/records?id=9007199254740993']);
|
|
175
|
+
} finally {
|
|
176
|
+
await server.close();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
void test('URL restrictions apply to the resolved destination without leaking credentials', async () => {
|
|
181
|
+
for (const value of [
|
|
182
|
+
'',
|
|
183
|
+
'bad-url',
|
|
184
|
+
'https://example.com',
|
|
185
|
+
'file:///tmp/test',
|
|
186
|
+
'http://user:secret@localhost',
|
|
187
|
+
]) {
|
|
188
|
+
await assert.rejects(get(value, '/records'), { code: 'INVALID_CONFIG' });
|
|
189
|
+
}
|
|
190
|
+
for (const path of ['https://example.com', '//example.com', 'http://127.0.0.1:99/records']) {
|
|
191
|
+
await assert.rejects(get('http://127.0.0.1:1', path), { code: 'INVALID_CONFIG' });
|
|
192
|
+
}
|
|
193
|
+
const result = await runCli([
|
|
194
|
+
'example-command',
|
|
195
|
+
'--id',
|
|
196
|
+
'1',
|
|
197
|
+
'--base-url',
|
|
198
|
+
'http://user:secret@localhost',
|
|
199
|
+
]);
|
|
200
|
+
assert.equal(result.code, 2);
|
|
201
|
+
assert.doesNotMatch(result.stdout, /user|secret/);
|
|
202
|
+
assert.equal(JSON.parse(result.stdout).error.code, 'INVALID_CONFIG');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
void test('production requests use Bearer authentication while mocks receive no token', async () => {
|
|
206
|
+
const originalFetch = globalThis.fetch;
|
|
207
|
+
const tokenName = customer.api.auth.environment_variable;
|
|
208
|
+
const originalToken = process.env[tokenName];
|
|
209
|
+
let authorization: string | null = null;
|
|
210
|
+
process.env[tokenName] = 'private-token';
|
|
211
|
+
globalThis.fetch = (input, init) => {
|
|
212
|
+
authorization = new Headers(init?.headers).get('authorization');
|
|
213
|
+
assert.equal(String(input), `${customer.api.base_url}/records`);
|
|
214
|
+
return Promise.resolve(new Response('{}'));
|
|
215
|
+
};
|
|
216
|
+
try {
|
|
217
|
+
await get(undefined, '/records');
|
|
218
|
+
assert.equal(authorization, 'Bearer private-token');
|
|
219
|
+
} finally {
|
|
220
|
+
globalThis.fetch = originalFetch;
|
|
221
|
+
if (originalToken === undefined) delete process.env[tokenName];
|
|
222
|
+
else process.env[tokenName] = originalToken;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const server = await mockServer((request, response) => {
|
|
226
|
+
assert.equal(request.headers.authorization, undefined);
|
|
227
|
+
response.end('{}');
|
|
228
|
+
});
|
|
229
|
+
try {
|
|
230
|
+
await get(server.url, '/records');
|
|
231
|
+
} finally {
|
|
232
|
+
await server.close();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
void test('HTTP errors are redacted, redirects are not followed and query encoding is exact', async () => {
|
|
237
|
+
const paths: string[] = [];
|
|
238
|
+
const server = await mockServer((request, response) => {
|
|
239
|
+
paths.push(request.url!);
|
|
240
|
+
if (request.url === '/error') {
|
|
241
|
+
response.writeHead(503, { 'content-type': 'application/problem+json' });
|
|
242
|
+
response.end('{"detail":"secret-token"}');
|
|
243
|
+
} else if (request.url === '/redirect') {
|
|
244
|
+
response.writeHead(302, { location: '/target' });
|
|
245
|
+
response.end();
|
|
246
|
+
} else response.end('ok');
|
|
247
|
+
});
|
|
248
|
+
try {
|
|
249
|
+
await assert.rejects(get(server.url, '/error'), {
|
|
250
|
+
code: 'UPSTREAM_ERROR',
|
|
251
|
+
message: 'API returned HTTP 503.',
|
|
252
|
+
hint: 'Check the API status and request inputs, then retry when appropriate.',
|
|
253
|
+
details: { status: 503 },
|
|
254
|
+
retryable: false,
|
|
255
|
+
});
|
|
256
|
+
await assert.rejects(get(server.url, '/redirect'), { code: 'REQUEST_FAILED' });
|
|
257
|
+
await get(
|
|
258
|
+
server.url,
|
|
259
|
+
'/records',
|
|
260
|
+
new URLSearchParams([
|
|
261
|
+
['name', 'a/b & c'],
|
|
262
|
+
['tag', 'one'],
|
|
263
|
+
['tag', 'two'],
|
|
264
|
+
]),
|
|
265
|
+
);
|
|
266
|
+
assert.deepEqual(paths, ['/error', '/redirect', '/records?name=a%2Fb+%26+c&tag=one&tag=two']);
|
|
267
|
+
} finally {
|
|
268
|
+
await server.close();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
void test('timeouts cover response headers and body; transport failure stays distinct', async () => {
|
|
273
|
+
for (const sendHeaders of [false, true]) {
|
|
274
|
+
const server = await mockServer((_request, response) => {
|
|
275
|
+
if (sendHeaders) {
|
|
276
|
+
response.writeHead(200);
|
|
277
|
+
response.write('partial');
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
try {
|
|
281
|
+
await assert.rejects(get(server.url, '/', undefined, 100), {
|
|
282
|
+
code: 'REQUEST_TIMEOUT',
|
|
283
|
+
retryable: true,
|
|
284
|
+
});
|
|
285
|
+
} finally {
|
|
286
|
+
await server.close();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const server = await mockServer((_request, response) => response.end());
|
|
290
|
+
await server.close();
|
|
291
|
+
await assert.rejects(get(server.url, '/'), { code: 'REQUEST_FAILED', retryable: true });
|
|
292
|
+
assert.throws(() => parseJson(Buffer.from('invalid')), { code: 'REQUEST_FAILED' });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
void test('file output preserves bytes and refuses to overwrite', async () => {
|
|
296
|
+
const dir = await mkdtemp(join(tmpdir(), 'cli-files-'));
|
|
297
|
+
const path = join(dir, 'artifact');
|
|
298
|
+
const body = new Uint8Array([0, 255, 128, 10]);
|
|
299
|
+
try {
|
|
300
|
+
assert.deepEqual(await saveFile(path, body, 'application/octet-stream'), {
|
|
301
|
+
path,
|
|
302
|
+
media_type: 'application/octet-stream',
|
|
303
|
+
});
|
|
304
|
+
assert.deepEqual(await readFile(path), Buffer.from(body));
|
|
305
|
+
await assert.rejects(
|
|
306
|
+
dryRun.run(true, () => saveFile(path, body, 'text/plain')),
|
|
307
|
+
{ code: 'FILE_WRITE_FAILED' },
|
|
308
|
+
);
|
|
309
|
+
await assert.rejects(saveFile(path, Buffer.from('changed'), 'text/plain'), {
|
|
310
|
+
code: 'FILE_WRITE_FAILED',
|
|
311
|
+
});
|
|
312
|
+
assert.deepEqual(await readFile(path), Buffer.from(body));
|
|
313
|
+
} finally {
|
|
314
|
+
await rm(dir, { recursive: true, force: true });
|
|
315
|
+
}
|
|
316
|
+
});
|