@agentrhq/webcmd 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/cli-manifest.json +278 -0
- package/clis/amazon-in/auth.js +44 -0
- package/clis/amazon-in/checkout-status.js +54 -0
- package/clis/amazon-in/checkout.js +479 -0
- package/clis/amazon-in/parsers.js +252 -0
- package/clis/amazon-in/parsers.test.js +230 -0
- package/clis/amazon-in/product.js +72 -0
- package/clis/amazon-in/search.js +76 -0
- package/clis/amazon-in/shared.js +40 -0
- package/clis/amazon-in/wishlist.js +98 -0
- package/dist/src/completion-shared.js +1 -1
- package/dist/src/docs-sync-review.js +13 -5
- package/dist/src/docs-sync-review.test.js +36 -0
- package/dist/src/generate-release-notes-cli.test.js +1 -1
- package/dist/src/hosted/client.d.ts +10 -0
- package/dist/src/hosted/client.js +36 -3
- package/dist/src/hosted/client.test.js +129 -21
- package/dist/src/hosted/manifest.test.js +2 -1
- package/dist/src/hosted/root-command-surface.test.js +7 -0
- package/dist/src/hosted/runner.js +66 -1
- package/dist/src/hosted/runner.test.js +102 -1
- package/dist/src/hosted/types.d.ts +5 -2
- package/dist/src/root-command-surface.js +10 -2
- package/hosted-contract.json +329 -1
- package/package.json +2 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CommandExecutionError,
|
|
3
|
+
EmptyResultError,
|
|
4
|
+
TimeoutError,
|
|
5
|
+
} from '@agentrhq/webcmd/errors';
|
|
6
|
+
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
7
|
+
import { normalizeWishlistRows } from './parsers.js';
|
|
8
|
+
import { gotoAmazon, SITE, WISHLIST_URL } from './shared.js';
|
|
9
|
+
|
|
10
|
+
cli({
|
|
11
|
+
site: SITE,
|
|
12
|
+
name: 'wishlist',
|
|
13
|
+
access: 'read',
|
|
14
|
+
description: 'Fetch current prices for products in the default Amazon.in wishlist',
|
|
15
|
+
domain: 'amazon.in',
|
|
16
|
+
strategy: Strategy.UI,
|
|
17
|
+
browser: true,
|
|
18
|
+
navigateBefore: false,
|
|
19
|
+
siteSession: 'persistent',
|
|
20
|
+
args: [
|
|
21
|
+
{
|
|
22
|
+
name: 'filter',
|
|
23
|
+
default: 'unpurchased',
|
|
24
|
+
choices: ['unpurchased', 'all'],
|
|
25
|
+
help: 'Wishlist items to include',
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
columns: [
|
|
29
|
+
'list_name', 'item_id', 'asin', 'title', 'price', 'mrp',
|
|
30
|
+
'availability', 'size', 'colour', 'image_url', 'product_url',
|
|
31
|
+
],
|
|
32
|
+
func: async (page, args) => {
|
|
33
|
+
const url = new URL(WISHLIST_URL);
|
|
34
|
+
url.searchParams.set('type', 'wishlist');
|
|
35
|
+
url.searchParams.set('filter', args.filter ?? 'unpurchased');
|
|
36
|
+
url.searchParams.set('sort', 'date-added');
|
|
37
|
+
url.searchParams.set('viewType', 'list');
|
|
38
|
+
await gotoAmazon(page, url.href, 'wishlist');
|
|
39
|
+
|
|
40
|
+
let reachedEnd = false;
|
|
41
|
+
for (let step = 0; step < 100; step += 1) {
|
|
42
|
+
reachedEnd = await page.evaluate(`
|
|
43
|
+
(() => Boolean(document.querySelector('#endOfListMarker')))()
|
|
44
|
+
`);
|
|
45
|
+
if (reachedEnd) break;
|
|
46
|
+
await page.scroll('down', 700);
|
|
47
|
+
await page.sleep(0.25);
|
|
48
|
+
}
|
|
49
|
+
if (!reachedEnd) {
|
|
50
|
+
throw new TimeoutError(
|
|
51
|
+
'Amazon.in wishlist loading',
|
|
52
|
+
25,
|
|
53
|
+
'Open the wishlist in the Webcmd browser and check whether Amazon is still loading items.',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const payload = await page.evaluate(`
|
|
58
|
+
(() => {
|
|
59
|
+
const text = (node) => (node?.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
60
|
+
const cards = [...document.querySelectorAll('#g-items li.g-item-sortable')].map((card) => {
|
|
61
|
+
const productLinks = [...card.querySelectorAll('a[href*="/dp/"]')];
|
|
62
|
+
const productLink = productLinks.find((link) => link.title) || productLinks[0];
|
|
63
|
+
const variants = [...card.querySelectorAll('#twisterText')].map((node) => text(node));
|
|
64
|
+
return {
|
|
65
|
+
cardItemId: card.getAttribute('data-itemid') || '',
|
|
66
|
+
cardHref: productLink?.href || '',
|
|
67
|
+
cardTitle: productLink?.title || text(productLink),
|
|
68
|
+
cardPriceText: text(card.querySelector('.price-section .a-price .a-offscreen, .a-price .a-offscreen')),
|
|
69
|
+
cardMrpText: text(card.querySelector('.wl-deal-price.a-text-strike, .a-price.a-text-price .a-offscreen')),
|
|
70
|
+
cardAvailabilityText: text(card.querySelector('[id^="availability-"], .itemAvailability, .a-color-price')),
|
|
71
|
+
cardSizeText: variants.find((value) => /^size\\s*:/i.test(value))?.replace(/^size\\s*:\\s*/i, '') || '',
|
|
72
|
+
cardColourText: variants.find((value) => /^colou?r\\s*:/i.test(value))?.replace(/^colou?r\\s*:\\s*/i, '') || '',
|
|
73
|
+
cardImageUrl: card.querySelector('img[alt]')?.currentSrc || card.querySelector('img[alt]')?.src || '',
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
listName: text(document.querySelector('#profile-list-name')),
|
|
78
|
+
cards,
|
|
79
|
+
empty: /no items|this list is empty/i.test(document.body?.innerText || ''),
|
|
80
|
+
};
|
|
81
|
+
})()
|
|
82
|
+
`);
|
|
83
|
+
if (!payload?.listName) {
|
|
84
|
+
throw new CommandExecutionError('Amazon.in wishlist name could not be read');
|
|
85
|
+
}
|
|
86
|
+
if (!payload.cards?.length) {
|
|
87
|
+
if (payload.empty) throw new EmptyResultError('amazon-in wishlist');
|
|
88
|
+
throw new CommandExecutionError('Amazon.in wishlist exposed no item cards');
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
return normalizeWishlistRows(payload.listName, payload.cards);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new CommandExecutionError(
|
|
94
|
+
`Amazon.in wishlist details could not be normalized: ${error.message}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
});
|
|
@@ -38,6 +38,7 @@ export const HOSTED_ROOT_HELP = {
|
|
|
38
38
|
{ name: 'browser', description: 'Browser control through a hosted browser session' },
|
|
39
39
|
{ name: 'completion <shell>', description: 'Output a shell completion script' },
|
|
40
40
|
{ name: 'list', description: 'List all available hosted CLI commands' },
|
|
41
|
+
{ name: 'profile', description: 'Manage hosted browser profiles' },
|
|
41
42
|
{ name: 'setup', description: 'Configure local or hosted mode' },
|
|
42
43
|
],
|
|
43
44
|
localOnlyCommands: [
|
|
@@ -49,7 +50,6 @@ export const HOSTED_ROOT_HELP = {
|
|
|
49
50
|
{ name: 'doctor', description: 'Diagnose local browser bridge connectivity' },
|
|
50
51
|
{ name: 'external', description: 'Manage local CLI passthrough commands' },
|
|
51
52
|
{ name: 'plugin', description: 'Manage plugins installed on this computer' },
|
|
52
|
-
{ name: 'profile', description: 'Manage profiles in the local browser runtime' },
|
|
53
53
|
{ name: 'skills', description: 'Manage bundled skills on this computer' },
|
|
54
54
|
{ name: 'validate', description: 'Validate local CLI definitions' },
|
|
55
55
|
{ name: 'verify', description: 'Validate and smoke-test local adapters' },
|
|
@@ -105,6 +105,12 @@ const BROWSER_DOCUMENTATION = [
|
|
|
105
105
|
'skills/webcmd-browser/SKILL.md',
|
|
106
106
|
'skills/webcmd-usage/SKILL.md',
|
|
107
107
|
];
|
|
108
|
+
const HOSTED_DOCUMENTATION = [
|
|
109
|
+
...BROWSER_DOCUMENTATION,
|
|
110
|
+
'docs/authentication-and-profiles.mdx',
|
|
111
|
+
'docs/local-or-cloud.mdx',
|
|
112
|
+
];
|
|
113
|
+
const HOSTED_PROFILE_PATHS = /^(?:src\/completion-shared|src\/hosted\/(?:browser-args|client|runner|types))\.ts$/;
|
|
108
114
|
const ADAPTER_DOCUMENTATION = [
|
|
109
115
|
'README.md',
|
|
110
116
|
'docs/authoring.mdx',
|
|
@@ -116,11 +122,13 @@ const ADAPTER_DOCUMENTATION = [
|
|
|
116
122
|
export function selectDocumentationPaths(files) {
|
|
117
123
|
const selected = new Set();
|
|
118
124
|
for (const file of files) {
|
|
119
|
-
const paths =
|
|
120
|
-
?
|
|
121
|
-
: /^
|
|
122
|
-
?
|
|
123
|
-
:
|
|
125
|
+
const paths = HOSTED_PROFILE_PATHS.test(file.path)
|
|
126
|
+
? HOSTED_DOCUMENTATION
|
|
127
|
+
: /^src\/browser\//.test(file.path)
|
|
128
|
+
? BROWSER_DOCUMENTATION
|
|
129
|
+
: /^(?:clis\/|plugins\/|src\/plugin)/.test(file.path)
|
|
130
|
+
? ADAPTER_DOCUMENTATION
|
|
131
|
+
: GENERAL_DOCUMENTATION;
|
|
124
132
|
paths.forEach((path) => selected.add(path));
|
|
125
133
|
}
|
|
126
134
|
return [...selected].sort();
|
|
@@ -95,6 +95,42 @@ describe('review context', () => {
|
|
|
95
95
|
'skills/webcmd-usage/SKILL.md',
|
|
96
96
|
]);
|
|
97
97
|
});
|
|
98
|
+
it('selects profile documentation for hosted profile changes', () => {
|
|
99
|
+
expect(selectDocumentationPaths([changed('src/hosted/runner.ts')])).toEqual([
|
|
100
|
+
'README.md',
|
|
101
|
+
'docs/agent-prompts.mdx',
|
|
102
|
+
'docs/authentication-and-profiles.mdx',
|
|
103
|
+
'docs/browser-and-sitemap-memory.mdx',
|
|
104
|
+
'docs/cli-reference.mdx',
|
|
105
|
+
'docs/concepts.mdx',
|
|
106
|
+
'docs/local-or-cloud.mdx',
|
|
107
|
+
'skills/webcmd-browser-sitemap/SKILL.md',
|
|
108
|
+
'skills/webcmd-browser/SKILL.md',
|
|
109
|
+
'skills/webcmd-usage/SKILL.md',
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
it('selects profile documentation for hosted root command changes', () => {
|
|
113
|
+
expect(selectDocumentationPaths([changed('src/completion-shared.ts')])).toEqual([
|
|
114
|
+
'README.md',
|
|
115
|
+
'docs/agent-prompts.mdx',
|
|
116
|
+
'docs/authentication-and-profiles.mdx',
|
|
117
|
+
'docs/browser-and-sitemap-memory.mdx',
|
|
118
|
+
'docs/cli-reference.mdx',
|
|
119
|
+
'docs/concepts.mdx',
|
|
120
|
+
'docs/local-or-cloud.mdx',
|
|
121
|
+
'skills/webcmd-browser-sitemap/SKILL.md',
|
|
122
|
+
'skills/webcmd-browser/SKILL.md',
|
|
123
|
+
'skills/webcmd-usage/SKILL.md',
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
it('does not select profile documentation for unrelated hosted changes', () => {
|
|
127
|
+
expect(selectDocumentationPaths([changed('src/hosted/files.ts')])).toEqual([
|
|
128
|
+
'README.md',
|
|
129
|
+
'docs/cli-reference.mdx',
|
|
130
|
+
'docs/concepts.mdx',
|
|
131
|
+
'skills/webcmd-usage/SKILL.md',
|
|
132
|
+
]);
|
|
133
|
+
});
|
|
98
134
|
it('selects adapter and plugin documentation', () => {
|
|
99
135
|
expect(selectDocumentationPaths([changed('clis/reddit/search.js')])).toEqual([
|
|
100
136
|
'README.md',
|
|
@@ -218,7 +218,7 @@ describe('runGenerateReleaseNotes', () => {
|
|
|
218
218
|
expect(workflow).toContain('release_body_file="$RUNNER_TEMP/release-body.md"');
|
|
219
219
|
expect(workflow).toContain('release_edit_args+=(--title "$release_title")');
|
|
220
220
|
expect(workflow).toContain('release_edit_args+=(--notes-file "$release_body_file")');
|
|
221
|
-
expect(workflow).toContain('if gh release edit "${{ steps.release.outputs.tag_name }}" "${release_edit_args[@]}"; then');
|
|
221
|
+
expect(workflow).toContain('if gh release edit "${{ steps.release.outputs.tag_name || inputs.publish_tag }}" "${release_edit_args[@]}"; then');
|
|
222
222
|
expect(workflow).toMatch(/Enhanced release notes could not be applied; keeping release-please notes\./);
|
|
223
223
|
});
|
|
224
224
|
});
|
|
@@ -3,8 +3,13 @@ import type { HostedBrowserActionRequest, HostedBrowserActionResponse, HostedBro
|
|
|
3
3
|
export interface HostedClientOptions {
|
|
4
4
|
apiBaseUrl: string;
|
|
5
5
|
apiKey: string;
|
|
6
|
+
workspace?: string;
|
|
6
7
|
fetchImpl?: typeof fetch;
|
|
7
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Resolves the active workspace from CLI flags/env, precedence: --workspace flag > WEBCMD_WORKSPACE env > undefined.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveWorkspace(argv: readonly string[], env: NodeJS.ProcessEnv): string | undefined;
|
|
8
13
|
export declare class HostedClientError extends CliError {
|
|
9
14
|
readonly execution?: HostedExecution;
|
|
10
15
|
readonly trace?: HostedTraceReceipt;
|
|
@@ -16,11 +21,16 @@ export declare class HostedClientError extends CliError {
|
|
|
16
21
|
export declare class HostedClient {
|
|
17
22
|
private readonly apiBaseUrl;
|
|
18
23
|
private readonly apiKey;
|
|
24
|
+
private readonly workspace;
|
|
19
25
|
private readonly fetchImpl;
|
|
20
26
|
constructor(options: HostedClientOptions);
|
|
21
27
|
getMe(): Promise<unknown>;
|
|
22
28
|
getManifest(): Promise<HostedManifest>;
|
|
23
29
|
listProfiles(): Promise<HostedProfilesResponse>;
|
|
30
|
+
deleteProfile(profileId: string): Promise<{
|
|
31
|
+
ok: true;
|
|
32
|
+
deleted: true;
|
|
33
|
+
}>;
|
|
24
34
|
execute(input: {
|
|
25
35
|
command: string;
|
|
26
36
|
args: Record<string, unknown>;
|
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
import { attachTraceReceipt, CliError, EXIT_CODES } from '../errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves the active workspace from CLI flags/env, precedence: --workspace flag > WEBCMD_WORKSPACE env > undefined.
|
|
4
|
+
*/
|
|
5
|
+
export function resolveWorkspace(argv, env) {
|
|
6
|
+
const idx = argv.indexOf('--workspace');
|
|
7
|
+
if (idx >= 0 && argv[idx + 1])
|
|
8
|
+
return argv[idx + 1];
|
|
9
|
+
const equalsForm = argv.find(arg => arg.startsWith('--workspace='));
|
|
10
|
+
if (equalsForm !== undefined) {
|
|
11
|
+
const value = equalsForm.slice('--workspace='.length).trim();
|
|
12
|
+
if (value)
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
const fromEnv = env.WEBCMD_WORKSPACE?.trim();
|
|
16
|
+
return fromEnv ? fromEnv : undefined;
|
|
17
|
+
}
|
|
2
18
|
export class HostedClientError extends CliError {
|
|
3
19
|
execution;
|
|
4
20
|
trace;
|
|
@@ -13,10 +29,12 @@ export class HostedClientError extends CliError {
|
|
|
13
29
|
export class HostedClient {
|
|
14
30
|
apiBaseUrl;
|
|
15
31
|
apiKey;
|
|
32
|
+
workspace;
|
|
16
33
|
fetchImpl;
|
|
17
34
|
constructor(options) {
|
|
18
35
|
this.apiBaseUrl = options.apiBaseUrl.replace(/\/+$/, '');
|
|
19
36
|
this.apiKey = options.apiKey;
|
|
37
|
+
this.workspace = options.workspace;
|
|
20
38
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
21
39
|
}
|
|
22
40
|
async getMe() {
|
|
@@ -36,6 +54,13 @@ export class HostedClient {
|
|
|
36
54
|
}
|
|
37
55
|
return body;
|
|
38
56
|
}
|
|
57
|
+
async deleteProfile(profileId) {
|
|
58
|
+
const body = await this.request(`/v1/profiles/${encodeURIComponent(profileId)}`, { method: 'DELETE' });
|
|
59
|
+
if (!hasExactKeys(body, ['ok', 'deleted']) || body.ok !== true || body.deleted !== true) {
|
|
60
|
+
throw protocolError('Webcmd Cloud returned an invalid profile deletion response.');
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, deleted: true };
|
|
63
|
+
}
|
|
39
64
|
async execute(input) {
|
|
40
65
|
const traceMode = normalizeTraceMode(input.trace);
|
|
41
66
|
const body = await this.request('/v1/execute', {
|
|
@@ -94,6 +119,7 @@ export class HostedClient {
|
|
|
94
119
|
headers: {
|
|
95
120
|
accept: 'application/octet-stream',
|
|
96
121
|
authorization: `Bearer ${this.apiKey}`,
|
|
122
|
+
...(this.workspace ? { 'x-webcmd-workspace': this.workspace } : {}),
|
|
97
123
|
},
|
|
98
124
|
});
|
|
99
125
|
if (!response.ok) {
|
|
@@ -159,6 +185,7 @@ export class HostedClient {
|
|
|
159
185
|
accept: 'application/json',
|
|
160
186
|
...(init.body ? { 'content-type': 'application/json' } : {}),
|
|
161
187
|
authorization: `Bearer ${this.apiKey}`,
|
|
188
|
+
...(this.workspace ? { 'x-webcmd-workspace': this.workspace } : {}),
|
|
162
189
|
...(init.headers ?? {}),
|
|
163
190
|
},
|
|
164
191
|
});
|
|
@@ -282,11 +309,17 @@ function isHostedProfilesResponse(value) {
|
|
|
282
309
|
&& value.profiles.every(isHostedPublicProfile);
|
|
283
310
|
}
|
|
284
311
|
function isHostedPublicProfile(value) {
|
|
285
|
-
return hasExactKeys(value, [
|
|
286
|
-
|
|
312
|
+
return hasExactKeys(value, [
|
|
313
|
+
'id', 'name', 'workspace', 'default', 'status',
|
|
314
|
+
'createdAt', 'updatedAt', 'lastUsedAt',
|
|
315
|
+
])
|
|
316
|
+
&& typeof value.id === 'string'
|
|
317
|
+
&& (value.name === null || typeof value.name === 'string')
|
|
318
|
+
&& (value.workspace === null || typeof value.workspace === 'string')
|
|
287
319
|
&& typeof value.default === 'boolean'
|
|
288
|
-
&& value.status === 'available'
|
|
320
|
+
&& (value.status === 'pending' || value.status === 'available')
|
|
289
321
|
&& typeof value.createdAt === 'string'
|
|
322
|
+
&& typeof value.updatedAt === 'string'
|
|
290
323
|
&& typeof value.lastUsedAt === 'string';
|
|
291
324
|
}
|
|
292
325
|
function isHostedManifestCommand(value) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { HostedClient } from './client.js';
|
|
2
|
+
import { HostedClient, resolveWorkspace } from './client.js';
|
|
3
3
|
const invalidTraceUrlCases = [
|
|
4
4
|
{
|
|
5
5
|
name: 'raw absolute Kernel URL with token',
|
|
@@ -165,31 +165,66 @@ describe('HostedClient', () => {
|
|
|
165
165
|
});
|
|
166
166
|
await expect(client.getManifest()).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' });
|
|
167
167
|
});
|
|
168
|
-
it('
|
|
168
|
+
it('manages public hosted profiles over the authenticated HTTP API', async () => {
|
|
169
|
+
const requests = [];
|
|
170
|
+
const profile = {
|
|
171
|
+
id: 'profile_work',
|
|
172
|
+
name: 'Work',
|
|
173
|
+
workspace: 'user_64256',
|
|
174
|
+
default: false,
|
|
175
|
+
status: 'available',
|
|
176
|
+
createdAt: '2026-07-08T00:00:00.000Z',
|
|
177
|
+
updatedAt: '2026-07-08T00:00:00.000Z',
|
|
178
|
+
lastUsedAt: '2026-07-08T00:00:00.000Z',
|
|
179
|
+
};
|
|
169
180
|
const client = new HostedClient({
|
|
170
181
|
apiBaseUrl: 'https://api.example.com',
|
|
171
182
|
apiKey: 'wcmd_live_test',
|
|
172
|
-
fetchImpl: async () =>
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
183
|
+
fetchImpl: async (url, init) => {
|
|
184
|
+
requests.push({
|
|
185
|
+
url: String(url),
|
|
186
|
+
method: init?.method ?? 'GET',
|
|
187
|
+
...(init?.body ? { body: String(init.body) } : {}),
|
|
188
|
+
});
|
|
189
|
+
const path = new URL(String(url)).pathname;
|
|
190
|
+
if (path.startsWith('/v1/profiles/') && init?.method === 'DELETE') {
|
|
191
|
+
return new Response(JSON.stringify({ ok: true, deleted: true }));
|
|
192
|
+
}
|
|
193
|
+
return new Response(JSON.stringify({ ok: true, profiles: [profile] }));
|
|
194
|
+
},
|
|
182
195
|
});
|
|
183
|
-
await expect(client.listProfiles()).resolves.toEqual({
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
196
|
+
await expect(client.listProfiles()).resolves.toEqual({ ok: true, profiles: [profile] });
|
|
197
|
+
await expect(client.deleteProfile('profile/work')).resolves.toEqual({ ok: true, deleted: true });
|
|
198
|
+
expect(requests).toEqual([
|
|
199
|
+
{ url: 'https://api.example.com/v1/profiles', method: 'GET' },
|
|
200
|
+
{ url: 'https://api.example.com/v1/profiles/profile%2Fwork', method: 'DELETE' },
|
|
201
|
+
]);
|
|
202
|
+
});
|
|
203
|
+
it.each([
|
|
204
|
+
{ name: 'private provider field', change: { kernelProfileId: 'private' } },
|
|
205
|
+
{ name: 'missing updatedAt', change: { updatedAt: undefined } },
|
|
206
|
+
{ name: 'non-nullable name shape', change: { name: 7 } },
|
|
207
|
+
{ name: 'non-nullable workspace shape', change: { workspace: false } },
|
|
208
|
+
])('rejects a hosted profile with $name', async ({ change }) => {
|
|
209
|
+
const profile = {
|
|
210
|
+
id: 'profile_work',
|
|
211
|
+
name: null,
|
|
212
|
+
workspace: null,
|
|
213
|
+
default: false,
|
|
214
|
+
status: 'pending',
|
|
215
|
+
createdAt: '2026-07-08T00:00:00.000Z',
|
|
216
|
+
updatedAt: '2026-07-08T00:00:00.000Z',
|
|
217
|
+
lastUsedAt: '2026-07-08T00:00:00.000Z',
|
|
218
|
+
...change,
|
|
219
|
+
};
|
|
220
|
+
if (change.updatedAt === undefined)
|
|
221
|
+
delete profile.updatedAt;
|
|
222
|
+
const client = new HostedClient({
|
|
223
|
+
apiBaseUrl: 'https://api.example.com',
|
|
224
|
+
apiKey: 'key',
|
|
225
|
+
fetchImpl: async () => new Response(JSON.stringify({ ok: true, profiles: [profile] })),
|
|
192
226
|
});
|
|
227
|
+
await expect(client.listProfiles()).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' });
|
|
193
228
|
});
|
|
194
229
|
it('maps hosted error envelopes to CliError-compatible errors', async () => {
|
|
195
230
|
const client = new HostedClient({
|
|
@@ -797,4 +832,77 @@ describe('HostedClient', () => {
|
|
|
797
832
|
},
|
|
798
833
|
]);
|
|
799
834
|
});
|
|
835
|
+
it('attaches the workspace header when a workspace is configured', async () => {
|
|
836
|
+
const requests = [];
|
|
837
|
+
const client = new HostedClient({
|
|
838
|
+
apiBaseUrl: 'https://api.example.com',
|
|
839
|
+
apiKey: 'key',
|
|
840
|
+
workspace: resolveWorkspace([], { WEBCMD_WORKSPACE: 'user_64256' }),
|
|
841
|
+
fetchImpl: async (_url, init) => {
|
|
842
|
+
requests.push({ workspace: new Headers(init?.headers).get('x-webcmd-workspace') });
|
|
843
|
+
return new Response(JSON.stringify({
|
|
844
|
+
ok: true,
|
|
845
|
+
result: [],
|
|
846
|
+
execution: { id: 'exec_1', command: 'github/whoami', status: 'succeeded' },
|
|
847
|
+
}), { status: 200 });
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
await client.execute({ command: 'github/whoami', args: {} });
|
|
851
|
+
expect(requests).toEqual([{ workspace: 'user_64256' }]);
|
|
852
|
+
});
|
|
853
|
+
it('attaches the workspace header for the --workspace=<value> equals form', async () => {
|
|
854
|
+
const requests = [];
|
|
855
|
+
const client = new HostedClient({
|
|
856
|
+
apiBaseUrl: 'https://api.example.com',
|
|
857
|
+
apiKey: 'key',
|
|
858
|
+
workspace: resolveWorkspace(['--workspace=user_64256'], {}),
|
|
859
|
+
fetchImpl: async (_url, init) => {
|
|
860
|
+
requests.push({ workspace: new Headers(init?.headers).get('x-webcmd-workspace') });
|
|
861
|
+
return new Response(JSON.stringify({
|
|
862
|
+
ok: true,
|
|
863
|
+
result: [],
|
|
864
|
+
execution: { id: 'exec_1', command: 'github/whoami', status: 'succeeded' },
|
|
865
|
+
}), { status: 200 });
|
|
866
|
+
},
|
|
867
|
+
});
|
|
868
|
+
await client.execute({ command: 'github/whoami', args: {} });
|
|
869
|
+
expect(requests).toEqual([{ workspace: 'user_64256' }]);
|
|
870
|
+
});
|
|
871
|
+
it('omits the workspace header when no workspace is configured', async () => {
|
|
872
|
+
const requests = [];
|
|
873
|
+
const client = new HostedClient({
|
|
874
|
+
apiBaseUrl: 'https://api.example.com',
|
|
875
|
+
apiKey: 'key',
|
|
876
|
+
fetchImpl: async (_url, init) => {
|
|
877
|
+
requests.push({ workspace: new Headers(init?.headers).get('x-webcmd-workspace') });
|
|
878
|
+
return new Response(JSON.stringify({
|
|
879
|
+
ok: true,
|
|
880
|
+
result: [],
|
|
881
|
+
execution: { id: 'exec_1', command: 'github/whoami', status: 'succeeded' },
|
|
882
|
+
}), { status: 200 });
|
|
883
|
+
},
|
|
884
|
+
});
|
|
885
|
+
await client.execute({ command: 'github/whoami', args: {} });
|
|
886
|
+
expect(requests).toEqual([{ workspace: null }]);
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
describe('resolveWorkspace', () => {
|
|
890
|
+
it('returns the --workspace flag value when present', () => {
|
|
891
|
+
expect(resolveWorkspace(['--workspace', 'flagws'], {})).toBe('flagws');
|
|
892
|
+
});
|
|
893
|
+
it('returns the --workspace=<value> flag value when present', () => {
|
|
894
|
+
expect(resolveWorkspace(['--workspace=user_64256'], {})).toBe('user_64256');
|
|
895
|
+
});
|
|
896
|
+
it('--workspace overrides the env var', () => {
|
|
897
|
+
expect(resolveWorkspace(['--workspace', 'flagws'], { WEBCMD_WORKSPACE: 'envws' })).toBe('flagws');
|
|
898
|
+
});
|
|
899
|
+
it('falls back to WEBCMD_WORKSPACE when no flag is present', () => {
|
|
900
|
+
expect(resolveWorkspace([], { WEBCMD_WORKSPACE: 'envws' })).toBe('envws');
|
|
901
|
+
});
|
|
902
|
+
it('trims WEBCMD_WORKSPACE', () => {
|
|
903
|
+
expect(resolveWorkspace([], { WEBCMD_WORKSPACE: ' envws ' })).toBe('envws');
|
|
904
|
+
});
|
|
905
|
+
it('returns undefined when neither --workspace nor WEBCMD_WORKSPACE is set', () => {
|
|
906
|
+
expect(resolveWorkspace([], {})).toBeUndefined();
|
|
907
|
+
});
|
|
800
908
|
});
|
|
@@ -98,6 +98,7 @@ describe('hosted manifest helpers', () => {
|
|
|
98
98
|
expect(result).toEqual({ handled: true, exitCode: 0 });
|
|
99
99
|
expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP));
|
|
100
100
|
expect(stdout.text()).toContain('completion');
|
|
101
|
+
expect(stdout.text()).toMatch(/profile\s+Manage hosted browser profiles/);
|
|
101
102
|
expect(stdout.text()).toContain('--profile <name>');
|
|
102
103
|
expect(stdout.text()).toContain('Local-only commands:');
|
|
103
104
|
expect(stdout.text()).toContain('Run `webcmd setup` and choose local mode to use local-only commands.');
|
|
@@ -160,6 +161,6 @@ describe('hosted manifest helpers', () => {
|
|
|
160
161
|
stdout: stdout.stream,
|
|
161
162
|
fetchImpl: async () => new Response(JSON.stringify({ ok: true, manifest }), { status: 200 }),
|
|
162
163
|
});
|
|
163
|
-
expect(stdout.text().trim().split('\n')).toEqual(['browser', 'completion', 'github', 'list', 'setup']);
|
|
164
|
+
expect(stdout.text().trim().split('\n')).toEqual(['browser', 'completion', 'github', 'list', 'profile', 'setup']);
|
|
164
165
|
});
|
|
165
166
|
});
|
|
@@ -156,6 +156,13 @@ const generatedTerminalCorpus = profileForms.flatMap(profile => dispatchForms.fl
|
|
|
156
156
|
{ name: `${profile.name}/clustered-version/${dispatch.name}`, argv: [...profile.tokens, '-Vx', ...dispatch.tokens], kind: 'version' },
|
|
157
157
|
]));
|
|
158
158
|
describe('hosted root command surface', () => {
|
|
159
|
+
it('advertises hosted profile management as a root command, not a local-only namespace', () => {
|
|
160
|
+
expect(HOSTED_ROOT_HELP.commands).toContainEqual({
|
|
161
|
+
name: 'profile',
|
|
162
|
+
description: 'Manage hosted browser profiles',
|
|
163
|
+
});
|
|
164
|
+
expect(HOSTED_ROOT_HELP.localOnlyCommands?.some(command => command.name === 'profile')).toBe(false);
|
|
165
|
+
});
|
|
159
166
|
it.each([
|
|
160
167
|
{ name: 'no args', argv: [], expected: { kind: 'help', exitCode: 1 } },
|
|
161
168
|
{ name: 'profile only', argv: ['--profile', 'work'], expected: { kind: 'help', exitCode: 1 } },
|
|
@@ -15,7 +15,7 @@ import { StreamWriteError, writeToStream } from '../stream-write.js';
|
|
|
15
15
|
import { PKG_VERSION } from '../version.js';
|
|
16
16
|
import { getCompletionScriptFast } from '../completion-fast.js';
|
|
17
17
|
import { browserCommandCatalog } from '../browser/command-catalog.js';
|
|
18
|
-
import { HostedClient, HostedClientError } from './client.js';
|
|
18
|
+
import { HostedClient, HostedClientError, resolveWorkspace } from './client.js';
|
|
19
19
|
import { parseHostedInvocation } from './args.js';
|
|
20
20
|
import { HostedBrowserHelp, parseHostedBrowserStructure } from './browser-args.js';
|
|
21
21
|
import { materializeHostedOutputs, prepareHostedFiles, rewriteHostedOutputResultPaths } from './files.js';
|
|
@@ -53,6 +53,7 @@ export async function runHostedCli(argv, opts = {}) {
|
|
|
53
53
|
const client = new HostedClient({
|
|
54
54
|
apiBaseUrl: config.hosted.apiBaseUrl,
|
|
55
55
|
apiKey: credential.apiKey,
|
|
56
|
+
workspace: resolveWorkspace(argv, opts.env ?? process.env),
|
|
56
57
|
fetchImpl: opts.fetchImpl,
|
|
57
58
|
});
|
|
58
59
|
await dispatchHosted(argv, client, stdout, stderr, opts.now ?? Date.now);
|
|
@@ -146,6 +147,18 @@ async function dispatchHosted(argv, client, stdout, stderr, now) {
|
|
|
146
147
|
await renderHostedList(manifest, parsed.format, parsed.formatExplicit, stdout);
|
|
147
148
|
return;
|
|
148
149
|
}
|
|
150
|
+
if (args[0] === 'profile') {
|
|
151
|
+
if (args[1] === 'rename' || args[1] === 'use') {
|
|
152
|
+
throw new ConfigError(`webcmd profile ${args[1]} is not available in hosted mode.`, 'Hosted mode supports: webcmd profile list and delete.');
|
|
153
|
+
}
|
|
154
|
+
const parsed = parseHostedProfileSurface(args.slice(1), normalized.literal);
|
|
155
|
+
if (parsed.kind === 'help') {
|
|
156
|
+
await writeToStream(stdout, parsed.output);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
await dispatchHostedProfile(parsed, client, stdout);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
149
162
|
const manifest = await client.getManifest();
|
|
150
163
|
validateManifestContractIdentity(manifest);
|
|
151
164
|
const site = args[0];
|
|
@@ -552,6 +565,58 @@ function parseHostedListSurface(argv, literal) {
|
|
|
552
565
|
throw new CommanderStructuralError("error: command 'list' did not run\n", 1);
|
|
553
566
|
return { kind: 'run', format: parsedFormat, formatExplicit };
|
|
554
567
|
}
|
|
568
|
+
function parseHostedProfileSurface(argv, literal) {
|
|
569
|
+
let stdout = '';
|
|
570
|
+
let stderr = '';
|
|
571
|
+
let parsed;
|
|
572
|
+
const root = new Command('webcmd');
|
|
573
|
+
const profile = root.command('profile').description('Manage hosted browser profiles');
|
|
574
|
+
const output = {
|
|
575
|
+
writeOut: (value) => { stdout += value; },
|
|
576
|
+
writeErr: (value) => { stderr += value; },
|
|
577
|
+
};
|
|
578
|
+
root.exitOverride().configureOutput(output);
|
|
579
|
+
profile.exitOverride().configureOutput(output);
|
|
580
|
+
const configureFormat = (command) => command.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table');
|
|
581
|
+
const setParsed = (command, surface, value) => {
|
|
582
|
+
const options = surface.opts();
|
|
583
|
+
parsed = {
|
|
584
|
+
kind: 'run',
|
|
585
|
+
command,
|
|
586
|
+
format: options.format,
|
|
587
|
+
formatExplicit: surface.getOptionValueSource('format') === 'cli',
|
|
588
|
+
...(value !== undefined ? { value } : {}),
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
const list = configureFormat(profile.command('list'));
|
|
592
|
+
list.exitOverride().configureOutput(output).action(() => setParsed('list', list));
|
|
593
|
+
const remove = configureFormat(profile.command('delete').argument('<profile-id>'));
|
|
594
|
+
remove.exitOverride().configureOutput(output).action((profileId) => setParsed('delete', remove, profileId));
|
|
595
|
+
try {
|
|
596
|
+
root.parse(literal ? ['--', 'profile', ...argv] : ['profile', ...argv], { from: 'user' });
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
if (!(error instanceof CommanderError))
|
|
600
|
+
throw error;
|
|
601
|
+
if (error.code === 'commander.helpDisplayed')
|
|
602
|
+
return { kind: 'help', output: stdout };
|
|
603
|
+
throw new CommanderStructuralError(stderr || `${error.message}\n`, error.exitCode);
|
|
604
|
+
}
|
|
605
|
+
if (!parsed) {
|
|
606
|
+
throw new CommanderStructuralError("error: command 'profile' did not run\n", 1);
|
|
607
|
+
}
|
|
608
|
+
return parsed;
|
|
609
|
+
}
|
|
610
|
+
async function dispatchHostedProfile(parsed, client, stdout) {
|
|
611
|
+
const result = parsed.command === 'list'
|
|
612
|
+
? (await client.listProfiles()).profiles
|
|
613
|
+
: await client.deleteProfile(parsed.value);
|
|
614
|
+
await renderOutput(result, {
|
|
615
|
+
fmt: parsed.format,
|
|
616
|
+
fmtExplicit: parsed.formatExplicit,
|
|
617
|
+
stdout,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
555
620
|
function parseHostedCompletionSurface(argv, literal) {
|
|
556
621
|
let stdout = '';
|
|
557
622
|
let stderr = '';
|