@jackwener/opencli 1.7.9 → 1.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/cli-manifest.json +60 -1
  4. package/clis/instagram/collection-create.js +57 -0
  5. package/clis/instagram/collection-delete.js +91 -0
  6. package/clis/instagram/saved.js +21 -7
  7. package/dist/src/adapter-shadow.d.ts +11 -0
  8. package/dist/src/adapter-shadow.js +72 -0
  9. package/dist/src/adapter-shadow.test.d.ts +1 -0
  10. package/dist/src/adapter-shadow.test.js +49 -0
  11. package/dist/src/browser/base-page.d.ts +6 -2
  12. package/dist/src/browser/base-page.js +88 -6
  13. package/dist/src/browser/base-page.test.js +61 -1
  14. package/dist/src/browser/bridge.d.ts +0 -2
  15. package/dist/src/browser/bridge.js +4 -32
  16. package/dist/src/browser/cdp.js +48 -0
  17. package/dist/src/browser/cdp.test.js +23 -0
  18. package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
  19. package/dist/src/browser/daemon-lifecycle.js +67 -0
  20. package/dist/src/browser/daemon-version.d.ts +4 -0
  21. package/dist/src/browser/daemon-version.js +12 -0
  22. package/dist/src/browser/dom-helpers.d.ts +1 -1
  23. package/dist/src/browser/dom-helpers.js +15 -3
  24. package/dist/src/browser/page.js +1 -1
  25. package/dist/src/browser/target-resolver.d.ts +8 -0
  26. package/dist/src/browser/target-resolver.js +75 -0
  27. package/dist/src/browser/verify-fixture.d.ts +1 -0
  28. package/dist/src/browser/verify-fixture.js +18 -0
  29. package/dist/src/browser/verify-fixture.test.js +16 -1
  30. package/dist/src/build-manifest.d.ts +68 -33
  31. package/dist/src/build-manifest.js +175 -29
  32. package/dist/src/build-manifest.test.js +75 -1
  33. package/dist/src/cli.js +25 -10
  34. package/dist/src/cli.test.js +153 -1
  35. package/dist/src/commands/daemon.d.ts +2 -0
  36. package/dist/src/commands/daemon.js +36 -1
  37. package/dist/src/commands/daemon.test.js +103 -2
  38. package/dist/src/doctor.d.ts +3 -0
  39. package/dist/src/doctor.js +27 -20
  40. package/dist/src/doctor.test.js +71 -1
  41. package/dist/src/manifest-types.d.ts +39 -0
  42. package/dist/src/manifest-types.js +9 -0
  43. package/package.json +2 -2
@@ -5,41 +5,76 @@
5
5
  * Scans all JS CLI definitions in clis/ and pre-compiles them into a single
6
6
  * manifest.json for instant cold-start registration.
7
7
  *
8
- * Usage: npx tsx src/build-manifest.ts
8
+ * Usage: npx tsx src/build-manifest.ts [--allow-removals[=N]]
9
+ *
9
10
  * Output: cli-manifest.json next to clis/
11
+ *
12
+ * Safety invariants:
13
+ * - Adapters whose source file does not call `cli(...)` are silently
14
+ * skipped (they are helpers / type modules, not commands).
15
+ * - Adapters that look like commands but fail to import are reported as
16
+ * failures, the manifest is NOT written, and the process exits 1. This
17
+ * prevents a stale dist or a broken adapter from silently dropping
18
+ * other adapters' entries (root cause of the "manifest lost 478 lines"
19
+ * incident).
20
+ * - Net-deletions vs the existing committed manifest abort the build by
21
+ * default; pass `--allow-removals=N` (or just `--allow-removals` for any
22
+ * amount) to confirm an intentional removal.
23
+ */
24
+ import type { ManifestEntry } from './manifest-types.js';
25
+ export type { ManifestEntry } from './manifest-types.js';
26
+ /**
27
+ * Thrown by `loadManifestEntries` when an adapter file looks like a CLI
28
+ * module (matches CLI_MODULE_PATTERN) but cannot be imported. Callers
29
+ * decide whether to abort or aggregate failures across the whole scan.
10
30
  */
