@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,1607 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { Writable } from 'node:stream';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import { browserCommandCatalog } from '../browser/command-catalog.js';
8
+ import { rewriteBrowserArgv } from '../cli-argv-preprocess.js';
9
+ import { createProgram } from '../cli.js';
10
+ import { formatRootHelp } from '../command-presentation.js';
11
+ import { HOSTED_ROOT_HELP } from '../completion-shared.js';
12
+ import { makeHostedConfig } from './config.js';
13
+ import { runHostedCli } from './runner.js';
14
+ const manifest = {
15
+ userId: 'user_demo',
16
+ metadata: {
17
+ contractSchemaVersion: 1,
18
+ webcmdPackageVersion: '0.3.0',
19
+ generatedAt: '2026-07-08T00:00:00.000Z',
20
+ },
21
+ commands: [
22
+ {
23
+ site: 'github',
24
+ name: 'whoami',
25
+ command: 'github/whoami',
26
+ description: 'Show GitHub identity',
27
+ access: 'read',
28
+ strategy: 'COOKIE',
29
+ browser: true,
30
+ args: [],
31
+ columns: ['username'],
32
+ domain: 'github.com',
33
+ },
34
+ {
35
+ site: 'docker',
36
+ name: 'ps',
37
+ command: 'docker/ps',
38
+ description: 'Local Docker containers',
39
+ access: 'read',
40
+ strategy: 'LOCAL',
41
+ browser: false,
42
+ args: [],
43
+ columns: ['id'],
44
+ },
45
+ ],
46
+ };
47
+ function sink(isTTY = false) {
48
+ let data = '';
49
+ const stream = new Writable({
50
+ write(chunk, _encoding, callback) {
51
+ data += String(chunk);
52
+ callback();
53
+ },
54
+ });
55
+ Object.defineProperty(stream, 'isTTY', { value: isTTY });
56
+ return { stream, text: () => data };
57
+ }
58
+ function manifestResponse() {
59
+ return new Response(JSON.stringify({ ok: true, manifest }), { status: 200 });
60
+ }
61
+ function executionResponse(input) {
62
+ return new Response(JSON.stringify({
63
+ ok: true,
64
+ result: input.result,
65
+ ...(input.columns ? { columns: input.columns } : {}),
66
+ execution: { id: 'exec_success', command: input.command ?? 'github/whoami', status: 'succeeded' },
67
+ ...(input.trace ? { trace: input.trace } : {}),
68
+ }), { status: 200 });
69
+ }
70
+ function manifestWithRequiredAccount() {
71
+ return {
72
+ ...manifest,
73
+ commands: manifest.commands.map(command => command.command === 'github/whoami'
74
+ ? {
75
+ ...command,
76
+ args: [
77
+ { name: 'account', positional: true, required: true, help: 'Account name' },
78
+ { name: 'mode', choices: ['valid'], help: 'Mode' },
79
+ ],
80
+ }
81
+ : command),
82
+ };
83
+ }
84
+ function manifestWithStructuralArguments() {
85
+ return {
86
+ ...manifest,
87
+ commands: manifest.commands.map(command => command.command === 'github/whoami'
88
+ ? {
89
+ ...command,
90
+ args: [
91
+ { name: 'account', positional: true, required: true, help: 'Account name' },
92
+ { name: 'token', required: true, valueRequired: true, help: 'Access token' },
93
+ { name: 'mode', choices: ['valid'], help: 'Mode' },
94
+ ],
95
+ }
96
+ : command),
97
+ };
98
+ }
99
+ function manifestWithFileCommand() {
100
+ return {
101
+ ...manifest,
102
+ commands: [
103
+ ...manifest.commands,
104
+ {
105
+ site: 'files',
106
+ name: 'copy',
107
+ command: 'files/copy',
108
+ description: 'Copy a hosted file',
109
+ access: 'write',
110
+ strategy: 'PUBLIC',
111
+ browser: false,
112
+ args: [
113
+ {
114
+ name: 'source',
115
+ required: true,
116
+ valueRequired: true,
117
+ help: 'Local input file',
118
+ file: {
119
+ direction: 'input',
120
+ pathKind: 'file',
121
+ multiple: false,
122
+ contentTypes: ['text/plain'],
123
+ maxBytes: 1024,
124
+ },
125
+ },
126
+ {
127
+ name: 'output',
128
+ required: true,
129
+ valueRequired: true,
130
+ help: 'Local output directory',
131
+ file: {
132
+ direction: 'output',
133
+ pathKind: 'directory',
134
+ multiple: false,
135
+ },
136
+ },
137
+ ],
138
+ columns: ['status'],
139
+ },
140
+ ],
141
+ };
142
+ }
143
+ function sampleBrowserPositionals(command) {
144
+ return command.positionals.flatMap((positional) => {
145
+ if (positional.variadic)
146
+ return [`${positional.name}-one`, `${positional.name}-two`];
147
+ if (positional.required)
148
+ return [`${positional.name}-value`];
149
+ return [`${positional.name}-value`];
150
+ });
151
+ }
152
+ class ControlledWritable extends Writable {
153
+ chunks = [];
154
+ releases = [];
155
+ constructor(options) {
156
+ super(options);
157
+ }
158
+ _write(chunk, _encoding, callback) {
159
+ this.chunks.push(Buffer.from(chunk));
160
+ this.releases.push(callback);
161
+ }
162
+ pendingCount() {
163
+ return this.releases.length;
164
+ }
165
+ release(error) {
166
+ const callback = this.releases.shift();
167
+ if (!callback)
168
+ throw new Error('No controlled write is pending');
169
+ callback(error);
170
+ }
171
+ text() {
172
+ return Buffer.concat(this.chunks).toString('utf8');
173
+ }
174
+ }
175
+ class CloseBeforeCallbackWritable extends Writable {
176
+ _write(_chunk, _encoding, _callback) {
177
+ this.destroy();
178
+ }
179
+ }
180
+ async function within(promise, milliseconds = 500) {
181
+ let timer;
182
+ try {
183
+ return await Promise.race([
184
+ promise,
185
+ new Promise((_resolve, reject) => {
186
+ timer = setTimeout(() => reject(new Error(`promise did not settle within ${milliseconds}ms`)), milliseconds);
187
+ }),
188
+ ]);
189
+ }
190
+ finally {
191
+ if (timer)
192
+ clearTimeout(timer);
193
+ }
194
+ }
195
+ function captureLocalBrowserStructure(argv) {
196
+ const program = createProgram('', '');
197
+ let stdout = '';
198
+ let stderr = '';
199
+ const configure = (command) => {
200
+ command
201
+ .exitOverride()
202
+ .configureOutput({
203
+ writeErr: value => { stderr += value; },
204
+ writeOut: value => { stdout += value; },
205
+ });
206
+ if (command.commands.length === 0)
207
+ command.action(() => undefined);
208
+ for (const child of command.commands)
209
+ configure(child);
210
+ };
211
+ configure(program);
212
+ try {
213
+ program.parse(rewriteBrowserArgv(argv), { from: 'user' });
214
+ return { exitCode: 0, stdout, stderr };
215
+ }
216
+ catch (error) {
217
+ const commander = error;
218
+ return { exitCode: commander.exitCode ?? 1, stdout, stderr };
219
+ }
220
+ }
221
+ describe('runHostedCli', () => {
222
+ it.each([
223
+ ['missing-site'],
224
+ ['missing-site', 'child'],
225
+ ['missing-site', 'child', 'grandchild'],
226
+ ['missing-site', '--format', 'json'],
227
+ ['missing-site', '--trace=on'],
228
+ ])('matches local unknown-site bytes when argv is %j', async (...argv) => {
229
+ const stdout = sink();
230
+ const stderr = sink();
231
+ const fetchImpl = vi.fn(async () => manifestResponse());
232
+ const result = await runHostedCli(argv, {
233
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
234
+ stdout: stdout.stream,
235
+ stderr: stderr.stream,
236
+ fetchImpl,
237
+ });
238
+ expect(result).toEqual({ handled: true, exitCode: 2 });
239
+ expect(stderr.text()).toBe("error: unknown command 'missing-site'\n");
240
+ expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP));
241
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
242
+ expect(String(fetchImpl.mock.calls[0][0])).toMatch(/\/v1\/manifest$/);
243
+ });
244
+ it('matches local Commander bytes for an unknown site command', async () => {
245
+ const stdout = sink();
246
+ const stderr = sink();
247
+ const result = await runHostedCli(['github', 'missing-command'], {
248
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
249
+ stdout: stdout.stream,
250
+ stderr: stderr.stream,
251
+ fetchImpl: async () => manifestResponse(),
252
+ });
253
+ expect(result).toEqual({ handled: true, exitCode: 1 });
254
+ expect(stderr.text()).toBe("error: unknown command 'missing-command'\n");
255
+ expect(stdout.text()).toBe('');
256
+ });
257
+ it('matches local Commander bytes for a missing required positional', async () => {
258
+ const requiredManifest = manifestWithRequiredAccount();
259
+ const stdout = sink();
260
+ const stderr = sink();
261
+ const result = await runHostedCli(['github', 'whoami'], {
262
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
263
+ stdout: stdout.stream,
264
+ stderr: stderr.stream,
265
+ fetchImpl: async () => new Response(JSON.stringify({ ok: true, manifest: requiredManifest }), { status: 200 }),
266
+ });
267
+ expect(result).toEqual({ handled: true, exitCode: 1 });
268
+ expect(stderr.text()).toBe("error: missing required argument 'account'\n");
269
+ expect(stdout.text()).toBe('');
270
+ });
271
+ it('transfers declared files through prepare/upload/run/download before rendering output', async () => {
272
+ const tempDir = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-runner-files-'));
273
+ try {
274
+ const imagePath = path.join(tempDir, 'one.png');
275
+ const outputDir = path.join(tempDir, 'downloads');
276
+ await writeFile(imagePath, 'png bytes');
277
+ const stdout = sink();
278
+ const stderr = sink();
279
+ const calls = [];
280
+ const body = new Uint8Array(Buffer.from('hello cloud'));
281
+ const fileManifest = {
282
+ ...manifest,
283
+ commands: [{
284
+ site: 'twitter',
285
+ name: 'post',
286
+ command: 'twitter/post',
287
+ description: 'Post a tweet',
288
+ access: 'write',
289
+ strategy: 'UI',
290
+ browser: true,
291
+ args: [
292
+ { name: 'text', positional: true, required: true },
293
+ {
294
+ name: 'images',
295
+ file: {
296
+ direction: 'input',
297
+ pathKind: 'file',
298
+ multiple: true,
299
+ separator: ',',
300
+ contentTypes: ['image/png'],
301
+ maxBytes: 1024,
302
+ },
303
+ },
304
+ {
305
+ name: 'output',
306
+ file: {
307
+ direction: 'output',
308
+ pathKind: 'directory',
309
+ multiple: false,
310
+ },
311
+ },
312
+ ],
313
+ columns: ['ok'],
314
+ }],
315
+ };
316
+ const fetchImpl = vi.fn(async (url, init) => {
317
+ const requestUrl = String(url);
318
+ calls.push(`${init?.method ?? 'GET'} ${new URL(requestUrl).pathname}`);
319
+ if (requestUrl.endsWith('/v1/manifest')) {
320
+ return new Response(JSON.stringify({ ok: true, manifest: fileManifest }), { status: 200 });
321
+ }
322
+ if (requestUrl.endsWith('/v1/executions') && init?.method === 'POST') {
323
+ return new Response(JSON.stringify({
324
+ ok: true,
325
+ execution: { id: 'exec_files', command: 'twitter/post', status: 'queued' },
326
+ fileArguments: [],
327
+ }), { status: 201 });
328
+ }
329
+ if (requestUrl.endsWith('/v1/executions/exec_files/artifacts/images') && init?.method === 'POST') {
330
+ expect(new Headers(init.headers).get('x-webcmd-filename')).toBe('one.png');
331
+ expect(init.body).toEqual(new Uint8Array(Buffer.from('png bytes')));
332
+ return new Response(JSON.stringify({
333
+ ok: true,
334
+ artifact: {
335
+ artifactId: 'artifact_in',
336
+ argument: 'images',
337
+ direction: 'input',
338
+ pathKind: 'file',
339
+ filename: 'one.png',
340
+ contentType: 'image/png',
341
+ byteSize: 9,
342
+ expiresAt: '2026-07-15T00:00:00.000Z',
343
+ },
344
+ reference: { $webcmdArtifact: { id: 'artifact_in', direction: 'input' } },
345
+ }), { status: 201 });
346
+ }
347
+ if (requestUrl.endsWith('/v1/executions/exec_files/run') && init?.method === 'POST') {
348
+ const payload = JSON.parse(String(init.body));
349
+ expect(JSON.stringify(payload.args)).not.toContain(tempDir);
350
+ expect(payload.args).toMatchObject({
351
+ text: 'hello',
352
+ images: [{ $webcmdArtifact: { id: 'artifact_in', direction: 'input' } }],
353
+ output: { $webcmdArtifact: { direction: 'output', filename: 'downloads' } },
354
+ });
355
+ return new Response(JSON.stringify({
356
+ ok: true,
357
+ result: null,
358
+ execution: { id: 'exec_files', command: 'twitter/post', status: 'succeeded' },
359
+ artifacts: [{
360
+ artifactId: 'artifact_out',
361
+ argument: 'output',
362
+ direction: 'output',
363
+ pathKind: 'file',
364
+ filename: 'result.txt',
365
+ contentType: 'text/plain',
366
+ byteSize: body.byteLength,
367
+ sha256: createHash('sha256').update(body).digest('hex'),
368
+ relativePath: 'result.txt',
369
+ expiresAt: '2026-07-15T00:00:00.000Z',
370
+ }],
371
+ }), { status: 200 });
372
+ }
373
+ if (requestUrl.endsWith('/v1/executions/exec_files/artifacts/artifact_out')) {
374
+ return new Response(body, { status: 200, headers: { 'content-type': 'text/plain' } });
375
+ }
376
+ return new Response(JSON.stringify({ ok: false, error: { code: 'UNEXPECTED', message: requestUrl, exitCode: 1 } }), { status: 500 });
377
+ });
378
+ const result = await runHostedCli(['twitter', 'post', 'hello', '--images', imagePath, '--output', outputDir], {
379
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
380
+ stdout: stdout.stream,
381
+ stderr: stderr.stream,
382
+ fetchImpl,
383
+ });
384
+ expect(result).toEqual({ handled: true, exitCode: 0 });
385
+ expect(stdout.text()).toBe('');
386
+ expect(stderr.text()).toBe('');
387
+ await expect(readFile(path.join(outputDir, 'result.txt'), 'utf8')).resolves.toBe('hello cloud');
388
+ expect(calls).toEqual([
389
+ 'GET /v1/manifest',
390
+ 'POST /v1/executions',
391
+ 'POST /v1/executions/exec_files/artifacts/images',
392
+ 'POST /v1/executions/exec_files/run',
393
+ 'GET /v1/executions/exec_files/artifacts/artifact_out',
394
+ ]);
395
+ }
396
+ finally {
397
+ await rm(tempDir, { recursive: true, force: true });
398
+ }
399
+ });
400
+ it.each([
401
+ ['--help', '-f', 'xml'],
402
+ ['-f', 'xml', '--help'],
403
+ ['--help', '--trace', 'always'],
404
+ ['--help', '--mode', 'invalid'],
405
+ ])('lets help win over invalid semantic options: %j', async (...tail) => {
406
+ const precedenceManifest = manifestWithRequiredAccount();
407
+ const stdout = sink();
408
+ const stderr = sink();
409
+ const result = await runHostedCli(['github', 'whoami', ...tail], {
410
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
411
+ stdout: stdout.stream,
412
+ stderr: stderr.stream,
413
+ fetchImpl: async () => new Response(JSON.stringify({ ok: true, manifest: precedenceManifest }), { status: 200 }),
414
+ });
415
+ expect(result).toEqual({ handled: true, exitCode: 0 });
416
+ expect(stderr.text()).toBe('');
417
+ expect(stdout.text()).toContain('Usage: webcmd github whoami <account> [options]');
418
+ });
419
+ it.each([
420
+ ['-f', 'xml'],
421
+ ['--trace', 'always'],
422
+ ['--mode', 'invalid'],
423
+ ])('lets a missing required positional win over invalid semantic options: %j', async (...tail) => {
424
+ const precedenceManifest = manifestWithRequiredAccount();
425
+ const stdout = sink();
426
+ const stderr = sink();
427
+ const result = await runHostedCli(['github', 'whoami', ...tail], {
428
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
429
+ stdout: stdout.stream,
430
+ stderr: stderr.stream,
431
+ fetchImpl: async () => new Response(JSON.stringify({ ok: true, manifest: precedenceManifest }), { status: 200 }),
432
+ });
433
+ expect(result).toEqual({ handled: true, exitCode: 1 });
434
+ expect(stderr.text()).toBe("error: missing required argument 'account'\n");
435
+ expect(stdout.text()).toBe('');
436
+ });
437
+ it.each([
438
+ {
439
+ name: 'unknown option before help',
440
+ tail: ['--unknown', '--help'],
441
+ exitCode: 0,
442
+ stderr: '',
443
+ help: true,
444
+ },
445
+ {
446
+ name: 'help before an unknown option',
447
+ tail: ['--help', '--unknown'],
448
+ exitCode: 0,
449
+ stderr: '',
450
+ help: true,
451
+ },
452
+ {
453
+ name: 'missing option value after help',
454
+ tail: ['--help', '--token'],
455
+ exitCode: 1,
456
+ stderr: "error: option '--token <value>' argument missing\n",
457
+ help: false,
458
+ },
459
+ {
460
+ name: 'help before excess positionals',
461
+ tail: ['--help', 'one', 'two'],
462
+ exitCode: 0,
463
+ stderr: '',
464
+ help: true,
465
+ },
466
+ {
467
+ name: 'help before invalid choice, format, and trace values',
468
+ tail: ['--help', '--mode', 'bad', '-f', 'xml', '--trace', 'always'],
469
+ exitCode: 0,
470
+ stderr: '',
471
+ help: true,
472
+ },
473
+ {
474
+ name: 'required named option before invalid format',
475
+ tail: ['account', '-f', 'xml'],
476
+ exitCode: 1,
477
+ stderr: "error: required option '--token <value>' not specified\n",
478
+ help: false,
479
+ },
480
+ {
481
+ name: 'required named option before invalid trace',
482
+ tail: ['account', '--trace', 'always'],
483
+ exitCode: 1,
484
+ stderr: "error: required option '--token <value>' not specified\n",
485
+ help: false,
486
+ },
487
+ {
488
+ name: 'required named option before invalid choice',
489
+ tail: ['account', '--mode', 'bad'],
490
+ exitCode: 1,
491
+ stderr: "error: required option '--token <value>' not specified\n",
492
+ help: false,
493
+ },
494
+ {
495
+ name: 'required positional before invalid format',
496
+ tail: ['--token', 'secret', '-f', 'xml'],
497
+ exitCode: 1,
498
+ stderr: "error: missing required argument 'account'\n",
499
+ help: false,
500
+ },
501
+ {
502
+ name: 'ordinary unknown option',
503
+ tail: ['account', '--token', 'secret', '--unknown'],
504
+ exitCode: 1,
505
+ stderr: "error: unknown option '--unknown'\n",
506
+ help: false,
507
+ },
508
+ {
509
+ name: 'ordinary missing option value',
510
+ tail: ['account', '--token'],
511
+ exitCode: 1,
512
+ stderr: "error: option '--token <value>' argument missing\n",
513
+ help: false,
514
+ },
515
+ {
516
+ name: 'ordinary excess positional',
517
+ tail: ['account', 'extra', '--token', 'secret'],
518
+ exitCode: 1,
519
+ stderr: "error: too many arguments for 'whoami'. Expected 1 argument but got 2.\n",
520
+ help: false,
521
+ },
522
+ ])('matches public Commander structural bytes and discovery order: $name', async ({ tail, exitCode, stderr: expectedStderr, help }) => {
523
+ const structuralManifest = manifestWithStructuralArguments();
524
+ const stdout = sink();
525
+ const stderr = sink();
526
+ const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ ok: true, manifest: structuralManifest }), { status: 200 }));
527
+ const result = await runHostedCli(['github', 'whoami', ...tail], {
528
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
529
+ stdout: stdout.stream,
530
+ stderr: stderr.stream,
531
+ fetchImpl,
532
+ });
533
+ expect(result).toEqual({ handled: true, exitCode });
534
+ expect(stderr.text()).toBe(expectedStderr);
535
+ if (help)
536
+ expect(stdout.text()).toContain('Usage: webcmd github whoami <account> [options]');
537
+ else
538
+ expect(stdout.text()).toBe('');
539
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
540
+ expect(String(fetchImpl.mock.calls[0][0])).toBe('https://api.example.com/v1/manifest');
541
+ });
542
+ it.each([
543
+ { argv: ['--profile'], calls: 0 },
544
+ { argv: ['--help', '--profile'], calls: 0 },
545
+ { argv: ['--unknown', '--profile'], calls: 0 },
546
+ { argv: ['missing-site', '--profile'], calls: 1 },
547
+ ])('matches root Commander missing --profile bytes for %j', async ({ argv, calls }) => {
548
+ const stdout = sink();
549
+ const stderr = sink();
550
+ const fetchImpl = vi.fn(async () => manifestResponse());
551
+ const result = await runHostedCli(argv, {
552
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
553
+ stdout: stdout.stream,
554
+ stderr: stderr.stream,
555
+ fetchImpl,
556
+ });
557
+ expect(result).toEqual({ handled: true, exitCode: 1 });
558
+ expect(stderr.text()).toBe("error: option '--profile <name>' argument missing\n");
559
+ expect(stdout.text()).toBe('');
560
+ expect(fetchImpl).toHaveBeenCalledTimes(calls);
561
+ });
562
+ it.each([
563
+ ['--help', '--unknown'],
564
+ ['--unknown', '--help'],
565
+ ])('lets root help win over an ordinary unknown root option: %j', async (...argv) => {
566
+ const stdout = sink();
567
+ const stderr = sink();
568
+ const fetchImpl = vi.fn();
569
+ const result = await runHostedCli(argv, {
570
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
571
+ stdout: stdout.stream,
572
+ stderr: stderr.stream,
573
+ fetchImpl,
574
+ });
575
+ expect(result).toEqual({ handled: true, exitCode: 0 });
576
+ expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP));
577
+ expect(stderr.text()).toBe('');
578
+ expect(fetchImpl).not.toHaveBeenCalled();
579
+ });
580
+ it.each([
581
+ { name: 'before command', argv: ['--profile', 'work', 'github', 'whoami', '-f', 'json'], profile: 'work' },
582
+ { name: 'equals form', argv: ['--profile=work', 'github', 'whoami', '-f', 'json'], profile: 'work' },
583
+ { name: 'dash-leading value', argv: ['--profile', '-dash', 'github', 'whoami', '-f', 'json'], profile: '-dash' },
584
+ ])('forwards a root profile in $name', async ({ argv, profile }) => {
585
+ const requests = [];
586
+ const result = await runHostedCli(argv, {
587
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
588
+ stdout: sink().stream,
589
+ stderr: sink().stream,
590
+ fetchImpl: async (url, init) => {
591
+ requests.push({
592
+ url: String(url),
593
+ ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}),
594
+ });
595
+ return String(url).endsWith('/v1/manifest')
596
+ ? manifestResponse()
597
+ : executionResponse({ result: [] });
598
+ },
599
+ });
600
+ expect(result).toEqual({ handled: true, exitCode: 0 });
601
+ expect(requests).toHaveLength(2);
602
+ expect(requests[1]?.body?.profile).toBe(profile);
603
+ });
604
+ it('does not consume a profile placed after a known leaf command', async () => {
605
+ const stdout = sink();
606
+ const stderr = sink();
607
+ const fetchImpl = vi.fn(async () => manifestResponse());
608
+ const result = await runHostedCli(['github', 'whoami', '--profile', 'work'], {
609
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
610
+ stdout: stdout.stream,
611
+ stderr: stderr.stream,
612
+ fetchImpl,
613
+ });
614
+ expect(result).toEqual({ handled: true, exitCode: 1 });
615
+ expect(stderr.text()).toBe("error: unknown option '--profile'\n");
616
+ expect(stdout.text()).toBe('');
617
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
618
+ });
619
+ it('matches Commander when a profile consumes -- and exposes the following dash-leading root token', async () => {
620
+ const stdout = sink();
621
+ const stderr = sink();
622
+ const fetchImpl = vi.fn();
623
+ const result = await runHostedCli(['--profile', '--', '-dash'], {
624
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
625
+ stdout: stdout.stream,
626
+ stderr: stderr.stream,
627
+ fetchImpl,
628
+ });
629
+ expect(result).toEqual({ handled: true, exitCode: 1 });
630
+ expect(stderr.text()).toBe("error: unknown option '-dash'\n");
631
+ expect(stdout.text()).toBe('');
632
+ expect(fetchImpl).not.toHaveBeenCalled();
633
+ });
634
+ it('does not resolve until its slow stdout write callback and drain complete', async () => {
635
+ const stdout = new ControlledWritable({ highWaterMark: 1 });
636
+ let settled = false;
637
+ const run = runHostedCli(['--help'], {
638
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
639
+ stdout,
640
+ }).then(result => {
641
+ settled = true;
642
+ return result;
643
+ });
644
+ await new Promise(resolve => setImmediate(resolve));
645
+ expect(settled).toBe(false);
646
+ expect(stdout.pendingCount()).toBe(1);
647
+ stdout.release();
648
+ await expect(run).resolves.toEqual({ handled: true, exitCode: 0 });
649
+ expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP));
650
+ });
651
+ it('writes unknown-site stderr before root-help stdout', async () => {
652
+ const order = [];
653
+ const orderedSink = (label) => new Writable({
654
+ write(_chunk, _encoding, callback) {
655
+ order.push(label);
656
+ callback();
657
+ },
658
+ });
659
+ const result = await runHostedCli(['missing-site', 'child'], {
660
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
661
+ stdout: orderedSink('stdout'),
662
+ stderr: orderedSink('stderr'),
663
+ fetchImpl: async () => manifestResponse(),
664
+ });
665
+ expect(result.exitCode).toBe(2);
666
+ expect(order).toEqual(['stderr', 'stdout']);
667
+ });
668
+ it('does not resolve until a slow typed-error stderr write completes', async () => {
669
+ const stderr = new ControlledWritable({ highWaterMark: 1 });
670
+ let settled = false;
671
+ const run = runHostedCli(['github', 'whoami', '--trace', 'always'], {
672
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
673
+ stderr,
674
+ fetchImpl: async () => manifestResponse(),
675
+ }).then(result => {
676
+ settled = true;
677
+ return result;
678
+ });
679
+ await new Promise(resolve => setImmediate(resolve));
680
+ expect(settled).toBe(false);
681
+ expect(stderr.pendingCount()).toBe(1);
682
+ stderr.release();
683
+ await expect(run).resolves.toEqual({ handled: true, exitCode: 2 });
684
+ expect(stderr.text()).toContain('code: ARGUMENT');
685
+ });
686
+ it('rejects output stream errors without translating them or ending the caller stream', async () => {
687
+ const stdout = new Writable({
688
+ write(_chunk, _encoding, callback) {
689
+ callback(new Error('hosted stdout failed'));
690
+ },
691
+ });
692
+ const end = vi.spyOn(stdout, 'end');
693
+ await expect(runHostedCli(['--help'], {
694
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
695
+ stdout,
696
+ })).rejects.toThrow('hosted stdout failed');
697
+ expect(end).not.toHaveBeenCalled();
698
+ });
699
+ it('rejects within a bound when caller-owned stdout closes before its callback', async () => {
700
+ const stdout = new CloseBeforeCallbackWritable();
701
+ const end = vi.spyOn(stdout, 'end');
702
+ await expect(within(runHostedCli(['--help'], {
703
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
704
+ stdout,
705
+ }))).rejects.toThrow('closed before the write completed');
706
+ expect(end).not.toHaveBeenCalled();
707
+ });
708
+ it('renders hosted list without LOCAL commands', async () => {
709
+ const stdout = sink();
710
+ const result = await runHostedCli(['list', '-f', 'json'], {
711
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
712
+ stdout: stdout.stream,
713
+ fetchImpl: async () => manifestResponse(),
714
+ });
715
+ expect(result).toEqual({ handled: true, exitCode: 0 });
716
+ expect(stdout.text()).toContain('github/whoami');
717
+ expect(stdout.text()).not.toContain('docker/ps');
718
+ });
719
+ it('dispatches hosted commands to /v1/execute', async () => {
720
+ const requests = [];
721
+ const stdout = sink();
722
+ const result = await runHostedCli(['github', 'whoami', '-f', 'json'], {
723
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
724
+ stdout: stdout.stream,
725
+ fetchImpl: async (url, init) => {
726
+ requests.push({
727
+ url: String(url),
728
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
729
+ });
730
+ if (String(url).endsWith('/v1/manifest')) {
731
+ return manifestResponse();
732
+ }
733
+ return executionResponse({ result: [{ username: 'octocat' }], columns: ['username'] });
734
+ },
735
+ });
736
+ expect(result).toEqual({ handled: true, exitCode: 0 });
737
+ expect(requests.at(-1)).toEqual({
738
+ url: 'https://api.example.com/v1/execute',
739
+ body: {
740
+ command: 'github/whoami',
741
+ args: {},
742
+ format: 'json',
743
+ trace: 'off',
744
+ },
745
+ });
746
+ expect(stdout.text()).toBe('[\n {\n "username": "octocat"\n }\n]\n');
747
+ });
748
+ it('uploads local file args, runs a prepared execution, and materializes hosted output artifacts', async () => {
749
+ const tempDir = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-files-'));
750
+ try {
751
+ const sourcePath = path.join(tempDir, 'source.txt');
752
+ const outputDir = path.join(tempDir, 'downloads');
753
+ await writeFile(sourcePath, 'input bytes');
754
+ const requests = [];
755
+ const stdout = sink();
756
+ const result = await runHostedCli(['files', 'copy', '--source', sourcePath, '--output', outputDir, '-f', 'json'], {
757
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
758
+ stdout: stdout.stream,
759
+ fetchImpl: async (url, init) => {
760
+ const parsedUrl = new URL(String(url));
761
+ const rawBody = init?.body;
762
+ requests.push({
763
+ method: init?.method ?? 'GET',
764
+ pathname: parsedUrl.pathname,
765
+ ...(rawBody instanceof Uint8Array
766
+ ? { body: Buffer.from(rawBody).toString('utf8') }
767
+ : rawBody
768
+ ? { body: JSON.parse(String(rawBody)) }
769
+ : {}),
770
+ filename: new Headers(init?.headers).get('x-webcmd-filename'),
771
+ contentType: new Headers(init?.headers).get('x-webcmd-content-type'),
772
+ });
773
+ if (parsedUrl.pathname === '/v1/manifest') {
774
+ return new Response(JSON.stringify({ ok: true, manifest: manifestWithFileCommand() }), { status: 200 });
775
+ }
776
+ if (parsedUrl.pathname === '/v1/executions') {
777
+ return new Response(JSON.stringify({
778
+ ok: true,
779
+ execution: { id: 'exec_files', command: 'files/copy', status: 'queued' },
780
+ fileArguments: [
781
+ {
782
+ name: 'source',
783
+ direction: 'input',
784
+ pathKind: 'file',
785
+ multiple: false,
786
+ required: true,
787
+ contentTypes: ['text/plain'],
788
+ maxBytes: 1024,
789
+ },
790
+ {
791
+ name: 'output',
792
+ direction: 'output',
793
+ pathKind: 'directory',
794
+ multiple: false,
795
+ required: true,
796
+ },
797
+ ],
798
+ }), { status: 201 });
799
+ }
800
+ if (parsedUrl.pathname === '/v1/executions/exec_files/artifacts/source') {
801
+ return new Response(JSON.stringify({
802
+ ok: true,
803
+ artifact: {
804
+ artifactId: 'ea_input',
805
+ argument: 'source',
806
+ direction: 'input',
807
+ pathKind: 'file',
808
+ filename: 'source.txt',
809
+ contentType: 'text/plain',
810
+ byteSize: 11,
811
+ expiresAt: '2026-07-10T00:00:00.000Z',
812
+ },
813
+ reference: { $webcmdArtifact: { id: 'ea_input', direction: 'input' } },
814
+ }), { status: 201 });
815
+ }
816
+ if (parsedUrl.pathname === '/v1/executions/exec_files/run') {
817
+ return new Response(JSON.stringify({
818
+ ok: true,
819
+ result: [{ status: 'copied', file: '/private/cloud-root/nested/result.txt' }],
820
+ columns: ['status', 'file'],
821
+ execution: { id: 'exec_files', command: 'files/copy', status: 'succeeded' },
822
+ artifacts: [{
823
+ artifactId: 'ea_output',
824
+ argument: 'output',
825
+ direction: 'output',
826
+ pathKind: 'file',
827
+ filename: 'result.txt',
828
+ contentType: 'text/plain',
829
+ byteSize: 11,
830
+ relativePath: 'nested/result.txt',
831
+ expiresAt: '2026-07-10T00:00:00.000Z',
832
+ }],
833
+ }), { status: 200 });
834
+ }
835
+ if (parsedUrl.pathname === '/v1/executions/exec_files/artifacts/ea_output') {
836
+ return new Response('hello cloud', { status: 200 });
837
+ }
838
+ return new Response(JSON.stringify({
839
+ ok: false,
840
+ error: { code: 'NOT_FOUND', message: parsedUrl.pathname, exitCode: 1 },
841
+ }), { status: 404 });
842
+ },
843
+ });
844
+ expect(result).toEqual({ handled: true, exitCode: 0 });
845
+ expect(requests.map(request => `${request.method} ${request.pathname}`)).toEqual([
846
+ 'GET /v1/manifest',
847
+ 'POST /v1/executions',
848
+ 'POST /v1/executions/exec_files/artifacts/source',
849
+ 'POST /v1/executions/exec_files/run',
850
+ 'GET /v1/executions/exec_files/artifacts/ea_output',
851
+ ]);
852
+ expect(requests[2]).toMatchObject({
853
+ body: 'input bytes',
854
+ filename: 'source.txt',
855
+ contentType: 'text/plain',
856
+ });
857
+ expect(requests[3]?.body).toMatchObject({
858
+ command: 'files/copy',
859
+ args: {
860
+ source: { $webcmdArtifact: { id: 'ea_input', direction: 'input' } },
861
+ output: { $webcmdArtifact: { direction: 'output', filename: 'downloads', contentType: 'application/octet-stream' } },
862
+ },
863
+ format: 'json',
864
+ trace: 'off',
865
+ });
866
+ await expect(readFile(path.join(outputDir, 'nested', 'result.txt'), 'utf8')).resolves.toBe('hello cloud');
867
+ expect(JSON.parse(stdout.text())).toEqual([{
868
+ status: 'copied',
869
+ file: path.join(outputDir, 'nested', 'result.txt'),
870
+ }]);
871
+ expect(stdout.text()).not.toContain('/private/cloud-root');
872
+ }
873
+ finally {
874
+ await rm(tempDir, { recursive: true, force: true });
875
+ }
876
+ });
877
+ it.each([
878
+ { name: 'scalar field', result: { value: 'hello' }, argv: ['-f', 'plain'], expected: 'hello\n' },
879
+ {
880
+ name: 'multiple rows',
881
+ result: [{ username: 'alice' }, { username: 'bob' }],
882
+ argv: ['-f', 'csv'],
883
+ expected: 'username\nalice\nbob\n',
884
+ },
885
+ {
886
+ name: 'CSV escaping',
887
+ result: [{ username: 'a,"b\nline 2' }],
888
+ argv: ['-f', 'csv'],
889
+ expected: 'username\n"a,""b\nline 2"\n',
890
+ },
891
+ {
892
+ name: 'literal Markdown cells',
893
+ result: [{ username: 'a|b\nline 2' }],
894
+ argv: ['-f', 'md'],
895
+ expected: '| username |\n| --- |\n| a|b\nline 2 |\n',
896
+ },
897
+ ])('renders hosted $name with canonical literal bytes', async ({ result, argv, expected }) => {
898
+ const stdout = sink(true);
899
+ await runHostedCli(['github', 'whoami', ...argv], {
900
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
901
+ stdout: stdout.stream,
902
+ fetchImpl: async (url) => String(url).endsWith('/v1/manifest')
903
+ ? manifestResponse()
904
+ : executionResponse({ result }),
905
+ });
906
+ expect(stdout.text()).toBe(expected);
907
+ });
908
+ it('uses the response columns and falls back to command columns', async () => {
909
+ const withResponseColumns = sink();
910
+ const withoutResponseColumns = sink();
911
+ const result = [{ username: 'octocat', secret: 'hidden' }];
912
+ const run = async (stdout, columns) => runHostedCli([
913
+ 'github', 'whoami', '-f', 'csv',
914
+ ], {
915
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
916
+ stdout: stdout.stream,
917
+ fetchImpl: async (url) => String(url).endsWith('/v1/manifest')
918
+ ? manifestResponse()
919
+ : executionResponse({ result, ...(columns ? { columns } : {}) }),
920
+ });
921
+ await run(withResponseColumns, ['secret']);
922
+ await run(withoutResponseColumns);
923
+ expect(withResponseColumns.text()).toBe('secret\nhidden\n');
924
+ expect(withoutResponseColumns.text()).toBe('username\noctocat\n');
925
+ });
926
+ it('propagates implicit versus explicit table format to non-TTY rendering', async () => {
927
+ const implicit = sink(false);
928
+ const explicit = sink(false);
929
+ const run = async (stdout, argv) => runHostedCli(argv, {
930
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
931
+ stdout: stdout.stream,
932
+ fetchImpl: async (url) => String(url).endsWith('/v1/manifest')
933
+ ? manifestResponse()
934
+ : executionResponse({ result: [{ username: 'octocat' }] }),
935
+ });
936
+ await run(implicit, ['github', 'whoami']);
937
+ await run(explicit, ['github', 'whoami', '-f', 'table']);
938
+ expect(implicit.text()).toBe('- username: octocat\n\n');
939
+ expect(explicit.text()).toContain('octocat');
940
+ expect(explicit.text()).not.toContain('username: octocat');
941
+ });
942
+ it('uses the command default format only when format was not explicit', async () => {
943
+ const implicit = sink(false);
944
+ const explicit = sink(false);
945
+ const manifestWithDefault = {
946
+ ...manifest,
947
+ commands: manifest.commands.map(command => command.command === 'github/whoami'
948
+ ? { ...command, defaultFormat: 'plain' }
949
+ : command),
950
+ };
951
+ const run = async (stdout, argv) => runHostedCli(argv, {
952
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
953
+ stdout: stdout.stream,
954
+ fetchImpl: async (url) => String(url).endsWith('/v1/manifest')
955
+ ? new Response(JSON.stringify({ ok: true, manifest: manifestWithDefault }), { status: 200 })
956
+ : executionResponse({ result: { response: 'hello' } }),
957
+ });
958
+ await run(implicit, ['github', 'whoami']);
959
+ await run(explicit, ['github', 'whoami', '-f', 'json']);
960
+ expect(implicit.text()).toBe('hello\n');
961
+ expect(explicit.text()).toBe('{\n "response": "hello"\n}\n');
962
+ });
963
+ it('renders only response.result with canonical local table labels and elapsed semantics', async () => {
964
+ const stdout = sink(true);
965
+ const times = [1_000, 1_250];
966
+ await runHostedCli(['github', 'whoami', '-f', 'table'], {
967
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
968
+ stdout: stdout.stream,
969
+ now: () => times.shift() ?? 1_250,
970
+ fetchImpl: async (url) => {
971
+ if (String(url).endsWith('/v1/manifest'))
972
+ return manifestResponse();
973
+ return new Response(JSON.stringify({
974
+ ok: true,
975
+ result: [{ username: 'octocat' }],
976
+ execution: { id: 'exec_success', command: 'github/whoami', status: 'succeeded' },
977
+ }), { status: 200 });
978
+ },
979
+ });
980
+ expect(stdout.text()).toContain('octocat');
981
+ expect(stdout.text()).toContain(' github/whoami');
982
+ expect(stdout.text()).toContain('1 items | 0.3s | github/whoami');
983
+ expect(stdout.text()).not.toContain('webcmd cloud');
984
+ });
985
+ it('writes a successful trace=on receipt to injected stderr exactly once', async () => {
986
+ const stdout = sink();
987
+ const stderr = sink();
988
+ await runHostedCli(['github', 'whoami', '--trace', 'on', '-f', 'json'], {
989
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
990
+ stdout: stdout.stream,
991
+ stderr: stderr.stream,
992
+ fetchImpl: async (url) => String(url).endsWith('/v1/manifest')
993
+ ? manifestResponse()
994
+ : executionResponse({
995
+ result: [{ username: 'octocat' }],
996
+ trace: {
997
+ receipt: 'trace_receipt',
998
+ executionId: 'exec_success',
999
+ artifactsUrl: '/v1/executions/exec_success/artifacts',
1000
+ },
1001
+ }),
1002
+ });
1003
+ expect(stderr.text()).toBe('Webcmd trace artifact: trace_receipt\n');
1004
+ expect(stdout.text()).not.toContain('trace_receipt');
1005
+ });
1006
+ it.each(['off', 'retain-on-failure'])('does not write a success trace notice for trace=%s', async (trace) => {
1007
+ const stderr = sink();
1008
+ await runHostedCli(['github', 'whoami', '--trace', trace, '-f', 'json'], {
1009
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1010
+ stdout: sink().stream,
1011
+ stderr: stderr.stream,
1012
+ fetchImpl: async (url) => String(url).endsWith('/v1/manifest')
1013
+ ? manifestResponse()
1014
+ : executionResponse({ result: [] }),
1015
+ });
1016
+ expect(stderr.text()).toBe('');
1017
+ });
1018
+ it('attaches hosted failure trace metadata to the local error envelope', async () => {
1019
+ const stderr = sink();
1020
+ const result = await runHostedCli(['github', 'whoami', '--trace', 'retain-on-failure'], {
1021
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1022
+ stdout: sink().stream,
1023
+ stderr: stderr.stream,
1024
+ fetchImpl: async (url) => {
1025
+ if (String(url).endsWith('/v1/manifest'))
1026
+ return manifestResponse();
1027
+ return new Response(JSON.stringify({
1028
+ ok: false,
1029
+ error: { code: 'AUTH_REQUIRED', message: 'Sign in first', exitCode: 77 },
1030
+ execution: { id: 'exec_failure', command: 'github/whoami', status: 'failed' },
1031
+ trace: {
1032
+ receipt: 'trace_failure',
1033
+ executionId: 'exec_failure',
1034
+ artifactsUrl: '/v1/executions/exec_failure/artifacts',
1035
+ },
1036
+ }), { status: 401 });
1037
+ },
1038
+ });
1039
+ expect(result).toEqual({ handled: true, exitCode: 77 });
1040
+ expect(stderr.text()).toContain('receipt: trace_failure');
1041
+ expect(stderr.text()).toContain('executionId: exec_failure');
1042
+ expect(stderr.text()).not.toContain('Webcmd trace artifact:');
1043
+ });
1044
+ it.each(['success', 'failure'])('rejects a raw provider trace URL before $phase output or attachment', async (phase) => {
1045
+ const rawUrl = 'https://kernel.example/session/private?token=kernel-secret-token';
1046
+ const stdout = sink();
1047
+ const stderr = sink();
1048
+ const success = phase === 'success';
1049
+ const result = await runHostedCli([
1050
+ 'github',
1051
+ 'whoami',
1052
+ '--trace',
1053
+ success ? 'on' : 'retain-on-failure',
1054
+ '-f',
1055
+ 'json',
1056
+ ], {
1057
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1058
+ stdout: stdout.stream,
1059
+ stderr: stderr.stream,
1060
+ fetchImpl: async (url) => {
1061
+ if (String(url).endsWith('/v1/manifest'))
1062
+ return manifestResponse();
1063
+ return new Response(JSON.stringify(success ? {
1064
+ ok: true,
1065
+ result: [{ username: 'octocat' }],
1066
+ execution: { id: 'exec_trace', command: 'github/whoami', status: 'succeeded' },
1067
+ trace: { receipt: 'trace_receipt', executionId: 'exec_trace', liveViewUrl: rawUrl },
1068
+ } : {
1069
+ ok: false,
1070
+ error: { code: 'AUTH_REQUIRED', message: 'Sign in first', exitCode: 77 },
1071
+ execution: { id: 'exec_trace', command: 'github/whoami', status: 'failed' },
1072
+ trace: { receipt: 'trace_receipt', executionId: 'exec_trace', liveViewUrl: rawUrl },
1073
+ }), { status: success ? 200 : 401 });
1074
+ },
1075
+ });
1076
+ expect(result).toEqual({ handled: true, exitCode: 1 });
1077
+ expect(stdout.text()).toBe('');
1078
+ expect(stderr.text()).toContain('HOSTED_PROTOCOL');
1079
+ expect(`${stdout.text()}\n${stderr.text()}`).not.toContain(rawUrl);
1080
+ expect(`${stdout.text()}\n${stderr.text()}`).not.toContain('kernel-secret-token');
1081
+ });
1082
+ it('accepts a manifest patch bump on the same hosted compatibility line', async () => {
1083
+ const requests = [];
1084
+ const stdout = sink();
1085
+ const patchBumped = {
1086
+ ...manifest,
1087
+ metadata: { ...manifest.metadata, webcmdPackageVersion: '0.3.99' },
1088
+ };
1089
+ const result = await runHostedCli(['github', 'whoami', '-f', 'json'], {
1090
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1091
+ stdout: stdout.stream,
1092
+ fetchImpl: async (url) => {
1093
+ requests.push(String(url));
1094
+ return String(url).endsWith('/v1/manifest')
1095
+ ? new Response(JSON.stringify({ ok: true, manifest: patchBumped }), { status: 200 })
1096
+ : executionResponse({ result: [{ username: 'octocat' }], columns: ['username'] });
1097
+ },
1098
+ });
1099
+ expect(result).toEqual({ handled: true, exitCode: 0 });
1100
+ expect(requests).toEqual([
1101
+ 'https://api.example.com/v1/manifest',
1102
+ 'https://api.example.com/v1/execute',
1103
+ ]);
1104
+ expect(stdout.text()).toBe('[\n {\n "username": "octocat"\n }\n]\n');
1105
+ });
1106
+ it('rejects a manifest whose compatibility line differs from installed hosted-contract.json before execution', async () => {
1107
+ const requests = [];
1108
+ const stderr = sink();
1109
+ const mismatched = {
1110
+ ...manifest,
1111
+ metadata: { ...manifest.metadata, webcmdPackageVersion: '0.4.0' },
1112
+ };
1113
+ const result = await runHostedCli(['github', 'whoami'], {
1114
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1115
+ stderr: stderr.stream,
1116
+ fetchImpl: async (url) => {
1117
+ requests.push(String(url));
1118
+ return new Response(JSON.stringify({ ok: true, manifest: mismatched }), { status: 200 });
1119
+ },
1120
+ });
1121
+ expect(result.exitCode).toBe(1);
1122
+ expect(stderr.text()).toMatch(/HOSTED_PROTOCOL|hosted contract/i);
1123
+ expect(requests).toEqual(['https://api.example.com/v1/manifest']);
1124
+ });
1125
+ it('writes a result larger than 1 MiB completely through injected stdout', async () => {
1126
+ const value = 'x'.repeat((1024 * 1024) + 31);
1127
+ const stdout = sink();
1128
+ await runHostedCli(['github', 'whoami', '-f', 'plain'], {
1129
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1130
+ stdout: stdout.stream,
1131
+ fetchImpl: async (url) => String(url).endsWith('/v1/manifest')
1132
+ ? manifestResponse()
1133
+ : executionResponse({ result: { value } }),
1134
+ });
1135
+ const expected = `${value}\n`;
1136
+ expect(stdout.text().length).toBe(expected.length);
1137
+ expect(createHash('sha256').update(stdout.text()).digest('hex'))
1138
+ .toBe(createHash('sha256').update(expected).digest('hex'));
1139
+ });
1140
+ it('rejects daemon commands in hosted mode', async () => {
1141
+ const stderr = sink();
1142
+ const result = await runHostedCli(['daemon', 'status'], {
1143
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1144
+ stderr: stderr.stream,
1145
+ });
1146
+ expect(result.exitCode).toBe(78);
1147
+ expect(stderr.text()).toMatch(/hosted mode has no local daemon/i);
1148
+ });
1149
+ it.each([
1150
+ { name: 'help before unknown leaf option', argv: ['browser', 'work', 'state', '--help', '--unknown'] },
1151
+ { name: 'unknown leaf option before help', argv: ['browser', 'work', 'state', '--unknown', '--help'] },
1152
+ { name: 'help followed by missing leaf option value', argv: ['browser', 'work', 'state', '--help', '--source'] },
1153
+ { name: 'ordinary unknown leaf option', argv: ['browser', 'work', 'state', '--unknown'] },
1154
+ { name: 'ordinary missing leaf option value', argv: ['browser', 'work', 'state', '--source'] },
1155
+ { name: 'ordinary excess leaf positional', argv: ['browser', 'work', 'state', 'extra'] },
1156
+ { name: 'ordinary missing required leaf positional', argv: ['browser', 'work', 'eval'] },
1157
+ { name: 'missing namespace window value', argv: ['browser', 'work', '--window'] },
1158
+ { name: 'unhoisted missing trailing window value', argv: ['browser', 'work', 'state', '--window'] },
1159
+ { name: 'invalid Commander-coerced screenshot dimension', argv: ['browser', 'work', 'screenshot', '--width=-10'] },
1160
+ { name: 'root profile after the leaf', argv: ['browser', 'work', 'state', '--profile', 'other'] },
1161
+ { name: 'root profile between namespace and leaf', argv: ['browser', 'work', '--profile', 'other', 'state'] },
1162
+ { name: 'adapter site-session option at browser namespace', argv: ['browser', 'work', '--site-session', 'persistent', 'state'] },
1163
+ { name: 'adapter keep-tab option at browser leaf', argv: ['browser', 'work', 'state', '--keep-tab', 'true'] },
1164
+ { name: 'unknown browser command', argv: ['browser', 'work', 'missing'] },
1165
+ ])('matches local browser Commander structural bytes/status with no cloud call: $name', async ({ argv }) => {
1166
+ const local = captureLocalBrowserStructure(argv);
1167
+ const stdout = sink();
1168
+ const stderr = sink();
1169
+ const fetchImpl = vi.fn();
1170
+ const result = await runHostedCli(argv, {
1171
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1172
+ stdout: stdout.stream,
1173
+ stderr: stderr.stream,
1174
+ fetchImpl,
1175
+ });
1176
+ expect({ exitCode: result.exitCode, stdout: stdout.text(), stderr: stderr.text() }).toEqual(local);
1177
+ expect(fetchImpl).not.toHaveBeenCalled();
1178
+ });
1179
+ it('matches the exact local text help for every catalogued browser leaf without a cloud call', async () => {
1180
+ const program = createProgram('', '');
1181
+ const browser = program.commands.find(command => command.name() === 'browser');
1182
+ if (!browser)
1183
+ throw new Error('Local browser namespace is missing');
1184
+ for (const contract of browserCommandCatalog) {
1185
+ const parts = contract.command.split('/');
1186
+ let local = browser;
1187
+ for (const part of parts) {
1188
+ const child = local.commands.find(command => command.name() === part || command.aliases().includes(part));
1189
+ if (!child)
1190
+ throw new Error(`Local browser command is missing: ${contract.command}`);
1191
+ local = child;
1192
+ }
1193
+ const stdout = sink();
1194
+ const stderr = sink();
1195
+ const fetchImpl = vi.fn();
1196
+ const result = await runHostedCli(['browser', 'work', ...parts, '--help'], {
1197
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1198
+ stdout: stdout.stream,
1199
+ stderr: stderr.stream,
1200
+ fetchImpl,
1201
+ });
1202
+ expect({ command: contract.command, result, stdout: stdout.text(), stderr: stderr.text() }).toEqual({
1203
+ command: contract.command,
1204
+ result: { handled: true, exitCode: 0 },
1205
+ stdout: local.helpInformation(),
1206
+ stderr: '',
1207
+ });
1208
+ expect(fetchImpl).not.toHaveBeenCalled();
1209
+ }
1210
+ });
1211
+ it('dispatches every catalogued hosted browser command except bind to the cloud command endpoint', async () => {
1212
+ const uploadDir = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-browser-upload-'));
1213
+ const uploadFile = path.join(uploadDir, 'sample-upload.txt');
1214
+ await writeFile(uploadFile, 'hello browser upload');
1215
+ try {
1216
+ for (const contract of browserCommandCatalog.filter(command => command.command !== 'bind')) {
1217
+ const requests = [];
1218
+ const positionals = contract.command === 'upload'
1219
+ ? ['input[type=file]', uploadFile]
1220
+ : sampleBrowserPositionals(contract);
1221
+ const result = await runHostedCli(['browser', 'work', ...contract.command.split('/'), ...positionals], {
1222
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1223
+ stdout: sink().stream,
1224
+ stderr: sink().stream,
1225
+ fetchImpl: async (url, init) => {
1226
+ const parsedUrl = new URL(String(url));
1227
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined;
1228
+ requests.push({ pathname: parsedUrl.pathname, ...(body ? { body } : {}) });
1229
+ if (parsedUrl.pathname === '/v1/manifest')
1230
+ return manifestResponse();
1231
+ if (parsedUrl.pathname === '/v1/browser/work/commands') {
1232
+ return new Response(JSON.stringify({
1233
+ ok: true,
1234
+ result: {},
1235
+ columns: [],
1236
+ trace: null,
1237
+ run: {
1238
+ executionId: `exec_${contract.command.replaceAll('/', '_')}`,
1239
+ session: 'work',
1240
+ profile: { id: 'profile_default', displayName: 'default' },
1241
+ },
1242
+ execution: { id: `exec_${contract.command.replaceAll('/', '_')}`, status: 'succeeded' },
1243
+ }), { status: 200 });
1244
+ }
1245
+ return new Response(JSON.stringify({
1246
+ ok: false,
1247
+ error: { code: 'UNEXPECTED', message: parsedUrl.pathname, exitCode: 1 },
1248
+ }), { status: 500 });
1249
+ },
1250
+ });
1251
+ expect({ command: contract.command, result }).toEqual({
1252
+ command: contract.command,
1253
+ result: { handled: true, exitCode: 0 },
1254
+ });
1255
+ expect({
1256
+ command: contract.command,
1257
+ action: requests.find(request => request.pathname === '/v1/browser/work/commands')?.body,
1258
+ }).toMatchObject({
1259
+ command: contract.command,
1260
+ action: { command: `browser/${contract.command}`, action: contract.action },
1261
+ });
1262
+ }
1263
+ }
1264
+ finally {
1265
+ await rm(uploadDir, { recursive: true, force: true });
1266
+ }
1267
+ });
1268
+ it('routes hosted browser positional commands through the atomic cloud action route', async () => {
1269
+ const requests = [];
1270
+ const stdout = sink();
1271
+ const result = await runHostedCli(['--profile', 'default', 'browser', 'work', 'open', 'https://example.com', '--window', 'background'], {
1272
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1273
+ stdout: stdout.stream,
1274
+ fetchImpl: async (url, init) => {
1275
+ requests.push({
1276
+ url: String(url),
1277
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
1278
+ });
1279
+ if (String(url).endsWith('/v1/manifest'))
1280
+ return manifestResponse();
1281
+ if (String(url).endsWith('/commands')) {
1282
+ return new Response(JSON.stringify({
1283
+ ok: true,
1284
+ result: { url: 'https://example.com' },
1285
+ columns: ['url'],
1286
+ trace: null,
1287
+ run: {
1288
+ executionId: 'exec_browser',
1289
+ session: 'work',
1290
+ profile: { id: 'profile_default', displayName: 'default' },
1291
+ },
1292
+ execution: { id: 'exec_browser', status: 'succeeded' },
1293
+ }), { status: 200 });
1294
+ }
1295
+ return new Response(JSON.stringify({ ok: false, error: { code: 'UNEXPECTED', message: String(url), exitCode: 1 } }), { status: 500 });
1296
+ },
1297
+ });
1298
+ expect(result).toEqual({ handled: true, exitCode: 0 });
1299
+ expect(stdout.text()).toContain('https://example.com');
1300
+ expect(requests).toEqual([
1301
+ {
1302
+ url: 'https://api.example.com/v1/manifest',
1303
+ body: undefined,
1304
+ },
1305
+ {
1306
+ url: 'https://api.example.com/v1/browser/work/commands',
1307
+ body: {
1308
+ command: 'browser/open',
1309
+ action: 'navigate',
1310
+ args: { url: 'https://example.com' },
1311
+ profile: 'default',
1312
+ windowMode: 'background',
1313
+ trace: 'off',
1314
+ },
1315
+ },
1316
+ ]);
1317
+ });
1318
+ it('uses the canonical Commander value for a dash-leading browser option in the Cloud request', async () => {
1319
+ const requests = [];
1320
+ const result = await runHostedCli(['browser', 'work', 'scroll', 'down', '--amount', '-5'], {
1321
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1322
+ stdout: sink().stream,
1323
+ stderr: sink().stream,
1324
+ fetchImpl: async (url, init) => {
1325
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined;
1326
+ requests.push({ url: String(url), ...(body ? { body } : {}) });
1327
+ if (String(url).endsWith('/v1/manifest'))
1328
+ return manifestResponse();
1329
+ if (String(url).endsWith('/commands')) {
1330
+ return new Response(JSON.stringify({
1331
+ ok: true,
1332
+ result: { scrolled: 'down', amount: -5 },
1333
+ columns: ['scrolled', 'amount'],
1334
+ trace: null,
1335
+ run: {
1336
+ executionId: 'exec_browser_scroll',
1337
+ session: 'work',
1338
+ profile: { id: 'profile_default', displayName: 'default' },
1339
+ },
1340
+ execution: { id: 'exec_browser_scroll', status: 'succeeded' },
1341
+ }), { status: 200 });
1342
+ }
1343
+ return new Response(JSON.stringify({ ok: false, error: { code: 'UNEXPECTED', message: String(url), exitCode: 1 } }), { status: 500 });
1344
+ },
1345
+ });
1346
+ expect(result).toEqual({ handled: true, exitCode: 0 });
1347
+ expect(requests[1]?.body).toEqual({
1348
+ command: 'browser/scroll',
1349
+ action: 'scroll',
1350
+ args: { direction: 'down', amount: '-5' },
1351
+ trace: 'off',
1352
+ });
1353
+ });
1354
+ it.each([
1355
+ {
1356
+ name: 'repeated and equals string option',
1357
+ argv: ['browser', 'work', 'scroll', 'down', '--amount', '10', '--amount=-5'],
1358
+ command: 'browser/scroll',
1359
+ action: 'scroll',
1360
+ args: { direction: 'down', amount: '-5' },
1361
+ },
1362
+ {
1363
+ name: 'equals dash-leading frame option',
1364
+ argv: ['browser', 'work', 'eval', 'return 1', '--frame=-2'],
1365
+ command: 'browser/eval',
1366
+ action: 'exec',
1367
+ args: { js: 'return 1', frame: '-2' },
1368
+ },
1369
+ {
1370
+ name: 'Commander-coerced dimensions and repeated boolean flag',
1371
+ argv: ['browser', 'work', 'screenshot', '--width=10', '--height', '20', '--full-page', '--full-page'],
1372
+ command: 'browser/screenshot',
1373
+ action: 'screenshot',
1374
+ args: { fullPage: true, annotate: false, width: 10, height: 20 },
1375
+ },
1376
+ {
1377
+ name: 'dash-leading timeout option',
1378
+ argv: ['browser', 'work', 'wait', 'time', '1', '--timeout', '-5'],
1379
+ command: 'browser/wait',
1380
+ action: 'wait',
1381
+ args: { type: 'time', value: '1', timeout: '-5' },
1382
+ },
1383
+ {
1384
+ name: 'dash-leading observation option and boolean flag',
1385
+ argv: ['browser', 'work', 'console', '--since', '-dash', '--follow'],
1386
+ command: 'browser/console',
1387
+ action: 'console',
1388
+ args: { level: 'all', follow: true, since: '-dash' },
1389
+ },
1390
+ {
1391
+ name: 'dash-leading positional behind separator',
1392
+ argv: ['browser', 'work', 'eval', '--', '-script'],
1393
+ command: 'browser/eval',
1394
+ action: 'exec',
1395
+ args: { js: '-script' },
1396
+ },
1397
+ {
1398
+ name: 'repeated namespace window option',
1399
+ argv: ['browser', 'work', '--window', 'foreground', '--window=background', 'state'],
1400
+ command: 'browser/state',
1401
+ action: 'snapshot',
1402
+ args: { source: 'dom', compareSources: false },
1403
+ windowMode: 'background',
1404
+ },
1405
+ {
1406
+ name: 'retained tab and comparison options',
1407
+ argv: ['browser', 'work', 'state', '--compare-sources', '--tab', 'page-2'],
1408
+ command: 'browser/state',
1409
+ action: 'snapshot',
1410
+ args: { source: 'dom', compareSources: true, tab: 'page-2' },
1411
+ },
1412
+ {
1413
+ name: 'semantic click without positional target',
1414
+ argv: ['browser', 'work', 'click', '--role', 'button', '--name', 'Submit', '--nth', '-1', '--tab', 'page-2'],
1415
+ command: 'browser/click',
1416
+ action: 'click',
1417
+ args: { role: 'button', name: 'Submit', nth: '-1', tab: 'page-2' },
1418
+ },
1419
+ {
1420
+ name: 'semantic type treats first positional as text',
1421
+ argv: ['browser', 'work', 'type', 'hello cloud', '--role', 'textbox', '--name', 'Search'],
1422
+ command: 'browser/type',
1423
+ action: 'type',
1424
+ args: { role: 'textbox', name: 'Search', text: 'hello cloud' },
1425
+ },
1426
+ {
1427
+ name: 'double-click retains action-specific options',
1428
+ argv: ['browser', 'work', 'dblclick', '#ok', '--nth', '2', '--tab', 'page-2'],
1429
+ command: 'browser/dblclick',
1430
+ action: 'dblclick',
1431
+ args: { target: '#ok', nth: '2', tab: 'page-2' },
1432
+ },
1433
+ {
1434
+ name: 'get text reaches hosted get action',
1435
+ argv: ['browser', 'work', 'get', 'text', '#result', '--nth', '0'],
1436
+ command: 'browser/get/text',
1437
+ action: 'get-text',
1438
+ args: { target: '#result', nth: '0' },
1439
+ },
1440
+ ])('sends canonical browser request bodies for $name', async ({ argv, command, action, args, windowMode }) => {
1441
+ const requests = [];
1442
+ const result = await runHostedCli(argv, {
1443
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1444
+ stdout: sink().stream,
1445
+ stderr: sink().stream,
1446
+ fetchImpl: async (url, init) => {
1447
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined;
1448
+ requests.push({ url: String(url), ...(body ? { body } : {}) });
1449
+ if (String(url).endsWith('/v1/manifest'))
1450
+ return manifestResponse();
1451
+ if (String(url).endsWith('/commands')) {
1452
+ return new Response(JSON.stringify({
1453
+ ok: true,
1454
+ result: {},
1455
+ columns: [],
1456
+ trace: null,
1457
+ run: {
1458
+ executionId: 'exec_browser_canonical',
1459
+ session: 'work',
1460
+ profile: { id: 'profile_default', displayName: 'default' },
1461
+ },
1462
+ execution: { id: 'exec_browser_canonical', status: 'succeeded' },
1463
+ }), { status: 200 });
1464
+ }
1465
+ return new Response(JSON.stringify({ ok: false, error: { code: 'UNEXPECTED', message: String(url), exitCode: 1 } }), { status: 500 });
1466
+ },
1467
+ });
1468
+ expect(result).toEqual({ handled: true, exitCode: 0 });
1469
+ expect(requests[1]?.body).toEqual({
1470
+ command,
1471
+ action,
1472
+ args,
1473
+ ...(windowMode ? { windowMode } : {}),
1474
+ trace: 'off',
1475
+ });
1476
+ });
1477
+ it('stages browser upload files without sending local paths to Cloud', async () => {
1478
+ const uploadDir = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-browser-upload-'));
1479
+ const uploadFile = path.join(uploadDir, '-one.txt');
1480
+ await writeFile(uploadFile, 'one file');
1481
+ const requests = [];
1482
+ try {
1483
+ const result = await runHostedCli(['browser', 'work', 'upload', 'input[type=file]', '--', uploadFile], {
1484
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1485
+ stdout: sink().stream,
1486
+ stderr: sink().stream,
1487
+ fetchImpl: async (url, init) => {
1488
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined;
1489
+ requests.push({ url: String(url), ...(body ? { body } : {}) });
1490
+ if (String(url).endsWith('/v1/manifest'))
1491
+ return manifestResponse();
1492
+ if (String(url).endsWith('/commands')) {
1493
+ return new Response(JSON.stringify({
1494
+ ok: true,
1495
+ result: { uploaded: true, file_names: ['-one.txt'] },
1496
+ columns: ['uploaded', 'file_names'],
1497
+ trace: null,
1498
+ run: {
1499
+ executionId: 'exec_browser_upload',
1500
+ session: 'work',
1501
+ profile: { id: 'profile_default', displayName: 'default' },
1502
+ },
1503
+ execution: { id: 'exec_browser_upload', status: 'succeeded' },
1504
+ }), { status: 200 });
1505
+ }
1506
+ return new Response(JSON.stringify({ ok: false, error: { code: 'UNEXPECTED', message: String(url), exitCode: 1 } }), { status: 500 });
1507
+ },
1508
+ });
1509
+ expect(result).toEqual({ handled: true, exitCode: 0 });
1510
+ const files = requests[1]?.body?.args && requests[1].body.args.files;
1511
+ expect(JSON.stringify(files)).not.toContain(uploadFile);
1512
+ expect(files).toEqual([{
1513
+ $webcmdBrowserUpload: {
1514
+ filename: '-one.txt',
1515
+ contentType: 'text/plain',
1516
+ base64: Buffer.from('one file').toString('base64'),
1517
+ },
1518
+ }]);
1519
+ }
1520
+ finally {
1521
+ await rm(uploadDir, { recursive: true, force: true });
1522
+ }
1523
+ });
1524
+ it('rejects a browser manifest mismatch before starting a provider run', async () => {
1525
+ const requests = [];
1526
+ const stdout = sink();
1527
+ const stderr = sink();
1528
+ const mismatched = {
1529
+ ...manifest,
1530
+ metadata: { ...manifest.metadata, webcmdPackageVersion: '0.4.0' },
1531
+ };
1532
+ const result = await runHostedCli(['browser', 'work', 'state'], {
1533
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1534
+ stdout: stdout.stream,
1535
+ stderr: stderr.stream,
1536
+ fetchImpl: async (url) => {
1537
+ requests.push(String(url));
1538
+ return new Response(JSON.stringify({ ok: true, manifest: mismatched }), { status: 200 });
1539
+ },
1540
+ });
1541
+ expect(result.exitCode).toBe(1);
1542
+ expect(requests).toEqual(['https://api.example.com/v1/manifest']);
1543
+ expect(stdout.text()).toBe('');
1544
+ expect(stderr.text()).toMatch(/HOSTED_PROTOCOL|hosted contract/i);
1545
+ });
1546
+ it('does not render private fields from a malformed browser action success', async () => {
1547
+ const requests = [];
1548
+ const stdout = sink();
1549
+ const stderr = sink();
1550
+ const privatePath = '/srv/private/token.json';
1551
+ const result = await runHostedCli(['browser', 'work', 'state'], {
1552
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1553
+ stdout: stdout.stream,
1554
+ stderr: stderr.stream,
1555
+ fetchImpl: async (url) => {
1556
+ requests.push(String(url));
1557
+ if (String(url).endsWith('/v1/manifest'))
1558
+ return manifestResponse();
1559
+ if (String(url).endsWith('/commands')) {
1560
+ return new Response(JSON.stringify({ ok: true, internalPath: privatePath }), { status: 200 });
1561
+ }
1562
+ return new Response(JSON.stringify({ ok: false, error: { code: 'UNEXPECTED', message: String(url), exitCode: 1 } }), { status: 500 });
1563
+ },
1564
+ });
1565
+ expect(result.exitCode).toBe(1);
1566
+ expect(stdout.text()).toBe('');
1567
+ expect(stdout.text()).not.toContain(privatePath);
1568
+ expect(stderr.text()).toContain('HOSTED_PROTOCOL');
1569
+ expect(stderr.text()).not.toContain(privatePath);
1570
+ expect(requests).toEqual([
1571
+ 'https://api.example.com/v1/manifest',
1572
+ 'https://api.example.com/v1/browser/work/commands',
1573
+ ]);
1574
+ });
1575
+ it('reconstructs AutoFix commands without treating global option values as command words', async () => {
1576
+ const stderr = sink();
1577
+ const result = await runHostedCli(['--profile', 'default', 'github', 'whoami'], {
1578
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1579
+ stderr: stderr.stream,
1580
+ fetchImpl: async () => { throw new Error('network failed'); },
1581
+ });
1582
+ expect(result.exitCode).toBe(1);
1583
+ expect(stderr.text()).toContain('# webcmd github whoami --trace retain-on-failure');
1584
+ expect(stderr.text()).not.toContain('# webcmd default github');
1585
+ });
1586
+ it('rejects the retired hosted browser --session flag', async () => {
1587
+ const stderr = sink();
1588
+ const result = await runHostedCli(['browser', '--session', 'work', 'state'], {
1589
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1590
+ stderr: stderr.stream,
1591
+ });
1592
+ expect(result.exitCode).toBe(78);
1593
+ expect(stderr.text()).toMatch(/session.*no longer a public option/i);
1594
+ });
1595
+ it('rejects browser bind before making a hosted request', async () => {
1596
+ const stderr = sink();
1597
+ const fetchImpl = vi.fn();
1598
+ const result = await runHostedCli(['browser', 'work', 'bind'], {
1599
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
1600
+ stderr: stderr.stream,
1601
+ fetchImpl,
1602
+ });
1603
+ expect(result).toEqual({ handled: true, exitCode: 78 });
1604
+ expect(stderr.text()).toMatch(/browser bind is not supported in hosted mode/i);
1605
+ expect(fetchImpl).not.toHaveBeenCalled();
1606
+ });
1607
+ });