@agentrhq/webcmd 0.3.0 → 0.3.2
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/README.md +13 -2
- package/cli-manifest.json +228 -24
- package/clis/chatgpt/ask.js +1 -1
- package/clis/chatgpt/ask.test.js +11 -0
- package/clis/chatgpt/commands.test.js +102 -1
- package/clis/chatgpt/deep-research-result.js +45 -3
- package/clis/chatgpt/image.js +22 -2
- package/clis/chatgpt/project-file-add.js +24 -1
- package/clis/chatgpt/utils.js +258 -52
- package/clis/chatgpt/utils.test.js +259 -1
- package/clis/claude/ask.js +21 -1
- package/clis/facebook/feed.js +110 -24
- package/clis/facebook/feed.test.js +62 -0
- package/clis/facebook/search.js +185 -38
- package/clis/facebook/search.test.js +95 -50
- package/clis/instagram/explore.js +30 -12
- package/clis/instagram/explore.test.js +77 -0
- package/clis/instagram/post.js +14 -1
- package/clis/instagram/reel.js +13 -1
- package/clis/linkedin/company.js +153 -0
- package/clis/linkedin/company.test.js +111 -0
- package/clis/linkedin/connections.js +135 -0
- package/clis/linkedin/connections.test.js +141 -0
- package/clis/mercury/reimbursement-draft.js +12 -1
- package/clis/producthunt/hot.js +1 -2
- package/clis/spotify/spotify.js +11 -11
- package/clis/twitter/article-evaluate.test.js +27 -0
- package/clis/twitter/article.js +73 -16
- package/clis/twitter/article.test.js +209 -0
- package/clis/twitter/download.js +6 -1
- package/clis/twitter/post.js +14 -1
- package/clis/twitter/profile.js +1 -1
- package/clis/twitter/profile.test.js +8 -0
- package/clis/twitter/quote.js +11 -1
- package/clis/twitter/reply.js +11 -1
- package/dist/src/browser/base-page.d.ts +26 -0
- package/dist/src/browser/base-page.js +23 -8
- package/dist/src/browser/base-page.test.js +45 -12
- package/dist/src/browser/command-catalog.d.ts +6 -0
- package/dist/src/browser/command-catalog.js +259 -0
- package/dist/src/browser/command-catalog.test.d.ts +1 -0
- package/dist/src/browser/command-catalog.test.js +79 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +7 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +23 -9
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
- package/dist/src/browser/target-resolver.d.ts +1 -0
- package/dist/src/browser/target-resolver.js +99 -4
- package/dist/src/browser/target-resolver.test.js +141 -1
- package/dist/src/build-manifest.d.ts +6 -0
- package/dist/src/build-manifest.js +18 -1
- package/dist/src/build-manifest.test.js +57 -1
- package/dist/src/builtin-command-surface.d.ts +9 -0
- package/dist/src/builtin-command-surface.js +16 -0
- package/dist/src/check-hosted-contract.test.d.ts +1 -0
- package/dist/src/check-hosted-contract.test.js +56 -0
- package/dist/src/cli.js +58 -106
- package/dist/src/cli.test.js +71 -1
- package/dist/src/command-presentation.d.ts +95 -0
- package/dist/src/command-presentation.js +486 -0
- package/dist/src/command-presentation.test.d.ts +1 -0
- package/dist/src/command-presentation.test.js +97 -0
- package/dist/src/command-surface.d.ts +43 -0
- package/dist/src/command-surface.js +205 -0
- package/dist/src/command-surface.test.d.ts +1 -0
- package/dist/src/command-surface.test.js +406 -0
- package/dist/src/commanderAdapter.d.ts +5 -1
- package/dist/src/commanderAdapter.js +12 -55
- package/dist/src/commanderAdapter.test.js +15 -3
- package/dist/src/community-plugin-sync.d.ts +11 -0
- package/dist/src/community-plugin-sync.js +138 -0
- package/dist/src/community-plugin-sync.test.d.ts +1 -0
- package/dist/src/community-plugin-sync.test.js +123 -0
- package/dist/src/completion-fast.d.ts +4 -4
- package/dist/src/completion-fast.js +15 -34
- package/dist/src/completion-shared.d.ts +4 -0
- package/dist/src/completion-shared.js +38 -0
- package/dist/src/completion.js +3 -27
- package/dist/src/completion.test.js +31 -2
- package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
- package/dist/src/docs-sync-review-cli.test.js +322 -0
- package/dist/src/docs-sync-review.d.ts +117 -0
- package/dist/src/docs-sync-review.js +475 -0
- package/dist/src/docs-sync-review.test.d.ts +1 -0
- package/dist/src/docs-sync-review.test.js +324 -0
- package/dist/src/errors.d.ts +12 -3
- package/dist/src/errors.js +11 -7
- package/dist/src/errors.test.js +14 -1
- package/dist/src/execution.d.ts +1 -2
- package/dist/src/execution.js +4 -45
- package/dist/src/generate-release-notes-cli.test.js +7 -2
- package/dist/src/help.d.ts +4 -0
- package/dist/src/help.js +50 -255
- package/dist/src/help.test.js +27 -1
- package/dist/src/hosted/args.d.ts +2 -7
- package/dist/src/hosted/args.js +2 -99
- package/dist/src/hosted/args.test.js +15 -1
- package/dist/src/hosted/availability.d.ts +13 -0
- package/dist/src/hosted/availability.js +16 -0
- package/dist/src/hosted/availability.test.d.ts +1 -0
- package/dist/src/hosted/availability.test.js +191 -0
- package/dist/src/hosted/browser-args.d.ts +18 -0
- package/dist/src/hosted/browser-args.js +152 -0
- package/dist/src/hosted/browser-args.test.d.ts +1 -0
- package/dist/src/hosted/browser-args.test.js +182 -0
- package/dist/src/hosted/client.d.ts +31 -2
- package/dist/src/hosted/client.js +441 -53
- package/dist/src/hosted/client.test.js +651 -32
- package/dist/src/hosted/config.d.ts +7 -2
- package/dist/src/hosted/config.js +26 -4
- package/dist/src/hosted/config.test.js +34 -1
- package/dist/src/hosted/contract.d.ts +94 -0
- package/dist/src/hosted/contract.js +208 -0
- package/dist/src/hosted/contract.test.d.ts +1 -0
- package/dist/src/hosted/contract.test.js +361 -0
- package/dist/src/hosted/credentials.d.ts +38 -0
- package/dist/src/hosted/credentials.js +248 -0
- package/dist/src/hosted/credentials.test.d.ts +1 -0
- package/dist/src/hosted/credentials.test.js +93 -0
- package/dist/src/hosted/file-contract.test.d.ts +1 -0
- package/dist/src/hosted/file-contract.test.js +174 -0
- package/dist/src/hosted/files.d.ts +29 -0
- package/dist/src/hosted/files.js +296 -0
- package/dist/src/hosted/files.test.d.ts +1 -0
- package/dist/src/hosted/files.test.js +231 -0
- package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
- package/dist/src/hosted/main-lifecycle.test.js +213 -0
- package/dist/src/hosted/manifest.d.ts +5 -0
- package/dist/src/hosted/manifest.js +21 -66
- package/dist/src/hosted/manifest.test.js +120 -2
- package/dist/src/hosted/output-parity.test.d.ts +1 -0
- package/dist/src/hosted/output-parity.test.js +108 -0
- package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
- package/dist/src/hosted/root-command-surface.test.js +646 -0
- package/dist/src/hosted/runner.d.ts +7 -0
- package/dist/src/hosted/runner.js +554 -253
- package/dist/src/hosted/runner.test.js +1471 -53
- package/dist/src/hosted/setup.d.ts +3 -2
- package/dist/src/hosted/setup.js +45 -15
- package/dist/src/hosted/setup.test.js +133 -6
- package/dist/src/hosted/types.d.ts +101 -23
- package/dist/src/main.js +120 -108
- package/dist/src/manifest-types.d.ts +2 -0
- package/dist/src/output.d.ts +17 -2
- package/dist/src/output.js +88 -81
- package/dist/src/output.test.js +141 -51
- package/dist/src/pipeline/executor.test.js +1 -0
- package/dist/src/pipeline/steps/download.test.js +1 -0
- package/dist/src/plugin-create-cli.test.d.ts +1 -0
- package/dist/src/plugin-create-cli.test.js +37 -0
- package/dist/src/plugin-manifest.d.ts +12 -0
- package/dist/src/plugin-manifest.js +16 -0
- package/dist/src/plugin-manifest.test.js +19 -1
- package/dist/src/plugin-scaffold.d.ts +4 -1
- package/dist/src/plugin-scaffold.js +4 -1
- package/dist/src/plugin-scaffold.test.js +23 -8
- package/dist/src/plugin.js +4 -1
- package/dist/src/plugin.test.js +13 -0
- package/dist/src/registry.d.ts +2 -0
- package/dist/src/release-notes.js +12 -4
- package/dist/src/release-notes.test.js +27 -15
- package/dist/src/root-command-surface.d.ts +31 -0
- package/dist/src/root-command-surface.js +106 -0
- package/dist/src/serialization.d.ts +1 -16
- package/dist/src/serialization.js +5 -55
- package/dist/src/stream-write.d.ts +12 -0
- package/dist/src/stream-write.js +91 -0
- package/dist/src/stream-write.test.d.ts +1 -0
- package/dist/src/stream-write.test.js +186 -0
- package/dist/src/types.d.ts +5 -0
- package/dist/src/utils.d.ts +1 -1
- package/dist/src/utils.js +2 -8
- package/dist/src/utils.test.js +50 -0
- package/hosted-contract.json +36759 -0
- package/package.json +6 -1
- package/scripts/check-hosted-contract.mjs +108 -0
- package/scripts/docs-sync-review.ts +332 -0
- package/scripts/sync-community-plugins.ts +14 -0
- package/skills/webcmd-autofix/SKILL.md +26 -37
- package/skills/webcmd-usage/SKILL.md +7 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { materializeHostedOutputs, prepareHostedFiles, rewriteHostedOutputResultPaths } from './files.js';
|
|
7
|
+
let tempDir;
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
if (tempDir)
|
|
10
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
11
|
+
tempDir = undefined;
|
|
12
|
+
});
|
|
13
|
+
const fileCommand = {
|
|
14
|
+
site: 'twitter',
|
|
15
|
+
name: 'post',
|
|
16
|
+
command: 'twitter/post',
|
|
17
|
+
description: 'Post',
|
|
18
|
+
access: 'write',
|
|
19
|
+
strategy: 'UI',
|
|
20
|
+
browser: true,
|
|
21
|
+
columns: ['ok'],
|
|
22
|
+
args: [
|
|
23
|
+
{ name: 'text', positional: true, required: true },
|
|
24
|
+
{
|
|
25
|
+
name: 'images',
|
|
26
|
+
file: {
|
|
27
|
+
direction: 'input',
|
|
28
|
+
pathKind: 'file',
|
|
29
|
+
multiple: true,
|
|
30
|
+
separator: ',',
|
|
31
|
+
contentTypes: ['image/png'],
|
|
32
|
+
maxBytes: 1024,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'output',
|
|
37
|
+
file: {
|
|
38
|
+
direction: 'output',
|
|
39
|
+
pathKind: 'directory',
|
|
40
|
+
multiple: false,
|
|
41
|
+
defaultPath: './downloads',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
describe('hosted file transfer helpers', () => {
|
|
47
|
+
it('uploads declared input files and reserves declared outputs without leaking local paths', async () => {
|
|
48
|
+
tempDir = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-files-'));
|
|
49
|
+
const one = path.join(tempDir, 'one.png');
|
|
50
|
+
const two = path.join(tempDir, 'two.png');
|
|
51
|
+
await writeFile(one, 'one');
|
|
52
|
+
await writeFile(two, 'two');
|
|
53
|
+
const client = fakeClient();
|
|
54
|
+
const prepared = await prepareHostedFiles({
|
|
55
|
+
client,
|
|
56
|
+
command: fileCommand,
|
|
57
|
+
cwd: tempDir,
|
|
58
|
+
args: {
|
|
59
|
+
text: 'hello',
|
|
60
|
+
images: './one.png,./two.png',
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
expect(client.prepareExecution).toHaveBeenCalledWith({ command: 'twitter/post' });
|
|
64
|
+
expect(client.uploadExecutionArtifact).toHaveBeenCalledTimes(2);
|
|
65
|
+
expect(client.uploadExecutionArtifact).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
|
66
|
+
executionId: 'exec_prepared',
|
|
67
|
+
argument: 'images',
|
|
68
|
+
filename: 'one.png',
|
|
69
|
+
contentType: 'image/png',
|
|
70
|
+
body: new Uint8Array(Buffer.from('one')),
|
|
71
|
+
}));
|
|
72
|
+
expect(prepared.args).toEqual({
|
|
73
|
+
text: 'hello',
|
|
74
|
+
images: [
|
|
75
|
+
{ $webcmdArtifact: { id: 'artifact_1', direction: 'input' } },
|
|
76
|
+
{ $webcmdArtifact: { id: 'artifact_2', direction: 'input' } },
|
|
77
|
+
],
|
|
78
|
+
output: {
|
|
79
|
+
$webcmdArtifact: {
|
|
80
|
+
direction: 'output',
|
|
81
|
+
filename: 'downloads',
|
|
82
|
+
contentType: 'application/octet-stream',
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
expect(prepared.outputs).toEqual([{
|
|
87
|
+
argument: 'output',
|
|
88
|
+
pathKind: 'directory',
|
|
89
|
+
localPath: path.join(tempDir, 'downloads'),
|
|
90
|
+
}]);
|
|
91
|
+
expect(JSON.stringify(prepared.args)).not.toContain(tempDir);
|
|
92
|
+
});
|
|
93
|
+
it('downloads output receipts, verifies hashes, and writes files atomically', async () => {
|
|
94
|
+
tempDir = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-files-'));
|
|
95
|
+
const body = new Uint8Array(Buffer.from('hello cloud'));
|
|
96
|
+
const client = {
|
|
97
|
+
downloadExecutionArtifact: vi.fn(async () => body),
|
|
98
|
+
};
|
|
99
|
+
const response = responseWithArtifact({
|
|
100
|
+
artifactId: 'artifact_out',
|
|
101
|
+
relativePath: 'nested/result.txt',
|
|
102
|
+
byteSize: body.byteLength,
|
|
103
|
+
sha256: sha256Hex(body),
|
|
104
|
+
});
|
|
105
|
+
const materialized = await materializeHostedOutputs({
|
|
106
|
+
client,
|
|
107
|
+
response,
|
|
108
|
+
outputs: [{
|
|
109
|
+
argument: 'output',
|
|
110
|
+
pathKind: 'directory',
|
|
111
|
+
localPath: path.join(tempDir, 'downloads'),
|
|
112
|
+
}],
|
|
113
|
+
});
|
|
114
|
+
await expect(readFile(path.join(tempDir, 'downloads', 'nested', 'result.txt'), 'utf8')).resolves.toBe('hello cloud');
|
|
115
|
+
expect(rewriteHostedOutputResultPaths({
|
|
116
|
+
file: '📁 /private/cloud-artifacts/output-directory/nested/result.txt',
|
|
117
|
+
}, materialized)).toEqual({
|
|
118
|
+
file: `📁 ${path.join(tempDir, 'downloads', 'nested', 'result.txt')}`,
|
|
119
|
+
});
|
|
120
|
+
expect(client.downloadExecutionArtifact).toHaveBeenCalledWith({
|
|
121
|
+
executionId: 'exec_success',
|
|
122
|
+
artifactId: 'artifact_out',
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
it('validates local inputs before creating a prepared cloud execution', async () => {
|
|
126
|
+
tempDir = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-files-'));
|
|
127
|
+
const client = fakeClient();
|
|
128
|
+
await expect(prepareHostedFiles({
|
|
129
|
+
client,
|
|
130
|
+
command: fileCommand,
|
|
131
|
+
cwd: tempDir,
|
|
132
|
+
args: {
|
|
133
|
+
text: 'hello',
|
|
134
|
+
images: './missing.png',
|
|
135
|
+
},
|
|
136
|
+
})).rejects.toMatchObject({ code: 'HOSTED_FILE_NOT_FOUND' });
|
|
137
|
+
expect(client.prepareExecution).not.toHaveBeenCalled();
|
|
138
|
+
expect(client.uploadExecutionArtifact).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
it('rejects unsafe relative output paths before downloading', async () => {
|
|
141
|
+
tempDir = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-files-'));
|
|
142
|
+
const client = {
|
|
143
|
+
downloadExecutionArtifact: vi.fn(),
|
|
144
|
+
};
|
|
145
|
+
await expect(materializeHostedOutputs({
|
|
146
|
+
client,
|
|
147
|
+
response: responseWithArtifact({
|
|
148
|
+
artifactId: 'artifact_out',
|
|
149
|
+
relativePath: '../escape.txt',
|
|
150
|
+
byteSize: 0,
|
|
151
|
+
}),
|
|
152
|
+
outputs: [{
|
|
153
|
+
argument: 'output',
|
|
154
|
+
pathKind: 'directory',
|
|
155
|
+
localPath: path.join(tempDir, 'downloads'),
|
|
156
|
+
}],
|
|
157
|
+
})).rejects.toMatchObject({ code: 'HOSTED_FILE_OUTPUT_INVALID' });
|
|
158
|
+
expect(client.downloadExecutionArtifact).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
it('removes temporary files when download hash verification fails', async () => {
|
|
161
|
+
tempDir = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-files-'));
|
|
162
|
+
const client = {
|
|
163
|
+
downloadExecutionArtifact: vi.fn(async () => new Uint8Array(Buffer.from('bad body'))),
|
|
164
|
+
};
|
|
165
|
+
await expect(materializeHostedOutputs({
|
|
166
|
+
client,
|
|
167
|
+
response: responseWithArtifact({
|
|
168
|
+
artifactId: 'artifact_out',
|
|
169
|
+
relativePath: 'result.txt',
|
|
170
|
+
byteSize: 8,
|
|
171
|
+
sha256: sha256Hex(new Uint8Array(Buffer.from('expected'))),
|
|
172
|
+
}),
|
|
173
|
+
outputs: [{
|
|
174
|
+
argument: 'output',
|
|
175
|
+
pathKind: 'directory',
|
|
176
|
+
localPath: path.join(tempDir, 'downloads'),
|
|
177
|
+
}],
|
|
178
|
+
})).rejects.toMatchObject({ code: 'HOSTED_FILE_HASH_MISMATCH' });
|
|
179
|
+
const entries = await readdir(path.join(tempDir, 'downloads')).catch(() => []);
|
|
180
|
+
expect(entries.filter(entry => entry.includes('.tmp'))).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
function fakeClient() {
|
|
184
|
+
let uploads = 0;
|
|
185
|
+
return {
|
|
186
|
+
prepareExecution: vi.fn(async () => ({
|
|
187
|
+
ok: true,
|
|
188
|
+
execution: { id: 'exec_prepared', command: 'twitter/post', status: 'queued' },
|
|
189
|
+
fileArguments: [],
|
|
190
|
+
})),
|
|
191
|
+
uploadExecutionArtifact: vi.fn(async (input) => {
|
|
192
|
+
uploads += 1;
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
artifact: {
|
|
196
|
+
artifactId: `artifact_${uploads}`,
|
|
197
|
+
argument: input.argument,
|
|
198
|
+
direction: 'input',
|
|
199
|
+
pathKind: 'file',
|
|
200
|
+
filename: `file_${uploads}.png`,
|
|
201
|
+
contentType: 'image/png',
|
|
202
|
+
byteSize: 3,
|
|
203
|
+
expiresAt: '2026-07-15T00:00:00.000Z',
|
|
204
|
+
},
|
|
205
|
+
reference: { $webcmdArtifact: { id: `artifact_${uploads}`, direction: 'input' } },
|
|
206
|
+
};
|
|
207
|
+
}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function responseWithArtifact(input) {
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
result: [{ ok: true }],
|
|
214
|
+
execution: { id: 'exec_success', command: 'twitter/post', status: 'succeeded' },
|
|
215
|
+
artifacts: [{
|
|
216
|
+
artifactId: input.artifactId,
|
|
217
|
+
argument: 'output',
|
|
218
|
+
direction: 'output',
|
|
219
|
+
pathKind: 'file',
|
|
220
|
+
filename: path.basename(input.relativePath),
|
|
221
|
+
contentType: 'text/plain',
|
|
222
|
+
byteSize: input.byteSize,
|
|
223
|
+
...(input.sha256 !== undefined ? { sha256: input.sha256 } : {}),
|
|
224
|
+
relativePath: input.relativePath,
|
|
225
|
+
expiresAt: '2026-07-15T00:00:00.000Z',
|
|
226
|
+
}],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function sha256Hex(body) {
|
|
230
|
+
return createHash('sha256').update(body).digest('hex');
|
|
231
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
9
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
10
|
+
const entrypoint = path.join(packageRoot, 'src/main.ts');
|
|
11
|
+
const largeOutput = 'hosted-lifecycle-chunk\n'.repeat(64 * 1024);
|
|
12
|
+
const traceReceipt = 'https://api.example.test/v1/traces/exec_lifecycle';
|
|
13
|
+
const tempRoots = [];
|
|
14
|
+
const servers = [];
|
|
15
|
+
const command = {
|
|
16
|
+
site: 'lifecycle',
|
|
17
|
+
name: 'stream',
|
|
18
|
+
command: 'lifecycle/stream',
|
|
19
|
+
description: 'Exercise hosted process lifecycle',
|
|
20
|
+
access: 'read',
|
|
21
|
+
strategy: 'PUBLIC',
|
|
22
|
+
browser: false,
|
|
23
|
+
args: [],
|
|
24
|
+
columns: ['value'],
|
|
25
|
+
defaultFormat: 'plain',
|
|
26
|
+
};
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await Promise.all(servers.splice(0).map(server => new Promise((resolve, reject) => {
|
|
29
|
+
server.close(error => error ? reject(error) : resolve());
|
|
30
|
+
})));
|
|
31
|
+
await Promise.all(tempRoots.splice(0).map(root => rm(root, { recursive: true, force: true })));
|
|
32
|
+
});
|
|
33
|
+
describe('hosted CLI process lifecycle', () => {
|
|
34
|
+
it.each([
|
|
35
|
+
{ name: 'version', argv: ['--version'] },
|
|
36
|
+
{ name: 'shell completion', argv: ['completion', 'bash'] },
|
|
37
|
+
])('preserves the legacy immediate-exit behavior for a backpressured $name fast path', async ({ argv }) => {
|
|
38
|
+
const fixture = await createHostedFixture('success');
|
|
39
|
+
const preload = await createDelayedStdoutPreload(path.dirname(fixture.discoverySentinel));
|
|
40
|
+
const result = await runCli(argv, fixture.env, [preload]);
|
|
41
|
+
expect(result.status).toBe(0);
|
|
42
|
+
expect(result.stdout).toBe('');
|
|
43
|
+
expect(result.stderr).toBe('');
|
|
44
|
+
await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
45
|
+
}, 20_000);
|
|
46
|
+
it('flushes delayed output and trace bytes, returns success, and never enters local discovery', async () => {
|
|
47
|
+
const fixture = await createHostedFixture('success');
|
|
48
|
+
const result = await runCli(['lifecycle', 'stream', '-f', 'plain', '--trace', 'on'], fixture.env);
|
|
49
|
+
expect(result.status).toBe(0);
|
|
50
|
+
expect(createHash('sha256').update(result.stdout).digest('hex'))
|
|
51
|
+
.toBe(createHash('sha256').update(`${largeOutput}\n`).digest('hex'));
|
|
52
|
+
expect(result.stdout).toBe(`${largeOutput}\n`);
|
|
53
|
+
expect(result.stderr).toBe(`Webcmd trace artifact: ${traceReceipt}\n`);
|
|
54
|
+
await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
55
|
+
}, 20_000);
|
|
56
|
+
it('flushes the typed error envelope and returns its public exit status without local discovery', async () => {
|
|
57
|
+
const fixture = await createHostedFixture('failure');
|
|
58
|
+
const result = await runCli(['lifecycle', 'stream', '-f', 'plain'], fixture.env);
|
|
59
|
+
expect(result.status).toBe(69);
|
|
60
|
+
expect(result.stdout).toBe('');
|
|
61
|
+
expect(result.stderr).toBe([
|
|
62
|
+
'ok: false',
|
|
63
|
+
'error:',
|
|
64
|
+
' code: LIFECYCLE_FAILURE',
|
|
65
|
+
' message: The hosted lifecycle fixture failed.',
|
|
66
|
+
' help: Retry the lifecycle fixture.',
|
|
67
|
+
' exitCode: 69',
|
|
68
|
+
'',
|
|
69
|
+
].join('\n'));
|
|
70
|
+
await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
71
|
+
}, 20_000);
|
|
72
|
+
it('keeps an unknown first site authoritative with trailing tokens and never enters local discovery', async () => {
|
|
73
|
+
const fixture = await createHostedFixture('success');
|
|
74
|
+
const result = await runCli(['missing-site', 'child', '--format', 'json'], fixture.env);
|
|
75
|
+
expect(result.status).toBe(2);
|
|
76
|
+
expect(result.stderr).toBe("error: unknown command 'missing-site'\n");
|
|
77
|
+
expect(result.stdout).toContain('Local-only commands:');
|
|
78
|
+
await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
79
|
+
}, 20_000);
|
|
80
|
+
});
|
|
81
|
+
async function createHostedFixture(outcome) {
|
|
82
|
+
const root = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-lifecycle-'));
|
|
83
|
+
tempRoots.push(root);
|
|
84
|
+
const configDir = path.join(root, 'config');
|
|
85
|
+
const userClis = path.join(root, '.webcmd', 'clis', 'lifecycle-sentinel');
|
|
86
|
+
const discoverySentinel = path.join(root, 'local-discovery-ran');
|
|
87
|
+
await mkdir(configDir, { recursive: true });
|
|
88
|
+
await mkdir(userClis, { recursive: true });
|
|
89
|
+
await writeFile(path.join(userClis, 'sentinel.js'), [
|
|
90
|
+
"import { writeFileSync } from 'node:fs';",
|
|
91
|
+
`writeFileSync(${JSON.stringify(discoverySentinel)}, 'read');`,
|
|
92
|
+
"export const sentinel = 'cli(';",
|
|
93
|
+
'',
|
|
94
|
+
].join('\n'));
|
|
95
|
+
const server = createServer((request, response) => {
|
|
96
|
+
if (request.url === '/v1/manifest') {
|
|
97
|
+
sendChunkedJson(response, {
|
|
98
|
+
ok: true,
|
|
99
|
+
manifest: {
|
|
100
|
+
userId: 'user_lifecycle',
|
|
101
|
+
metadata: {
|
|
102
|
+
contractSchemaVersion: 1,
|
|
103
|
+
webcmdPackageVersion: '0.3.0',
|
|
104
|
+
generatedAt: '2026-07-14T00:00:00.000Z',
|
|
105
|
+
},
|
|
106
|
+
commands: [command],
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (request.url === '/v1/execute' && request.method === 'POST') {
|
|
112
|
+
if (outcome === 'failure') {
|
|
113
|
+
sendChunkedJson(response, {
|
|
114
|
+
ok: false,
|
|
115
|
+
error: {
|
|
116
|
+
code: 'LIFECYCLE_FAILURE',
|
|
117
|
+
message: 'The hosted lifecycle fixture failed.',
|
|
118
|
+
help: 'Retry the lifecycle fixture.',
|
|
119
|
+
exitCode: 69,
|
|
120
|
+
},
|
|
121
|
+
execution: { id: 'exec_lifecycle', command: command.command, status: 'failed' },
|
|
122
|
+
}, 422);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
sendChunkedJson(response, {
|
|
126
|
+
ok: true,
|
|
127
|
+
result: { value: largeOutput },
|
|
128
|
+
columns: ['value'],
|
|
129
|
+
execution: { id: 'exec_lifecycle', command: command.command, status: 'succeeded' },
|
|
130
|
+
trace: { executionId: 'exec_lifecycle', receipt: traceReceipt },
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
response.writeHead(404).end();
|
|
135
|
+
});
|
|
136
|
+
servers.push(server);
|
|
137
|
+
await new Promise((resolve, reject) => {
|
|
138
|
+
server.once('error', reject);
|
|
139
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
140
|
+
});
|
|
141
|
+
const address = server.address();
|
|
142
|
+
if (!address || typeof address === 'string')
|
|
143
|
+
throw new Error('Expected TCP lifecycle fixture address');
|
|
144
|
+
await writeFile(path.join(configDir, 'config.json'), `${JSON.stringify({
|
|
145
|
+
mode: 'hosted',
|
|
146
|
+
updatedAt: '2026-07-14T00:00:00.000Z',
|
|
147
|
+
hosted: {
|
|
148
|
+
apiBaseUrl: `http://127.0.0.1:${address.port}`,
|
|
149
|
+
apiKeyRef: 'wcmd_cred_lifecycle',
|
|
150
|
+
credentialBackend: 'file-fallback',
|
|
151
|
+
},
|
|
152
|
+
})}\n`);
|
|
153
|
+
await writeFile(path.join(configDir, 'hosted-credentials.json'), `${JSON.stringify({
|
|
154
|
+
version: 1,
|
|
155
|
+
credentials: { wcmd_cred_lifecycle: 'wcmd_lifecycle' },
|
|
156
|
+
updatedAt: '2026-07-14T00:00:00.000Z',
|
|
157
|
+
})}\n`, { mode: 0o600 });
|
|
158
|
+
return {
|
|
159
|
+
discoverySentinel,
|
|
160
|
+
env: {
|
|
161
|
+
...process.env,
|
|
162
|
+
HOME: root,
|
|
163
|
+
WEBCMD_CONFIG_DIR: configDir,
|
|
164
|
+
WEBCMD_NO_UPDATE_CHECK: '1',
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function sendChunkedJson(response, value, status = 200) {
|
|
169
|
+
const body = JSON.stringify(value);
|
|
170
|
+
const split = Math.floor(body.length / 2);
|
|
171
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
172
|
+
response.write(body.slice(0, split));
|
|
173
|
+
setTimeout(() => response.end(body.slice(split)), 25);
|
|
174
|
+
}
|
|
175
|
+
function runCli(args, env, imports = []) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const importArgs = imports.flatMap(specifier => ['--import', pathToFileURL(specifier).href]);
|
|
178
|
+
const child = spawn(process.execPath, [...importArgs, '--import', 'tsx', entrypoint, ...args], {
|
|
179
|
+
cwd: packageRoot,
|
|
180
|
+
env,
|
|
181
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
182
|
+
});
|
|
183
|
+
const stdout = [];
|
|
184
|
+
const stderr = [];
|
|
185
|
+
child.stdout.on('data', chunk => stdout.push(Buffer.from(chunk)));
|
|
186
|
+
child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk)));
|
|
187
|
+
child.once('error', reject);
|
|
188
|
+
child.once('close', status => resolve({
|
|
189
|
+
status,
|
|
190
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
191
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
192
|
+
}));
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
async function createDelayedStdoutPreload(root) {
|
|
196
|
+
const preload = path.join(root, 'delay-stdout.mjs');
|
|
197
|
+
await writeFile(preload, [
|
|
198
|
+
'const originalWrite = process.stdout.write.bind(process.stdout);',
|
|
199
|
+
'process.stdout.write = function delayedWrite(chunk, encoding, callback) {',
|
|
200
|
+
" const actualEncoding = typeof encoding === 'string' ? encoding : undefined;",
|
|
201
|
+
" const done = typeof encoding === 'function' ? encoding : callback;",
|
|
202
|
+
' setTimeout(() => {',
|
|
203
|
+
' originalWrite(chunk, actualEncoding, (error) => {',
|
|
204
|
+
' done?.(error);',
|
|
205
|
+
" process.stdout.emit('drain');",
|
|
206
|
+
' });',
|
|
207
|
+
' }, 75);',
|
|
208
|
+
' return false;',
|
|
209
|
+
'};',
|
|
210
|
+
'',
|
|
211
|
+
].join('\n'));
|
|
212
|
+
return preload;
|
|
213
|
+
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { type PresentableCommand, type CommandListPresentation } from '../command-presentation.js';
|
|
1
2
|
import type { HostedCommand, HostedManifest } from './types.js';
|
|
2
3
|
export declare function isLocalOnlyHostedCommand(command: HostedCommand): boolean;
|
|
3
4
|
export declare function hostedCommands(manifest: HostedManifest): HostedCommand[];
|
|
4
5
|
export declare function findHostedCommand(manifest: HostedManifest, site: string, name: string): HostedCommand | null;
|
|
6
|
+
export declare function presentHostedCommand(command: HostedCommand): PresentableCommand;
|
|
5
7
|
export declare function hostedListRows(manifest: HostedManifest, structured: boolean): Record<string, unknown>[];
|
|
8
|
+
export declare function hostedListPresentation(manifest: HostedManifest, format: string): CommandListPresentation;
|
|
6
9
|
export declare function siteNames(manifest: HostedManifest): string[];
|
|
7
10
|
export declare function commandNamesForSite(manifest: HostedManifest, site: string): string[];
|
|
8
11
|
export declare function renderHostedSiteHelp(manifest: HostedManifest, site: string): string;
|
|
12
|
+
export declare function hostedSiteHelpData(manifest: HostedManifest, site: string): Record<string, unknown> | null;
|
|
9
13
|
export declare function renderHostedCommandHelp(command: HostedCommand): string;
|
|
14
|
+
export declare function hostedCommandHelpData(command: HostedCommand): Record<string, unknown>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { commandHelpData, commandListPresentation, commandListRows, formatCommandHelp, formatSiteHelp, getCommandCompletionCandidates, siteHelpData, toPresentableCommand, } from '../command-presentation.js';
|
|
2
2
|
export function isLocalOnlyHostedCommand(command) {
|
|
3
3
|
return command.strategy.toUpperCase() === 'LOCAL';
|
|
4
4
|
}
|
|
@@ -12,81 +12,36 @@ export function findHostedCommand(manifest, site, name) {
|
|
|
12
12
|
return command.site === site && (command.name === name || command.aliases?.includes(name));
|
|
13
13
|
}) ?? null;
|
|
14
14
|
}
|
|
15
|
+
export function presentHostedCommand(command) {
|
|
16
|
+
return toPresentableCommand(command);
|
|
17
|
+
}
|
|
15
18
|
export function hostedListRows(manifest, structured) {
|
|
16
|
-
return hostedCommands(manifest).map(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
name: command.name,
|
|
21
|
-
aliases: command.aliases ?? [],
|
|
22
|
-
description: command.description,
|
|
23
|
-
access: command.access,
|
|
24
|
-
strategy: command.strategy.toLowerCase(),
|
|
25
|
-
browser: command.browser,
|
|
26
|
-
args: command.args,
|
|
27
|
-
columns: command.columns ?? [],
|
|
28
|
-
domain: command.domain ?? null,
|
|
29
|
-
};
|
|
30
|
-
if (structured)
|
|
31
|
-
return row;
|
|
32
|
-
return {
|
|
33
|
-
...row,
|
|
34
|
-
aliases: command.aliases?.join(', ') ?? '',
|
|
35
|
-
args: formatArgSummary(command.args.map((arg) => ({
|
|
36
|
-
name: arg.name,
|
|
37
|
-
type: arg.type,
|
|
38
|
-
required: arg.required,
|
|
39
|
-
valueRequired: arg.valueRequired,
|
|
40
|
-
positional: arg.positional,
|
|
41
|
-
help: arg.help,
|
|
42
|
-
choices: arg.choices?.map(String),
|
|
43
|
-
default: arg.default,
|
|
44
|
-
}))),
|
|
45
|
-
};
|
|
46
|
-
});
|
|
19
|
+
return commandListRows(hostedCommands(manifest).map(presentHostedCommand), structured);
|
|
20
|
+
}
|
|
21
|
+
export function hostedListPresentation(manifest, format) {
|
|
22
|
+
return commandListPresentation(hostedCommands(manifest).map(presentHostedCommand), format);
|
|
47
23
|
}
|
|
48
24
|
export function siteNames(manifest) {
|
|
49
|
-
return
|
|
25
|
+
return getCommandCompletionCandidates(hostedCommands(manifest), [], 1, []);
|
|
50
26
|
}
|
|
51
27
|
export function commandNamesForSite(manifest, site) {
|
|
52
|
-
return hostedCommands(manifest)
|
|
53
|
-
.filter((command) => command.site === site)
|
|
54
|
-
.flatMap((command) => [command.name, ...(command.aliases ?? [])])
|
|
55
|
-
.sort((a, b) => a.localeCompare(b));
|
|
28
|
+
return getCommandCompletionCandidates(hostedCommands(manifest), [site], 2, []);
|
|
56
29
|
}
|
|
57
30
|
export function renderHostedSiteHelp(manifest, site) {
|
|
58
31
|
const commands = hostedCommands(manifest).filter((command) => command.site === site);
|
|
59
32
|
if (commands.length === 0)
|
|
60
33
|
return `Unknown hosted Webcmd site: ${site}\n`;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
34
|
+
return formatSiteHelp(site, commands.map(presentHostedCommand));
|
|
35
|
+
}
|
|
36
|
+
export function hostedSiteHelpData(manifest, site) {
|
|
37
|
+
const commands = hostedCommands(manifest).filter((command) => command.site === site);
|
|
38
|
+
if (commands.length === 0)
|
|
39
|
+
return null;
|
|
40
|
+
return siteHelpData(site, commands.map(presentHostedCommand));
|
|
67
41
|
}
|
|
68
42
|
export function renderHostedCommandHelp(command) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
required: arg.required,
|
|
74
|
-
valueRequired: arg.valueRequired,
|
|
75
|
-
positional: arg.positional,
|
|
76
|
-
help: arg.help,
|
|
77
|
-
choices: arg.choices?.map(String),
|
|
78
|
-
default: arg.default,
|
|
79
|
-
})))} [options]`,
|
|
80
|
-
'',
|
|
81
|
-
command.description,
|
|
82
|
-
'',
|
|
83
|
-
`Access: ${command.access}`,
|
|
84
|
-
`Strategy: ${command.strategy.toLowerCase()}`,
|
|
85
|
-
`Browser: ${command.browser ? 'yes' : 'no'}`,
|
|
86
|
-
];
|
|
87
|
-
if (command.domain)
|
|
88
|
-
lines.push(`Domain: ${command.domain}`);
|
|
89
|
-
if (command.columns?.length)
|
|
90
|
-
lines.push(`Output columns: ${command.columns.join(', ')}`);
|
|
91
|
-
return `${lines.join('\n')}\n`;
|
|
43
|
+
return formatCommandHelp(presentHostedCommand(command));
|
|
44
|
+
}
|
|
45
|
+
export function hostedCommandHelpData(command) {
|
|
46
|
+
return commandHelpData(presentHostedCommand(command));
|
|
92
47
|
}
|