11
- export interface ManifestEntry {
12
- site: string;
13
- name: string;
14
- aliases?: string[];
15
- description: string;
16
- domain?: string;
17
- strategy: string;
18
- browser: boolean;
19
- args: Array<{
20
- name: string;
21
- type?: string;
22
- default?: unknown;
23
- required?: boolean;
24
- valueRequired?: boolean;
25
- positional?: boolean;
26
- help?: string;
27
- choices?: string[];
28
- }>;
29
- columns?: string[];
30
- pipeline?: Record<string, unknown>[];
31
- timeout?: number;
32
- deprecated?: boolean | string;
33
- replacedBy?: string;
34
- type: 'js';
35
- /** Relative path from clis/ dir, e.g. 'bilibili/search.js' */
36
- modulePath?: string;
37
- /** Relative path to the source file from clis/ dir (e.g. 'site/cmd.js') */
38
- sourceFile?: string;
39
- /** Pre-navigation control — see CliCommand.navigateBefore */
40
- navigateBefore?: boolean | string;
31
+ export declare class ManifestImportError extends Error {
32
+ readonly filePath: string;
33
+ readonly cause: unknown;
34
+ constructor(filePath: string, cause: unknown);
35
+ }
36
+ export interface BuildManifestResult {
37
+ entries: ManifestEntry[];
38
+ /** Adapters that look like CLI modules but failed to import. */
39
+ failures: ManifestImportError[];
40
+ }
41
+ export interface BuildManifestArgs {
42
+ /** Maximum number of entries that may be removed vs the existing manifest.
43
+ * `Number.POSITIVE_INFINITY` disables the safety net entirely. */
44
+ allowRemovals: number;
41
45
  }
42
46
  export declare function normalizeManifestPath(relativePath: string): string;
43
- export declare function loadManifestEntries(filePath: string, site: string, importer?: (moduleHref: string) => Promise<unknown>): Promise<ManifestEntry[]>;
44
- export declare function buildManifest(): Promise<ManifestEntry[]>;
47
+ /**
48
+ * Load all manifest entries from a single adapter file.
49
+ *
50
+ * Returns `[]` for files that do not register a CLI command (helpers, types).
51
+ * Throws `ManifestImportError` when a file looks like a CLI module but its
52
+ * import or post-import processing fails — callers must decide whether to
53
+ * surface or aggregate the failure.
54
+ *
55
+ * The third argument `clisDir` is used to compute the POSIX-style
56
+ * `sourceFile` relative path; it defaults to the package's `clis/` dir so
57
+ * existing test callers stay backward-compatible.
58
+ */
59
+ export declare function loadManifestEntries(filePath: string, site: string, importer?: (moduleHref: string) => Promise<unknown>, clisDir?: string): Promise<ManifestEntry[]>;
60
+ /**
61
+ * Scan a `clis/` directory and aggregate per-adapter results. Import
62
+ * failures are collected in `failures` instead of crashing the whole scan,
63
+ * but the caller (e.g. `main()`) is expected to fail loud if any failure
64
+ * is present.
65
+ */
66
+ export declare function scanClisDir(clisDir: string, importer?: (moduleHref: string) => Promise<unknown>): Promise<BuildManifestResult>;
67
+ export declare function buildManifest(): Promise<BuildManifestResult>;
45
68
  export declare function serializeManifest(manifest: ManifestEntry[]): string;
69
+ /**
70
+ * Diff helper: returns site/name keys that exist in `prev` but not in
71
+ * `next`. Used as a safety net to detect accidental mass-deletions caused
72
+ * by silently failing adapter imports.
73
+ */
74
+ export declare function diffRemovedEntries(prev: readonly ManifestEntry[], next: readonly ManifestEntry[]): string[];
75
+ /**
76
+ * Parse `--allow-removals` and `--allow-removals=N` from argv.
77
+ * Bare `--allow-removals` disables the safety net (`Infinity`); the
78
+ * numeric form sets an explicit upper bound.
79
+ */
80
+ export declare function parseBuildManifestArgs(argv: readonly string[]): BuildManifestArgs;
@@ -5,8 +5,21 @@
5
5
  * Scans all JS CLI definitions in clis/ and pre-compiles them into a single
6
6
  * manifest.json for instant cold-start registration.
7
7
  *
8
- * Usage: npx tsx src/build-manifest.ts
8
+ * Usage: npx tsx src/build-manifest.ts [--allow-removals[=N]]
9
+ *
9
10
  * Output: cli-manifest.json next to clis/
11
+ *
12
+ * Safety invariants:
13
+ * - Adapters whose source file does not call `cli(...)` are silently
14
+ * skipped (they are helpers / type modules, not commands).
15
+ * - Adapters that look like commands but fail to import are reported as
16
+ * failures, the manifest is NOT written, and the process exits 1. This
17
+ * prevents a stale dist or a broken adapter from silently dropping
18
+ * other adapters' entries (root cause of the "manifest lost 478 lines"
19
+ * incident).
20
+ * - Net-deletions vs the existing committed manifest abort the build by
21
+ * default; pass `--allow-removals=N` (or just `--allow-removals` for any
22
+ * amount) to confirm an intentional removal.
10
23
  */
