@agentrhq/webcmd 0.3.4 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -268
- package/cli-manifest.json +184 -311
- package/clis/_shared/site-auth.js +37 -40
- package/clis/_shared/site-auth.test.js +109 -22
- package/clis/amazon/auth.js +0 -6
- package/clis/band/auth.js +0 -6
- package/clis/blinkit/auth.js +13 -40
- package/clis/blinkit/blinkit.test.js +39 -2
- package/clis/chatgpt/auth.js +0 -6
- package/clis/claude/auth.js +0 -6
- package/clis/coupang/auth.js +0 -6
- package/clis/district/auth.js +0 -2
- package/clis/facebook/auth.js +0 -6
- package/clis/gemini/auth.js +0 -6
- package/clis/github/auth.js +0 -6
- package/clis/grok/auth.js +0 -6
- package/clis/hackernews/hackernews.test.js +20 -1
- package/clis/hackernews/jobs.js +1 -1
- package/clis/hf/auth.js +2 -7
- package/clis/instagram/auth.js +0 -6
- package/clis/linkedin/auth.js +0 -6
- package/clis/linkedin-learning/auth.js +0 -6
- package/clis/manus/auth.js +0 -6
- package/clis/notebooklm/auth.js +0 -6
- package/clis/pixiv/auth.js +1 -7
- package/clis/practo/login.js +12 -38
- package/clis/practo/practo.test.js +13 -9
- package/clis/reddit/auth.js +0 -6
- package/clis/reuters/auth.js +0 -14
- package/clis/suno/auth.js +0 -6
- package/clis/tiktok/auth.js +0 -6
- package/clis/twitter/auth.js +0 -6
- package/clis/upwork/auth.js +0 -6
- package/clis/youtube/auth.js +0 -6
- package/clis/zepto/auth.js +16 -29
- package/clis/zepto/zepto.test.js +45 -2
- package/dist/src/browser/cdp.js +3 -1
- package/dist/src/browser/cdp.test.js +12 -0
- package/dist/src/browser/page.js +6 -2
- package/dist/src/browser/page.test.js +1 -0
- package/dist/src/browser/protocol.d.ts +2 -0
- package/dist/src/browser/runtime/local-cloak/actions.js +42 -21
- package/dist/src/browser/runtime/local-cloak/provider.test.js +33 -5
- package/dist/src/build-manifest.js +3 -3
- package/dist/src/build-manifest.test.js +14 -0
- package/dist/src/completion-shared.js +1 -1
- package/dist/src/docs-sync-review.js +15 -7
- package/dist/src/docs-sync-review.test.js +38 -2
- package/dist/src/generate-release-notes-cli.test.js +1 -1
- package/dist/src/hosted/client.d.ts +14 -2
- package/dist/src/hosted/client.js +46 -5
- package/dist/src/hosted/client.test.js +73 -20
- package/dist/src/hosted/main-lifecycle.test.js +2 -1
- package/dist/src/hosted/manifest.test.js +4 -2
- package/dist/src/hosted/output-parity.test.js +2 -1
- package/dist/src/hosted/root-command-surface.test.js +8 -1
- package/dist/src/hosted/runner.js +93 -0
- package/dist/src/hosted/runner.test.js +134 -5
- package/dist/src/hosted/types.d.ts +9 -2
- package/dist/src/skills.test.js +54 -0
- package/hosted-contract.json +191 -366
- package/package.json +4 -4
- package/skills/webcmd-adapter-author/SKILL.md +2 -0
- package/skills/webcmd-autofix/SKILL.md +5 -3
- package/skills/webcmd-browser/SKILL.md +12 -11
- package/skills/webcmd-usage/SKILL.md +8 -0
- package/clis/practo/whoami.js +0 -28
|
@@ -428,4 +428,18 @@ describe('manifest helper rules', () => {
|
|
|
428
428
|
// Unknown flags are ignored.
|
|
429
429
|
expect(parseBuildManifestArgs(['--something-else']).allowRemovals).toBe(0);
|
|
430
430
|
});
|
|
431
|
+
it('keeps every browser login on the local handoff contract', () => {
|
|
432
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'cli-manifest.json'), 'utf8'));
|
|
433
|
+
const logins = manifest.filter((entry) => entry.browser === true && entry.name === 'login');
|
|
434
|
+
const keys = new Set(manifest.map((entry) => `${entry.site}/${entry.name}`));
|
|
435
|
+
expect(logins.length).toBeGreaterThan(0);
|
|
436
|
+
for (const login of logins) {
|
|
437
|
+
expect(login.args.some((arg) => arg.name === 'timeout'), `${login.site}/login`).toBe(false);
|
|
438
|
+
expect(login.description, `${login.site}/login`).not.toMatch(/wait until/i);
|
|
439
|
+
expect(login.columns, `${login.site}/login`).toEqual(expect.arrayContaining([
|
|
440
|
+
'status', 'logged_in', 'site', 'action', 'verify_command',
|
|
441
|
+
]));
|
|
442
|
+
expect(keys.has(`${login.site}/whoami`), `${login.site}/whoami`).toBe(true);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
431
445
|
});
|
|
@@ -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' },
|
|
@@ -98,29 +98,37 @@ const GENERAL_DOCUMENTATION = [
|
|
|
98
98
|
const BROWSER_DOCUMENTATION = [
|
|
99
99
|
'README.md',
|
|
100
100
|
'docs/agent-prompts.mdx',
|
|
101
|
-
'docs/
|
|
101
|
+
'docs/browser-and-sitemap-memory.mdx',
|
|
102
102
|
'docs/cli-reference.mdx',
|
|
103
103
|
'docs/concepts.mdx',
|
|
104
104
|
'skills/webcmd-browser-sitemap/SKILL.md',
|
|
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',
|
|
111
117
|
'docs/cli-reference.mdx',
|
|
112
|
-
'docs/
|
|
118
|
+
'docs/skills.mdx',
|
|
113
119
|
'skills/webcmd-adapter-author/SKILL.md',
|
|
114
120
|
'skills/webcmd-usage/SKILL.md',
|
|
115
121
|
];
|
|
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();
|
|
@@ -87,7 +87,7 @@ describe('review context', () => {
|
|
|
87
87
|
])).toEqual([
|
|
88
88
|
'README.md',
|
|
89
89
|
'docs/agent-prompts.mdx',
|
|
90
|
-
'docs/
|
|
90
|
+
'docs/browser-and-sitemap-memory.mdx',
|
|
91
91
|
'docs/cli-reference.mdx',
|
|
92
92
|
'docs/concepts.mdx',
|
|
93
93
|
'skills/webcmd-browser-sitemap/SKILL.md',
|
|
@@ -95,12 +95,48 @@ 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',
|
|
101
137
|
'docs/authoring.mdx',
|
|
102
138
|
'docs/cli-reference.mdx',
|
|
103
|
-
'docs/
|
|
139
|
+
'docs/skills.mdx',
|
|
104
140
|
'skills/webcmd-adapter-author/SKILL.md',
|
|
105
141
|
'skills/webcmd-usage/SKILL.md',
|
|
106
142
|
]);
|
|
@@ -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
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CliError, type ExitCode } from '../errors.js';
|
|
2
|
-
import type { HostedBrowserActionRequest, HostedBrowserActionResponse, HostedBrowserFinishRequest, HostedBrowserFinishResponse, HostedBrowserRunActionInput, HostedBrowserRunActionResponse, HostedBrowserRunRequest, HostedBrowserRunResponse, HostedExecution, HostedExecuteResponse, HostedPrepareExecutionResponse, HostedProfilesResponse, HostedUploadArtifactResponse, HostedManifest, HostedTraceReceipt } from './types.js';
|
|
2
|
+
import type { HostedBrowserActionRequest, HostedBrowserActionResponse, HostedBrowserFinishRequest, HostedBrowserFinishResponse, HostedBrowserRunActionInput, HostedBrowserRunActionResponse, HostedBrowserRunRequest, HostedBrowserRunResponse, HostedExecution, HostedExecuteResponse, HostedPrepareExecutionResponse, HostedProfileResponse, HostedProfilesResponse, HostedUploadArtifactResponse, HostedManifest, HostedTraceReceipt } from './types.js';
|
|
3
3
|
export interface HostedClientOptions {
|
|
4
4
|
apiBaseUrl: string;
|
|
5
5
|
apiKey: string;
|
|
@@ -20,7 +20,19 @@ export declare class HostedClient {
|
|
|
20
20
|
constructor(options: HostedClientOptions);
|
|
21
21
|
getMe(): Promise<unknown>;
|
|
22
22
|
getManifest(): Promise<HostedManifest>;
|
|
23
|
-
listProfiles(
|
|
23
|
+
listProfiles(filters?: {
|
|
24
|
+
name?: string;
|
|
25
|
+
userId?: string;
|
|
26
|
+
}): Promise<HostedProfilesResponse>;
|
|
27
|
+
createProfile(input: {
|
|
28
|
+
name: string;
|
|
29
|
+
userId?: string;
|
|
30
|
+
}): Promise<HostedProfileResponse>;
|
|
31
|
+
getProfile(profileId: string): Promise<HostedProfileResponse>;
|
|
32
|
+
deleteProfile(profileId: string): Promise<{
|
|
33
|
+
ok: true;
|
|
34
|
+
deleted: true;
|
|
35
|
+
}>;
|
|
24
36
|
execute(input: {
|
|
25
37
|
command: string;
|
|
26
38
|
args: Record<string, unknown>;
|
|
@@ -29,13 +29,43 @@ export class HostedClient {
|
|
|
29
29
|
}
|
|
30
30
|
return body.manifest;
|
|
31
31
|
}
|
|
32
|
-
async listProfiles() {
|
|
33
|
-
const
|
|
32
|
+
async listProfiles(filters = {}) {
|
|
33
|
+
const params = new URLSearchParams();
|
|
34
|
+
if (filters.name !== undefined)
|
|
35
|
+
params.set('name', filters.name);
|
|
36
|
+
if (filters.userId !== undefined)
|
|
37
|
+
params.set('userId', filters.userId);
|
|
38
|
+
const query = params.toString();
|
|
39
|
+
const body = await this.request(`/v1/profiles${query ? `?${query}` : ''}`);
|
|
34
40
|
if (!isHostedProfilesResponse(body)) {
|
|
35
41
|
throw protocolError('Webcmd Cloud returned an invalid profiles response.');
|
|
36
42
|
}
|
|
37
43
|
return body;
|
|
38
44
|
}
|
|
45
|
+
async createProfile(input) {
|
|
46
|
+
const body = await this.request('/v1/profiles', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
body: JSON.stringify(input),
|
|
49
|
+
});
|
|
50
|
+
if (!isHostedProfileResponse(body)) {
|
|
51
|
+
throw protocolError('Webcmd Cloud returned an invalid profile response.');
|
|
52
|
+
}
|
|
53
|
+
return body;
|
|
54
|
+
}
|
|
55
|
+
async getProfile(profileId) {
|
|
56
|
+
const body = await this.request(`/v1/profiles/${encodeURIComponent(profileId)}`);
|
|
57
|
+
if (!isHostedProfileResponse(body)) {
|
|
58
|
+
throw protocolError('Webcmd Cloud returned an invalid profile response.');
|
|
59
|
+
}
|
|
60
|
+
return body;
|
|
61
|
+
}
|
|
62
|
+
async deleteProfile(profileId) {
|
|
63
|
+
const body = await this.request(`/v1/profiles/${encodeURIComponent(profileId)}`, { method: 'DELETE' });
|
|
64
|
+
if (!hasExactKeys(body, ['ok', 'deleted']) || body.ok !== true || body.deleted !== true) {
|
|
65
|
+
throw protocolError('Webcmd Cloud returned an invalid profile deletion response.');
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, deleted: true };
|
|
68
|
+
}
|
|
39
69
|
async execute(input) {
|
|
40
70
|
const traceMode = normalizeTraceMode(input.trace);
|
|
41
71
|
const body = await this.request('/v1/execute', {
|
|
@@ -281,12 +311,23 @@ function isHostedProfilesResponse(value) {
|
|
|
281
311
|
&& Array.isArray(value.profiles)
|
|
282
312
|
&& value.profiles.every(isHostedPublicProfile);
|
|
283
313
|
}
|
|
314
|
+
function isHostedProfileResponse(value) {
|
|
315
|
+
return hasExactKeys(value, ['ok', 'profile'])
|
|
316
|
+
&& value.ok === true
|
|
317
|
+
&& isHostedPublicProfile(value.profile);
|
|
318
|
+
}
|
|
284
319
|
function isHostedPublicProfile(value) {
|
|
285
|
-
return hasExactKeys(value, [
|
|
286
|
-
|
|
320
|
+
return hasExactKeys(value, [
|
|
321
|
+
'id', 'name', 'userId', 'default', 'status',
|
|
322
|
+
'createdAt', 'updatedAt', 'lastUsedAt',
|
|
323
|
+
])
|
|
324
|
+
&& typeof value.id === 'string'
|
|
325
|
+
&& (value.name === null || typeof value.name === 'string')
|
|
326
|
+
&& (value.userId === null || typeof value.userId === 'string')
|
|
287
327
|
&& typeof value.default === 'boolean'
|
|
288
|
-
&& value.status === 'available'
|
|
328
|
+
&& (value.status === 'pending' || value.status === 'available')
|
|
289
329
|
&& typeof value.createdAt === 'string'
|
|
330
|
+
&& typeof value.updatedAt === 'string'
|
|
290
331
|
&& typeof value.lastUsedAt === 'string';
|
|
291
332
|
}
|
|
292
333
|
function isHostedManifestCommand(value) {
|
|
@@ -165,31 +165,84 @@ 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
|
+
userId: '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 === '/v1/profiles' && init?.method === 'POST') {
|
|
191
|
+
return new Response(JSON.stringify({ ok: true, profile }), { status: 201 });
|
|
192
|
+
}
|
|
193
|
+
if (path.startsWith('/v1/profiles/') && init?.method === 'DELETE') {
|
|
194
|
+
return new Response(JSON.stringify({ ok: true, deleted: true }));
|
|
195
|
+
}
|
|
196
|
+
if (path.startsWith('/v1/profiles/')) {
|
|
197
|
+
return new Response(JSON.stringify({ ok: true, profile }));
|
|
198
|
+
}
|
|
199
|
+
return new Response(JSON.stringify({ ok: true, profiles: [profile] }));
|
|
200
|
+
},
|
|
182
201
|
});
|
|
183
|
-
await expect(client.listProfiles())
|
|
184
|
-
ok: true,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
202
|
+
await expect(client.listProfiles({ name: 'Work', userId: 'user_64256' }))
|
|
203
|
+
.resolves.toEqual({ ok: true, profiles: [profile] });
|
|
204
|
+
await expect(client.createProfile({ name: 'Work', userId: 'user_64256' }))
|
|
205
|
+
.resolves.toMatchObject({ profile: { id: 'profile_work', status: 'available' } });
|
|
206
|
+
await expect(client.getProfile('profile_work')).resolves.toMatchObject({
|
|
207
|
+
profile: { id: 'profile_work' },
|
|
208
|
+
});
|
|
209
|
+
await expect(client.deleteProfile('profile/work')).resolves.toEqual({ ok: true, deleted: true });
|
|
210
|
+
expect(requests).toEqual([
|
|
211
|
+
{ url: 'https://api.example.com/v1/profiles?name=Work&userId=user_64256', method: 'GET' },
|
|
212
|
+
{
|
|
213
|
+
url: 'https://api.example.com/v1/profiles',
|
|
214
|
+
method: 'POST',
|
|
215
|
+
body: JSON.stringify({ name: 'Work', userId: 'user_64256' }),
|
|
216
|
+
},
|
|
217
|
+
{ url: 'https://api.example.com/v1/profiles/profile_work', method: 'GET' },
|
|
218
|
+
{ url: 'https://api.example.com/v1/profiles/profile%2Fwork', method: 'DELETE' },
|
|
219
|
+
]);
|
|
220
|
+
});
|
|
221
|
+
it.each([
|
|
222
|
+
{ name: 'private provider field', change: { kernelProfileId: 'private' } },
|
|
223
|
+
{ name: 'missing updatedAt', change: { updatedAt: undefined } },
|
|
224
|
+
{ name: 'non-nullable name shape', change: { name: 7 } },
|
|
225
|
+
{ name: 'non-nullable user ID shape', change: { userId: false } },
|
|
226
|
+
])('rejects a hosted profile with $name', async ({ change }) => {
|
|
227
|
+
const profile = {
|
|
228
|
+
id: 'profile_work',
|
|
229
|
+
name: null,
|
|
230
|
+
userId: null,
|
|
231
|
+
default: false,
|
|
232
|
+
status: 'pending',
|
|
233
|
+
createdAt: '2026-07-08T00:00:00.000Z',
|
|
234
|
+
updatedAt: '2026-07-08T00:00:00.000Z',
|
|
235
|
+
lastUsedAt: '2026-07-08T00:00:00.000Z',
|
|
236
|
+
...change,
|
|
237
|
+
};
|
|
238
|
+
if (change.updatedAt === undefined)
|
|
239
|
+
delete profile.updatedAt;
|
|
240
|
+
const client = new HostedClient({
|
|
241
|
+
apiBaseUrl: 'https://api.example.com',
|
|
242
|
+
apiKey: 'key',
|
|
243
|
+
fetchImpl: async () => new Response(JSON.stringify({ ok: true, profiles: [profile] })),
|
|
192
244
|
});
|
|
245
|
+
await expect(client.listProfiles()).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' });
|
|
193
246
|
});
|
|
194
247
|
it('maps hosted error envelopes to CliError-compatible errors', async () => {
|
|
195
248
|
const client = new HostedClient({
|
|
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
8
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
9
|
+
import { PKG_VERSION } from '../version.js';
|
|
9
10
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
10
11
|
const entrypoint = path.join(packageRoot, 'src/main.ts');
|
|
11
12
|
const largeOutput = 'hosted-lifecycle-chunk\n'.repeat(64 * 1024);
|
|
@@ -152,7 +153,7 @@ async function createHostedFixture(outcome) {
|
|
|
152
153
|
userId: 'user_lifecycle',
|
|
153
154
|
metadata: {
|
|
154
155
|
contractSchemaVersion: 1,
|
|
155
|
-
webcmdPackageVersion:
|
|
156
|
+
webcmdPackageVersion: PKG_VERSION,
|
|
156
157
|
generatedAt: '2026-07-14T00:00:00.000Z',
|
|
157
158
|
},
|
|
158
159
|
commands: [command, authCommand],
|
|
@@ -8,11 +8,12 @@ import { makeHostedConfig } from './config.js';
|
|
|
8
8
|
import { runHostedCli } from './runner.js';
|
|
9
9
|
import { formatRootHelp } from '../command-presentation.js';
|
|
10
10
|
import { HOSTED_ROOT_HELP } from '../completion-shared.js';
|
|
11
|
+
import { PKG_VERSION } from '../version.js';
|
|
11
12
|
const manifest = {
|
|
12
13
|
userId: 'user_demo',
|
|
13
14
|
metadata: {
|
|
14
15
|
contractSchemaVersion: 1,
|
|
15
|
-
webcmdPackageVersion:
|
|
16
|
+
webcmdPackageVersion: PKG_VERSION,
|
|
16
17
|
generatedAt: '2026-07-08T00:00:00.000Z',
|
|
17
18
|
},
|
|
18
19
|
commands: [
|
|
@@ -97,6 +98,7 @@ describe('hosted manifest helpers', () => {
|
|
|
97
98
|
expect(result).toEqual({ handled: true, exitCode: 0 });
|
|
98
99
|
expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP));
|
|
99
100
|
expect(stdout.text()).toContain('completion');
|
|
101
|
+
expect(stdout.text()).toMatch(/profile\s+Manage hosted browser profiles/);
|
|
100
102
|
expect(stdout.text()).toContain('--profile <name>');
|
|
101
103
|
expect(stdout.text()).toContain('Local-only commands:');
|
|
102
104
|
expect(stdout.text()).toContain('Run `webcmd setup` and choose local mode to use local-only commands.');
|
|
@@ -159,6 +161,6 @@ describe('hosted manifest helpers', () => {
|
|
|
159
161
|
stdout: stdout.stream,
|
|
160
162
|
fetchImpl: async () => new Response(JSON.stringify({ ok: true, manifest }), { status: 200 }),
|
|
161
163
|
});
|
|
162
|
-
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']);
|
|
163
165
|
});
|
|
164
166
|
});
|
|
@@ -3,6 +3,7 @@ import { Command } from 'commander';
|
|
|
3
3
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { Strategy } from '../registry.js';
|
|
5
5
|
import { registerCommandToProgram } from '../commanderAdapter.js';
|
|
6
|
+
import { PKG_VERSION } from '../version.js';
|
|
6
7
|
import { makeHostedConfig } from './config.js';
|
|
7
8
|
import { runHostedCli } from './runner.js';
|
|
8
9
|
const { mockExecuteCommand } = vi.hoisted(() => ({
|
|
@@ -26,7 +27,7 @@ const manifest = {
|
|
|
26
27
|
userId: 'user_demo',
|
|
27
28
|
metadata: {
|
|
28
29
|
contractSchemaVersion: 1,
|
|
29
|
-
webcmdPackageVersion:
|
|
30
|
+
webcmdPackageVersion: PKG_VERSION,
|
|
30
31
|
generatedAt: '2026-07-14T00:00:00.000Z',
|
|
31
32
|
},
|
|
32
33
|
commands: [{
|
|
@@ -26,7 +26,7 @@ const manifest = {
|
|
|
26
26
|
userId: 'user_demo',
|
|
27
27
|
metadata: {
|
|
28
28
|
contractSchemaVersion: 1,
|
|
29
|
-
webcmdPackageVersion:
|
|
29
|
+
webcmdPackageVersion: PKG_VERSION,
|
|
30
30
|
generatedAt: '2026-07-08T00:00:00.000Z',
|
|
31
31
|
},
|
|
32
32
|
commands: [{
|
|
@@ -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 } },
|
|
@@ -146,6 +146,18 @@ async function dispatchHosted(argv, client, stdout, stderr, now) {
|
|
|
146
146
|
await renderHostedList(manifest, parsed.format, parsed.formatExplicit, stdout);
|
|
147
147
|
return;
|
|
148
148
|
}
|
|
149
|
+
if (args[0] === 'profile') {
|
|
150
|
+
if (args[1] === 'rename' || args[1] === 'use') {
|
|
151
|
+
throw new ConfigError(`webcmd profile ${args[1]} is not available in hosted mode.`, 'Hosted mode supports: webcmd profile list, create, get, and delete.');
|
|
152
|
+
}
|
|
153
|
+
const parsed = parseHostedProfileSurface(args.slice(1), normalized.literal);
|
|
154
|
+
if (parsed.kind === 'help') {
|
|
155
|
+
await writeToStream(stdout, parsed.output);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
await dispatchHostedProfile(parsed, client, stdout);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
149
161
|
const manifest = await client.getManifest();
|
|
150
162
|
validateManifestContractIdentity(manifest);
|
|
151
163
|
const site = args[0];
|
|
@@ -552,6 +564,87 @@ function parseHostedListSurface(argv, literal) {
|
|
|
552
564
|
throw new CommanderStructuralError("error: command 'list' did not run\n", 1);
|
|
553
565
|
return { kind: 'run', format: parsedFormat, formatExplicit };
|
|
554
566
|
}
|
|
567
|
+
function parseHostedProfileSurface(argv, literal) {
|
|
568
|
+
let stdout = '';
|
|
569
|
+
let stderr = '';
|
|
570
|
+
let parsed;
|
|
571
|
+
const root = new Command('webcmd');
|
|
572
|
+
const profile = root.command('profile').description('Manage hosted browser profiles');
|
|
573
|
+
const output = {
|
|
574
|
+
writeOut: (value) => { stdout += value; },
|
|
575
|
+
writeErr: (value) => { stderr += value; },
|
|
576
|
+
};
|
|
577
|
+
root.exitOverride().configureOutput(output);
|
|
578
|
+
profile.exitOverride().configureOutput(output);
|
|
579
|
+
const configureFormat = (command) => command.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table');
|
|
580
|
+
const setParsed = (command, surface, value, userId) => {
|
|
581
|
+
const options = surface.opts();
|
|
582
|
+
parsed = {
|
|
583
|
+
kind: 'run',
|
|
584
|
+
command,
|
|
585
|
+
format: options.format,
|
|
586
|
+
formatExplicit: surface.getOptionValueSource('format') === 'cli',
|
|
587
|
+
...(value !== undefined ? { value } : {}),
|
|
588
|
+
...(userId !== undefined ? { userId } : {}),
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
const list = configureFormat(profile.command('list'));
|
|
592
|
+
list.exitOverride().configureOutput(output).action(() => setParsed('list', list));
|
|
593
|
+
const create = configureFormat(profile.command('create').argument('<name>').option('--user-id <id>'));
|
|
594
|
+
create.exitOverride().configureOutput(output).action((name, options) => setParsed('create', create, name, options.userId));
|
|
595
|
+
const get = configureFormat(profile.command('get').argument('<profile>'));
|
|
596
|
+
get.exitOverride().configureOutput(output).action((selector) => setParsed('get', get, selector));
|
|
597
|
+
const remove = configureFormat(profile.command('delete').argument('<profile-id>'));
|
|
598
|
+
remove.exitOverride().configureOutput(output).action((profileId) => setParsed('delete', remove, profileId));
|
|
599
|
+
try {
|
|
600
|
+
root.parse(literal ? ['--', 'profile', ...argv] : ['profile', ...argv], { from: 'user' });
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
if (!(error instanceof CommanderError))
|
|
604
|
+
throw error;
|
|
605
|
+
if (error.code === 'commander.helpDisplayed')
|
|
606
|
+
return { kind: 'help', output: stdout };
|
|
607
|
+
throw new CommanderStructuralError(stderr || `${error.message}\n`, error.exitCode);
|
|
608
|
+
}
|
|
609
|
+
if (!parsed) {
|
|
610
|
+
throw new CommanderStructuralError("error: command 'profile' did not run\n", 1);
|
|
611
|
+
}
|
|
612
|
+
return parsed;
|
|
613
|
+
}
|
|
614
|
+
async function dispatchHostedProfile(parsed, client, stdout) {
|
|
615
|
+
let result;
|
|
616
|
+
if (parsed.command === 'list') {
|
|
617
|
+
result = (await client.listProfiles()).profiles;
|
|
618
|
+
}
|
|
619
|
+
else if (parsed.command === 'create') {
|
|
620
|
+
result = (await client.createProfile({
|
|
621
|
+
name: parsed.value,
|
|
622
|
+
...(parsed.userId !== undefined ? { userId: parsed.userId } : {}),
|
|
623
|
+
})).profile;
|
|
624
|
+
}
|
|
625
|
+
else if (parsed.command === 'get') {
|
|
626
|
+
result = resolveHostedProfile((await client.listProfiles()).profiles, parsed.value);
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
result = await client.deleteProfile(parsed.value);
|
|
630
|
+
}
|
|
631
|
+
await renderOutput(result, {
|
|
632
|
+
fmt: parsed.format,
|
|
633
|
+
fmtExplicit: parsed.formatExplicit,
|
|
634
|
+
stdout,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
function resolveHostedProfile(profiles, selector) {
|
|
638
|
+
const matches = profiles.filter(profile => profile.id === selector || profile.name === selector || profile.userId === selector);
|
|
639
|
+
const distinct = [...new Map(matches.map(profile => [profile.id, profile])).values()];
|
|
640
|
+
if (distinct.length === 0) {
|
|
641
|
+
throw new CliError('PROFILE_NOT_FOUND', 'Profile was not found.', undefined, EXIT_CODES.EMPTY_RESULT);
|
|
642
|
+
}
|
|
643
|
+
if (distinct.length > 1) {
|
|
644
|
+
throw new CliError('AMBIGUOUS_PROFILE', 'The profile selector matched multiple profiles.', 'Use the immutable profile ID returned by webcmd profile list.', EXIT_CODES.TEMPFAIL);
|
|
645
|
+
}
|
|
646
|
+
return distinct[0];
|
|
647
|
+
}
|
|
555
648
|
function parseHostedCompletionSurface(argv, literal) {
|
|
556
649
|
let stdout = '';
|
|
557
650
|
let stderr = '';
|