@sovovs/bycli 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/cli-manifest.json +169 -0
  2. package/clis/twitter/search.js +3 -3
  3. package/clis/weixin/_wechat/args.js +48 -0
  4. package/clis/weixin/_wechat/article-content.js +53 -0
  5. package/clis/weixin/_wechat/article-service.js +124 -0
  6. package/clis/weixin/_wechat/auth-session.js +142 -0
  7. package/clis/weixin/_wechat/fingerprint.js +443 -0
  8. package/clis/weixin/_wechat/fixtures/articles-auth-expired.json +3 -0
  9. package/clis/weixin/_wechat/fixtures/articles-page.json +4 -0
  10. package/clis/weixin/_wechat/fixtures/search-auth-expired.json +4 -0
  11. package/clis/weixin/_wechat/fixtures/search-success.json +7 -0
  12. package/clis/weixin/_wechat/markdown.js +29 -0
  13. package/clis/weixin/_wechat/redact.js +405 -0
  14. package/clis/weixin/_wechat/save-service.js +175 -0
  15. package/clis/weixin/_wechat/search-biz.js +102 -0
  16. package/clis/weixin/_wechat/wechat-api.js +133 -0
  17. package/clis/weixin/accounts.js +38 -0
  18. package/clis/weixin/articles.js +35 -0
  19. package/clis/weixin/download.js +5 -47
  20. package/clis/weixin/save-articles.js +175 -0
  21. package/dist/src/browser/cdp.js +3 -0
  22. package/dist/src/browser/daemon-client.d.ts +2 -0
  23. package/dist/src/browser/daemon-client.js +1 -1
  24. package/dist/src/browser/extension-capabilities.d.ts +13 -0
  25. package/dist/src/browser/extension-capabilities.js +22 -0
  26. package/dist/src/browser/extension-capabilities.test.d.ts +1 -0
  27. package/dist/src/browser/extension-version-metadata.test.d.ts +1 -0
  28. package/dist/src/browser/page.d.ts +1 -0
  29. package/dist/src/browser/page.js +20 -1
  30. package/dist/src/build-manifest.js +4 -2
  31. package/dist/src/capabilityRouting.d.ts +3 -2
  32. package/dist/src/capabilityRouting.js +10 -2
  33. package/dist/src/cli.js +1 -1
  34. package/dist/src/commanderAdapter.js +5 -5
  35. package/dist/src/daemon.js +16 -0
  36. package/dist/src/discovery.d.ts +5 -0
  37. package/dist/src/discovery.js +12 -4
  38. package/dist/src/discovery.test.d.ts +1 -0
  39. package/dist/src/download/article-download.d.ts +6 -0
  40. package/dist/src/download/article-download.js +78 -17
  41. package/dist/src/download/wechat-article.d.ts +8 -0
  42. package/dist/src/download/wechat-article.js +137 -0
  43. package/dist/src/download/wechat-article.test.d.ts +1 -0
  44. package/dist/src/execution.d.ts +5 -0
  45. package/dist/src/execution.js +269 -50
  46. package/dist/src/help.js +8 -8
  47. package/dist/src/manifest-schema.d.ts +9 -0
  48. package/dist/src/manifest-schema.js +162 -0
  49. package/dist/src/manifest-schema.test.d.ts +1 -0
  50. package/dist/src/manifest-types.d.ts +1 -1
  51. package/dist/src/observation/redaction.js +10 -4
  52. package/dist/src/recorder/highlevel/verify.d.ts +3 -0
  53. package/dist/src/recorder/highlevel/verify.js +4 -0
  54. package/dist/src/recorder/highlevel/verify.test.d.ts +1 -0
  55. package/dist/src/recorder/runner/runner-port.js +1 -0
  56. package/dist/src/recorder/runner/verify-runner-main.d.ts +23 -7
  57. package/dist/src/recorder/runner/verify-runner-main.js +92 -19
  58. package/dist/src/registry-api.d.ts +1 -1
  59. package/dist/src/registry-api.types.test.d.ts +1 -0
  60. package/dist/src/registry-transaction.d.ts +42 -0
  61. package/dist/src/registry-transaction.js +194 -0
  62. package/dist/src/registry-transaction.test.d.ts +1 -0
  63. package/dist/src/registry.d.ts +58 -16
  64. package/dist/src/registry.js +131 -15
  65. package/dist/src/serialization.d.ts +1 -1
  66. package/dist/src/serialization.js +3 -3
  67. package/dist/src/types.d.ts +2 -0
  68. package/dist/src/weixin-built-in-docs.test.d.ts +1 -0
  69. package/package.json +7 -3
  70. package/scripts/check-package-install.mjs +71 -0
  71. package/scripts/recorder.sh +0 -186