11
24
  import * as fs from 'node:fs';
12
25
  import * as path from 'node:path';
@@ -14,12 +27,27 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
14
27
  import { getErrorMessage } from './errors.js';
15
28
  import { fullName, getRegistry } from './registry.js';
16
29
  import { findPackageRoot, getCliManifestPath } from './package-paths.js';
30
+ import { isRecord } from './utils.js';
17
31
  const PACKAGE_ROOT = findPackageRoot(fileURLToPath(import.meta.url));
18
32
  const CLIS_DIR = path.join(PACKAGE_ROOT, 'clis');
19
33
  // Write manifest next to clis/ so both dev and installed runtime can find it.
20
34
  const OUTPUT = getCliManifestPath(CLIS_DIR);
21
- import { isRecord } from './utils.js';
22
35
  const CLI_MODULE_PATTERN = /\bcli\s*\(/;
36
+ /**
37
+ * Thrown by `loadManifestEntries` when an adapter file looks like a CLI
38
+ * module (matches CLI_MODULE_PATTERN) but cannot be imported. Callers
39
+ * decide whether to abort or aggregate failures across the whole scan.
40
+ */
41
+ export class ManifestImportError extends Error {
42
+ filePath;
43
+ cause;
44
+ constructor(filePath, cause) {
45
+ super(`failed to scan ${filePath}: ${getErrorMessage(cause)}`);
46
+ this.filePath = filePath;
47
+ this.cause = cause;
48
+ this.name = 'ManifestImportError';
49
+ }
50
+ }
23
51
  function toManifestArgs(args) {
24
52
  return args.map(arg => ({
25
53
  name: arg.name,
@@ -39,8 +67,8 @@ function toModulePath(filePath, site) {
39
67
  export function normalizeManifestPath(relativePath) {
40
68
  return relativePath.replace(/\\/g, '/');
41
69
  }
42
- function toManifestRelativePath(filePath) {
43
- return normalizeManifestPath(path.relative(CLIS_DIR, filePath));
70
+ function toManifestRelativePath(filePath, clisDir) {
71
+ return normalizeManifestPath(path.relative(clisDir, filePath));
44
72
  }
45
73
  function isCliCommandValue(value, site) {
46
74
  return isRecord(value)
@@ -69,12 +97,30 @@ function toManifestEntry(cmd, modulePath, sourceFile) {
69
97
  navigateBefore: cmd.navigateBefore,
70
98
  };
71
99
  }
72
- export async function loadManifestEntries(filePath, site, importer = moduleHref => import(moduleHref)) {
100
+ /**
101
+ * Load all manifest entries from a single adapter file.
102
+ *
103
+ * Returns `[]` for files that do not register a CLI command (helpers, types).
104
+ * Throws `ManifestImportError` when a file looks like a CLI module but its
105
+ * import or post-import processing fails — callers must decide whether to
106
+ * surface or aggregate the failure.
107
+ *
108
+ * The third argument `clisDir` is used to compute the POSIX-style
109
+ * `sourceFile` relative path; it defaults to the package's `clis/` dir so
110
+ * existing test callers stay backward-compatible.
111
+ */
112
+ export async function loadManifestEntries(filePath, site, importer = moduleHref => import(moduleHref), clisDir = CLIS_DIR) {
113
+ let src;
114
+ try {
115
+ src = fs.readFileSync(filePath, 'utf-8');
116
+ }
117
+ catch (err) {
118
+ throw new ManifestImportError(filePath, err);
119
+ }
120
+ // Helper / test modules that do not call cli() are not commands.
121
+ if (!CLI_MODULE_PATTERN.test(src))
122
+ return [];
73
123
  try {
74
- const src = fs.readFileSync(filePath, 'utf-8');
75
- // Helper/test modules should not appear as CLI commands in the manifest.
76
- if (!CLI_MODULE_PATTERN.test(src))
77
- return [];
78
124
  const modulePath = toModulePath(filePath, site);
79
125
  const registry = getRegistry();
80
126
  const before = new Map(registry.entries());
@@ -93,7 +139,7 @@ export async function loadManifestEntries(filePath, site, importer = moduleHref
93
139
  .map(([, cmd]) => cmd);
94
140
  // Manifest paths are cross-platform artifacts; keep them POSIX-style even
95
141
  // when build-manifest runs on Windows.
96
- const sourceRelative = toManifestRelativePath(filePath);
142
+ const sourceRelative = toManifestRelativePath(filePath, clisDir);
97
143
  const seen = new Set();
98
144
  return runtimeCommands
99
145
  .filter((cmd) => {
@@ -107,42 +153,142 @@ export async function loadManifestEntries(filePath, site, importer = moduleHref
107
153
  .map(cmd => toManifestEntry(cmd, modulePath, sourceRelative));
108
154
  }
109
155
  catch (err) {
110
- // If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry.
111
- process.stderr.write(`Warning: failed to scan ${filePath}: ${getErrorMessage(err)}\n`);
112
- return [];
156
+ throw new ManifestImportError(filePath, err);
113
157
  }
114
158
  }
115
- export async function buildManifest() {
159
+ /**
160
+ * Scan a `clis/` directory and aggregate per-adapter results. Import
161
+ * failures are collected in `failures` instead of crashing the whole scan,
162
+ * but the caller (e.g. `main()`) is expected to fail loud if any failure
163
+ * is present.
164
+ */
165
+ export async function scanClisDir(clisDir, importer = moduleHref => import(moduleHref)) {
116
166
  const manifest = new Map();
117
- // Scan JS adapters directly from clis/.
118
- // Adapters are now JS-first — no compilation step needed.
119
- if (fs.existsSync(CLIS_DIR)) {
120
- for (const site of fs.readdirSync(CLIS_DIR)) {
121
- const siteDir = path.join(CLIS_DIR, site);
122
- if (!fs.statSync(siteDir).isDirectory())
123
- continue;
124
- for (const file of fs.readdirSync(siteDir)) {
125
- if (file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js') {
126
- const filePath = path.join(siteDir, file);
127
- const entries = await loadManifestEntries(filePath, site);
167
+ const failures = [];
168
+ if (!fs.existsSync(clisDir)) {
169
+ return { entries: [], failures };
170
+ }
171
+ for (const site of fs.readdirSync(clisDir)) {
172
+ const siteDir = path.join(clisDir, site);
173
+ if (!fs.statSync(siteDir).isDirectory())
174
+ continue;
175
+ for (const file of fs.readdirSync(siteDir)) {
176
+ if (file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js') {
177
+ const filePath = path.join(siteDir, file);
178
+ try {
179
+ const entries = await loadManifestEntries(filePath, site, importer, clisDir);
128
180
  for (const entry of entries) {
129
181
  const key = `${entry.site}/${entry.name}`;
130
182
  manifest.set(key, entry);
131
183
  }
132
184
  }
185
+ catch (err) {
186
+ if (err instanceof ManifestImportError) {
187
+ failures.push(err);
188
+ continue;
189
+ }
190
+ throw err;
191
+ }
133
192
  }
134
193
  }
135
194
  }
136
- return [...manifest.values()].sort((a, b) => a.site.localeCompare(b.site) || a.name.localeCompare(b.name));
195
+ const entries = [...manifest.values()].sort((a, b) => a.site.localeCompare(b.site) || a.name.localeCompare(b.name));
196
+ return { entries, failures };
197
+ }
198
+ export async function buildManifest() {
199
+ return scanClisDir(CLIS_DIR);
137
200
  }
138
201
  export function serializeManifest(manifest) {
139
202
  return `${JSON.stringify(manifest, null, 2)}\n`;
140
203
  }
204
+ /**
205
+ * Diff helper: returns site/name keys that exist in `prev` but not in
206
+ * `next`. Used as a safety net to detect accidental mass-deletions caused
207
+ * by silently failing adapter imports.
208
+ */
209
+ export function diffRemovedEntries(prev, next) {
210
+ const nextKeys = new Set(next.map(e => `${e.site}/${e.name}`));
211
+ return prev
212
+ .map(e => `${e.site}/${e.name}`)
213
+ .filter(key => !nextKeys.has(key))
214
+ .sort();
215
+ }
216
+ /**
217
+ * Parse `--allow-removals` and `--allow-removals=N` from argv.
218
+ * Bare `--allow-removals` disables the safety net (`Infinity`); the
219
+ * numeric form sets an explicit upper bound.
220
+ */
221
+ export function parseBuildManifestArgs(argv) {
222
+ let allowRemovals = 0;
223
+ for (const arg of argv) {
224
+ if (arg === '--allow-removals') {
225
+ allowRemovals = Number.POSITIVE_INFINITY;
226
+ continue;
227
+ }
228
+ const m = arg.match(/^--allow-removals=(\d+)$/);
229
+ if (m) {
230
+ allowRemovals = Number.parseInt(m[1], 10);
231
+ continue;
232
+ }
233
+ }
234
+ return { allowRemovals };
235
+ }
236
+ function readExistingManifest(filePath) {
237
+ try {
238
+ if (!fs.existsSync(filePath))
239
+ return null;
240
+ const raw = fs.readFileSync(filePath, 'utf-8');
241
+ const parsed = JSON.parse(raw);
242
+ return Array.isArray(parsed) ? parsed : null;
243
+ }
244
+ catch {
245
+ return null;
246
+ }
247
+ }
141
248
  async function main() {
142
- const manifest = await buildManifest();
249
+ // Runtime guard: refuse to run from dist/. tsc transitively emits this
250
+ // file (the test file imports from it) so dist/src/build-manifest.js
251
+ // physically exists. If a developer or agent runs that compiled copy,
252
+ // any stale dist will silently break adapter imports — the exact failure
253
+ // mode this script is meant to prevent. Direct them at the tsx entry
254
+ // before they can shoot themselves in the foot.
255
+ if (fileURLToPath(import.meta.url).includes(`${path.sep}dist${path.sep}`)) {
256
+ process.stderr.write(`❌ Refusing to run build-manifest from dist/.\n`
257
+ + ` Stale compiled output silently drops adapters that import renamed/removed exports.\n`
258
+ + ` Run \`npm run build-manifest\` (or \`tsx src/build-manifest.ts\`) from the source tree instead.\n`);
259
+ process.exit(1);
260
+ }
261
+ const args = parseBuildManifestArgs(process.argv.slice(2));
262
+ const { entries, failures } = await buildManifest();
263
+ if (failures.length > 0) {
264
+ process.stderr.write(`❌ ${failures.length} adapter(s) failed to load:\n`);
265
+ for (const failure of failures) {
266
+ const rel = path.relative(PACKAGE_ROOT, failure.filePath) || failure.filePath;
267
+ process.stderr.write(` - ${rel}: ${getErrorMessage(failure.cause)}\n`);
268
+ }
269
+ process.stderr.write(`\nManifest NOT written. Likely cause: stale dist/ or a broken adapter import.\n`
270
+ + `Always run via tsx (\`npm run build-manifest\`), not against compiled dist/.\n`);
271
+ process.exit(1);
272
+ }
273
+ const existing = readExistingManifest(OUTPUT);
274
+ if (existing) {
275
+ const removed = diffRemovedEntries(existing, entries);
276
+ if (removed.length > args.allowRemovals) {
277
+ process.stderr.write(`❌ ${removed.length} manifest entries would be removed; refusing to overwrite.\n`);
278
+ const preview = removed.slice(0, 20);
279
+ for (const key of preview)
280
+ process.stderr.write(` - ${key}\n`);
281
+ if (removed.length > preview.length) {
282
+ process.stderr.write(` ... ${removed.length - preview.length} more\n`);
283
+ }
284
+ process.stderr.write(`\nIf this removal is intentional, rerun with `
285
+ + `\`--allow-removals=${removed.length}\` (or \`--allow-removals\` to disable the check).\n`);
286
+ process.exit(1);
287
+ }
288
+ }
143
289
  fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
144
- fs.writeFileSync(OUTPUT, serializeManifest(manifest));
145
- console.log(`✅ Manifest compiled: ${manifest.length} entries → ${OUTPUT}`);
290
+ fs.writeFileSync(OUTPUT, serializeManifest(entries));
291
+ console.log(`✅ Manifest compiled: ${entries.length} entries → ${OUTPUT}`);
146
292
  // Restore executable permissions on bin entries.
147
293
  // tsc does not preserve the +x bit, so after a clean rebuild the CLI
148
294
  // entry-point loses its executable permission, causing "Permission denied".
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import { cli, getRegistry, Strategy } from './registry.js';
6
- import { loadManifestEntries, normalizeManifestPath, serializeManifest } from './build-manifest.js';
6
+ import { ManifestImportError, diffRemovedEntries, loadManifestEntries, normalizeManifestPath, parseBuildManifestArgs, scanClisDir, serializeManifest, } from './build-manifest.js';
7
7
  describe('manifest helper rules', () => {
8
8
  const tempDirs = [];
9
9
  afterEach(() => {
@@ -157,4 +157,78 @@ describe('manifest helper rules', () => {
157
157
  expect(serialized.endsWith('\n')).toBe(true);
158
158
  expect(serialized).toContain('\n]');
159
159
  });
160
+ it('throws ManifestImportError when an adapter looks like a CLI module but fails to import', async () => {
161
+ // Reproduces the "stale dist drops adapters silently" incident: the file
162
+ // matches the cli() pattern (so it's not just a helper), but the importer
163
+ // throws — we want the failure surfaced, not swallowed.
164
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-fail-'));
165
+ tempDirs.push(dir);
166
+ const file = path.join(dir, 'broken.ts');
167
+ fs.writeFileSync(file, `export const command = cli({ site: 'demo', name: 'broken' });`);
168
+ const importer = async () => { throw new Error('boom: stale dist'); };
169
+ await expect(loadManifestEntries(file, 'demo', importer))
170
+ .rejects.toBeInstanceOf(ManifestImportError);
171
+ try {
172
+ await loadManifestEntries(file, 'demo', importer);
173
+ }
174
+ catch (err) {
175
+ expect(err).toBeInstanceOf(ManifestImportError);
176
+ const e = err;
177
+ expect(e.filePath).toBe(file);
178
+ expect(e.message).toContain('boom: stale dist');
179
+ }
180
+ });
181
+ it('still silently skips files that do not call cli() even if the importer would have thrown', async () => {
182
+ // The cli() pattern check happens before importing — we don't even ask
183
+ // the importer about helper modules, so a thrown import does not turn
184
+ // them into failures.
185
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-helper-'));
186
+ tempDirs.push(dir);
187
+ const file = path.join(dir, 'helper.ts');
188
+ fs.writeFileSync(file, `export const helper = () => 42;`);
189
+ const importer = async () => { throw new Error('should never be called'); };
190
+ await expect(loadManifestEntries(file, 'demo', importer)).resolves.toEqual([]);
191
+ });
192
+ it('scanClisDir aggregates per-adapter import failures instead of silently dropping them', async () => {
193
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-clis-'));
194
+ tempDirs.push(root);
195
+ const siteDir = path.join(root, 'demo');
196
+ fs.mkdirSync(siteDir);
197
+ fs.writeFileSync(path.join(siteDir, 'good.js'), `export const cmd = cli({ site: 'demo', name: 'good' });`);
198
+ fs.writeFileSync(path.join(siteDir, 'broken.js'), `export const cmd = cli({ site: 'demo', name: 'broken' });`);
199
+ const importer = async (href) => {
200
+ if (href.endsWith('broken.js'))
201
+ throw new Error('stale dist drops broken');
202
+ return { cmd: cli({ site: 'demo', name: 'good', description: 'ok' }) };
203
+ };
204
+ const result = await scanClisDir(root, importer);
205
+ expect(result.failures).toHaveLength(1);
206
+ expect(result.failures[0]).toBeInstanceOf(ManifestImportError);
207
+ expect(result.failures[0].filePath).toMatch(/broken\.js$/);
208
+ expect(result.failures[0].message).toContain('stale dist drops broken');
209
+ expect(result.entries.map(e => e.name)).toEqual(['good']);
210
+ getRegistry().delete('demo/good');
211
+ });
212
+ it('diffRemovedEntries returns site/name keys present only in prev', () => {
213
+ const prev = [
214
+ { site: 'a', name: '1', description: '', strategy: 'public', browser: false, args: [], type: 'js' },
215
+ { site: 'a', name: '2', description: '', strategy: 'public', browser: false, args: [], type: 'js' },
216
+ { site: 'b', name: '3', description: '', strategy: 'public', browser: false, args: [], type: 'js' },
217
+ ];
218
+ const next = [
219
+ { site: 'a', name: '1', description: '', strategy: 'public', browser: false, args: [], type: 'js' },
220
+ ];
221
+ expect(diffRemovedEntries(prev, next)).toEqual(['a/2', 'b/3']);
222
+ expect(diffRemovedEntries(prev, prev)).toEqual([]);
223
+ expect(diffRemovedEntries([], next)).toEqual([]);
224
+ });
225
+ it('parseBuildManifestArgs reads --allow-removals[=N]', () => {
226
+ expect(parseBuildManifestArgs([]).allowRemovals).toBe(0);
227
+ expect(parseBuildManifestArgs(['--allow-removals=5']).allowRemovals).toBe(5);
228
+ expect(parseBuildManifestArgs(['--allow-removals=0']).allowRemovals).toBe(0);
229
+ // Bare flag is the explicit "I know what I'm doing" escape hatch.
230
+ expect(parseBuildManifestArgs(['--allow-removals']).allowRemovals).toBe(Number.POSITIVE_INFINITY);
231
+ // Unknown flags are ignored.
232
+ expect(parseBuildManifestArgs(['--something-else']).allowRemovals).toBe(0);
233
+ });
160
234
  });
package/dist/src/cli.js CHANGED
@@ -29,10 +29,11 @@ import { parseFilter, shapeMatchesFilter } from './browser/shape-filter.js';
29
29
  import { buildHtmlTreeJs } from './browser/html-tree.js';
30
30
  import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js';
31
31
  import { analyzeSite } from './browser/analyze.js';
32
- import { daemonStatus, daemonStop } from './commands/daemon.js';
32
+ import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js';
33
33
  import { log } from './logger.js';
34
34
  import { bindTab, BrowserCommandError, fetchDaemonStatus, sendCommand } from './browser/daemon-client.js';
35
35
  import { aliasForContextId, loadProfileConfig, renameProfile, resolveProfileContextId, setDefaultProfile } from './browser/profile.js';
36
+ import { formatDaemonVersion, isDaemonStale } from './browser/daemon-version.js';
36
37
  const CLI_FILE = fileURLToPath(import.meta.url);
37
38
  const DEFAULT_BROWSER_WORKSPACE = 'browser:default';
38
39
  const DEFAULT_BOUND_WORKSPACE = 'bound:default';
@@ -1758,6 +1759,7 @@ cli({
1758
1759
  fs.mkdirSync(dir, { recursive: true });
1759
1760
  fs.writeFileSync(filePath, template, 'utf-8');
1760
1761
  console.log(`Created: ${filePath}`);
1762
+ console.log('First time on this site? Run: opencli browser analyze <url>');
1761
1763
  console.log(`Edit the file to implement your adapter, then run: opencli browser verify ${name}`);
1762
1764
  }
1763
1765
  catch (err) {
@@ -1772,6 +1774,8 @@ cli({
1772
1774
  .option('--update-fixture', 'Overwrite an existing fixture with one derived from current output')
1773
1775
  .option('--no-fixture', 'Ignore any fixture file for this run (no value-level validation)')
1774
1776
  .option('--strict-memory', 'Fail (not just warn) when ~/.opencli/sites/<site>/endpoints.json or notes.md is missing')
1777
+ .option('--seed-args <value>', 'Seed args when no fixture exists; use JSON array/object for multiple args or flags')
1778
+ .option('--trace <mode>', 'Trace capture for the adapter subprocess: off, on, retain-on-failure', 'off')
1775
1779
  .description('Execute an adapter and validate output; uses fixture at ~/.opencli/sites/<site>/verify/<cmd>.json when present')
1776
1780
  .action(async (name, opts = {}) => {
1777
1781
  try {
@@ -1788,7 +1792,7 @@ cli({
1788
1792
  return;
1789
1793
  }
1790
1794
  const { execFileSync } = await import('node:child_process');
1791
- const { loadFixture, writeFixture, deriveFixture, validateRows, fixturePath, expandFixtureArgs } = await import('./browser/verify-fixture.js');
1795
+ const { loadFixture, writeFixture, deriveFixture, validateRows, fixturePath, expandFixtureArgs, parseSeedArgs } = await import('./browser/verify-fixture.js');
1792
1796
  const filePath = path.join(os.homedir(), '.opencli', 'clis', site, `${command}.js`);
1793
1797
  if (!fs.existsSync(filePath)) {
1794
1798
  console.error(`Adapter not found: ${filePath}`);
@@ -1805,14 +1809,16 @@ cli({
1805
1809
  // - array form ["123", "--limit", "3"] → verbatim (for positional subjects)
1806
1810
  const adapterSrc = fs.readFileSync(filePath, 'utf-8');
1807
1811
  const hasLimitArg = /['"]limit['"]/.test(adapterSrc);
1808
- const fixtureArgs = fixture?.args;
1809
- const cliArgs = expandFixtureArgs(fixtureArgs);
1810
- if (cliArgs.length === 0 && hasLimitArg)
1812
+ const seedArgs = parseSeedArgs(opts.seedArgs);
1813
+ const explicitArgs = fixture?.args ?? seedArgs;
1814
+ const cliArgs = expandFixtureArgs(explicitArgs);
1815
+ if (explicitArgs === undefined && cliArgs.length === 0 && hasLimitArg)
1811
1816
  cliArgs.push('--limit', '3');
1812
- const argDisplay = cliArgs.join(' ');
1817
+ const traceArgs = opts.trace && opts.trace !== 'off' ? ['--trace', opts.trace] : [];
1818
+ const argDisplay = [...cliArgs, ...traceArgs].join(' ');
1813
1819
  const invocation = resolveBrowserVerifyInvocation();
1814
1820
  // Always request JSON so we can validate structurally.
1815
- const execArgs = [...invocation.args, site, command, ...cliArgs, '--format', 'json'];
1821
+ const execArgs = [...invocation.args, site, command, ...cliArgs, ...traceArgs, '--format', 'json'];
1816
1822
  let rawJson;
1817
1823
  try {
1818
1824
  rawJson = execFileSync(invocation.binary, execArgs, {
@@ -1855,10 +1861,10 @@ cli({
1855
1861
  console.log(` Use --update-fixture to overwrite.`);
1856
1862
  }
1857
1863
  else {
1858
- const seedArgs = fixtureArgs !== undefined
1859
- ? fixtureArgs
1864
+ const fixtureArgs = explicitArgs !== undefined
1865
+ ? explicitArgs
1860
1866
  : (hasLimitArg ? { limit: 3 } : undefined);
1861
- const derived = deriveFixture(rows, seedArgs);
1867
+ const derived = deriveFixture(rows, fixtureArgs);
1862
1868
  const p = writeFixture(site, command, derived);
1863
1869
  console.log(`\n ${fixture ? '↻ Updated' : '✎ Wrote'} fixture: ${p}`);
1864
1870
  console.log(` Review and hand-tune the derived expectations (add patterns / notEmpty, tighten rowCount).`);
@@ -2232,6 +2238,11 @@ cli({
2232
2238
  console.log(styleText('yellow', 'Daemon is not running. Run opencli doctor after opening Chrome.'));
2233
2239
  return;
2234
2240
  }
2241
+ if (isDaemonStale(status, PKG_VERSION) || !Array.isArray(status.profiles)) {
2242
+ console.log(styleText('yellow', `Daemon ${formatDaemonVersion(status)} is stale for CLI v${PKG_VERSION}.`));
2243
+ console.log(styleText('dim', 'Run: opencli daemon restart'));
2244
+ return;
2245
+ }
2235
2246
  if (profiles.length === 0) {
2236
2247
  console.log(styleText('yellow', 'No Browser Bridge profiles connected.'));
2237
2248
  console.log(styleText('dim', 'Open a Chrome profile with the OpenCLI extension installed, then run opencli profile list again.'));
@@ -2301,6 +2312,10 @@ cli({
2301
2312
  .command('stop')
2302
2313
  .description('Stop the daemon')
2303
2314
  .action(async () => { await daemonStop(); });
2315
+ daemonCmd
2316
+ .command('restart')
2317
+ .description('Restart the daemon')
2318
+ .action(async () => { await daemonRestart(); });
2304
2319
  // ── External CLIs ─────────────────────────────────────────────────────────
2305
2320
  const externalClis = loadExternalClis();
2306
2321
  const externalCmd = program