@agentrhq/webcmd 0.2.5 → 0.3.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 (200) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +217 -13
  3. package/clis/_shared/site-auth.js +3 -3
  4. package/clis/_shared/site-auth.test.js +4 -4
  5. package/clis/chatgpt/ask.js +1 -1
  6. package/clis/chatgpt/ask.test.js +11 -0
  7. package/clis/chatgpt/commands.test.js +102 -1
  8. package/clis/chatgpt/deep-research-result.js +45 -3
  9. package/clis/chatgpt/image.js +22 -2
  10. package/clis/chatgpt/project-file-add.js +24 -1
  11. package/clis/chatgpt/utils.js +258 -52
  12. package/clis/chatgpt/utils.test.js +259 -1
  13. package/clis/claude/ask.js +21 -1
  14. package/clis/facebook/feed.js +110 -24
  15. package/clis/facebook/feed.test.js +62 -0
  16. package/clis/facebook/search.js +185 -38
  17. package/clis/facebook/search.test.js +95 -50
  18. package/clis/instagram/explore.js +30 -12
  19. package/clis/instagram/explore.test.js +77 -0
  20. package/clis/instagram/post.js +14 -1
  21. package/clis/instagram/reel.js +13 -1
  22. package/clis/linkedin/company.js +153 -0
  23. package/clis/linkedin/company.test.js +111 -0
  24. package/clis/linkedin/connections.js +135 -0
  25. package/clis/linkedin/connections.test.js +141 -0
  26. package/clis/mercury/reimbursement-draft.js +12 -1
  27. package/clis/slock/whoami.test.js +2 -2
  28. package/clis/twitter/article-evaluate.test.js +27 -0
  29. package/clis/twitter/article.js +73 -16
  30. package/clis/twitter/article.test.js +209 -0
  31. package/clis/twitter/download.js +6 -1
  32. package/clis/twitter/post.js +14 -1
  33. package/clis/twitter/profile.js +1 -1
  34. package/clis/twitter/profile.test.js +8 -0
  35. package/clis/twitter/quote.js +11 -1
  36. package/clis/twitter/reply.js +11 -1
  37. package/dist/src/browser/base-page.d.ts +26 -0
  38. package/dist/src/browser/base-page.js +23 -8
  39. package/dist/src/browser/base-page.test.js +45 -12
  40. package/dist/src/browser/command-catalog.d.ts +6 -0
  41. package/dist/src/browser/command-catalog.js +259 -0
  42. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  43. package/dist/src/browser/command-catalog.test.js +79 -0
  44. package/dist/src/browser/daemon-client.d.ts +2 -1
  45. package/dist/src/browser/runtime/local-cloak/actions.js +4 -1
  46. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  47. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  48. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  49. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  50. package/dist/src/browser/runtime/local-cloak/provider.test.js +36 -0
  51. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +10 -2
  52. package/dist/src/browser/runtime/local-cloak/session-manager.js +25 -9
  53. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  54. package/dist/src/browser/target-resolver.d.ts +1 -0
  55. package/dist/src/browser/target-resolver.js +99 -4
  56. package/dist/src/browser/target-resolver.test.js +141 -1
  57. package/dist/src/build-manifest.d.ts +6 -0
  58. package/dist/src/build-manifest.js +18 -1
  59. package/dist/src/build-manifest.test.js +57 -1
  60. package/dist/src/builtin-command-surface.d.ts +9 -0
  61. package/dist/src/builtin-command-surface.js +16 -0
  62. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  63. package/dist/src/check-hosted-contract.test.js +56 -0
  64. package/dist/src/cli.js +63 -110
  65. package/dist/src/cli.test.js +82 -6
  66. package/dist/src/command-presentation.d.ts +95 -0
  67. package/dist/src/command-presentation.js +486 -0
  68. package/dist/src/command-presentation.test.d.ts +1 -0
  69. package/dist/src/command-presentation.test.js +97 -0
  70. package/dist/src/command-surface.d.ts +43 -0
  71. package/dist/src/command-surface.js +205 -0
  72. package/dist/src/command-surface.test.d.ts +1 -0
  73. package/dist/src/command-surface.test.js +406 -0
  74. package/dist/src/commanderAdapter.d.ts +5 -1
  75. package/dist/src/commanderAdapter.js +12 -55
  76. package/dist/src/commanderAdapter.test.js +15 -3
  77. package/dist/src/commands/auth.js +3 -2
  78. package/dist/src/commands/auth.test.js +6 -6
  79. package/dist/src/community-plugin-sync.d.ts +11 -0
  80. package/dist/src/community-plugin-sync.js +138 -0
  81. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  82. package/dist/src/community-plugin-sync.test.js +123 -0
  83. package/dist/src/completion-fast.d.ts +4 -4
  84. package/dist/src/completion-fast.js +15 -34
  85. package/dist/src/completion-shared.d.ts +4 -0
  86. package/dist/src/completion-shared.js +38 -0
  87. package/dist/src/completion.js +3 -27
  88. package/dist/src/completion.test.js +31 -2
  89. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  90. package/dist/src/docs-sync-review-cli.test.js +322 -0
  91. package/dist/src/docs-sync-review.d.ts +117 -0
  92. package/dist/src/docs-sync-review.js +475 -0
  93. package/dist/src/docs-sync-review.test.d.ts +1 -0
  94. package/dist/src/docs-sync-review.test.js +324 -0
  95. package/dist/src/errors.d.ts +12 -3
  96. package/dist/src/errors.js +11 -7
  97. package/dist/src/errors.test.js +14 -1
  98. package/dist/src/execution.d.ts +1 -2
  99. package/dist/src/execution.js +4 -45
  100. package/dist/src/generate-release-notes-cli.test.js +11 -1
  101. package/dist/src/help.d.ts +4 -0
  102. package/dist/src/help.js +50 -255
  103. package/dist/src/help.test.js +27 -1
  104. package/dist/src/hosted/args.d.ts +4 -0
  105. package/dist/src/hosted/args.js +4 -0
  106. package/dist/src/hosted/args.test.d.ts +1 -0
  107. package/dist/src/hosted/args.test.js +49 -0
  108. package/dist/src/hosted/availability.d.ts +13 -0
  109. package/dist/src/hosted/availability.js +16 -0
  110. package/dist/src/hosted/availability.test.d.ts +1 -0
  111. package/dist/src/hosted/availability.test.js +180 -0
  112. package/dist/src/hosted/browser-args.d.ts +18 -0
  113. package/dist/src/hosted/browser-args.js +152 -0
  114. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  115. package/dist/src/hosted/browser-args.test.js +182 -0
  116. package/dist/src/hosted/client.d.ts +59 -0
  117. package/dist/src/hosted/client.js +510 -0
  118. package/dist/src/hosted/client.test.d.ts +1 -0
  119. package/dist/src/hosted/client.test.js +738 -0
  120. package/dist/src/hosted/config.d.ts +55 -0
  121. package/dist/src/hosted/config.js +112 -0
  122. package/dist/src/hosted/config.test.d.ts +1 -0
  123. package/dist/src/hosted/config.test.js +81 -0
  124. package/dist/src/hosted/contract.d.ts +94 -0
  125. package/dist/src/hosted/contract.js +208 -0
  126. package/dist/src/hosted/contract.test.d.ts +1 -0
  127. package/dist/src/hosted/contract.test.js +361 -0
  128. package/dist/src/hosted/credentials.d.ts +38 -0
  129. package/dist/src/hosted/credentials.js +248 -0
  130. package/dist/src/hosted/credentials.test.d.ts +1 -0
  131. package/dist/src/hosted/credentials.test.js +93 -0
  132. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  133. package/dist/src/hosted/file-contract.test.js +174 -0
  134. package/dist/src/hosted/files.d.ts +29 -0
  135. package/dist/src/hosted/files.js +296 -0
  136. package/dist/src/hosted/files.test.d.ts +1 -0
  137. package/dist/src/hosted/files.test.js +231 -0
  138. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  139. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  140. package/dist/src/hosted/manifest.d.ts +14 -0
  141. package/dist/src/hosted/manifest.js +47 -0
  142. package/dist/src/hosted/manifest.test.d.ts +1 -0
  143. package/dist/src/hosted/manifest.test.js +164 -0
  144. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  145. package/dist/src/hosted/output-parity.test.js +108 -0
  146. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  147. package/dist/src/hosted/root-command-surface.test.js +646 -0
  148. package/dist/src/hosted/runner.d.ts +19 -0
  149. package/dist/src/hosted/runner.js +705 -0
  150. package/dist/src/hosted/runner.test.d.ts +1 -0
  151. package/dist/src/hosted/runner.test.js +1607 -0
  152. package/dist/src/hosted/setup.d.ts +10 -0
  153. package/dist/src/hosted/setup.js +79 -0
  154. package/dist/src/hosted/setup.test.d.ts +1 -0
  155. package/dist/src/hosted/setup.test.js +195 -0
  156. package/dist/src/hosted/types.d.ts +175 -0
  157. package/dist/src/hosted/types.js +1 -0
  158. package/dist/src/main.js +123 -97
  159. package/dist/src/manifest-types.d.ts +2 -0
  160. package/dist/src/output.d.ts +17 -2
  161. package/dist/src/output.js +88 -81
  162. package/dist/src/output.test.js +141 -51
  163. package/dist/src/pipeline/executor.test.js +1 -0
  164. package/dist/src/pipeline/steps/download.test.js +1 -0
  165. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  166. package/dist/src/plugin-create-cli.test.js +37 -0
  167. package/dist/src/plugin-manifest.d.ts +12 -0
  168. package/dist/src/plugin-manifest.js +16 -0
  169. package/dist/src/plugin-manifest.test.js +19 -1
  170. package/dist/src/plugin-scaffold.d.ts +4 -1
  171. package/dist/src/plugin-scaffold.js +4 -1
  172. package/dist/src/plugin-scaffold.test.js +23 -8
  173. package/dist/src/plugin.js +4 -1
  174. package/dist/src/plugin.test.js +13 -0
  175. package/dist/src/registry.d.ts +2 -0
  176. package/dist/src/release-notes.d.ts +6 -1
  177. package/dist/src/release-notes.js +192 -4
  178. package/dist/src/release-notes.test.js +143 -4
  179. package/dist/src/root-command-surface.d.ts +31 -0
  180. package/dist/src/root-command-surface.js +106 -0
  181. package/dist/src/serialization.d.ts +1 -16
  182. package/dist/src/serialization.js +5 -55
  183. package/dist/src/stream-write.d.ts +12 -0
  184. package/dist/src/stream-write.js +91 -0
  185. package/dist/src/stream-write.test.d.ts +1 -0
  186. package/dist/src/stream-write.test.js +186 -0
  187. package/dist/src/types.d.ts +5 -0
  188. package/dist/src/utils.d.ts +1 -1
  189. package/dist/src/utils.js +2 -8
  190. package/dist/src/utils.test.js +50 -0
  191. package/hosted-contract.json +36748 -0
  192. package/package.json +6 -1
  193. package/scripts/check-hosted-contract.mjs +108 -0
  194. package/scripts/docs-sync-review.ts +332 -0
  195. package/scripts/generate-release-notes.ts +1 -1
  196. package/scripts/sync-community-plugins.ts +14 -0
  197. package/skills/smart-search/SKILL.md +14 -3
  198. package/skills/webcmd-autofix/SKILL.md +26 -37
  199. package/skills/webcmd-usage/SKILL.md +21 -7
  200. package/clis/antigravity/SKILL.md +0 -38
