@agentrhq/webcmd 0.2.3 → 0.2.4
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/cli-manifest.json +12 -3
- package/clis/practo/login.js +34 -7
- package/clis/practo/practo.test.js +19 -1
- package/dist/src/browser/daemon-lifecycle.d.ts +2 -0
- package/dist/src/browser/daemon-lifecycle.js +28 -6
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +5 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +116 -2
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
- package/dist/src/browser.test.js +8 -4
- package/dist/src/cli.js +96 -0
- package/dist/src/cli.test.js +2 -2
- package/dist/src/generate-release-notes-cli.test.js +35 -15
- package/dist/src/plugin-catalog.d.ts +46 -0
- package/dist/src/plugin-catalog.js +178 -0
- package/dist/src/plugin-catalog.test.d.ts +1 -0
- package/dist/src/plugin-catalog.test.js +95 -0
- package/dist/src/release-notes.d.ts +2 -2
- package/dist/src/release-notes.js +25 -21
- package/dist/src/release-notes.test.js +29 -9
- package/package.json +2 -1
- package/plugin-catalog.json +10 -0
- package/scripts/generate-release-notes.ts +4 -6
package/cli-manifest.json
CHANGED
|
@@ -16873,16 +16873,25 @@
|
|
|
16873
16873
|
{
|
|
16874
16874
|
"site": "practo",
|
|
16875
16875
|
"name": "login",
|
|
16876
|
-
"description": "Open Practo login
|
|
16876
|
+
"description": "Open Practo login and wait until the browser session is authenticated",
|
|
16877
16877
|
"access": "write",
|
|
16878
16878
|
"domain": "www.practo.com",
|
|
16879
16879
|
"strategy": "cookie",
|
|
16880
16880
|
"browser": true,
|
|
16881
|
-
"args": [
|
|
16881
|
+
"args": [
|
|
16882
|
+
{
|
|
16883
|
+
"name": "timeout",
|
|
16884
|
+
"type": "int",
|
|
16885
|
+
"default": 300,
|
|
16886
|
+
"required": false,
|
|
16887
|
+
"help": "Maximum seconds to wait for the user to finish login"
|
|
16888
|
+
}
|
|
16889
|
+
],
|
|
16882
16890
|
"columns": [
|
|
16883
16891
|
"status",
|
|
16892
|
+
"logged_in",
|
|
16884
16893
|
"site",
|
|
16885
|
-
"
|
|
16894
|
+
"name"
|
|
16886
16895
|
],
|
|
16887
16896
|
"type": "js",
|
|
16888
16897
|
"modulePath": "practo/login.js",
|
package/clis/practo/login.js
CHANGED
|
@@ -1,21 +1,48 @@
|
|
|
1
|
+
import { AuthRequiredError, TimeoutError } from '@agentrhq/webcmd/errors';
|
|
1
2
|
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
2
|
-
import { PRACTO } from './utils.js';
|
|
3
|
+
import { PRACTO, probeIdentity } from './utils.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_SECONDS = 300;
|
|
6
|
+
|
|
7
|
+
function isAuthRequired(error) {
|
|
8
|
+
return error instanceof AuthRequiredError;
|
|
9
|
+
}
|
|
3
10
|
|
|
4
11
|
cli({
|
|
5
12
|
site: 'practo',
|
|
6
13
|
name: 'login',
|
|
7
14
|
access: 'write',
|
|
8
|
-
description: 'Open Practo login
|
|
15
|
+
description: 'Open Practo login and wait until the browser session is authenticated',
|
|
9
16
|
domain: 'www.practo.com',
|
|
10
17
|
strategy: Strategy.COOKIE,
|
|
11
18
|
browser: true,
|
|
12
19
|
navigateBefore: false,
|
|
13
20
|
defaultWindowMode: 'foreground',
|
|
14
21
|
siteSession: 'persistent',
|
|
15
|
-
args: [
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
args: [
|
|
23
|
+
{ name: 'timeout', type: 'int', default: DEFAULT_TIMEOUT_SECONDS, help: 'Maximum seconds to wait for the user to finish login' },
|
|
24
|
+
],
|
|
25
|
+
columns: ['status', 'logged_in', 'site', 'name'],
|
|
26
|
+
func: async (page, kwargs) => {
|
|
27
|
+
try {
|
|
28
|
+
const rows = await probeIdentity(page);
|
|
29
|
+
return [{ status: 'already_logged_in', ...rows[0] }];
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (!isAuthRequired(error)) throw error;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await page.goto(`${PRACTO}/login`, { waitUntil: 'none', settleMs: 1500 });
|
|
35
|
+
const timeout = Number(kwargs.timeout ?? DEFAULT_TIMEOUT_SECONDS);
|
|
36
|
+
const deadline = Date.now() + timeout * 1000;
|
|
37
|
+
while (Date.now() < deadline) {
|
|
38
|
+
await page.wait(2);
|
|
39
|
+
try {
|
|
40
|
+
const rows = await probeIdentity(page);
|
|
41
|
+
return [{ status: 'login_complete', ...rows[0] }];
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (!isAuthRequired(error)) throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw new TimeoutError('practo login', timeout, 'Complete Practo login in the browser, then retry.');
|
|
20
47
|
},
|
|
21
48
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { ArgumentError } from '@agentrhq/webcmd/errors';
|
|
3
3
|
import { getRegistry } from '@agentrhq/webcmd/registry';
|
|
4
4
|
import './appointment.js';
|
|
@@ -124,6 +124,24 @@ describe('practo command registry', () => {
|
|
|
124
124
|
expect(cancel.args.find((arg) => arg.name === 'confirm')?.type).toBe('boolean');
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
it('waits for manual login to complete', async () => {
|
|
128
|
+
const login = getRegistry().get('practo/login');
|
|
129
|
+
const page = {
|
|
130
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
131
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
132
|
+
evaluate: vi.fn()
|
|
133
|
+
.mockResolvedValueOnce({ __error: 'HTTP 401', status: 401 })
|
|
134
|
+
.mockResolvedValueOnce({ name: 'Ada' }),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
await expect(login.func(page, { timeout: 1 })).resolves.toEqual([{
|
|
138
|
+
status: 'login_complete',
|
|
139
|
+
logged_in: true,
|
|
140
|
+
site: 'practo',
|
|
141
|
+
name: 'Ada',
|
|
142
|
+
}]);
|
|
143
|
+
});
|
|
144
|
+
|
|
127
145
|
it('refuses real booking without --confirm true', async () => {
|
|
128
146
|
const book = getRegistry().get('practo/book-confirm');
|
|
129
147
|
await expect(book.func({}, { practice_doctor_id: '859054', time: '2026-07-10 10:30:00' })).rejects.toThrow(ArgumentError);
|
|
@@ -23,8 +23,10 @@ export declare function waitForDaemonStatus(timeoutMs: number): Promise<DaemonSt
|
|
|
23
23
|
export declare const daemonLifecycleHooks: {
|
|
24
24
|
requestDaemonShutdown: typeof requestDaemonShutdown;
|
|
25
25
|
spawnDaemonProcess: typeof spawnDaemonProcess;
|
|
26
|
+
signalDaemonProcessTree: typeof signalDaemonProcessTree;
|
|
26
27
|
waitForDaemonStop: typeof waitForDaemonStop;
|
|
27
28
|
};
|
|
29
|
+
export declare function signalDaemonProcessTree(pid: number, signal: NodeJS.Signals): boolean;
|
|
28
30
|
export declare function restartDaemon(opts?: {
|
|
29
31
|
stopTimeoutMs?: number;
|
|
30
32
|
startTimeoutMs?: number;
|
|
@@ -53,8 +53,31 @@ export async function waitForDaemonStatus(timeoutMs) {
|
|
|
53
53
|
export const daemonLifecycleHooks = {
|
|
54
54
|
requestDaemonShutdown,
|
|
55
55
|
spawnDaemonProcess,
|
|
56
|
+
signalDaemonProcessTree,
|
|
56
57
|
waitForDaemonStop,
|
|
57
58
|
};
|
|
59
|
+
export function signalDaemonProcessTree(pid, signal) {
|
|
60
|
+
let signaled = false;
|
|
61
|
+
if (process.platform !== 'win32') {
|
|
62
|
+
try {
|
|
63
|
+
process.kill(-pid, signal);
|
|
64
|
+
signaled = true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Fall back to the daemon PID below.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!signaled) {
|
|
71
|
+
try {
|
|
72
|
+
process.kill(pid, signal);
|
|
73
|
+
signaled = true;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// The process may have already exited; the caller polls the daemon port.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return signaled;
|
|
80
|
+
}
|
|
58
81
|
export async function restartDaemon(opts = {}) {
|
|
59
82
|
const previousStatus = await fetchDaemonStatus();
|
|
60
83
|
let stopped = previousStatus === null;
|
|
@@ -91,13 +114,12 @@ export async function ensureBrowserBridgeReady(opts = {}) {
|
|
|
91
114
|
if (!portReleased) {
|
|
92
115
|
const stalePid = health.status?.pid;
|
|
93
116
|
if (typeof stalePid === 'number' && Number.isInteger(stalePid) && stalePid > 0) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
daemonLifecycleHooks.signalDaemonProcessTree(stalePid, 'SIGTERM');
|
|
118
|
+
portReleased = await daemonLifecycleHooks.waitForDaemonStop(1000);
|
|
119
|
+
if (!portReleased) {
|
|
120
|
+
daemonLifecycleHooks.signalDaemonProcessTree(stalePid, 'SIGKILL');
|
|
121
|
+
portReleased = await daemonLifecycleHooks.waitForDaemonStop(2000);
|
|
99
122
|
}
|
|
100
|
-
portReleased = await daemonLifecycleHooks.waitForDaemonStop(2000);
|
|
101
123
|
}
|
|
102
124
|
}
|
|
103
125
|
if (!portReleased) {
|
|
@@ -3,6 +3,7 @@ import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbr
|
|
|
3
3
|
import type { BrowserSurface, SiteSessionMode } from '../../protocol.js';
|
|
4
4
|
import { CloakNetworkCapture } from './network.js';
|
|
5
5
|
export type LaunchPersistentContext = typeof cloakLaunchPersistentContext;
|
|
6
|
+
export type RecoverLockedProfile = (userDataDir: string) => Promise<boolean>;
|
|
6
7
|
export interface SessionKeyInput {
|
|
7
8
|
profileId?: string;
|
|
8
9
|
session?: string;
|
|
@@ -33,13 +34,16 @@ export interface CloakTabInfo {
|
|
|
33
34
|
export interface CloakSessionManagerOptions {
|
|
34
35
|
baseDir?: string;
|
|
35
36
|
launchPersistentContext?: LaunchPersistentContext;
|
|
37
|
+
recoverLockedProfile?: RecoverLockedProfile;
|
|
36
38
|
}
|
|
37
39
|
export declare function resolveLeaseKey(input: SessionKeyInput): string;
|
|
38
40
|
export declare class CloakSessionManager {
|
|
39
41
|
private readonly opts;
|
|
40
42
|
readonly networkCapture: CloakNetworkCapture;
|
|
41
43
|
private readonly launchPersistentContext;
|
|
44
|
+
private readonly recoverLockedProfile;
|
|
42
45
|
private readonly profiles;
|
|
46
|
+
private readonly profileLaunches;
|
|
43
47
|
constructor(opts?: CloakSessionManagerOptions);
|
|
44
48
|
profileStatuses(): {
|
|
45
49
|
contextId: string;
|
|
@@ -72,6 +76,7 @@ export declare class CloakSessionManager {
|
|
|
72
76
|
release(input: SessionKeyInput): Promise<void>;
|
|
73
77
|
shutdown(): Promise<void>;
|
|
74
78
|
private getProfileRuntime;
|
|
79
|
+
private launchProfileRuntime;
|
|
75
80
|
private openEntries;
|
|
76
81
|
private findEntryByPageId;
|
|
77
82
|
private refreshIdleTimer;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
2
3
|
import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser';
|
|
3
4
|
import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js';
|
|
4
5
|
import { CloakNetworkCapture } from './network.js';
|
|
@@ -17,10 +18,13 @@ export class CloakSessionManager {
|
|
|
17
18
|
opts;
|
|
18
19
|
networkCapture = new CloakNetworkCapture();
|
|
19
20
|
launchPersistentContext;
|
|
21
|
+
recoverLockedProfile;
|
|
20
22
|
profiles = new Map();
|
|
23
|
+
profileLaunches = new Map();
|
|
21
24
|
constructor(opts = {}) {
|
|
22
25
|
this.opts = opts;
|
|
23
26
|
this.launchPersistentContext = opts.launchPersistentContext ?? cloakLaunchPersistentContext;
|
|
27
|
+
this.recoverLockedProfile = opts.recoverLockedProfile ?? recoverLockedCloakProfile;
|
|
24
28
|
}
|
|
25
29
|
profileStatuses() {
|
|
26
30
|
return [...this.profiles.entries()].map(([contextId, runtime]) => ({
|
|
@@ -235,13 +239,35 @@ export class CloakSessionManager {
|
|
|
235
239
|
const existing = this.profiles.get(profileId);
|
|
236
240
|
if (existing)
|
|
237
241
|
return existing;
|
|
242
|
+
const pending = this.profileLaunches.get(profileId);
|
|
243
|
+
if (pending)
|
|
244
|
+
return pending;
|
|
245
|
+
const launch = this.launchProfileRuntime(profileId);
|
|
246
|
+
this.profileLaunches.set(profileId, launch);
|
|
247
|
+
try {
|
|
248
|
+
return await launch;
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
this.profileLaunches.delete(profileId);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async launchProfileRuntime(profileId) {
|
|
238
255
|
const userDataDir = resolveCloakProfileDir(profileId, { baseDir: this.opts.baseDir });
|
|
239
256
|
fs.mkdirSync(userDataDir, { recursive: true });
|
|
240
|
-
const
|
|
257
|
+
const launchOptions = {
|
|
241
258
|
userDataDir,
|
|
242
259
|
headless: false,
|
|
243
260
|
humanize: true,
|
|
244
|
-
}
|
|
261
|
+
};
|
|
262
|
+
let context;
|
|
263
|
+
try {
|
|
264
|
+
context = await this.launchPersistentContext(launchOptions);
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
if (!isProfileAlreadyInUseError(err) || !(await this.recoverLockedProfile(userDataDir)))
|
|
268
|
+
throw err;
|
|
269
|
+
context = await this.launchPersistentContext(launchOptions);
|
|
270
|
+
}
|
|
245
271
|
const runtime = { context, pages: new Map(), lastSeenAt: Date.now() };
|
|
246
272
|
this.profiles.set(profileId, runtime);
|
|
247
273
|
return runtime;
|
|
@@ -291,3 +317,91 @@ function requireSession(session) {
|
|
|
291
317
|
throw new Error('Browser session is required.');
|
|
292
318
|
return normalized;
|
|
293
319
|
}
|
|
320
|
+
function isProfileAlreadyInUseError(err) {
|
|
321
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
322
|
+
return message.includes('Opening in existing browser session')
|
|
323
|
+
|| message.includes('Failed to create a ProcessSingleton for your profile directory');
|
|
324
|
+
}
|
|
325
|
+
async function recoverLockedCloakProfile(userDataDir) {
|
|
326
|
+
if (process.platform === 'win32')
|
|
327
|
+
return false;
|
|
328
|
+
const initial = await findCloakProfileProcesses(userDataDir);
|
|
329
|
+
if (initial.length === 0)
|
|
330
|
+
return false;
|
|
331
|
+
signalPids(initial, 'SIGTERM');
|
|
332
|
+
if (await waitForProfileProcessesToExit(userDataDir, 2500))
|
|
333
|
+
return true;
|
|
334
|
+
signalPids(await findCloakProfileProcesses(userDataDir), 'SIGKILL');
|
|
335
|
+
return waitForProfileProcessesToExit(userDataDir, 1500);
|
|
336
|
+
}
|
|
337
|
+
async function waitForProfileProcessesToExit(userDataDir, timeoutMs) {
|
|
338
|
+
const deadline = Date.now() + timeoutMs;
|
|
339
|
+
while (Date.now() < deadline) {
|
|
340
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
341
|
+
if ((await findCloakProfileProcesses(userDataDir)).length === 0)
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
return (await findCloakProfileProcesses(userDataDir)).length === 0;
|
|
345
|
+
}
|
|
346
|
+
function signalPids(pids, signal) {
|
|
347
|
+
for (const pid of pids) {
|
|
348
|
+
try {
|
|
349
|
+
process.kill(pid, signal);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// Already exited or not signalable; the follow-up poll decides recovery.
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async function findCloakProfileProcesses(userDataDir) {
|
|
357
|
+
const profileDirs = profileDirAliases(userDataDir);
|
|
358
|
+
const stdout = await psOutput();
|
|
359
|
+
const pids = [];
|
|
360
|
+
for (const line of stdout.split('\n')) {
|
|
361
|
+
const match = line.match(/^\s*(\d+)\s+(.+)$/);
|
|
362
|
+
if (!match)
|
|
363
|
+
continue;
|
|
364
|
+
const pid = Number(match[1]);
|
|
365
|
+
const command = match[2];
|
|
366
|
+
if (!Number.isInteger(pid) || pid === process.pid)
|
|
367
|
+
continue;
|
|
368
|
+
if (!isCloakBrowserCommand(command))
|
|
369
|
+
continue;
|
|
370
|
+
if (!commandUsesProfileDir(command, profileDirs))
|
|
371
|
+
continue;
|
|
372
|
+
pids.push(pid);
|
|
373
|
+
}
|
|
374
|
+
return [...new Set(pids)];
|
|
375
|
+
}
|
|
376
|
+
function commandUsesProfileDir(command, profileDirs) {
|
|
377
|
+
for (const dir of profileDirs) {
|
|
378
|
+
const marker = `--user-data-dir=${dir}`;
|
|
379
|
+
const index = command.indexOf(marker);
|
|
380
|
+
if (index < 0)
|
|
381
|
+
continue;
|
|
382
|
+
const next = command[index + marker.length];
|
|
383
|
+
if (next === undefined || /\s/.test(next))
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
function profileDirAliases(userDataDir) {
|
|
389
|
+
const aliases = new Set([userDataDir]);
|
|
390
|
+
try {
|
|
391
|
+
aliases.add(fs.realpathSync.native(userDataDir));
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// The launch path is still useful even if realpath cannot resolve it.
|
|
395
|
+
}
|
|
396
|
+
return [...aliases];
|
|
397
|
+
}
|
|
398
|
+
function isCloakBrowserCommand(command) {
|
|
399
|
+
return command.includes('/.cloakbrowser/') || command.includes('\\.cloakbrowser\\');
|
|
400
|
+
}
|
|
401
|
+
function psOutput() {
|
|
402
|
+
return new Promise((resolve) => {
|
|
403
|
+
execFile('ps', ['-axo', 'pid=,command='], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 2000 }, (err, stdout) => {
|
|
404
|
+
resolve(err ? '' : String(stdout));
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
}
|
|
@@ -42,6 +42,42 @@ describe('CloakSessionManager', () => {
|
|
|
42
42
|
expect(launchPersistentContext).toHaveBeenCalledTimes(1);
|
|
43
43
|
expect(launchPersistentContext.mock.calls[0][0]).toMatchObject({ headless: false });
|
|
44
44
|
});
|
|
45
|
+
it('coalesces concurrent persistent context launches for the same profile', async () => {
|
|
46
|
+
const launched = fakeContext();
|
|
47
|
+
let resolveLaunch;
|
|
48
|
+
const launchPersistentContext = vi.fn(() => new Promise((resolve) => {
|
|
49
|
+
resolveLaunch = resolve;
|
|
50
|
+
}));
|
|
51
|
+
const manager = new CloakSessionManager({
|
|
52
|
+
baseDir: '/tmp/webcmd-test',
|
|
53
|
+
launchPersistentContext,
|
|
54
|
+
});
|
|
55
|
+
const firstPage = manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' });
|
|
56
|
+
const secondPage = manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' });
|
|
57
|
+
await Promise.resolve();
|
|
58
|
+
expect(launchPersistentContext).toHaveBeenCalledTimes(1);
|
|
59
|
+
resolveLaunch(launched.context);
|
|
60
|
+
const [first, second] = await Promise.all([firstPage, secondPage]);
|
|
61
|
+
expect(first.context).toBe(launched.context);
|
|
62
|
+
expect(second.context).toBe(launched.context);
|
|
63
|
+
expect(first.page).toBe(second.page);
|
|
64
|
+
});
|
|
65
|
+
it('clears a stale Cloak profile owner and retries when Chromium reports an existing session', async () => {
|
|
66
|
+
const launched = fakeContext();
|
|
67
|
+
const launchPersistentContext = vi.fn()
|
|
68
|
+
.mockRejectedValueOnce(new Error('browserType.launchPersistentContext: Opening in existing browser session.'))
|
|
69
|
+
.mockResolvedValueOnce(launched.context);
|
|
70
|
+
const recoverLockedProfile = vi.fn().mockResolvedValue(true);
|
|
71
|
+
const manager = new CloakSessionManager({
|
|
72
|
+
baseDir: '/tmp/webcmd-test',
|
|
73
|
+
launchPersistentContext,
|
|
74
|
+
recoverLockedProfile,
|
|
75
|
+
});
|
|
76
|
+
const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' });
|
|
77
|
+
expect(lease.context).toBe(launched.context);
|
|
78
|
+
expect(recoverLockedProfile).toHaveBeenCalledWith(expectedProfileDir('default'));
|
|
79
|
+
expect(launchPersistentContext).toHaveBeenCalledTimes(2);
|
|
80
|
+
});
|
|
45
81
|
it('freshPage closes the existing persistent lease page and creates a new one', async () => {
|
|
46
82
|
const makePage = () => ({
|
|
47
83
|
goto: vi.fn().mockResolvedValue(undefined),
|
package/dist/src/browser.test.js
CHANGED
|
@@ -196,7 +196,7 @@ describe('BrowserBridge state', () => {
|
|
|
196
196
|
const bridge = new BrowserBridge();
|
|
197
197
|
await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
|
|
198
198
|
});
|
|
199
|
-
it('falls back to SIGKILL when stale daemon refuses graceful shutdown', async () => {
|
|
199
|
+
it('falls back to process-group SIGKILL when stale daemon refuses graceful shutdown', async () => {
|
|
200
200
|
vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({
|
|
201
201
|
state: 'no-runtime',
|
|
202
202
|
status: {
|
|
@@ -213,8 +213,10 @@ describe('BrowserBridge state', () => {
|
|
|
213
213
|
});
|
|
214
214
|
vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'requestDaemonShutdown').mockResolvedValue(false);
|
|
215
215
|
// Graceful shutdown short-circuits to false (requestDaemonShutdown -> false).
|
|
216
|
-
//
|
|
217
|
-
vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'waitForDaemonStop')
|
|
216
|
+
// SIGTERM does not release the port; process-group SIGKILL does.
|
|
217
|
+
vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'waitForDaemonStop')
|
|
218
|
+
.mockResolvedValueOnce(false)
|
|
219
|
+
.mockResolvedValueOnce(true);
|
|
218
220
|
vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'spawnDaemonProcess').mockReturnValue(null);
|
|
219
221
|
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
|
220
222
|
const bridge = new BrowserBridge();
|
|
@@ -222,7 +224,9 @@ describe('BrowserBridge state', () => {
|
|
|
222
224
|
// no-runtime wait (which times out with `timeout: 0.1`), producing the
|
|
223
225
|
// runtime-not-ready error rather than the stale-daemon error.
|
|
224
226
|
await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser runtime is not ready');
|
|
225
|
-
|
|
227
|
+
const signalPid = process.platform === 'win32' ? 99999 : -99999;
|
|
228
|
+
expect(killSpy).toHaveBeenCalledWith(signalPid, 'SIGTERM');
|
|
229
|
+
expect(killSpy).toHaveBeenCalledWith(signalPid, 'SIGKILL');
|
|
226
230
|
});
|
|
227
231
|
it('reports stale daemon error when SIGKILL fails to release the port', async () => {
|
|
228
232
|
vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({
|
package/dist/src/cli.js
CHANGED
|
@@ -2999,6 +2999,102 @@ cli({
|
|
|
2999
2999
|
console.log(` ${plugins.length} plugin(s) installed`);
|
|
3000
3000
|
console.log();
|
|
3001
3001
|
});
|
|
3002
|
+
const catalogCmd = pluginCmd
|
|
3003
|
+
.command('catalog')
|
|
3004
|
+
.description('Manage plugin marketplace sources');
|
|
3005
|
+
catalogCmd
|
|
3006
|
+
.command('list')
|
|
3007
|
+
.description('List configured plugin marketplace sources')
|
|
3008
|
+
.option('-f, --format <fmt>', 'Output format: table, json', 'table')
|
|
3009
|
+
.action(async (opts) => {
|
|
3010
|
+
const { readCatalog } = await import('./plugin-catalog.js');
|
|
3011
|
+
try {
|
|
3012
|
+
const catalog = readCatalog();
|
|
3013
|
+
if (opts.format === 'json') {
|
|
3014
|
+
renderOutput(catalog, { fmt: 'json' });
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
renderOutput(catalog.sources, {
|
|
3018
|
+
fmt: opts.format,
|
|
3019
|
+
columns: ['id', 'source', 'manifestUrl'],
|
|
3020
|
+
title: `${CLI_COMMAND}/plugin-catalog`,
|
|
3021
|
+
source: `${CLI_COMMAND} plugin catalog list`,
|
|
3022
|
+
});
|
|
3023
|
+
}
|
|
3024
|
+
catch (err) {
|
|
3025
|
+
console.error(`Error: ${getErrorMessage(err)}`);
|
|
3026
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
3027
|
+
}
|
|
3028
|
+
});
|
|
3029
|
+
catalogCmd
|
|
3030
|
+
.command('add')
|
|
3031
|
+
.description('Add a plugin marketplace source')
|
|
3032
|
+
.argument('<source>', 'Marketplace source, e.g. github:owner/repo')
|
|
3033
|
+
.option('-f, --format <fmt>', 'Output format: table, json', 'table')
|
|
3034
|
+
.action(async (source, opts) => {
|
|
3035
|
+
const { addCatalogSource } = await import('./plugin-catalog.js');
|
|
3036
|
+
try {
|
|
3037
|
+
const added = await addCatalogSource(source);
|
|
3038
|
+
renderOutput(opts.format === 'json' ? added : [added], {
|
|
3039
|
+
fmt: opts.format,
|
|
3040
|
+
columns: ['id', 'source', 'manifestUrl'],
|
|
3041
|
+
title: `${CLI_COMMAND}/plugin-catalog`,
|
|
3042
|
+
source: `${CLI_COMMAND} plugin catalog add`,
|
|
3043
|
+
});
|
|
3044
|
+
}
|
|
3045
|
+
catch (err) {
|
|
3046
|
+
console.error(`Error: ${getErrorMessage(err)}`);
|
|
3047
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
3048
|
+
}
|
|
3049
|
+
});
|
|
3050
|
+
catalogCmd
|
|
3051
|
+
.command('remove')
|
|
3052
|
+
.description('Remove a plugin marketplace source')
|
|
3053
|
+
.argument('<id>', 'Catalog source id')
|
|
3054
|
+
.action(async (id) => {
|
|
3055
|
+
const { removeCatalogSource } = await import('./plugin-catalog.js');
|
|
3056
|
+
try {
|
|
3057
|
+
removeCatalogSource(id);
|
|
3058
|
+
console.log(`✅ Catalog source "${id}" removed.`);
|
|
3059
|
+
}
|
|
3060
|
+
catch (err) {
|
|
3061
|
+
console.error(`Error: ${getErrorMessage(err)}`);
|
|
3062
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
3063
|
+
}
|
|
3064
|
+
});
|
|
3065
|
+
pluginCmd
|
|
3066
|
+
.command('search')
|
|
3067
|
+
.description('Search installable marketplace plugins')
|
|
3068
|
+
.argument('[query]', 'Search query matched against plugin name and description')
|
|
3069
|
+
.option('-f, --format <fmt>', 'Output format: table, json', 'table')
|
|
3070
|
+
.action(async (query, opts) => {
|
|
3071
|
+
const { readCatalog, searchCatalogPlugins } = await import('./plugin-catalog.js');
|
|
3072
|
+
try {
|
|
3073
|
+
const catalog = readCatalog();
|
|
3074
|
+
const result = await searchCatalogPlugins(catalog, { query });
|
|
3075
|
+
if (opts.format === 'json') {
|
|
3076
|
+
renderOutput(result, { fmt: 'json' });
|
|
3077
|
+
}
|
|
3078
|
+
else {
|
|
3079
|
+
for (const err of result.errors) {
|
|
3080
|
+
console.error(`Warning: ${err.sourceId}: ${err.message}`);
|
|
3081
|
+
}
|
|
3082
|
+
renderOutput(result.plugins, {
|
|
3083
|
+
fmt: opts.format,
|
|
3084
|
+
columns: ['name', 'description', 'version', 'sourceId', 'installSource', 'webcmd'],
|
|
3085
|
+
title: `${CLI_COMMAND}/plugin-search`,
|
|
3086
|
+
source: `${CLI_COMMAND} plugin search`,
|
|
3087
|
+
});
|
|
3088
|
+
}
|
|
3089
|
+
if (catalog.sources.length > 0 && result.errors.length === catalog.sources.length) {
|
|
3090
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
catch (err) {
|
|
3094
|
+
console.error(`Error: ${getErrorMessage(err)}`);
|
|
3095
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
3096
|
+
}
|
|
3097
|
+
});
|
|
3002
3098
|
pluginCmd
|
|
3003
3099
|
.command('create')
|
|
3004
3100
|
.description('Create a new plugin scaffold')
|
package/dist/src/cli.test.js
CHANGED
|
@@ -52,7 +52,7 @@ describe('createProgram root help descriptions', () => {
|
|
|
52
52
|
expect(descriptionFor(program, 'browser')).toContain('verify');
|
|
53
53
|
expect(descriptionFor(program, 'browser')).not.toContain('Browser control');
|
|
54
54
|
expect(descriptionFor(program, 'auth')).toBe('refresh, status');
|
|
55
|
-
expect(descriptionFor(program, 'plugin')).toBe('create, install, list, uninstall, update');
|
|
55
|
+
expect(descriptionFor(program, 'plugin')).toBe('catalog, create, install, list, search, uninstall, update');
|
|
56
56
|
expect(descriptionFor(program, 'adapter')).toBe('eject, reset, status');
|
|
57
57
|
expect(descriptionFor(program, 'profile')).toBe('list, rename, use');
|
|
58
58
|
expect(descriptionFor(program, 'daemon')).toBe('restart, status, stop');
|
|
@@ -648,7 +648,7 @@ describe('createProgram root help descriptions', () => {
|
|
|
648
648
|
description: 'Manage webcmd plugins',
|
|
649
649
|
namespace_options: [],
|
|
650
650
|
});
|
|
651
|
-
expect(data.commands.map((cmd) => cmd.name)).toEqual(['create', 'install', 'list', 'uninstall', 'update']);
|
|
651
|
+
expect(data.commands.map((cmd) => cmd.name)).toEqual(['catalog add', 'catalog list', 'catalog remove', 'create', 'install', 'list', 'search', 'uninstall', 'update']);
|
|
652
652
|
const update = data.commands.find((cmd) => cmd.name === 'update');
|
|
653
653
|
expect(update).toMatchObject({
|
|
654
654
|
usage: 'webcmd plugin update [name] [options]',
|
|
@@ -68,25 +68,45 @@ describe('runGenerateReleaseNotes', () => {
|
|
|
68
68
|
'## Highlights',
|
|
69
69
|
'- Better summaries.',
|
|
70
70
|
'',
|
|
71
|
-
'## Improvements',
|
|
72
|
-
'None.',
|
|
73
|
-
'',
|
|
74
|
-
'## Fixes',
|
|
75
|
-
'None.',
|
|
76
|
-
'',
|
|
77
|
-
'## Adapters',
|
|
78
|
-
'None.',
|
|
79
|
-
'',
|
|
80
|
-
'## Contributors',
|
|
81
|
-
'- @alice',
|
|
82
|
-
'',
|
|
83
|
-
'## Reverts',
|
|
84
|
-
'None.',
|
|
85
|
-
'',
|
|
86
71
|
].join('\n'),
|
|
87
72
|
stderr: '',
|
|
88
73
|
});
|
|
89
74
|
});
|
|
75
|
+
it('prints nothing when generated notes only contain empty placeholders', async () => {
|
|
76
|
+
const { io, read } = createIo();
|
|
77
|
+
const context = {
|
|
78
|
+
tag: 'v1.2.3',
|
|
79
|
+
previousTag: 'v1.2.2',
|
|
80
|
+
currentRef: 'abcdef1',
|
|
81
|
+
pullRequests: [
|
|
82
|
+
{
|
|
83
|
+
number: 42,
|
|
84
|
+
title: 'chore: no user visible release changes',
|
|
85
|
+
author: { login: 'alice' },
|
|
86
|
+
labels: [],
|
|
87
|
+
files: [],
|
|
88
|
+
url: 'https://example.com/42',
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
const loadContext = vi.fn(async () => context);
|
|
93
|
+
const generateText = vi.fn(async () => [
|
|
94
|
+
'## Highlights',
|
|
95
|
+
'None.',
|
|
96
|
+
'',
|
|
97
|
+
'## Reverts',
|
|
98
|
+
'There are no reverts in this release.',
|
|
99
|
+
'',
|
|
100
|
+
'## Contributors',
|
|
101
|
+
'- @alice',
|
|
102
|
+
].join('\n'));
|
|
103
|
+
const exitCode = await runGenerateReleaseNotes(['node', 'script', 'v1.2.3'], { GEMINI_API_KEY: 'test-key' }, { loadContext, generateText }, io);
|
|
104
|
+
expect(exitCode).toBe(0);
|
|
105
|
+
expect(read()).toEqual({
|
|
106
|
+
stdout: '',
|
|
107
|
+
stderr: '',
|
|
108
|
+
});
|
|
109
|
+
});
|
|
90
110
|
it('swallows generator errors and keeps release-please notes intact', async () => {
|
|
91
111
|
const { io, read } = createIo();
|
|
92
112
|
const loadContext = vi.fn(async () => {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type PluginManifest } from './plugin-manifest.js';
|
|
2
|
+
export interface PluginCatalogSource {
|
|
3
|
+
id: string;
|
|
4
|
+
source: string;
|
|
5
|
+
manifestUrl: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PluginCatalog {
|
|
8
|
+
version: 1;
|
|
9
|
+
sources: PluginCatalogSource[];
|
|
10
|
+
}
|
|
11
|
+
export interface PluginSearchRow {
|
|
12
|
+
name: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
version?: string;
|
|
15
|
+
sourceId: string;
|
|
16
|
+
installSource: string;
|
|
17
|
+
webcmd?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PluginSearchError {
|
|
20
|
+
sourceId: string;
|
|
21
|
+
manifestUrl: string;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
export interface PluginSearchResult {
|
|
25
|
+
plugins: PluginSearchRow[];
|
|
26
|
+
errors: PluginSearchError[];
|
|
27
|
+
}
|
|
28
|
+
type FetchJson = (url: string) => Promise<unknown>;
|
|
29
|
+
interface CatalogOptions {
|
|
30
|
+
packageRoot?: string;
|
|
31
|
+
homeDir?: string;
|
|
32
|
+
fetchJson?: FetchJson;
|
|
33
|
+
}
|
|
34
|
+
export declare function getUserPluginCatalogPath(homeDir?: string): string;
|
|
35
|
+
export declare function getPackagedPluginCatalogPath(packageRoot?: string): string;
|
|
36
|
+
export declare function readCatalog(options?: CatalogOptions): PluginCatalog;
|
|
37
|
+
export declare function writeCatalog(catalog: PluginCatalog, options?: CatalogOptions): void;
|
|
38
|
+
export declare function deriveGithubCatalogSource(source: string): PluginCatalogSource;
|
|
39
|
+
export declare function addCatalogSource(source: string, options?: CatalogOptions): Promise<PluginCatalogSource>;
|
|
40
|
+
export declare function removeCatalogSource(id: string, options?: CatalogOptions): PluginCatalogSource;
|
|
41
|
+
export declare function flattenPluginManifest(source: PluginCatalogSource, manifest: PluginManifest): PluginSearchRow[];
|
|
42
|
+
export declare function searchCatalogPlugins(catalog: PluginCatalog, options?: {
|
|
43
|
+
query?: string;
|
|
44
|
+
fetchJson?: FetchJson;
|
|
45
|
+
}): Promise<PluginSearchResult>;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { getEnabledPlugins, isMonorepo } from './plugin-manifest.js';
|
|
6
|
+
import { findPackageRoot } from './package-paths.js';
|
|
7
|
+
const MODULE_FILE = fileURLToPath(import.meta.url);
|
|
8
|
+
const CATALOG_FILENAME = 'plugin-catalog.json';
|
|
9
|
+
export function getUserPluginCatalogPath(homeDir = os.homedir()) {
|
|
10
|
+
return path.join(homeDir, '.webcmd', CATALOG_FILENAME);
|
|
11
|
+
}
|
|
12
|
+
export function getPackagedPluginCatalogPath(packageRoot = findPackageRoot(MODULE_FILE)) {
|
|
13
|
+
return path.join(packageRoot, CATALOG_FILENAME);
|
|
14
|
+
}
|
|
15
|
+
export function readCatalog(options = {}) {
|
|
16
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
17
|
+
const userPath = getUserPluginCatalogPath(homeDir);
|
|
18
|
+
if (!fs.existsSync(userPath))
|
|
19
|
+
seedUserCatalog(options.packageRoot, homeDir);
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = JSON.parse(fs.readFileSync(userPath, 'utf-8'));
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
throw new Error(`Malformed plugin catalog at ${userPath}: ${errorMessage(err)}`);
|
|
26
|
+
}
|
|
27
|
+
return normalizeCatalog(parsed, userPath);
|
|
28
|
+
}
|
|
29
|
+
export function writeCatalog(catalog, options = {}) {
|
|
30
|
+
const userPath = getUserPluginCatalogPath(options.homeDir ?? os.homedir());
|
|
31
|
+
fs.mkdirSync(path.dirname(userPath), { recursive: true });
|
|
32
|
+
fs.writeFileSync(userPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
33
|
+
}
|
|
34
|
+
export function deriveGithubCatalogSource(source) {
|
|
35
|
+
const parsed = parseGithubSource(source);
|
|
36
|
+
if (!parsed)
|
|
37
|
+
throw new Error(`Unsupported catalog source "${source}". Use github:owner/repo.`);
|
|
38
|
+
const { owner, repo } = parsed;
|
|
39
|
+
return {
|
|
40
|
+
id: `${owner}/${repo}`,
|
|
41
|
+
source,
|
|
42
|
+
manifestUrl: `https://raw.githubusercontent.com/${owner}/${repo}/main/webcmd-plugin.json`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export async function addCatalogSource(source, options = {}) {
|
|
46
|
+
const entry = deriveGithubCatalogSource(source);
|
|
47
|
+
const catalog = readCatalog(options);
|
|
48
|
+
const duplicate = catalog.sources.find((existing) => (existing.id === entry.id || existing.source === entry.source || existing.manifestUrl === entry.manifestUrl));
|
|
49
|
+
if (duplicate)
|
|
50
|
+
throw new Error(`Catalog source already exists: ${duplicate.id}`);
|
|
51
|
+
const manifest = await fetchManifest(entry, options.fetchJson);
|
|
52
|
+
validateMarketplaceManifest(manifest, entry.manifestUrl);
|
|
53
|
+
catalog.sources.push(entry);
|
|
54
|
+
writeCatalog(catalog, options);
|
|
55
|
+
return entry;
|
|
56
|
+
}
|
|
57
|
+
export function removeCatalogSource(id, options = {}) {
|
|
58
|
+
const catalog = readCatalog(options);
|
|
59
|
+
const index = catalog.sources.findIndex((source) => source.id === id);
|
|
60
|
+
if (index < 0)
|
|
61
|
+
throw new Error(`Catalog source not found: ${id}`);
|
|
62
|
+
const [removed] = catalog.sources.splice(index, 1);
|
|
63
|
+
writeCatalog(catalog, options);
|
|
64
|
+
return removed;
|
|
65
|
+
}
|
|
66
|
+
export function flattenPluginManifest(source, manifest) {
|
|
67
|
+
if (isMonorepo(manifest)) {
|
|
68
|
+
return getEnabledPlugins(manifest).map(({ name, entry }) => ({
|
|
69
|
+
name,
|
|
70
|
+
description: entry.description,
|
|
71
|
+
version: entry.version,
|
|
72
|
+
sourceId: source.id,
|
|
73
|
+
installSource: `${source.source}/${name}`,
|
|
74
|
+
webcmd: entry.webcmd ?? manifest.webcmd,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
if (!manifest.name)
|
|
78
|
+
return [];
|
|
79
|
+
return [{
|
|
80
|
+
name: manifest.name,
|
|
81
|
+
description: manifest.description,
|
|
82
|
+
version: manifest.version,
|
|
83
|
+
sourceId: source.id,
|
|
84
|
+
installSource: source.source,
|
|
85
|
+
webcmd: manifest.webcmd,
|
|
86
|
+
}];
|
|
87
|
+
}
|
|
88
|
+
export async function searchCatalogPlugins(catalog, options = {}) {
|
|
89
|
+
const plugins = [];
|
|
90
|
+
const errors = [];
|
|
91
|
+
await Promise.all(catalog.sources.map(async (source) => {
|
|
92
|
+
try {
|
|
93
|
+
const manifest = await fetchManifest(source, options.fetchJson);
|
|
94
|
+
validateMarketplaceManifest(manifest, source.manifestUrl);
|
|
95
|
+
plugins.push(...flattenPluginManifest(source, manifest));
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
errors.push({ sourceId: source.id, manifestUrl: source.manifestUrl, message: errorMessage(err) });
|
|
99
|
+
}
|
|
100
|
+
}));
|
|
101
|
+
const query = options.query?.trim().toLowerCase();
|
|
102
|
+
const filtered = query
|
|
103
|
+
? plugins.filter((plugin) => `${plugin.name} ${plugin.description ?? ''}`.toLowerCase().includes(query))
|
|
104
|
+
: plugins;
|
|
105
|
+
filtered.sort((a, b) => a.name.localeCompare(b.name));
|
|
106
|
+
errors.sort((a, b) => a.sourceId.localeCompare(b.sourceId));
|
|
107
|
+
return { plugins: filtered, errors };
|
|
108
|
+
}
|
|
109
|
+
function seedUserCatalog(packageRoot, homeDir) {
|
|
110
|
+
const packagedPath = getPackagedPluginCatalogPath(packageRoot);
|
|
111
|
+
const userPath = getUserPluginCatalogPath(homeDir);
|
|
112
|
+
if (!fs.existsSync(packagedPath))
|
|
113
|
+
throw new Error(`Packaged plugin catalog not found: ${packagedPath}`);
|
|
114
|
+
fs.mkdirSync(path.dirname(userPath), { recursive: true });
|
|
115
|
+
fs.copyFileSync(packagedPath, userPath);
|
|
116
|
+
}
|
|
117
|
+
function normalizeCatalog(value, filePath) {
|
|
118
|
+
if (!isRecord(value))
|
|
119
|
+
throw new Error(`Malformed plugin catalog at ${filePath}: expected object`);
|
|
120
|
+
if (value.version !== 1)
|
|
121
|
+
throw new Error(`Malformed plugin catalog at ${filePath}: expected version 1`);
|
|
122
|
+
if (!Array.isArray(value.sources))
|
|
123
|
+
throw new Error(`Malformed plugin catalog at ${filePath}: expected sources array`);
|
|
124
|
+
return {
|
|
125
|
+
version: 1,
|
|
126
|
+
sources: value.sources.map((source, index) => normalizeCatalogSource(source, `${filePath} sources[${index}]`)),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function normalizeCatalogSource(value, label) {
|
|
130
|
+
if (!isRecord(value))
|
|
131
|
+
throw new Error(`Malformed plugin catalog at ${label}: expected object`);
|
|
132
|
+
if (typeof value.id !== 'string' || !value.id)
|
|
133
|
+
throw new Error(`Malformed plugin catalog at ${label}: expected id`);
|
|
134
|
+
if (typeof value.source !== 'string' || !value.source)
|
|
135
|
+
throw new Error(`Malformed plugin catalog at ${label}: expected source`);
|
|
136
|
+
if (typeof value.manifestUrl !== 'string' || !value.manifestUrl)
|
|
137
|
+
throw new Error(`Malformed plugin catalog at ${label}: expected manifestUrl`);
|
|
138
|
+
const github = parseGithubSource(value.source);
|
|
139
|
+
return {
|
|
140
|
+
id: github ? `${github.owner}/${github.repo}` : value.id,
|
|
141
|
+
source: value.source,
|
|
142
|
+
manifestUrl: value.manifestUrl,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function parseGithubSource(source) {
|
|
146
|
+
const match = /^github:([\w.-]+)\/([\w.-]+)$/.exec(source);
|
|
147
|
+
if (!match)
|
|
148
|
+
return null;
|
|
149
|
+
return { owner: match[1], repo: match[2] };
|
|
150
|
+
}
|
|
151
|
+
async function fetchManifest(source, fetchJson = defaultFetchJson) {
|
|
152
|
+
const value = await fetchJson(source.manifestUrl);
|
|
153
|
+
if (!isRecord(value))
|
|
154
|
+
throw new Error(`Invalid plugin manifest at ${source.manifestUrl}: expected object`);
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
function validateMarketplaceManifest(manifest, manifestUrl) {
|
|
158
|
+
if (isMonorepo(manifest)) {
|
|
159
|
+
if (getEnabledPlugins(manifest).length === 0) {
|
|
160
|
+
throw new Error(`Invalid plugin manifest at ${manifestUrl}: no enabled plugins`);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!manifest.name)
|
|
165
|
+
throw new Error(`Invalid plugin manifest at ${manifestUrl}: missing name or plugins`);
|
|
166
|
+
}
|
|
167
|
+
async function defaultFetchJson(url) {
|
|
168
|
+
const response = await fetch(url);
|
|
169
|
+
if (!response.ok)
|
|
170
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}`.trim());
|
|
171
|
+
return response.json();
|
|
172
|
+
}
|
|
173
|
+
function isRecord(value) {
|
|
174
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
175
|
+
}
|
|
176
|
+
function errorMessage(err) {
|
|
177
|
+
return err instanceof Error ? err.message : String(err);
|
|
178
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { addCatalogSource, deriveGithubCatalogSource, flattenPluginManifest, getUserPluginCatalogPath, readCatalog, removeCatalogSource, searchCatalogPlugins, } from './plugin-catalog.js';
|
|
6
|
+
describe('plugin catalog', () => {
|
|
7
|
+
let tmpDir;
|
|
8
|
+
let packageRoot;
|
|
9
|
+
let homeDir;
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-plugin-catalog-'));
|
|
12
|
+
packageRoot = path.join(tmpDir, 'package');
|
|
13
|
+
homeDir = path.join(tmpDir, 'home');
|
|
14
|
+
fs.mkdirSync(packageRoot, { recursive: true });
|
|
15
|
+
fs.mkdirSync(homeDir, { recursive: true });
|
|
16
|
+
fs.writeFileSync(path.join(packageRoot, 'plugin-catalog.json'), JSON.stringify({
|
|
17
|
+
version: 1,
|
|
18
|
+
sources: [{
|
|
19
|
+
id: 'agentrhq/webcmd',
|
|
20
|
+
source: 'github:agentrhq/webcmd',
|
|
21
|
+
manifestUrl: 'https://raw.githubusercontent.com/agentrhq/webcmd/main/webcmd-plugin.json',
|
|
22
|
+
}],
|
|
23
|
+
}));
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
it('seeds the user catalog from the packaged default', () => {
|
|
29
|
+
const catalog = readCatalog({ packageRoot, homeDir });
|
|
30
|
+
expect(catalog.sources).toEqual([{ id: 'agentrhq/webcmd', source: 'github:agentrhq/webcmd', manifestUrl: 'https://raw.githubusercontent.com/agentrhq/webcmd/main/webcmd-plugin.json' }]);
|
|
31
|
+
expect(fs.existsSync(getUserPluginCatalogPath(homeDir))).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
it('derives catalog metadata from github shorthand', () => {
|
|
34
|
+
expect(deriveGithubCatalogSource('github:other-org/webcmd-travel-plugins')).toEqual({
|
|
35
|
+
id: 'other-org/webcmd-travel-plugins',
|
|
36
|
+
source: 'github:other-org/webcmd-travel-plugins',
|
|
37
|
+
manifestUrl: 'https://raw.githubusercontent.com/other-org/webcmd-travel-plugins/main/webcmd-plugin.json',
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
it('adds and removes catalog sources in the user catalog', async () => {
|
|
41
|
+
const fetchJson = async () => ({ name: 'travel', description: 'Travel tools' });
|
|
42
|
+
const added = await addCatalogSource('github:other-org/webcmd-travel-plugins', { packageRoot, homeDir, fetchJson });
|
|
43
|
+
expect(added.id).toBe('other-org/webcmd-travel-plugins');
|
|
44
|
+
expect(readCatalog({ packageRoot, homeDir }).sources.map((source) => source.id)).toEqual([
|
|
45
|
+
'agentrhq/webcmd',
|
|
46
|
+
'other-org/webcmd-travel-plugins',
|
|
47
|
+
]);
|
|
48
|
+
removeCatalogSource('other-org/webcmd-travel-plugins', { packageRoot, homeDir });
|
|
49
|
+
expect(readCatalog({ packageRoot, homeDir }).sources.map((source) => source.id)).toEqual(['agentrhq/webcmd']);
|
|
50
|
+
});
|
|
51
|
+
it('rejects duplicate catalog sources', async () => {
|
|
52
|
+
const fetchJson = async () => ({ name: 'travel', description: 'Travel tools' });
|
|
53
|
+
await addCatalogSource('github:other-org/webcmd-travel-plugins', { packageRoot, homeDir, fetchJson });
|
|
54
|
+
await expect(addCatalogSource('github:other-org/webcmd-travel-plugins', { packageRoot, homeDir, fetchJson })).rejects.toThrow('already exists');
|
|
55
|
+
});
|
|
56
|
+
it('flattens monorepo manifests into installable rows', () => {
|
|
57
|
+
const rows = flattenPluginManifest({
|
|
58
|
+
id: 'agentrhq/webcmd',
|
|
59
|
+
source: 'github:agentrhq/webcmd',
|
|
60
|
+
manifestUrl: 'https://example.com/webcmd-plugin.json',
|
|
61
|
+
}, {
|
|
62
|
+
webcmd: '>=0.2.0',
|
|
63
|
+
plugins: {
|
|
64
|
+
skyscanner: { path: 'plugins/skyscanner', version: '0.1.0', description: 'Flights', webcmd: '>=0.2.1' },
|
|
65
|
+
disabled: { path: 'plugins/disabled', disabled: true },
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
expect(rows).toEqual([{ name: 'skyscanner', description: 'Flights', version: '0.1.0', sourceId: 'agentrhq/webcmd', installSource: 'github:agentrhq/webcmd/skyscanner', webcmd: '>=0.2.1' }]);
|
|
69
|
+
});
|
|
70
|
+
it('flattens single-plugin manifests into installable rows', () => {
|
|
71
|
+
const rows = flattenPluginManifest({
|
|
72
|
+
id: 'research-tools',
|
|
73
|
+
source: 'github:someone/research-tools',
|
|
74
|
+
manifestUrl: 'https://example.com/webcmd-plugin.json',
|
|
75
|
+
}, { name: 'research-tools', version: '0.1.0', description: 'Research helpers', webcmd: '>=0.2.0' });
|
|
76
|
+
expect(rows).toEqual([{ name: 'research-tools', description: 'Research helpers', version: '0.1.0', sourceId: 'research-tools', installSource: 'github:someone/research-tools', webcmd: '>=0.2.0' }]);
|
|
77
|
+
});
|
|
78
|
+
it('fetches manifests live and preserves remote errors in search output', async () => {
|
|
79
|
+
const catalog = {
|
|
80
|
+
version: 1,
|
|
81
|
+
sources: [
|
|
82
|
+
{ id: 'ok', source: 'github:ok/repo', manifestUrl: 'https://ok.test/webcmd-plugin.json' },
|
|
83
|
+
{ id: 'bad', source: 'github:bad/repo', manifestUrl: 'https://bad.test/webcmd-plugin.json' },
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
const fetchJson = async (url) => {
|
|
87
|
+
if (url.includes('bad'))
|
|
88
|
+
throw new Error('network failed');
|
|
89
|
+
return { plugins: { flights: { path: 'plugins/flights', description: 'Flight search' } } };
|
|
90
|
+
};
|
|
91
|
+
const result = await searchCatalogPlugins(catalog, { query: 'flight', fetchJson });
|
|
92
|
+
expect(result.plugins.map((plugin) => plugin.name)).toEqual(['flights']);
|
|
93
|
+
expect(result.errors).toEqual([{ sourceId: 'bad', manifestUrl: 'https://bad.test/webcmd-plugin.json', message: 'network failed' }]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const RELEASE_NOTE_SECTIONS: readonly ["Highlights", "Improvements", "Fixes", "Adapters", "
|
|
1
|
+
export declare const RELEASE_NOTE_SECTIONS: readonly ["Highlights", "Improvements", "Fixes", "Adapters", "Reverts"];
|
|
2
2
|
export type ReleaseNoteSection = typeof RELEASE_NOTE_SECTIONS[number];
|
|
3
3
|
export interface PullRequestLabel {
|
|
4
4
|
name: string;
|
|
@@ -36,5 +36,5 @@ export declare function releaseVersionFromTag(tag: string): string;
|
|
|
36
36
|
export declare function replaceChangelogReleaseNotes(changelog: string, tag: string, notes: string): string;
|
|
37
37
|
export declare function extractPullRequestNumber(message: string): number | null;
|
|
38
38
|
export declare function filterReleasePullRequests(prs: PullRequestDetails[]): PullRequestDetails[];
|
|
39
|
-
export declare function normalizeReleaseNotes(raw: string
|
|
39
|
+
export declare function normalizeReleaseNotes(raw: string): string;
|
|
40
40
|
export declare function buildReleaseNotesPrompt(context: ReleaseContext): string;
|
|
@@ -3,22 +3,29 @@ export const RELEASE_NOTE_SECTIONS = [
|
|
|
3
3
|
'Improvements',
|
|
4
4
|
'Fixes',
|
|
5
5
|
'Adapters',
|
|
6
|
-
'Contributors',
|
|
7
6
|
'Reverts',
|
|
8
7
|
];
|
|
9
8
|
const SQUASH_MERGE_PR_NUMBER_PATTERN = /\(#(?<number>\d+)\)\s*$/;
|
|
10
9
|
const MERGE_COMMIT_PR_NUMBER_PATTERN = /^Merge pull request #(?<number>\d+) /;
|
|
11
10
|
const RELEASE_PLEASE_TITLE_PATTERN = /^chore(?:\([^)]+\))?: release(?:\s|$)/;
|
|
12
|
-
function
|
|
13
|
-
const
|
|
14
|
-
|
|
11
|
+
function isNoChangeContent(content) {
|
|
12
|
+
const lines = content
|
|
13
|
+
.split(/\r?\n/)
|
|
14
|
+
.map((line) => line.trim().replace(/^[-*]\s+/, '').replace(/[.。]+$/, '').trim().toLowerCase())
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
if (lines.length === 0)
|
|
17
|
+
return true;
|
|
18
|
+
return lines.every((line) => (line === 'none'
|
|
19
|
+
|| line === 'n/a'
|
|
20
|
+
|| line === 'not applicable'
|
|
21
|
+
|| /^no .*?(?:changes|updates|reverts|fixes|improvements|adapters|highlights)(?: in this release)?$/.test(line)
|
|
22
|
+
|| /^there (?:are|were) no .*?(?:changes|updates|reverts|fixes|improvements|adapters|highlights)(?: in this release)?$/.test(line)));
|
|
15
23
|
}
|
|
16
|
-
function
|
|
17
|
-
return [...new Set(handles.map(normalizeHandle).filter(Boolean))].sort((left, right) => left.localeCompare(right));
|
|
18
|
-
}
|
|
19
|
-
function formatSectionContent(content) {
|
|
24
|
+
function normalizeSectionContent(content) {
|
|
20
25
|
const trimmed = content?.trim();
|
|
21
|
-
|
|
26
|
+
if (!trimmed || isNoChangeContent(trimmed))
|
|
27
|
+
return null;
|
|
28
|
+
return trimmed;
|
|
22
29
|
}
|
|
23
30
|
function escapeRegExp(value) {
|
|
24
31
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -26,7 +33,7 @@ function escapeRegExp(value) {
|
|
|
26
33
|
function formatReleaseNotesForChangelog(notes) {
|
|
27
34
|
const trimmed = notes.trim();
|
|
28
35
|
if (!trimmed)
|
|
29
|
-
return '
|
|
36
|
+
return '';
|
|
30
37
|
return trimmed.replace(/^##\s+/gm, '### ');
|
|
31
38
|
}
|
|
32
39
|
export function releaseVersionFromTag(tag) {
|
|
@@ -97,16 +104,11 @@ function parseReleaseNoteSections(raw) {
|
|
|
97
104
|
}
|
|
98
105
|
return sections;
|
|
99
106
|
}
|
|
100
|
-
export function normalizeReleaseNotes(raw
|
|
107
|
+
export function normalizeReleaseNotes(raw) {
|
|
101
108
|
const sections = parseReleaseNoteSections(raw);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const content = normalizedContributors.length > 0 ? normalizedContributors.join('\n') : 'None.';
|
|
106
|
-
return `## ${section}\n${content}`;
|
|
107
|
-
}
|
|
108
|
-
const content = formatSectionContent(sections[section]?.join('\n'));
|
|
109
|
-
return `## ${section}\n${content}`;
|
|
109
|
+
return RELEASE_NOTE_SECTIONS.flatMap((section) => {
|
|
110
|
+
const content = normalizeSectionContent(sections[section]?.join('\n'));
|
|
111
|
+
return content ? [`## ${section}\n${content}`] : [];
|
|
110
112
|
}).join('\n\n');
|
|
111
113
|
}
|
|
112
114
|
function formatPullRequest(pr) {
|
|
@@ -130,11 +132,13 @@ export function buildReleaseNotesPrompt(context) {
|
|
|
130
132
|
`Write user-facing release notes for ${context.tag}.`,
|
|
131
133
|
`Release range: ${context.previousTag}...${context.currentRef}.`,
|
|
132
134
|
'Use only the supplied pull requests below. Do not invent changes or pull in information from elsewhere.',
|
|
133
|
-
`
|
|
134
|
-
'
|
|
135
|
+
`Allowed sections: ${RELEASE_NOTE_SECTIONS.map((section) => `## ${section}`).join(', ')}.`,
|
|
136
|
+
'Include only sections that have user-visible changes. Omit empty sections entirely; do not write "None", "N/A", or similar placeholder text.',
|
|
137
|
+
'Do not include a Contributors section.',
|
|
135
138
|
'In this project, CLI commands and adapters are the same thing. Treat any PR that adds, removes, or changes files under clis/** as an adapter change, even if the PR title says "CLI" instead of "adapter".',
|
|
136
139
|
'Put new site adapters/CLIs, adapter promotions, adapter hardening, adapter output changes, selector/API updates, and site-specific workflow improvements in ## Adapters.',
|
|
137
140
|
'Use ## Improvements for non-adapter product, runtime, CLI, docs, or workflow improvements.',
|
|
141
|
+
'Use ## Reverts only when the release includes actual reverted changes.',
|
|
138
142
|
'',
|
|
139
143
|
`Pull requests included for this release:`,
|
|
140
144
|
prSummaries,
|
|
@@ -17,22 +17,39 @@ describe('release notes helpers', () => {
|
|
|
17
17
|
];
|
|
18
18
|
expect(filterReleasePullRequests(prs).map((pr) => pr.number)).toEqual([1]);
|
|
19
19
|
});
|
|
20
|
-
it('normalizes
|
|
20
|
+
it('normalizes only sections with real release-note content', () => {
|
|
21
21
|
const raw = [
|
|
22
22
|
'## Highlights',
|
|
23
23
|
'- Better release notes.',
|
|
24
24
|
'',
|
|
25
|
+
'## Improvements',
|
|
26
|
+
'No improvements.',
|
|
27
|
+
'',
|
|
28
|
+
'## Adapters',
|
|
29
|
+
'No adapter changes.',
|
|
30
|
+
'',
|
|
25
31
|
'## Fixes',
|
|
26
32
|
'- Fixed release fallback.',
|
|
33
|
+
'',
|
|
34
|
+
'## Contributors',
|
|
35
|
+
'- @alice',
|
|
36
|
+
'',
|
|
37
|
+
'## Reverts',
|
|
38
|
+
'There are no reverts in this release.',
|
|
27
39
|
].join('\n');
|
|
28
|
-
const normalized = normalizeReleaseNotes(raw
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
const normalized = normalizeReleaseNotes(raw);
|
|
41
|
+
expect(RELEASE_NOTE_SECTIONS).toEqual(['Highlights', 'Improvements', 'Fixes', 'Adapters', 'Reverts']);
|
|
42
|
+
expect(normalized).toBe([
|
|
43
|
+
'## Highlights',
|
|
44
|
+
'- Better release notes.',
|
|
45
|
+
'',
|
|
46
|
+
'## Fixes',
|
|
47
|
+
'- Fixed release fallback.',
|
|
48
|
+
].join('\n'));
|
|
49
|
+
expect(normalized).not.toContain('## Improvements');
|
|
50
|
+
expect(normalized).not.toContain('## Contributors');
|
|
51
|
+
expect(normalized).not.toContain('## Reverts');
|
|
52
|
+
expect(normalized).not.toContain('None.');
|
|
36
53
|
});
|
|
37
54
|
it('replaces a matching changelog release entry with generated notes', () => {
|
|
38
55
|
const changelog = [
|
|
@@ -91,6 +108,9 @@ describe('release notes helpers', () => {
|
|
|
91
108
|
expect(prompt).toContain('docs/docs.json');
|
|
92
109
|
expect(prompt).toContain('## Highlights');
|
|
93
110
|
expect(prompt).toContain('## Adapters');
|
|
111
|
+
expect(prompt).not.toContain('## Contributors');
|
|
112
|
+
expect(prompt).toContain('Omit empty sections entirely');
|
|
113
|
+
expect(prompt).toContain('Do not include a Contributors section');
|
|
94
114
|
expect(prompt).toContain('CLI commands and adapters are the same thing');
|
|
95
115
|
expect(prompt).toContain('files under clis/** as an adapter change');
|
|
96
116
|
expect(prompt).toContain('Put new site adapters/CLIs, adapter promotions, adapter hardening');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentrhq/webcmd",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Turn websites, browser sessions, desktop apps, and local tools into deterministic CLI surfaces for humans and AI agents.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0"
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"clis/",
|
|
33
33
|
"skills/**",
|
|
34
34
|
"cli-manifest.json",
|
|
35
|
+
"plugin-catalog.json",
|
|
35
36
|
"scripts/",
|
|
36
37
|
"README.md",
|
|
37
38
|
"LICENSE",
|
|
@@ -193,10 +193,6 @@ async function generateText(prompt: string, model: string, apiKey: string): Prom
|
|
|
193
193
|
return text;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
function contributorHandles(pullRequests: PullRequestDetails[]): string[] {
|
|
197
|
-
return pullRequests.flatMap((pr) => (pr.author?.login ? [pr.author.login] : []));
|
|
198
|
-
}
|
|
199
|
-
|
|
200
196
|
function updateChangelog(tag: string | undefined, notesPath: string | undefined, changelogPath: string | undefined, io: Io): number {
|
|
201
197
|
if (!tag || !notesPath) {
|
|
202
198
|
io.writeStderr('Usage: generate-release-notes --update-changelog <tag> <notes-file> [changelog-file]\n');
|
|
@@ -249,8 +245,10 @@ export async function runGenerateReleaseNotes(
|
|
|
249
245
|
const model = env.GEMINI_RELEASE_NOTES_MODEL || DEFAULT_MODEL;
|
|
250
246
|
const prompt = buildReleaseNotesPrompt(context);
|
|
251
247
|
const raw = await (deps.generateText ?? generateText)(prompt, model, apiKey);
|
|
252
|
-
const normalized = normalizeReleaseNotes(raw
|
|
253
|
-
|
|
248
|
+
const normalized = normalizeReleaseNotes(raw);
|
|
249
|
+
if (normalized) {
|
|
250
|
+
io.writeStdout(`${normalized}\n`);
|
|
251
|
+
}
|
|
254
252
|
return 0;
|
|
255
253
|
} catch (error) {
|
|
256
254
|
const message = error instanceof Error ? error.message : String(error);
|