@agentrhq/webcmd 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +228 -24
  3. package/clis/chatgpt/ask.js +1 -1
  4. package/clis/chatgpt/ask.test.js +11 -0
  5. package/clis/chatgpt/commands.test.js +102 -1
  6. package/clis/chatgpt/deep-research-result.js +45 -3
  7. package/clis/chatgpt/image.js +22 -2
  8. package/clis/chatgpt/project-file-add.js +24 -1
  9. package/clis/chatgpt/utils.js +258 -52
  10. package/clis/chatgpt/utils.test.js +259 -1
  11. package/clis/claude/ask.js +21 -1
  12. package/clis/facebook/feed.js +110 -24
  13. package/clis/facebook/feed.test.js +62 -0
  14. package/clis/facebook/search.js +185 -38
  15. package/clis/facebook/search.test.js +95 -50
  16. package/clis/instagram/explore.js +30 -12
  17. package/clis/instagram/explore.test.js +77 -0
  18. package/clis/instagram/post.js +14 -1
  19. package/clis/instagram/reel.js +13 -1
  20. package/clis/linkedin/company.js +153 -0
  21. package/clis/linkedin/company.test.js +111 -0
  22. package/clis/linkedin/connections.js +135 -0
  23. package/clis/linkedin/connections.test.js +141 -0
  24. package/clis/mercury/reimbursement-draft.js +12 -1
  25. package/clis/producthunt/hot.js +1 -2
  26. package/clis/spotify/spotify.js +11 -11
  27. package/clis/twitter/article-evaluate.test.js +27 -0
  28. package/clis/twitter/article.js +73 -16
  29. package/clis/twitter/article.test.js +209 -0
  30. package/clis/twitter/download.js +6 -1
  31. package/clis/twitter/post.js +14 -1
  32. package/clis/twitter/profile.js +1 -1
  33. package/clis/twitter/profile.test.js +8 -0
  34. package/clis/twitter/quote.js +11 -1
  35. package/clis/twitter/reply.js +11 -1
  36. package/dist/src/browser/base-page.d.ts +26 -0
  37. package/dist/src/browser/base-page.js +23 -8
  38. package/dist/src/browser/base-page.test.js +45 -12
  39. package/dist/src/browser/command-catalog.d.ts +6 -0
  40. package/dist/src/browser/command-catalog.js +259 -0
  41. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  42. package/dist/src/browser/command-catalog.test.js +79 -0
  43. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  44. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  45. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  46. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  47. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +7 -0
  48. package/dist/src/browser/runtime/local-cloak/session-manager.js +23 -9
  49. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  50. package/dist/src/browser/target-resolver.d.ts +1 -0
  51. package/dist/src/browser/target-resolver.js +99 -4
  52. package/dist/src/browser/target-resolver.test.js +141 -1
  53. package/dist/src/build-manifest.d.ts +6 -0
  54. package/dist/src/build-manifest.js +18 -1
  55. package/dist/src/build-manifest.test.js +57 -1
  56. package/dist/src/builtin-command-surface.d.ts +9 -0
  57. package/dist/src/builtin-command-surface.js +16 -0
  58. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  59. package/dist/src/check-hosted-contract.test.js +56 -0
  60. package/dist/src/cli.js +58 -106
  61. package/dist/src/cli.test.js +71 -1
  62. package/dist/src/command-presentation.d.ts +95 -0
  63. package/dist/src/command-presentation.js +486 -0
  64. package/dist/src/command-presentation.test.d.ts +1 -0
  65. package/dist/src/command-presentation.test.js +97 -0
  66. package/dist/src/command-surface.d.ts +43 -0
  67. package/dist/src/command-surface.js +205 -0
  68. package/dist/src/command-surface.test.d.ts +1 -0
  69. package/dist/src/command-surface.test.js +406 -0
  70. package/dist/src/commanderAdapter.d.ts +5 -1
  71. package/dist/src/commanderAdapter.js +12 -55
  72. package/dist/src/commanderAdapter.test.js +15 -3
  73. package/dist/src/community-plugin-sync.d.ts +11 -0
  74. package/dist/src/community-plugin-sync.js +138 -0
  75. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  76. package/dist/src/community-plugin-sync.test.js +123 -0
  77. package/dist/src/completion-fast.d.ts +4 -4
  78. package/dist/src/completion-fast.js +15 -34
  79. package/dist/src/completion-shared.d.ts +4 -0
  80. package/dist/src/completion-shared.js +38 -0
  81. package/dist/src/completion.js +3 -27
  82. package/dist/src/completion.test.js +31 -2
  83. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  84. package/dist/src/docs-sync-review-cli.test.js +322 -0
  85. package/dist/src/docs-sync-review.d.ts +117 -0
  86. package/dist/src/docs-sync-review.js +475 -0
  87. package/dist/src/docs-sync-review.test.d.ts +1 -0
  88. package/dist/src/docs-sync-review.test.js +324 -0
  89. package/dist/src/errors.d.ts +12 -3
  90. package/dist/src/errors.js +11 -7
  91. package/dist/src/errors.test.js +14 -1
  92. package/dist/src/execution.d.ts +1 -2
  93. package/dist/src/execution.js +4 -45
  94. package/dist/src/generate-release-notes-cli.test.js +7 -2
  95. package/dist/src/help.d.ts +4 -0
  96. package/dist/src/help.js +50 -255
  97. package/dist/src/help.test.js +27 -1
  98. package/dist/src/hosted/args.d.ts +2 -7
  99. package/dist/src/hosted/args.js +2 -99
  100. package/dist/src/hosted/args.test.js +15 -1
  101. package/dist/src/hosted/availability.d.ts +13 -0
  102. package/dist/src/hosted/availability.js +16 -0
  103. package/dist/src/hosted/availability.test.d.ts +1 -0
  104. package/dist/src/hosted/availability.test.js +191 -0
  105. package/dist/src/hosted/browser-args.d.ts +18 -0
  106. package/dist/src/hosted/browser-args.js +152 -0
  107. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  108. package/dist/src/hosted/browser-args.test.js +182 -0
  109. package/dist/src/hosted/client.d.ts +31 -2
  110. package/dist/src/hosted/client.js +441 -53
  111. package/dist/src/hosted/client.test.js +651 -32
  112. package/dist/src/hosted/config.d.ts +7 -2
  113. package/dist/src/hosted/config.js +26 -4
  114. package/dist/src/hosted/config.test.js +34 -1
  115. package/dist/src/hosted/contract.d.ts +94 -0
  116. package/dist/src/hosted/contract.js +208 -0
  117. package/dist/src/hosted/contract.test.d.ts +1 -0
  118. package/dist/src/hosted/contract.test.js +361 -0
  119. package/dist/src/hosted/credentials.d.ts +38 -0
  120. package/dist/src/hosted/credentials.js +248 -0
  121. package/dist/src/hosted/credentials.test.d.ts +1 -0
  122. package/dist/src/hosted/credentials.test.js +93 -0
  123. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  124. package/dist/src/hosted/file-contract.test.js +174 -0
  125. package/dist/src/hosted/files.d.ts +29 -0
  126. package/dist/src/hosted/files.js +296 -0
  127. package/dist/src/hosted/files.test.d.ts +1 -0
  128. package/dist/src/hosted/files.test.js +231 -0
  129. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  130. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  131. package/dist/src/hosted/manifest.d.ts +5 -0
  132. package/dist/src/hosted/manifest.js +21 -66
  133. package/dist/src/hosted/manifest.test.js +120 -2
  134. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  135. package/dist/src/hosted/output-parity.test.js +108 -0
  136. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  137. package/dist/src/hosted/root-command-surface.test.js +646 -0
  138. package/dist/src/hosted/runner.d.ts +7 -0
  139. package/dist/src/hosted/runner.js +554 -253
  140. package/dist/src/hosted/runner.test.js +1471 -53
  141. package/dist/src/hosted/setup.d.ts +3 -2
  142. package/dist/src/hosted/setup.js +45 -15
  143. package/dist/src/hosted/setup.test.js +133 -6
  144. package/dist/src/hosted/types.d.ts +101 -23
  145. package/dist/src/main.js +120 -108
  146. package/dist/src/manifest-types.d.ts +2 -0
  147. package/dist/src/output.d.ts +17 -2
  148. package/dist/src/output.js +88 -81
  149. package/dist/src/output.test.js +141 -51
  150. package/dist/src/pipeline/executor.test.js +1 -0
  151. package/dist/src/pipeline/steps/download.test.js +1 -0
  152. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  153. package/dist/src/plugin-create-cli.test.js +37 -0
  154. package/dist/src/plugin-manifest.d.ts +12 -0
  155. package/dist/src/plugin-manifest.js +16 -0
  156. package/dist/src/plugin-manifest.test.js +19 -1
  157. package/dist/src/plugin-scaffold.d.ts +4 -1
  158. package/dist/src/plugin-scaffold.js +4 -1
  159. package/dist/src/plugin-scaffold.test.js +23 -8
  160. package/dist/src/plugin.js +4 -1
  161. package/dist/src/plugin.test.js +13 -0
  162. package/dist/src/registry.d.ts +2 -0
  163. package/dist/src/release-notes.js +12 -4
  164. package/dist/src/release-notes.test.js +27 -15
  165. package/dist/src/root-command-surface.d.ts +31 -0
  166. package/dist/src/root-command-surface.js +106 -0
  167. package/dist/src/serialization.d.ts +1 -16
  168. package/dist/src/serialization.js +5 -55
  169. package/dist/src/stream-write.d.ts +12 -0
  170. package/dist/src/stream-write.js +91 -0
  171. package/dist/src/stream-write.test.d.ts +1 -0
  172. package/dist/src/stream-write.test.js +186 -0
  173. package/dist/src/types.d.ts +5 -0
  174. package/dist/src/utils.d.ts +1 -1
  175. package/dist/src/utils.js +2 -8
  176. package/dist/src/utils.test.js +50 -0
  177. package/hosted-contract.json +36759 -0
  178. package/package.json +6 -1
  179. package/scripts/check-hosted-contract.mjs +108 -0
  180. package/scripts/docs-sync-review.ts +332 -0
  181. package/scripts/sync-community-plugins.ts +14 -0
  182. package/skills/webcmd-autofix/SKILL.md +26 -37
  183. package/skills/webcmd-usage/SKILL.md +7 -0
