@agentrhq/webcmd 0.2.2 → 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 +1278 -115
- package/clis/bigbasket/add-to-cart.js +82 -0
- package/clis/bigbasket/bigbasket.test.js +255 -0
- package/clis/bigbasket/cart.js +81 -0
- package/clis/bigbasket/category.js +30 -0
- package/clis/bigbasket/checkout.js +71 -0
- package/clis/bigbasket/location.js +30 -0
- package/clis/bigbasket/product.js +79 -0
- package/clis/bigbasket/search.js +30 -0
- package/clis/bigbasket/utils.js +207 -0
- package/clis/blinkit/add-to-cart.js +123 -0
- package/clis/blinkit/auth.js +99 -0
- package/clis/blinkit/blinkit.test.js +168 -0
- package/clis/blinkit/cart.js +34 -0
- package/clis/blinkit/checkout.js +32 -0
- package/clis/blinkit/location.js +29 -0
- package/clis/blinkit/place-order.js +78 -0
- package/clis/blinkit/product.js +63 -0
- package/clis/blinkit/search.js +89 -0
- package/clis/blinkit/utils.js +223 -0
- package/clis/district/checkout.js +71 -1
- package/clis/practo/appointment.js +21 -0
- package/clis/practo/appointments.js +27 -0
- package/clis/practo/book-confirm.js +35 -0
- package/clis/practo/book-preview.js +24 -0
- package/clis/practo/booking-link.js +24 -0
- package/clis/practo/cancel.js +29 -0
- package/clis/practo/contact.js +21 -0
- package/clis/practo/login.js +48 -0
- package/clis/practo/practo.test.js +154 -0
- package/clis/practo/profile.js +31 -0
- package/clis/practo/search.js +30 -0
- package/clis/practo/slots.js +19 -0
- package/clis/practo/utils.js +374 -0
- package/clis/practo/whoami.js +28 -0
- package/clis/zepto/add-to-cart.js +53 -0
- package/clis/zepto/auth.js +59 -0
- package/clis/zepto/cart.js +23 -0
- package/clis/zepto/checkout.js +60 -0
- package/clis/zepto/location.js +20 -0
- package/clis/zepto/place-order.js +47 -0
- package/clis/zepto/product.js +52 -0
- package/clis/zepto/search.js +30 -0
- package/clis/zepto/utils.js +228 -0
- package/clis/zepto/zepto.test.js +238 -0
- 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 +87 -13
- 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 +4 -2
- package/dist/src/release-notes.js +67 -20
- package/dist/src/release-notes.test.js +67 -9
- package/package.json +2 -1
- package/plugin-catalog.json +10 -0
- package/scripts/generate-release-notes.ts +33 -4
- package/skills/webcmd-adapter-author/SKILL.md +9 -0
|
@@ -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]',
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import { fileURLToPath } from 'node:url';
|
|
3
5
|
import { describe, expect, it, vi } from 'vitest';
|
|
4
6
|
import { loadReleaseContext, runGenerateReleaseNotes } from '../scripts/generate-release-notes.js';
|
|
@@ -66,22 +68,45 @@ describe('runGenerateReleaseNotes', () => {
|
|
|
66
68
|
'## Highlights',
|
|
67
69
|
'- Better summaries.',
|
|
68
70
|
'',
|
|
69
|
-
'## Improvements',
|
|
70
|
-
'None.',
|
|
71
|
-
'',
|
|
72
|
-
'## Fixes',
|
|
73
|
-
'None.',
|
|
74
|
-
'',
|
|
75
|
-
'## Contributors',
|
|
76
|
-
'- @alice',
|
|
77
|
-
'',
|
|
78
|
-
'## Reverts',
|
|
79
|
-
'None.',
|
|
80
|
-
'',
|
|
81
71
|
].join('\n'),
|
|
82
72
|
stderr: '',
|
|
83
73
|
});
|
|
84
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
|
+
});
|
|
85
110
|
it('swallows generator errors and keeps release-please notes intact', async () => {
|
|
86
111
|
const { io, read } = createIo();
|
|
87
112
|
const loadContext = vi.fn(async () => {
|
|
@@ -94,6 +119,52 @@ describe('runGenerateReleaseNotes', () => {
|
|
|
94
119
|
stderr: 'Gemini release notes failed: gh timed out\n',
|
|
95
120
|
});
|
|
96
121
|
});
|
|
122
|
+
it('updates the matching changelog entry from an existing notes file', async () => {
|
|
123
|
+
const { io, read } = createIo();
|
|
124
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'webcmd-release-notes-'));
|
|
125
|
+
const notesPath = join(tempDir, 'release-notes.md');
|
|
126
|
+
const changelogPath = join(tempDir, 'CHANGELOG.md');
|
|
127
|
+
try {
|
|
128
|
+
writeFileSync(notesPath, [
|
|
129
|
+
'## Highlights',
|
|
130
|
+
'- Better generated notes.',
|
|
131
|
+
'',
|
|
132
|
+
'## Adapters',
|
|
133
|
+
'- Added checkout adapter polish.',
|
|
134
|
+
'',
|
|
135
|
+
].join('\n'));
|
|
136
|
+
writeFileSync(changelogPath, [
|
|
137
|
+
'# Changelog',
|
|
138
|
+
'',
|
|
139
|
+
'## [0.2.3](https://github.com/agentrhq/webcmd/compare/webcmd-v0.2.2...webcmd-v0.2.3) (2026-07-09)',
|
|
140
|
+
'',
|
|
141
|
+
'### Features',
|
|
142
|
+
'',
|
|
143
|
+
'* release-please generated note',
|
|
144
|
+
'',
|
|
145
|
+
'## [0.2.2](https://github.com/agentrhq/webcmd/compare/webcmd-v0.2.1...webcmd-v0.2.2) (2026-07-08)',
|
|
146
|
+
'',
|
|
147
|
+
'### Bug Fixes',
|
|
148
|
+
'',
|
|
149
|
+
'* older note',
|
|
150
|
+
'',
|
|
151
|
+
].join('\n'));
|
|
152
|
+
const exitCode = await runGenerateReleaseNotes(['node', 'script', '--update-changelog', 'webcmd-v0.2.3', notesPath, changelogPath], {}, {}, io);
|
|
153
|
+
expect(exitCode).toBe(0);
|
|
154
|
+
expect(read()).toEqual({
|
|
155
|
+
stdout: `Updated ${changelogPath} for webcmd-v0.2.3\n`,
|
|
156
|
+
stderr: '',
|
|
157
|
+
});
|
|
158
|
+
const changelog = readFileSync(changelogPath, 'utf8');
|
|
159
|
+
expect(changelog).toContain('### Highlights\n- Better generated notes.');
|
|
160
|
+
expect(changelog).toContain('### Adapters\n- Added checkout adapter polish.');
|
|
161
|
+
expect(changelog).not.toContain('release-please generated note');
|
|
162
|
+
expect(changelog).toContain('* older note');
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
97
168
|
it('loads bounded PR diffs into release context', async () => {
|
|
98
169
|
const gh = vi.fn((args) => {
|
|
99
170
|
const key = args.join(' ');
|
|
@@ -134,6 +205,9 @@ describe('runGenerateReleaseNotes', () => {
|
|
|
134
205
|
it('keeps npm publish unblocked when enhanced release-note editing fails', () => {
|
|
135
206
|
const workflowPath = fileURLToPath(new URL('../.github/workflows/release.yml', import.meta.url));
|
|
136
207
|
const workflow = readFileSync(workflowPath, 'utf8');
|
|
208
|
+
expect(workflow.indexOf('- name: Publish to npm')).toBeLessThan(workflow.indexOf('- name: Update changelog with enhanced release notes'));
|
|
209
|
+
expect(workflow).toContain('npm --silent run generate-release-notes -- --update-changelog');
|
|
210
|
+
expect(workflow).toContain('git push origin "HEAD:${{ github.ref_name }}"');
|
|
137
211
|
expect(workflow).toMatch(/if gh release edit "\$\{\{ steps\.release\.outputs\.tag_name \}\}" --notes-file "\$RUNNER_TEMP\/release-notes\.md"; then/);
|
|
138
212
|
expect(workflow).toMatch(/Enhanced release notes could not be applied; keeping release-please notes\./);
|
|
139
213
|
});
|
|
@@ -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 {};
|