@@ -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
+ }
@@ -0,0 +1,14 @@
1
+ import { type PresentableCommand, type CommandListPresentation } from '../command-presentation.js';
2
+ import type { HostedCommand, HostedManifest } from './types.js';
3
+ export declare function isLocalOnlyHostedCommand(command: HostedCommand): boolean;
4
+ export declare function hostedCommands(manifest: HostedManifest): HostedCommand[];
5
+ export declare function findHostedCommand(manifest: HostedManifest, site: string, name: string): HostedCommand | null;
6
+ export declare function presentHostedCommand(command: HostedCommand): PresentableCommand;
7
+ export declare function hostedListRows(manifest: HostedManifest, structured: boolean): Record<string, unknown>[];
8
+ export declare function hostedListPresentation(manifest: HostedManifest, format: string): CommandListPresentation;
9
+ export declare function siteNames(manifest: HostedManifest): string[];
10
+ export declare function commandNamesForSite(manifest: HostedManifest, site: string): string[];
11
+ export declare function renderHostedSiteHelp(manifest: HostedManifest, site: string): string;
12
+ export declare function hostedSiteHelpData(manifest: HostedManifest, site: string): Record<string, unknown> | null;
13
+ export declare function renderHostedCommandHelp(command: HostedCommand): string;
14
+ export declare function hostedCommandHelpData(command: HostedCommand): Record<string, unknown>;
@@ -0,0 +1,47 @@
1
+ import { commandHelpData, commandListPresentation, commandListRows, formatCommandHelp, formatSiteHelp, getCommandCompletionCandidates, siteHelpData, toPresentableCommand, } from '../command-presentation.js';
2
+ export function isLocalOnlyHostedCommand(command) {
3
+ return command.strategy.toUpperCase() === 'LOCAL';
4
+ }
5
+ export function hostedCommands(manifest) {
6
+ return manifest.commands
7
+ .filter((command) => !isLocalOnlyHostedCommand(command))
8
+ .sort((a, b) => a.command.localeCompare(b.command));
9
+ }
10
+ export function findHostedCommand(manifest, site, name) {
11
+ return manifest.commands.find((command) => {
12
+ return command.site === site && (command.name === name || command.aliases?.includes(name));
13
+ }) ?? null;
14
+ }
15
+ export function presentHostedCommand(command) {
16
+ return toPresentableCommand(command);
17
+ }
18
+ export function hostedListRows(manifest, structured) {
19
+ return commandListRows(hostedCommands(manifest).map(presentHostedCommand), structured);
20
+ }
21
+ export function hostedListPresentation(manifest, format) {
22
+ return commandListPresentation(hostedCommands(manifest).map(presentHostedCommand), format);
23
+ }
24
+ export function siteNames(manifest) {
25
+ return getCommandCompletionCandidates(hostedCommands(manifest), [], 1, []);
26
+ }
27
+ export function commandNamesForSite(manifest, site) {
28
+ return getCommandCompletionCandidates(hostedCommands(manifest), [site], 2, []);
29
+ }
30
+ export function renderHostedSiteHelp(manifest, site) {
31
+ const commands = hostedCommands(manifest).filter((command) => command.site === site);
32
+ if (commands.length === 0)
33
+ return `Unknown hosted Webcmd site: ${site}\n`;
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));
41
+ }
42
+ export function renderHostedCommandHelp(command) {
43
+ return formatCommandHelp(presentHostedCommand(command));
44
+ }
45
+ export function hostedCommandHelpData(command) {
46
+ return commandHelpData(presentHostedCommand(command));
47
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,164 @@
1
+ import { Writable } from 'node:stream';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { commandHelpData, formatCommandHelpText, formatSiteHelpText, renderStructuredHelp, siteHelpData, } from '../help.js';
4
+ import { serializeCommand } from '../serialization.js';
5
+ import { Strategy } from '../registry.js';
6
+ import { commandNamesForSite, findHostedCommand, hostedListRows, renderHostedCommandHelp, renderHostedSiteHelp, siteNames, } from './manifest.js';
7
+ import { makeHostedConfig } from './config.js';
8
+ import { runHostedCli } from './runner.js';
9
+ import { formatRootHelp } from '../command-presentation.js';
10
+ import { HOSTED_ROOT_HELP } from '../completion-shared.js';
11
+ const manifest = {
12
+ userId: 'user_demo',
13
+ metadata: {
14
+ contractSchemaVersion: 1,
15
+ webcmdPackageVersion: '0.3.0',
16
+ generatedAt: '2026-07-08T00:00:00.000Z',
17
+ },
18
+ commands: [
19
+ {
20
+ site: 'github',
21
+ name: 'whoami',
22
+ aliases: ['me'],
23
+ command: 'github/whoami',
24
+ description: 'Show GitHub identity',
25
+ access: 'read',
26
+ strategy: 'COOKIE',
27
+ browser: true,
28
+ args: [],
29
+ columns: ['username'],
30
+ domain: 'github.com',
31
+ },
32
+ {
33
+ site: 'docker',
34
+ name: 'ps',
35
+ command: 'docker/ps',
36
+ description: 'Local Docker containers',
37
+ access: 'read',
38
+ strategy: 'LOCAL',
39
+ browser: false,
40
+ args: [],
41
+ columns: [],
42
+ },
43
+ ],
44
+ };
45
+ const equivalentLocalCommand = {
46
+ site: 'github',
47
+ name: 'whoami',
48
+ aliases: ['me'],
49
+ description: 'Show GitHub identity',
50
+ access: 'read',
51
+ strategy: Strategy.COOKIE,
52
+ browser: true,
53
+ args: [],
54
+ columns: ['username'],
55
+ domain: 'github.com',
56
+ };
57
+ function sink() {
58
+ let data = '';
59
+ return {
60
+ stream: new Writable({
61
+ write(chunk, _encoding, callback) {
62
+ data += String(chunk);
63
+ callback();
64
+ },
65
+ }),
66
+ text: () => data,
67
+ };
68
+ }
69
+ describe('hosted manifest helpers', () => {
70
+ it('filters LOCAL commands from hosted list rows', () => {
71
+ expect(hostedListRows(manifest, true).map((row) => row.command)).toEqual(['github/whoami']);
72
+ });
73
+ it('finds canonical commands and aliases', () => {
74
+ expect(findHostedCommand(manifest, 'github', 'whoami')?.command).toBe('github/whoami');
75
+ expect(findHostedCommand(manifest, 'github', 'me')?.command).toBe('github/whoami');
76
+ });
77
+ it('renders hosted help and completion names from supported commands', () => {
78
+ expect(siteNames(manifest)).toEqual(['github']);
79
+ expect(commandNamesForSite(manifest, 'github')).toEqual(['me', 'whoami']);
80
+ expect(renderHostedSiteHelp(manifest, 'github')).toContain('whoami');
81
+ expect(renderHostedCommandHelp(manifest.commands[0])).toContain('Output columns: username');
82
+ });
83
+ it('matches local site and command help byte-for-byte for equal metadata', () => {
84
+ expect(renderHostedSiteHelp(manifest, 'github')).toBe(formatSiteHelpText('github', [equivalentLocalCommand]));
85
+ expect(renderHostedCommandHelp(manifest.commands[0])).toBe(formatCommandHelpText(equivalentLocalCommand));
86
+ });
87
+ it('matches local structured list rows for equal metadata', () => {
88
+ expect(hostedListRows({ ...manifest, commands: [manifest.commands[0]] }, true))
89
+ .toEqual([serializeCommand(equivalentLocalCommand)]);
90
+ });
91
+ it('describes universal hosted surfaces and accepted local-only commands at the root', async () => {
92
+ const stdout = sink();
93
+ const result = await runHostedCli(['--help'], {
94
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
95
+ stdout: stdout.stream,
96
+ });
97
+ expect(result).toEqual({ handled: true, exitCode: 0 });
98
+ expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP));
99
+ expect(stdout.text()).toContain('completion');
100
+ expect(stdout.text()).toContain('--profile <name>');
101
+ expect(stdout.text()).toContain('Local-only commands:');
102
+ expect(stdout.text()).toContain('Run `webcmd setup` and choose local mode to use local-only commands.');
103
+ });
104
+ it('completes private hosted manifest commands without local discovery', async () => {
105
+ const stdout = sink();
106
+ const privateManifest = {
107
+ ...manifest,
108
+ commands: [
109
+ ...manifest.commands,
110
+ {
111
+ site: 'private-tools',
112
+ name: 'deploy-preview',
113
+ aliases: ['preview'],
114
+ command: 'private-tools/deploy-preview',
115
+ description: 'Deploy a private preview',
116
+ access: 'write',
117
+ strategy: 'PUBLIC',
118
+ browser: false,
119
+ args: [],
120
+ columns: ['url'],
121
+ },
122
+ ],
123
+ };
124
+ const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ ok: true, manifest: privateManifest }), { status: 200 }));
125
+ const result = await runHostedCli(['--get-completions', '--cursor', '2', 'private-tools'], {
126
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
127
+ stdout: stdout.stream,
128
+ fetchImpl,
129
+ });
130
+ expect(result).toEqual({ handled: true, exitCode: 0 });
131
+ expect(stdout.text().trim().split('\n')).toEqual(['deploy-preview', 'preview']);
132
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
133
+ });
134
+ it.each([
135
+ ['json', 'json'],
136
+ ['yaml', 'yaml'],
137
+ ['yml', 'yaml'],
138
+ ])('matches local structured site and command help bytes for -f %s', async (requested, rendered) => {
139
+ const fetchImpl = async () => new Response(JSON.stringify({ ok: true, manifest }), { status: 200 });
140
+ const siteStdout = sink();
141
+ await runHostedCli(['github', '--help', '-f', requested], {
142
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
143
+ stdout: siteStdout.stream,
144
+ fetchImpl,
145
+ });
146
+ expect(siteStdout.text()).toBe(renderStructuredHelp(siteHelpData('github', [equivalentLocalCommand]), rendered));
147
+ const commandStdout = sink();
148
+ await runHostedCli(['github', 'whoami', '--help', '-f', requested], {
149
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
150
+ stdout: commandStdout.stream,
151
+ fetchImpl,
152
+ });
153
+ expect(commandStdout.text()).toBe(renderStructuredHelp(commandHelpData(equivalentLocalCommand), rendered));
154
+ });
155
+ it('uses only executable hosted root capabilities as root completion candidates', async () => {
156
+ const stdout = sink();
157
+ await runHostedCli(['--get-completions', '--cursor', '1'], {
158
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
159
+ stdout: stdout.stream,
160
+ fetchImpl: async () => new Response(JSON.stringify({ ok: true, manifest }), { status: 200 }),
161
+ });
162
+ expect(stdout.text().trim().split('\n')).toEqual(['browser', 'completion', 'github', 'list', 'setup']);
163
+ });
164
+ });