@@ -1,10 +1,23 @@
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';
1
5
  import { Writable } from 'node:stream';
2
- import { afterEach, describe, expect, it, vi } from 'vitest';
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';
3
12
  import { makeHostedConfig } from './config.js';
4
13
  import { runHostedCli } from './runner.js';
5
14
  const manifest = {
6
15
  userId: 'user_demo',
7
- generatedAt: '2026-07-08T00:00:00.000Z',
16
+ metadata: {
17
+ contractSchemaVersion: 1,
18
+ webcmdPackageVersion: '0.3.0',
19
+ generatedAt: '2026-07-08T00:00:00.000Z',
20
+ },
8
21
  commands: [
9
22
  {
10
23
  site: 'github',
@@ -31,53 +44,693 @@ const manifest = {
31
44
  },
32
45
  ],
33
46
  };
34
- function sink() {
47
+ function sink(isTTY = false) {
35
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() {
36
71
  return {
37
- stream: new Writable({
38
- write(chunk, _encoding, callback) {
39
- data += String(chunk);
40
- callback();
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'],
41
139
  },
42
- }),
43
- text: () => data,
140
+ ],
44
141
  };
45
142
  }
46
- afterEach(() => {
47
- vi.restoreAllMocks();
48
- });
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
+ }
49
221
  describe('runHostedCli', () => {
50
- it('renders hosted list without LOCAL commands', async () => {
51
- const logs = [];
52
- vi.spyOn(console, 'log').mockImplementation((message = '') => {
53
- logs.push(String(message));
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 }),
54
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();
55
710
  const result = await runHostedCli(['list', '-f', 'json'], {
56
711
  config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
57
- fetchImpl: async () => new Response(JSON.stringify({ ok: true, manifest }), { status: 200 }),
712
+ stdout: stdout.stream,
713
+ fetchImpl: async () => manifestResponse(),
58
714
  });
59
715
  expect(result).toEqual({ handled: true, exitCode: 0 });
60
- expect(logs.join('\n')).toContain('github/whoami');
61
- expect(logs.join('\n')).not.toContain('docker/ps');
716
+ expect(stdout.text()).toContain('github/whoami');
717
+ expect(stdout.text()).not.toContain('docker/ps');
62
718
  });
63
719
  it('dispatches hosted commands to /v1/execute', async () => {
64
720
  const requests = [];
65
- vi.spyOn(console, 'log').mockImplementation(() => undefined);
721
+ const stdout = sink();
66
722
  const result = await runHostedCli(['github', 'whoami', '-f', 'json'], {
67
723
  config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
724
+ stdout: stdout.stream,
68
725
  fetchImpl: async (url, init) => {
69
726
  requests.push({
70
727
  url: String(url),
71
728
  body: init?.body ? JSON.parse(String(init.body)) : undefined,
72
729
  });
73
730
  if (String(url).endsWith('/v1/manifest')) {
74
- return new Response(JSON.stringify({ ok: true, manifest }), { status: 200 });
731
+ return manifestResponse();
75
732
  }
76
- return new Response(JSON.stringify({
77
- ok: true,
78
- result: [{ username: 'octocat' }],
79
- columns: ['username'],
80
- }), { status: 200 });
733
+ return executionResponse({ result: [{ username: 'octocat' }], columns: ['username'] });
81
734
  },
82
735
  });
83
736
  expect(result).toEqual({ handled: true, exitCode: 0 });
@@ -90,6 +743,399 @@ describe('runHostedCli', () => {
90
743
  trace: 'off',
91
744
  },
92
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'));
93
1139
  });
94
1140
  it('rejects daemon commands in hosted mode', async () => {
95
1141
  const stderr = sink();
@@ -100,7 +1146,126 @@ describe('runHostedCli', () => {
100
1146
  expect(result.exitCode).toBe(78);
101
1147
  expect(stderr.text()).toMatch(/hosted mode has no local daemon/i);
102
1148
  });
103
- it('routes hosted browser positional commands through the cloud lifecycle', async () => {
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 () => {
104
1269
  const requests = [];
105
1270
  const stdout = sink();
106
1271
  const result = await runHostedCli(['--profile', 'default', 'browser', 'work', 'open', 'https://example.com', '--window', 'background'], {
@@ -111,60 +1276,313 @@ describe('runHostedCli', () => {
111
1276
  url: String(url),
112
1277
  body: init?.body ? JSON.parse(String(init.body)) : undefined,
113
1278
  });
114
- if (String(url).endsWith('/runs')) {
1279
+ if (String(url).endsWith('/v1/manifest'))
1280
+ return manifestResponse();
1281
+ if (String(url).endsWith('/commands')) {
115
1282
  return new Response(JSON.stringify({
116
1283
  ok: true,
1284
+ result: { url: 'https://example.com' },
1285
+ columns: ['url'],
1286
+ trace: null,
117
1287
  run: {
118
1288
  executionId: 'exec_browser',
119
1289
  session: 'work',
120
1290
  profile: { id: 'profile_default', displayName: 'default' },
121
1291
  },
122
- }), { status: 201 });
123
- }
124
- if (String(url).endsWith('/actions')) {
125
- return new Response(JSON.stringify({
126
- ok: true,
127
- result: { url: 'https://example.com' },
128
- columns: ['url'],
129
- trace: null,
1292
+ execution: { id: 'exec_browser', status: 'succeeded' },
130
1293
  }), { status: 200 });
131
1294
  }
132
- return new Response(JSON.stringify({
133
- ok: true,
134
- execution: { id: 'exec_browser', status: 'succeeded' },
135
- }), { status: 200 });
1295
+ return new Response(JSON.stringify({ ok: false, error: { code: 'UNEXPECTED', message: String(url), exitCode: 1 } }), { status: 500 });
136
1296
  },
137
1297
  });
138
1298
  expect(result).toEqual({ handled: true, exitCode: 0 });
139
1299
  expect(stdout.text()).toContain('https://example.com');
140
1300
  expect(requests).toEqual([
141
1301
  {
142
- url: 'https://api.example.com/v1/browser/work/runs',
1302
+ url: 'https://api.example.com/v1/manifest',
1303
+ body: undefined,
1304
+ },
1305
+ {
1306
+ url: 'https://api.example.com/v1/browser/work/commands',
143
1307
  body: {
144
1308
  command: 'browser/open',
1309
+ action: 'navigate',
145
1310
  args: { url: 'https://example.com' },
146
1311
  profile: 'default',
147
1312
  windowMode: 'background',
148
1313
  trace: 'off',
149
1314
  },
150
1315
  },
151
- {
152
- url: 'https://api.example.com/v1/browser/work/runs/exec_browser/actions',
153
- body: {
154
- action: 'navigate',
155
- args: { url: 'https://example.com' },
156
- profile: 'default',
157
- },
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 });
158
1344
  },
159
- {
160
- url: 'https://api.example.com/v1/browser/work/runs/exec_browser/finish',
161
- body: {
162
- status: 'succeeded',
163
- profile: 'default',
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 });
164
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 });
165
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',
166
1573
  ]);
167
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
+ });
168
1586
  it('rejects the retired hosted browser --session flag', async () => {
169
1587
  const stderr = sink();
170
1588
  const result = await runHostedCli(['browser', '--session', 'work', 'state'], {