@@ -10,7 +10,13 @@ const SENSITIVE_HEADER_NAMES = new Set([
10
10
  'x-xsrf-token',
11
11
  ]);
12
12
  const SENSITIVE_FIELD_PATTERN = /(password|passwd|pwd|token|secret|authorization|cookie|set-cookie|api[_-]?key|access[_-]?token|refresh[_-]?token|session[_-]?id|csrf|xsrf)/i;
13
- const SENSITIVE_URL_PARAMS = /([?&])(token|key|secret|password|auth|access_token|api_key|session_id|csrf|xsrf)=[^&]*/gi;
13
+ const SENSITIVE_URL_PARAMS = /([?&])(token|key|secret|fingerprint|password|auth|access_token|api_key|session_id|csrf|xsrf)=[^&]*/gi;
14
+ function hasFingerprintFieldSegment(name) {
15
+ return name
16
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
17
+ .split(/[-_.\s]+/)
18
+ .some((segment) => segment.toLowerCase() === 'fingerprint');
19
+ }
14
20
  export function redactUrl(url) {
15
21
  return url.replace(SENSITIVE_URL_PARAMS, '$1$2=[REDACTED]');
16
22
  }
@@ -31,8 +37,8 @@ export function redactText(text, opts = {}) {
31
37
  const max = opts.maxStringLength ?? 50_000;
32
38
  let out = text
33
39
  .replace(/Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, 'Bearer [REDACTED]')
34
- .replace(/(["'])(password|passwd|pwd|token|secret|api_key|apikey|access_token|session_id)\1\s*:\s*(["'])(.*?)\3/gi, '$1$2$1:$3[REDACTED]$3')
35
- .replace(/(token|secret|password|api_key|apikey|access_token|session_id)[=:]\s*['"]?[^'"\s,;}&]+['"]?/gi, '$1=[REDACTED]')
40
+ .replace(/(["'])(password|passwd|pwd|token|secret|fingerprint|api_key|apikey|access_token|session_id)\1\s*:\s*(["'])(.*?)\3/gi, '$1$2$1:$3[REDACTED]$3')
41
+ .replace(/(token|secret|fingerprint|password|api_key|apikey|access_token|session_id)\s*[=:]\s*['"]?[^'"\s,;}&]+['"]?/gi, '$1=[REDACTED]')
36
42
  .replace(/(cookie[=:]\s*)[^\n;]{3,}/gi, '$1[REDACTED]')
37
43
  .replace(/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, '[REDACTED_JWT]');
38
44
  if (out.length > max)
@@ -41,7 +47,7 @@ export function redactText(text, opts = {}) {
41
47
  }
42
48
  export function redactValue(value, opts = {}, keyHint, depth = 0) {
43
49
  const allow = new Set((opts.allowlist ?? []).map((key) => key.toLowerCase()));
44
- if (keyHint && SENSITIVE_FIELD_PATTERN.test(keyHint) && !allow.has(keyHint.toLowerCase())) {
50
+ if (keyHint && (SENSITIVE_FIELD_PATTERN.test(keyHint) || hasFingerprintFieldSegment(keyHint)) && !allow.has(keyHint.toLowerCase())) {
45
51
  return DEFAULT_REDACTION;
46
52
  }
47
53
  if (typeof value === 'string') {
@@ -29,6 +29,8 @@ export interface VerifyInput {
29
29
  trace?: 'off' | 'retain-on-failure' | 'always';
30
30
  /** N3:显式 adapter 路径 override —— verify 录制器 LLM 生成的临时草稿(不在 clis/),缺省按 name 派生。 */
31
31
  adapterPath?: string;
32
+ /** Optional lowercase SHA-256 expected for the exact adapter bytes the runner will execute. */
33
+ expectedSourceSha256?: string;
32
34
  }
33
35
  /** The runner boundary (08). M6 provides the real child-process implementation. */
34
36
  export interface RunnerPort {
@@ -42,6 +44,7 @@ export interface RunnerPort {
42
44
  trace: string;
43
45
  /** N3: explicit adapter path override (recorder draft verify); default = name→clis path. */
44
46
  adapterPath?: string;
47
+ expectedSourceSha256?: string;
45
48
  }): Promise<{
46
49
  requestId: string;
47
50
  }>;
@@ -49,6 +49,9 @@ export async function verifyAdapter(input, sessionHmacKey, runner) {
49
49
  }
50
50
  adapterPath = abs;
51
51
  }
52
+ if (input.expectedSourceSha256 !== undefined && !/^[0-9a-f]{64}$/.test(input.expectedSourceSha256)) {
53
+ return { ok: false, errorCode: 'validation_failed', reason: 'expectedSourceSha256 must be 64 lowercase hex characters' };
54
+ }
52
55
  const port = runner ?? defaultRunnerPort();
53
56
  const rawSeedArgs = input.executionSeedArgs ?? {};
54
57
  const evidenceSeedArgs = deriveEvidenceSeedArgs(rawSeedArgs, sessionHmacKey);
@@ -61,6 +64,7 @@ export async function verifyAdapter(input, sessionHmacKey, runner) {
61
64
  fixture: input.fixture ?? 'ignore',
62
65
  trace: input.trace ?? 'retain-on-failure',
63
66
  adapterPath, // N3: validated draft path override (undefined → name→clis)
67
+ expectedSourceSha256: input.expectedSourceSha256,
64
68
  });
65
69
  return { ok: true, requestId };
66
70
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -252,6 +252,7 @@ export function createRunnerPort(opts = {}) {
252
252
  requestId,
253
253
  name: input.name,
254
254
  adapterPath: input.adapterPath ?? resolveAdapterPath(input.name),
255
+ expectedSourceSha256: input.expectedSourceSha256,
255
256
  executionSeedArgs: input.rawSeedArgs, // raw → input.json only
256
257
  fixture: input.fixture,
257
258
  trace: input.trace,
@@ -11,10 +11,11 @@
11
11
  * adapters report not-yet (their Page must come from the daemon — M6b). The spawn /
12
12
  * input.json security / timeout / byte-cap mechanism lives in `runner-port.ts`.
13
13
  *
14
- * SECURITY (07:123-124): raw executionSeedArgs arrive via input.json (0600) and are used
15
- * as the adapter's call args; they are never echoed into the emitted result/started events.
14
+ * SECURITY (07:123-124): raw executionSeedArgs arrive via input.json (0600), then are
15
+ * defaulted, coerced, and validated before execution. Neither raw nor prepared arguments
16
+ * are echoed into the emitted result/started events.
16
17
  */
17
- import { type BrowserCliCommand, type CliCommand } from '../../registry.js';
18
+ import { type BrowserCliCommand, type CliCommand, type ConditionalBrowserCliCommand } from '../../registry.js';
18
19
  import type { RunnerEvent, RunnerResultEvent } from '@sovovs/bycli-recorder-core';
19
20
  /** Parsed input.json (written by RunnerPort; raw seed args are execution-only). */
20
21
  export interface RunnerInput {
@@ -22,9 +23,11 @@ export interface RunnerInput {
22
23
  name: string;
23
24
  /** Resolved adapter module file to import. */
24
25
  adapterPath: string;
26
+ /** Expected hash supplied by the caller; mismatch means the captured module is never loaded. */
27
+ expectedSourceSha256?: string;
25
28
  /** Browser profile contextId for browser adapters (M6b). Omitted → daemon default profile. */
26
29
  contextId?: string;
27
- /** Raw seed args — used as the adapter call args, never echoed into events. */
30
+ /** Raw seed args — prepared before resolver/adapter calls and never echoed into events. */
28
31
  executionSeedArgs?: Record<string, unknown>;
29
32
  fixture?: 'ignore' | 'match' | 'update';
30
33
  trace?: 'off' | 'retain-on-failure' | 'always';
@@ -46,13 +49,26 @@ export declare function installRunnerBackstops(maxRuntimeMs: number): void;
46
49
  * Load an adapter by importing its module (which registers via `cli()`), then look it up
47
50
  * in the registry by name. Mirrors execution.ts's lazy-import pattern (118-135).
48
51
  */
49
- export declare function loadAdapterByName(adapterPath: string, name: string): Promise<CliCommand | undefined>;
52
+ export interface AdapterSourceSnapshot {
53
+ canonicalUrl: string;
54
+ source: ArrayBuffer;
55
+ sourceSha256: string;
56
+ }
57
+ /** Read the main module once. The same exact bytes are hashed and transferred to the ESM loader. */
58
+ export declare function captureAdapterSource(adapterPath: string): AdapterSourceSnapshot;
59
+ /** Import exactly the captured main-module bytes while preserving its canonical URL as import base. */
60
+ export declare function loadAdapterSnapshot(snapshot: AdapterSourceSnapshot, name: string): Promise<CliCommand | undefined>;
61
+ export interface VerifyRunnerDependencies {
62
+ capture?: (adapterPath: string) => AdapterSourceSnapshot | Promise<AdapterSourceSnapshot>;
63
+ load?: (snapshot: AdapterSourceSnapshot, name: string) => Promise<CliCommand | undefined>;
64
+ browserRunner?: BrowserAdapterRunner;
65
+ }
50
66
  /**
51
67
  * The browser-adapter execution seam (M6b). A browser adapter's `func` needs an IPage; the
52
68
  * default implementation connects BACK to the running daemon for one. Injectable so unit
53
69
  * tests can exercise executeAdapterForVerify without a real daemon/browser.
54
70
  */
55
- export type BrowserAdapterRunner = (command: BrowserCliCommand, opts: {
71
+ export type BrowserAdapterRunner = (command: BrowserCliCommand | ConditionalBrowserCliCommand, opts: {
56
72
  seedArgs: Record<string, unknown>;
57
73
  contextId?: string;
58
74
  preNavUrl: string | null;
@@ -84,7 +100,7 @@ export declare function executeAdapterForVerify(command: CliCommand | undefined,
84
100
  * tests); `load` is injected so unit tests can supply an in-memory command. Never throws —
85
101
  * any failure becomes a terminal result so the parent always sees one and only one.
86
102
  */
87
- export declare function runVerifyRunner(input: RunnerInput, emit: (event: RunnerEvent) => void, load?: (adapterPath: string, name: string) => Promise<CliCommand | undefined>, browserRunner?: BrowserAdapterRunner): Promise<void>;
103
+ export declare function runVerifyRunner(input: RunnerInput, emit: (event: RunnerEvent) => void, dependencies?: VerifyRunnerDependencies): Promise<void>;
88
104
  /**
89
105
  * Entry point for `bycli internal verify-runner --jsonl --request-id … --name … --input …`.
90
106
  * Writes JSONL events to the dedicated protocol fd (`--protocol-fd`, set by the parent RunnerPort
@@ -11,13 +11,16 @@
11
11
  * adapters report not-yet (their Page must come from the daemon — M6b). The spawn /
12
12
  * input.json security / timeout / byte-cap mechanism lives in `runner-port.ts`.
13
13
  *
14
- * SECURITY (07:123-124): raw executionSeedArgs arrive via input.json (0600) and are used
15
- * as the adapter's call args; they are never echoed into the emitted result/started events.
14
+ * SECURITY (07:123-124): raw executionSeedArgs arrive via input.json (0600), then are
15
+ * defaulted, coerced, and validated before execution. Neither raw nor prepared arguments
16
+ * are echoed into the emitted result/started events.
16
17
  */
17
18
  import * as fs from 'node:fs';
18
- import { randomUUID } from 'node:crypto';
19
+ import { createHash, randomUUID } from 'node:crypto';
20
+ import { register } from 'node:module';
19
21
  import { pathToFileURL } from 'node:url';
20
- import { getRegistry } from '../../registry.js';
22
+ import { getRegistry, } from '../../registry.js';
23
+ import { prepareCommandArgsOrThrowArgumentError } from '../../execution.js';
21
24
  // ── Lease cleanup on signal / orphan watchdog (Codex #4, #7) ────────────────
22
25
  // A browser adapter holds a daemon-side tab lease. If the parent SIGTERMs/cancels this child
23
26
  // (timeout/cancel) or this child is orphaned (parent crashed → no SIGTERM, and on win32 the reaper
@@ -76,14 +79,48 @@ function fieldCountOf(rows) {
76
79
  }
77
80
  return undefined;
78
81
  }
79
- /**
80
- * Load an adapter by importing its module (which registers via `cli()`), then look it up
81
- * in the registry by name. Mirrors execution.ts's lazy-import pattern (118-135).
82
- */
83
- export async function loadAdapterByName(adapterPath, name) {
84
- const url = pathToFileURL(adapterPath).href; // runner-side; a bad path surfaces verbatim
82
+ const SNAPSHOT_LOADER_URL = `data:text/javascript,${encodeURIComponent(`
83
+ let targetSpecifier = '';
84
+ let targetUrl = '';
85
+ let source = new ArrayBuffer(0);
86
+ export function initialize(data) {
87
+ targetSpecifier = data.targetSpecifier;
88
+ targetUrl = data.targetUrl;
89
+ source = data.source;
90
+ }
91
+ export async function resolve(specifier, context, nextResolve) {
92
+ if (specifier === targetSpecifier) {
93
+ return { url: targetUrl, shortCircuit: true };
94
+ }
95
+ return nextResolve(specifier, context);
96
+ }
97
+ export async function load(url, context, nextLoad) {
98
+ if (url === targetUrl) {
99
+ return { format: 'module', shortCircuit: true, source: new Uint8Array(source) };
100
+ }
101
+ return nextLoad(url, context);
102
+ }
103
+ `)}`;
104
+ /** Read the main module once. The same exact bytes are hashed and transferred to the ESM loader. */
105
+ export function captureAdapterSource(adapterPath) {
106
+ const canonicalPath = fs.realpathSync(adapterPath);
107
+ const bytes = fs.readFileSync(canonicalPath);
108
+ const source = Uint8Array.from(bytes).buffer;
109
+ return {
110
+ canonicalUrl: pathToFileURL(canonicalPath).href,
111
+ source,
112
+ sourceSha256: createHash('sha256').update(new Uint8Array(source)).digest('hex'),
113
+ };
114
+ }
115
+ /** Import exactly the captured main-module bytes while preserving its canonical URL as import base. */
116
+ export async function loadAdapterSnapshot(snapshot, name) {
85
117
  try {
86
- await import(url);
118
+ const targetSpecifier = `bycli-verify-snapshot:${randomUUID()}`;
119
+ register(SNAPSHOT_LOADER_URL, import.meta.url, {
120
+ data: { targetSpecifier, targetUrl: snapshot.canonicalUrl, source: snapshot.source },
121
+ transferList: [snapshot.source],
122
+ });
123
+ await import(targetSpecifier);
87
124
  }
88
125
  catch (e) {
89
126
  // The adapter module's top-level code threw during evaluation (a SyntaxError, or a deliberate
@@ -131,9 +168,24 @@ export async function executeAdapterForVerify(command, opts) {
131
168
  return { ok: false, data: { stage: 'load', trace }, error: { code: 'runner_protocol_error', message: `adapter "${opts.name}" has no func` } };
132
169
  }
133
170
  try {
171
+ const preparedArgs = prepareCommandArgsOrThrowArgumentError(command, opts.seedArgs);
134
172
  let rows;
135
173
  if (command.browser === false) {
136
- rows = await command.func(opts.seedArgs, false);
174
+ rows = await command.func(preparedArgs, false);
175
+ }
176
+ else if (command.browser === 'conditional') {
177
+ const browserRequired = Boolean(command.requiresBrowser(preparedArgs));
178
+ if (!browserRequired) {
179
+ rows = await command.func(null, preparedArgs, false);
180
+ }
181
+ else {
182
+ const runner = opts.browserRunner ?? defaultBrowserAdapterRunner;
183
+ rows = await runner(command, {
184
+ seedArgs: preparedArgs,
185
+ contextId: opts.contextId,
186
+ preNavUrl: typeof command.navigateBefore === 'string' ? command.navigateBefore : null,
187
+ });
188
+ }
137
189
  }
138
190
  else {
139
191
  // M6b: browser adapter connects back to the daemon for a Page. navigateBefore is a
@@ -141,7 +193,7 @@ export async function executeAdapterForVerify(command, opts) {
141
193
  // `true`/`undefined` mean "adapter handles its own navigation".
142
194
  const runner = opts.browserRunner ?? defaultBrowserAdapterRunner;
143
195
  rows = await runner(command, {
144
- seedArgs: opts.seedArgs,
196
+ seedArgs: preparedArgs,
145
197
  contextId: opts.contextId,
146
198
  preNavUrl: typeof command.navigateBefore === 'string' ? command.navigateBefore : null,
147
199
  });
@@ -172,29 +224,50 @@ export async function executeAdapterForVerify(command, opts) {
172
224
  * tests); `load` is injected so unit tests can supply an in-memory command. Never throws —
173
225
  * any failure becomes a terminal result so the parent always sees one and only one.
174
226
  */
175
- export async function runVerifyRunner(input, emit, load = loadAdapterByName, browserRunner) {
227
+ export async function runVerifyRunner(input, emit, dependencies = {}) {
176
228
  emit({ type: 'started', requestId: input.requestId, pid: process.pid, stage: 'load' });
229
+ let sourceSha256;
177
230
  try {
178
- const command = await load(input.adapterPath, input.name);
231
+ const capture = dependencies.capture ?? captureAdapterSource;
232
+ const load = dependencies.load ?? loadAdapterSnapshot;
233
+ const snapshot = await capture(input.adapterPath);
234
+ sourceSha256 = snapshot.sourceSha256;
235
+ if (input.expectedSourceSha256 !== undefined && input.expectedSourceSha256 !== sourceSha256) {
236
+ emit({
237
+ type: 'result', requestId: input.requestId, ok: false,
238
+ data: { stage: 'load', sourceSha256 },
239
+ error: { code: 'source_hash_mismatch', message: 'adapter source hash does not match expected source' },
240
+ });
241
+ return;
242
+ }
243
+ const command = await load(snapshot, input.name);
179
244
  const r = await executeAdapterForVerify(command, {
180
245
  name: input.name,
181
246
  fixture: input.fixture,
182
247
  trace: input.trace,
183
248
  seedArgs: input.executionSeedArgs ?? {},
184
249
  contextId: input.contextId,
185
- browserRunner,
250
+ browserRunner: dependencies.browserRunner,
251
+ });
252
+ emit({
253
+ type: 'result', requestId: input.requestId, ok: r.ok,
254
+ data: { ...r.data, sourceSha256 },
255
+ error: r.ok ? null : r.error,
186
256
  });
187
- emit({ type: 'result', requestId: input.requestId, ok: r.ok, data: r.data, error: r.ok ? null : r.error });
188
257
  }
189
258
  catch (e) {
190
- // Load failure → single terminal result. An adapter-evaluation error (tagged by loadAdapterByName)
259
+ // Load failure → single terminal result. An adapter-evaluation error (tagged by loadAdapterSnapshot)
191
260
  // is adapter-controlled and may echo adapter-file contents, so its message is redacted; a
192
261
  // runner-side failure (bad path / resolve) is runner-generated and surfaces verbatim (Codex M7c).
193
262
  const adapterEval = e?.adapterEvaluation === true;
194
263
  const message = adapterEval ? REDACTED_ADAPTER_LOAD_MESSAGE : (e instanceof Error ? e.message : String(e));
195
264
  emit({
196
265
  type: 'result', requestId: input.requestId, ok: false,
197
- data: { stage: 'load', trace: { policy: input.trace ?? 'retain-on-failure', retained: false, path: null } },
266
+ data: {
267
+ stage: 'load',
268
+ ...(sourceSha256 === undefined ? {} : { sourceSha256 }),
269
+ trace: { policy: input.trace ?? 'retain-on-failure', retained: false, path: null },
270
+ },
198
271
  error: { code: 'adapter_runtime_error', message, hint: 'adapter failed to load' },
199
272
  });
200
273
  }
@@ -7,7 +7,7 @@
7
7
  * plugins are dynamically imported during discoverPlugins().
8
8
  */
9
9
  export { cli, Strategy, getRegistry, fullName, registerCommand } from './registry.js';
10
- export type { CliCommand, Arg, CliOptions, CommandArgs, SiteSessionMode } from './registry.js';
10
+ export type { CliCommand, Arg, CliOptions, CommandArgs, SiteSessionMode, BrowserDeclaration, BrowserRequirementResolver, ConditionalBrowserCommandFunc, NormalizedBrowserRequirement, } from './registry.js';
11
11
  export type { IPage } from './types.js';
12
12
  export { onStartup, onBeforeExecute, onAfterExecute } from './hooks.js';
13
13
  export type { HookFn, HookContext, HookName } from './hooks.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import type { CliCommand } from './registry.js';
2
+ interface RegistrySlot {
3
+ present: boolean;
4
+ value: CliCommand | undefined;
5
+ }
6
+ export interface RegistryTransactionWrite {
7
+ key: string;
8
+ before: RegistrySlot;
9
+ after: RegistrySlot;
10
+ group: number;
11
+ beforeRevision: number;
12
+ afterRevision: number;
13
+ }
14
+ export interface RegistryTransaction {
15
+ readonly writes: RegistryTransactionWrite[];
16
+ readonly id: number;
17
+ active: boolean;
18
+ currentGroup: number | undefined;
19
+ finalized: boolean;
20
+ readonly finalizedGroups: Set<number>;
21
+ }
22
+ export declare function createRegistryTransaction(): RegistryTransaction;
23
+ export declare function closeRegistryTransaction(transaction: RegistryTransaction): void;
24
+ export declare function runRegistryTransaction<T>(transaction: RegistryTransaction, operation: () => Promise<T>): Promise<T>;
25
+ export declare function withRegistryMutationGroup<T>(operation: () => T): T;
26
+ export declare function recordRegistryMutation(key: string, before: RegistrySlot, after: RegistrySlot): void;
27
+ /**
28
+ * Keys with revision ownership, including absent-key tombstones.
29
+ *
30
+ * Tombstones intentionally remain until a later mutation or rollback supersedes
31
+ * them. Pruning an absent revision while an overlapping transaction may still
32
+ * reference it would allow an older rollback to resurrect the key.
33
+ */
34
+ export declare function registryMutationKeys(): string[];
35
+ export declare function pruneRegistryMutationKey(key: string, registry: ReadonlyMap<string, CliCommand>): void;
36
+ export declare function finalizeRegistryTransaction(transaction: RegistryTransaction, registry: ReadonlyMap<string, CliCommand>, groups?: ReadonlySet<number>): void;
37
+ export declare function capturedRegistryValue(transaction: RegistryTransaction, key: string): RegistrySlot;
38
+ export declare function capturedRegistryValues(transaction: RegistryTransaction): Map<string, CliCommand>;
39
+ export declare function transactionGroupsForKey(transaction: RegistryTransaction, key: string): Set<number>;
40
+ export declare function rollbackRegistryTransaction(transaction: RegistryTransaction, registry: Map<string, CliCommand>, groups?: ReadonlySet<number>): void;
41
+ export declare function resetRegistryTransactionStateForTests(): void;
42
+ export {};
@@ -0,0 +1,194 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ const TRANSACTION_STATE_KEY = Symbol.for('@sovovs/bycli/registry-transaction-state');
3
+ const globalState = globalThis;
4
+ const state = globalState[TRANSACTION_STATE_KEY] ??= {
5
+ storage: new AsyncLocalStorage(),
6
+ transactionCounter: 0,
7
+ groupCounter: 0,
8
+ revisionCounter: 0,
9
+ revisions: new Map(),
10
+ transactions: new Map(),
11
+ owners: new Map(),
12
+ };
13
+ state.transactions ??= new Map();
14
+ state.owners ??= new Map();
15
+ export function createRegistryTransaction() {
16
+ const transaction = {
17
+ writes: [],
18
+ id: ++state.transactionCounter,
19
+ active: false,
20
+ currentGroup: undefined,
21
+ finalized: false,
22
+ finalizedGroups: new Set(),
23
+ };
24
+ state.transactions.set(transaction.id, transaction);
25
+ return transaction;
26
+ }
27
+ export function closeRegistryTransaction(transaction) {
28
+ transaction.active = false;
29
+ transaction.currentGroup = undefined;
30
+ }
31
+ export async function runRegistryTransaction(transaction, operation) {
32
+ if (transaction.finalized) {
33
+ throw new Error('Adapter registration transaction is finalized');
34
+ }
35
+ transaction.active = true;
36
+ return state.storage.run(transaction, async () => {
37
+ try {
38
+ return await operation();
39
+ }
40
+ finally {
41
+ closeRegistryTransaction(transaction);
42
+ }
43
+ });
44
+ }
45
+ export function withRegistryMutationGroup(operation) {
46
+ const transaction = state.storage.getStore();
47
+ if (!transaction)
48
+ return operation();
49
+ if (!transaction.active) {
50
+ throw new Error('Adapter registration transaction is closed; delayed registration is not allowed');
51
+ }
52
+ const previousGroup = transaction.currentGroup;
53
+ transaction.currentGroup = ++state.groupCounter;
54
+ try {
55
+ return operation();
56
+ }
57
+ finally {
58
+ transaction.currentGroup = previousGroup;
59
+ }
60
+ }
61
+ export function recordRegistryMutation(key, before, after) {
62
+ const transaction = state.storage.getStore();
63
+ if (transaction && !transaction.active) {
64
+ throw new Error('Adapter registration transaction is closed; delayed registration is not allowed');
65
+ }
66
+ if (transaction?.finalized) {
67
+ throw new Error('Adapter registration transaction is finalized');
68
+ }
69
+ const beforeRevision = state.revisions.get(key) ?? 0;
70
+ const afterRevision = ++state.revisionCounter;
71
+ state.revisions.set(key, afterRevision);
72
+ if (!transaction)
73
+ return;
74
+ const group = transaction.currentGroup ?? ++state.groupCounter;
75
+ transaction.writes.push({
76
+ key,
77
+ before,
78
+ after,
79
+ group,
80
+ beforeRevision,
81
+ afterRevision,
82
+ });
83
+ const owners = state.owners.get(key) ?? new Set();
84
+ owners.add(transaction.id);
85
+ state.owners.set(key, owners);
86
+ }
87
+ /**
88
+ * Keys with revision ownership, including absent-key tombstones.
89
+ *
90
+ * Tombstones intentionally remain until a later mutation or rollback supersedes
91
+ * them. Pruning an absent revision while an overlapping transaction may still
92
+ * reference it would allow an older rollback to resurrect the key.
93
+ */
94
+ export function registryMutationKeys() {
95
+ return [...state.revisions.keys()];
96
+ }
97
+ export function pruneRegistryMutationKey(key, registry) {
98
+ if (registry.has(key))
99
+ return;
100
+ if ((state.owners.get(key)?.size ?? 0) > 0)
101
+ return;
102
+ state.revisions.delete(key);
103
+ }
104
+ export function finalizeRegistryTransaction(transaction, registry, groups) {
105
+ if (transaction.active) {
106
+ throw new Error('Cannot finalize an active adapter registration transaction');
107
+ }
108
+ const selectedGroups = groups ?? new Set(transaction.writes.map(write => write.group));
109
+ const newGroups = new Set([...selectedGroups].filter(group => !transaction.finalizedGroups.has(group)));
110
+ const affectedKeys = new Set(transaction.writes
111
+ .filter(write => newGroups.has(write.group))
112
+ .map(write => write.key));
113
+ for (const group of newGroups)
114
+ transaction.finalizedGroups.add(group);
115
+ for (const key of affectedKeys) {
116
+ const stillOwned = transaction.writes.some(write => write.key === key && !transaction.finalizedGroups.has(write.group));
117
+ if (!stillOwned) {
118
+ const owners = state.owners.get(key);
119
+ owners?.delete(transaction.id);
120
+ if (owners?.size === 0)
121
+ state.owners.delete(key);
122
+ }
123
+ pruneRegistryMutationKey(key, registry);
124
+ }
125
+ const hasUnfinalizedWrites = transaction.writes.some(write => !transaction.finalizedGroups.has(write.group));
126
+ if (!hasUnfinalizedWrites) {
127
+ transaction.finalized = true;
128
+ state.transactions.delete(transaction.id);
129
+ }
130
+ }
131
+ export function capturedRegistryValue(transaction, key) {
132
+ for (let index = transaction.writes.length - 1; index >= 0; index -= 1) {
133
+ const write = transaction.writes[index];
134
+ if (write.key === key)
135
+ return write.after;
136
+ }
137
+ return { present: false, value: undefined };
138
+ }
139
+ export function capturedRegistryValues(transaction) {
140
+ const keys = new Set(transaction.writes.map(write => write.key));
141
+ const values = new Map();
142
+ for (const key of keys) {
143
+ const captured = capturedRegistryValue(transaction, key);
144
+ if (captured.present && captured.value)
145
+ values.set(key, captured.value);
146
+ }
147
+ return values;
148
+ }
149
+ export function transactionGroupsForKey(transaction, key) {
150
+ return new Set(transaction.writes.filter(write => write.key === key).map(write => write.group));
151
+ }
152
+ export function rollbackRegistryTransaction(transaction, registry, groups) {
153
+ const groupIds = [...new Set(transaction.writes.map(write => write.group))]
154
+ .filter(group => !transaction.finalizedGroups.has(group) && (!groups || groups.has(group)))
155
+ .sort((a, b) => b - a);
156
+ for (const group of groupIds) {
157
+ const writesByKey = new Map();
158
+ for (const write of transaction.writes) {
159
+ if (write.group !== group)
160
+ continue;
161
+ const writes = writesByKey.get(write.key) ?? [];
162
+ writes.push(write);
163
+ writesByKey.set(write.key, writes);
164
+ }
165
+ for (const writes of writesByKey.values()) {
166
+ const finalWrite = writes.at(-1);
167
+ if ((state.revisions.get(finalWrite.key) ?? 0) !== finalWrite.afterRevision)
168
+ continue;
169
+ for (let index = writes.length - 1; index >= 0; index -= 1) {
170
+ const write = writes[index];
171
+ if (write.before.present)
172
+ Map.prototype.set.call(registry, write.key, write.before.value);
173
+ else
174
+ Map.prototype.delete.call(registry, write.key);
175
+ if (write.beforeRevision === 0)
176
+ state.revisions.delete(write.key);
177
+ else
178
+ state.revisions.set(write.key, write.beforeRevision);
179
+ }
180
+ }
181
+ }
182
+ finalizeRegistryTransaction(transaction, registry, new Set(groupIds));
183
+ }
184
+ export function resetRegistryTransactionStateForTests() {
185
+ for (const transaction of state.transactions.values()) {
186
+ closeRegistryTransaction(transaction);
187
+ transaction.finalized = true;
188
+ for (const write of transaction.writes)
189
+ transaction.finalizedGroups.add(write.group);
190
+ }
191
+ state.transactions.clear();
192
+ state.owners.clear();
193
+ state.revisions.clear();
194
+ }
@@ -0,0 +1 @@
1
+ export {};