@sovovs/bycli 2.1.39 → 2.1.41

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 (47) hide show
  1. package/cli-manifest.json +56 -3
  2. package/clis/ima/knowledge-list.js +48 -0
  3. package/clis/ima/knowledge.js +23 -0
  4. package/clis/ima/native-api.js +67 -10
  5. package/clis/ima/native-client.js +23 -7
  6. package/clis/ima/utils.js +1 -0
  7. package/clis/weixin/_wechat/article-artifact.js +55 -0
  8. package/clis/weixin/_wechat/article-identity.js +27 -0
  9. package/clis/weixin/_wechat/publish-analysis.js +8 -4
  10. package/clis/weixin/_wechat/publish-download.js +3 -1
  11. package/clis/weixin/download-publish-data.js +20 -0
  12. package/clis/weixin/download.js +15 -2
  13. package/dist/src/adapter-coordination.d.ts +26 -0
  14. package/dist/src/adapter-coordination.js +183 -0
  15. package/dist/src/adapter-coordination.test.d.ts +1 -0
  16. package/dist/src/adapter-execution-context.d.ts +6 -0
  17. package/dist/src/adapter-execution-context.js +8 -0
  18. package/dist/src/adapter-scheduler.d.ts +86 -0
  19. package/dist/src/adapter-scheduler.js +349 -0
  20. package/dist/src/adapter-scheduler.test.d.ts +1 -0
  21. package/dist/src/browser/daemon-client.d.ts +11 -0
  22. package/dist/src/browser/daemon-client.js +53 -1
  23. package/dist/src/browser/extension-capabilities.d.ts +1 -0
  24. package/dist/src/browser/extension-capabilities.js +18 -5
  25. package/dist/src/browser/page.d.ts +2 -1
  26. package/dist/src/browser/page.js +3 -0
  27. package/dist/src/build-manifest.js +1 -0
  28. package/dist/src/cli-argv-preprocess.d.ts +3 -0
  29. package/dist/src/cli-argv-preprocess.js +4 -0
  30. package/dist/src/commanderAdapter.js +11 -0
  31. package/dist/src/daemon.js +118 -0
  32. package/dist/src/discovery.js +1 -0
  33. package/dist/src/download/article-download.d.ts +2 -0
  34. package/dist/src/download/article-download.js +30 -4
  35. package/dist/src/errors.d.ts +3 -0
  36. package/dist/src/errors.js +5 -0
  37. package/dist/src/execution.d.ts +2 -0
  38. package/dist/src/execution.js +168 -105
  39. package/dist/src/help.d.ts +1 -0
  40. package/dist/src/help.js +40 -0
  41. package/dist/src/manifest-types.d.ts +4 -0
  42. package/dist/src/registry.d.ts +6 -0
  43. package/dist/src/registry.js +23 -0
  44. package/dist/src/serialization.d.ts +1 -0
  45. package/dist/src/serialization.js +1 -0
  46. package/dist/src/types.d.ts +2 -0
  47. package/package.json +3 -2
@@ -7,6 +7,8 @@ import { sleep } from '../utils.js';
7
7
  import { resolveDaemonPort } from './daemon-config.js';
8
8
  import { classifyBrowserError } from './errors.js';
9
9
  import { resolveProfileContextId } from './profile.js';
10
+ import { AdapterCoordinationError } from '../errors.js';
11
+ import { getAdapterExecutionContext } from '../adapter-execution-context.js';
10
12
  const BYCLI_HEADERS = { 'X-byCLI': '1' };
11
13
  let _idCounter = 0;
12
14
  function generateId() {
@@ -44,6 +46,40 @@ async function consumeDaemonResponse(pathname, init, consume, port) {
44
46
  async function requestDaemon(pathname, init) {
45
47
  return consumeDaemonResponse(pathname, init, async (response) => response);
46
48
  }
49
+ async function postAdapterLease(pathname, body, timeout) {
50
+ const response = await requestDaemon(pathname, {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify(body),
54
+ timeout,
55
+ });
56
+ const envelope = await response.json();
57
+ if (!response.ok || envelope.ok !== true || envelope.data === undefined) {
58
+ throw new AdapterCoordinationError(envelope.errorCode ?? 'ADAPTER_QUEUE_RESET', envelope.error ?? 'Adapter scheduler request failed', true);
59
+ }
60
+ return envelope.data;
61
+ }
62
+ export function acquireAdapterLease(request) {
63
+ return postAdapterLease('/v1/adapter-leases/acquire', request, request.queueTimeoutMs + 5_000);
64
+ }
65
+ export function heartbeatAdapterLease(lease) {
66
+ return postAdapterLease('/v1/adapter-leases/heartbeat', lease, 5_000);
67
+ }
68
+ export async function releaseAdapterLease(release) {
69
+ const data = await postAdapterLease('/v1/adapter-leases/release', release, 5_000);
70
+ return data.released;
71
+ }
72
+ export async function cancelAdapterLease(requestId) {
73
+ const data = await postAdapterLease('/v1/adapter-leases/cancel', { requestId }, 5_000);
74
+ return data.cancelled;
75
+ }
76
+ export function acquireAdapterResources(lease, keys, timeoutMs) {
77
+ return postAdapterLease('/v1/adapter-resources/acquire', { lease, keys, timeoutMs }, timeoutMs + 5_000);
78
+ }
79
+ export async function releaseAdapterResources(lease, grantId) {
80
+ const data = await postAdapterLease('/v1/adapter-resources/release', { lease, grantId }, 5_000);
81
+ return data.released;
82
+ }
47
83
  function errorCode(error) {
48
84
  if (!error || typeof error !== 'object')
49
85
  return undefined;
@@ -102,6 +138,16 @@ export async function getDaemonHealth(opts) {
102
138
  return { state: 'no-extension', status };
103
139
  return { state: 'ready', status };
104
140
  }
141
+ /** Resolve the concrete daemon profile used to key Adapter scheduler pools. */
142
+ export async function resolveAdapterLeaseContextId(requestedContextId) {
143
+ const health = await getDaemonHealth({ contextId: requestedContextId });
144
+ if (health.state === 'ready' && health.status.contextId) {
145
+ return health.status.contextId;
146
+ }
147
+ throw new AdapterCoordinationError('ADAPTER_PROFILE_UNAVAILABLE', 'The browser daemon could not identify the authenticated profile for this Adapter session.', true, requestedContextId
148
+ ? `Check that profile "${requestedContextId}" is connected.`
149
+ : 'Connect exactly one browser profile or pass --profile explicitly.');
150
+ }
105
151
  export async function requestDaemonShutdown(opts) {
106
152
  try {
107
153
  const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 });
@@ -130,7 +176,13 @@ async function sendCommandRaw(action, params) {
130
176
  : undefined;
131
177
  const contextId = params.contextId ?? resolveProfileContextId();
132
178
  const windowMode = params.windowMode ?? envWindowMode;
133
- const command = { id, action, ...params, ...(contextId && { contextId }), ...(windowMode && { windowMode }) };
179
+ const adapterLease = getAdapterExecutionContext()?.lease;
180
+ const command = {
181
+ id, action, ...params,
182
+ ...(contextId && { contextId }),
183
+ ...(windowMode && { windowMode }),
184
+ ...(adapterLease && { adapterLease }),
185
+ };
134
186
  try {
135
187
  const res = await requestDaemon('/command', {
136
188
  method: 'POST',
@@ -1,4 +1,5 @@
1
1
  export declare const FOCUS_WINDOW_CAPABILITY = "focus-window-v1";
2
+ export declare const IMA_READER_CAPABILITY = "ima-reader-v1";
2
3
  export declare const EXTENSION_CAPABILITY_MISSING_ERROR_CODE = "extension_capability_missing";
3
4
  export declare const EXTENSION_CAPABILITY_MISSING_HTTP_STATUS = 412;
4
5
  export declare function normalizeExtensionCapabilities(value: unknown): string[];
@@ -1,14 +1,23 @@
1
1
  export const FOCUS_WINDOW_CAPABILITY = 'focus-window-v1';
2
+ export const IMA_READER_CAPABILITY = 'ima-reader-v1';
2
3
  export const EXTENSION_CAPABILITY_MISSING_ERROR_CODE = 'extension_capability_missing';
3
4
  export const EXTENSION_CAPABILITY_MISSING_HTTP_STATUS = 412;
5
+ const IMA_READER_ACTIONS = new Set([
6
+ 'ima-auth-start',
7
+ 'ima-auth-read',
8
+ 'ima-reader-request',
9
+ 'ima-auth-release',
10
+ ]);
4
11
  export function normalizeExtensionCapabilities(value) {
5
12
  if (!Array.isArray(value))
6
13
  return [];
7
14
  return [...new Set(value.filter((entry) => typeof entry === 'string' && entry.length > 0))];
8
15
  }
9
16
  export function requiredExtensionCapability(command) {
10
- return command.action === 'tabs' && command.op === 'focus'
11
- ? FOCUS_WINDOW_CAPABILITY
17
+ if (command.action === 'tabs' && command.op === 'focus')
18
+ return FOCUS_WINDOW_CAPABILITY;
19
+ return typeof command.action === 'string' && IMA_READER_ACTIONS.has(command.action)
20
+ ? IMA_READER_CAPABILITY
12
21
  : undefined;
13
22
  }
14
23
  export function missingRequiredExtensionCapability(command, capabilities) {
@@ -16,7 +25,11 @@ export function missingRequiredExtensionCapability(command, capabilities) {
16
25
  return required && !capabilities.includes(required) ? required : undefined;
17
26
  }
18
27
  export function extensionCapabilityHint(capability) {
19
- return capability === FOCUS_WINDOW_CAPABILITY
20
- ? 'Update and reload the byCLI Browser Bridge extension, then retry the login flow.'
21
- : 'Update and reload the byCLI Browser Bridge extension, then retry.';
28
+ if (capability === FOCUS_WINDOW_CAPABILITY) {
29
+ return 'Update and reload the byCLI Browser Bridge extension, then retry the login flow.';
30
+ }
31
+ if (capability === IMA_READER_CAPABILITY) {
32
+ return 'Update and reload the byCLI Browser Bridge extension with private ima reader support, then retry.';
33
+ }
34
+ return 'Update and reload the byCLI Browser Bridge extension, then retry.';
22
35
  }
@@ -15,12 +15,13 @@ import { BasePage } from './base-page.js';
15
15
  */
16
16
  export declare class Page extends BasePage {
17
17
  private readonly session;
18
- readonly contextId?: string | undefined;
18
+ contextId?: string | undefined;
19
19
  private readonly windowMode?;
20
20
  private readonly surface;
21
21
  private readonly siteSession?;
22
22
  private readonly _idleTimeout;
23
23
  constructor(session: string, idleTimeout?: number, contextId?: string | undefined, windowMode?: "foreground" | "background" | undefined, surface?: 'browser' | 'adapter', siteSession?: "ephemeral" | "persistent" | undefined);
24
+ setContextId(contextId: string): void;
24
25
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
25
26
  private _page;
26
27
  private _networkCaptureUnsupported;
@@ -51,6 +51,9 @@ export class Page extends BasePage {
51
51
  this.siteSession = siteSession;
52
52
  this._idleTimeout = idleTimeout;
53
53
  }
54
+ setContextId(contextId) {
55
+ this.contextId = contextId;
56
+ }
54
57
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
55
58
  _page;
56
59
  _networkCaptureUnsupported = false;
@@ -105,6 +105,7 @@ function toManifestEntry(cmd, modulePath, sourceFile) {
105
105
  sourceFile,
106
106
  navigateBefore: cmd.navigateBefore,
107
107
  siteSession: cmd.siteSession,
108
+ adapterConcurrency: cmd.adapterConcurrency,
108
109
  };
109
110
  }
110
111
  /**
@@ -50,6 +50,9 @@ export interface DashPositionalManifestEntry {
50
50
  default?: unknown;
51
51
  }>;
52
52
  browser?: boolean;
53
+ adapterConcurrency?: {
54
+ isolatedTabs?: boolean;
55
+ };
53
56
  }
54
57
  /**
55
58
  * `bycli boss detail -abc123def` fails because commander parses
@@ -144,6 +144,10 @@ function knownCommandOptions(cmd) {
144
144
  options.set('--site-session', 'required');
145
145
  options.set('--keep-tab', 'required');
146
146
  }
147
+ if (cmd.adapterConcurrency?.isolatedTabs === true) {
148
+ options.set('--adapter-session', 'required');
149
+ options.set('--adapter-queue-timeout', 'required');
150
+ }
147
151
  for (const arg of cmd.args ?? []) {
148
152
  if (arg.positional)
149
153
  continue;
@@ -54,6 +54,11 @@ export function registerCommandToProgram(siteCmd, cmd) {
54
54
  .option('--site-session <mode>', 'Adapter site session lifecycle: ephemeral or persistent')
55
55
  .option('--keep-tab <bool>', 'Keep the browser tab lease after the command finishes');
56
56
  }
57
+ if (cmd.adapterConcurrency?.isolatedTabs === true) {
58
+ subCmd
59
+ .option('--adapter-session <name>', 'Named persistent Adapter tab session')
60
+ .option('--adapter-queue-timeout <seconds>', 'Seconds to wait for an Adapter command lease');
61
+ }
57
62
  const originalHelpInformation = subCmd.helpInformation.bind(subCmd);
58
63
  subCmd.helpInformation = ((contextOptions) => {
59
64
  const format = getRequestedHelpFormat();
@@ -111,6 +116,12 @@ export function registerCommandToProgram(siteCmd, cmd) {
111
116
  ...(hasBrowserCapability(cmd) && typeof optionsRecord.window === 'string' ? { windowMode: optionsRecord.window } : {}),
112
117
  ...(hasBrowserCapability(cmd) && typeof optionsRecord.siteSession === 'string' ? { siteSession: optionsRecord.siteSession } : {}),
113
118
  ...(hasBrowserCapability(cmd) && typeof optionsRecord.keepTab === 'string' ? { keepTab: optionsRecord.keepTab } : {}),
119
+ ...(cmd.adapterConcurrency?.isolatedTabs === true && typeof optionsRecord.adapterSession === 'string'
120
+ ? { adapterSession: optionsRecord.adapterSession }
121
+ : {}),
122
+ ...(cmd.adapterConcurrency?.isolatedTabs === true && typeof optionsRecord.adapterQueueTimeout === 'string'
123
+ ? { adapterQueueTimeout: optionsRecord.adapterQueueTimeout }
124
+ : {}),
114
125
  });
115
126
  if (result === null || result === undefined) {
116
127
  return;
@@ -39,6 +39,7 @@ import { recordExtensionVersion } from './update-check.js';
39
39
  import { EXTENSION_CAPABILITY_MISSING_ERROR_CODE, EXTENSION_CAPABILITY_MISSING_HTTP_STATUS, extensionCapabilityHint, missingRequiredExtensionCapability, normalizeExtensionCapabilities, } from './browser/extension-capabilities.js';
40
40
  import { buildCommandDispatchFailure, buildExtensionDisconnectFailure, getResponseCorsHeaders, } from './daemon-utils.js';
41
41
  import { resolveDaemonHost } from './daemon-config.js';
42
+ import { AdapterScheduler, AdapterSchedulerError, } from './adapter-scheduler.js';
42
43
  const PORT = parseInt(process.env.BYCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
43
44
  const HOST = resolveDaemonHost();
44
45
  const BROWSER_RECOVERY_COMMAND = process.env.BYCLI_BROWSER_RECOVERY_COMMAND?.trim();
@@ -60,6 +61,9 @@ const logger = createRecorderLogger(LOG_LEVELS.includes(envLogLevel) ? envLogLev
60
61
  // runner counters surface on GET /metrics and runner logs share the daemon's level.
61
62
  setDefaultRunnerObservability(metrics, logger);
62
63
  const extensionProfiles = new Map();
64
+ const adapterScheduler = new AdapterScheduler();
65
+ const adapterSchedulerSweep = setInterval(() => adapterScheduler.sweepExpired(), 5_000);
66
+ adapterSchedulerSweep.unref?.();
63
67
  const pending = new Map();
64
68
  let commandResultUnknownCount = 0;
65
69
  const LOG_BUFFER_SIZE = 200;
@@ -386,6 +390,92 @@ async function handleRequest(req, res) {
386
390
  }
387
391
  return;
388
392
  }
393
+ if (req.method === 'POST' && pathname.startsWith('/v1/adapter-leases/')) {
394
+ try {
395
+ const body = JSON.parse(await readBody(req));
396
+ if (pathname === '/v1/adapter-leases/acquire') {
397
+ const request = body;
398
+ let clientGone = false;
399
+ const onClose = () => {
400
+ if (res.writableEnded)
401
+ return;
402
+ clientGone = true;
403
+ adapterScheduler.cancel(request.requestId);
404
+ };
405
+ res.once('close', onClose);
406
+ const lease = await adapterScheduler.acquire(request);
407
+ res.off('close', onClose);
408
+ if (clientGone || res.destroyed) {
409
+ adapterScheduler.release({ ...lease, reason: 'cancelled' });
410
+ return;
411
+ }
412
+ jsonResponse(res, 200, { ok: true, data: lease });
413
+ return;
414
+ }
415
+ if (pathname === '/v1/adapter-leases/heartbeat') {
416
+ const lease = adapterScheduler.heartbeat(body);
417
+ jsonResponse(res, 200, { ok: true, data: lease });
418
+ return;
419
+ }
420
+ if (pathname === '/v1/adapter-leases/release') {
421
+ const released = adapterScheduler.release(body);
422
+ jsonResponse(res, 200, { ok: true, data: { released } });
423
+ return;
424
+ }
425
+ if (pathname === '/v1/adapter-leases/cancel') {
426
+ const requestId = typeof body.requestId === 'string' ? body.requestId : '';
427
+ const cancelled = requestId ? adapterScheduler.cancel(requestId) : false;
428
+ jsonResponse(res, 200, { ok: true, data: { cancelled } });
429
+ return;
430
+ }
431
+ jsonResponse(res, 404, { ok: false, errorCode: 'ADAPTER_QUEUE_RESET', error: 'Unknown Adapter lease operation' });
432
+ }
433
+ catch (error) {
434
+ if (res.destroyed)
435
+ return;
436
+ const schedulerError = error instanceof AdapterSchedulerError ? error : null;
437
+ const status = schedulerError?.code === 'ADAPTER_QUEUE_TIMEOUT' ? 408
438
+ : schedulerError?.code === 'ADAPTER_LEASE_LOST' ? 409
439
+ : schedulerError?.code === 'ADAPTER_POOL_AUTH_GATE' || schedulerError?.code === 'ADAPTER_POOL_RATE_LIMITED' ? 409
440
+ : 400;
441
+ jsonResponse(res, status, {
442
+ ok: false,
443
+ errorCode: schedulerError?.code ?? 'ADAPTER_QUEUE_RESET',
444
+ error: error instanceof Error ? error.message : 'Adapter scheduler request failed',
445
+ });
446
+ }
447
+ return;
448
+ }
449
+ if (req.method === 'POST' && pathname.startsWith('/v1/adapter-resources/')) {
450
+ try {
451
+ const body = JSON.parse(await readBody(req));
452
+ const lease = body.lease;
453
+ if (pathname === '/v1/adapter-resources/acquire') {
454
+ const keys = Array.isArray(body.keys) ? body.keys.filter((key) => typeof key === 'string') : [];
455
+ const timeoutMs = typeof body.timeoutMs === 'number' ? body.timeoutMs : 0;
456
+ const grant = await adapterScheduler.acquireResources(lease, keys, timeoutMs);
457
+ jsonResponse(res, 200, { ok: true, data: grant });
458
+ return;
459
+ }
460
+ if (pathname === '/v1/adapter-resources/release') {
461
+ const grantId = typeof body.grantId === 'string' ? body.grantId : '';
462
+ const released = adapterScheduler.releaseResources(lease, grantId);
463
+ jsonResponse(res, 200, { ok: true, data: { released } });
464
+ return;
465
+ }
466
+ jsonResponse(res, 404, { ok: false, errorCode: 'ADAPTER_QUEUE_RESET', error: 'Unknown Adapter resource operation' });
467
+ }
468
+ catch (error) {
469
+ const schedulerError = error instanceof AdapterSchedulerError ? error : null;
470
+ const status = schedulerError?.code === 'ADAPTER_RESOURCE_TIMEOUT' ? 408 : 409;
471
+ jsonResponse(res, status, {
472
+ ok: false,
473
+ errorCode: schedulerError?.code ?? 'ADAPTER_LEASE_LOST',
474
+ error: error instanceof Error ? error.message : 'Adapter resource request failed',
475
+ });
476
+ }
477
+ return;
478
+ }
389
479
  if (req.method === 'GET' && pathname === '/status') {
390
480
  const uptime = process.uptime();
391
481
  const mem = process.memoryUsage();
@@ -415,6 +505,8 @@ async function handleRequest(req, res) {
415
505
  profileDisconnected: route.errorCode === 'profile_disconnected',
416
506
  profiles,
417
507
  pending: pending.size,
508
+ adapterLeases: adapterScheduler.snapshot(),
509
+ adapterResources: adapterScheduler.resourceSnapshot(),
418
510
  commandResultUnknown: commandResultUnknownCount,
419
511
  memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
420
512
  port: PORT,
@@ -454,6 +546,30 @@ async function handleRequest(req, res) {
454
546
  jsonResponse(res, 400, { ok: false, error: 'Missing command id' });
455
547
  return;
456
548
  }
549
+ const namedAdapterSession = body.surface === 'adapter'
550
+ && body.siteSession === 'persistent'
551
+ && typeof body.session === 'string'
552
+ && /^site:[^:\s]+:[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(body.session);
553
+ if (namedAdapterSession && !body.adapterLease) {
554
+ throw new DaemonCommandFailure('Named Adapter browser commands require an active lease', 'ADAPTER_LEASE_LOST', undefined, 409);
555
+ }
556
+ if (body.adapterLease) {
557
+ try {
558
+ const lease = adapterScheduler.assertLease(body.adapterLease);
559
+ if (body.contextId !== lease.contextId
560
+ || body.surface !== 'adapter'
561
+ || body.siteSession !== 'persistent'
562
+ || body.session !== lease.sessionKey) {
563
+ throw new AdapterSchedulerError('ADAPTER_LEASE_LOST', 'Adapter browser command does not match its lease scope');
564
+ }
565
+ }
566
+ catch (error) {
567
+ if (error instanceof AdapterSchedulerError) {
568
+ throw new DaemonCommandFailure(error.message, error.code, undefined, 409);
569
+ }
570
+ throw error;
571
+ }
572
+ }
457
573
  const route = resolveExtensionConnection(typeof body.contextId === 'string' ? body.contextId : undefined);
458
574
  if (!route.connection) {
459
575
  jsonResponse(res, route.errorCode === 'profile_required' ? 409 : 503, {
@@ -722,6 +838,8 @@ function shutdown() {
722
838
  p.reject(new Error('Daemon shutting down'));
723
839
  }
724
840
  pending.clear();
841
+ clearInterval(adapterSchedulerSweep);
842
+ adapterScheduler.reset();
725
843
  for (const profile of extensionProfiles.values())
726
844
  profile.ws.close();
727
845
  httpServer.close();
@@ -137,6 +137,7 @@ export async function loadFromManifest(manifestPath, clisDir) {
137
137
  source: entry.sourceFile ? path.resolve(clisDir, entry.sourceFile) : modulePath,
138
138
  navigateBefore: entry.navigateBefore,
139
139
  siteSession: entry.siteSession,
140
+ adapterConcurrency: entry.adapterConcurrency,
140
141
  _lazy: true,
141
142
  _modulePath: modulePath,
142
143
  };
@@ -53,6 +53,8 @@ export interface ArticleDownloadOptions {
53
53
  stdout?: boolean;
54
54
  /** Opt-in hardened Markdown rules used by HTML-focused adapters. */
55
55
  secureMarkdown?: boolean;
56
+ /** Lease fencing check invoked immediately before the final Markdown write. */
57
+ beforePublish?: () => Promise<void>;
56
58
  }
57
59
  export interface ArticleDownloadResult {
58
60
  title: string;
@@ -325,7 +325,7 @@ async function downloadImages(imgUrls, imgDir, headers, detectExt) {
325
325
  * 6. File write
326
326
  */
327
327
  export async function downloadArticle(data, options) {
328
- const { output, downloadImages: shouldDownloadImages = true, imageHeaders, maxTitleLength = 80, configureTurndown, detectImageExt, frontmatterLabels, cleanSelectors, stdout = false, secureMarkdown = false, } = options;
328
+ const { output, downloadImages: shouldDownloadImages = true, imageHeaders, maxTitleLength = 80, configureTurndown, detectImageExt, frontmatterLabels, cleanSelectors, stdout = false, secureMarkdown = false, beforePublish, } = options;
329
329
  const labels = { ...DEFAULT_LABELS, ...frontmatterLabels };
330
330
  if (!data.title) {
331
331
  return [{
@@ -350,12 +350,14 @@ export async function downloadArticle(data, options) {
350
350
  // Convert HTML to Markdown
351
351
  let markdown = convertToMarkdown(data.contentHtml, data.codeBlocks || [], configureTurndown, cleanSelectors, secureMarkdown);
352
352
  const safeTitle = sanitizeFilename(data.title, maxTitleLength);
353
+ const stagingDir = !stdout && beforePublish
354
+ ? path.join(output, `.bycli-article-${crypto.randomUUID()}.tmp`)
355
+ : undefined;
353
356
  // Download images only when writing to disk. In stdout mode remote URLs
354
357
  // stay intact so the piped output is self-contained.
355
358
  if (!stdout && shouldDownloadImages && data.imageUrls && data.imageUrls.length > 0) {
356
359
  const articleDir = path.join(output, safeTitle);
357
- fs.mkdirSync(articleDir, { recursive: true });
358
- const imagesDir = path.join(articleDir, 'images');
360
+ const imagesDir = path.join(stagingDir ?? articleDir, 'images');
359
361
  fs.mkdirSync(imagesDir, { recursive: true });
360
362
  const urlMap = await downloadImages(data.imageUrls, imagesDir, imageHeaders, detectImageExt);
361
363
  markdown = replaceImageUrls(markdown, urlMap);
@@ -396,7 +398,31 @@ export async function downloadArticle(data, options) {
396
398
  fs.mkdirSync(articleDir, { recursive: true });
397
399
  const filename = `${safeTitle}.md`;
398
400
  const filePath = path.join(articleDir, filename);
399
- fs.writeFileSync(filePath, fullContent, 'utf-8');
401
+ if (stagingDir) {
402
+ fs.mkdirSync(stagingDir, { recursive: true });
403
+ const stagedMarkdown = path.join(stagingDir, filename);
404
+ fs.writeFileSync(stagedMarkdown, fullContent, { encoding: 'utf-8', flag: 'wx' });
405
+ try {
406
+ await beforePublish?.();
407
+ const stagedImages = path.join(stagingDir, 'images');
408
+ if (fs.existsSync(stagedImages)) {
409
+ const finalImages = path.join(articleDir, 'images');
410
+ fs.mkdirSync(finalImages, { recursive: true });
411
+ for (const image of fs.readdirSync(stagedImages)) {
412
+ fs.linkSync(path.join(stagedImages, image), path.join(finalImages, image));
413
+ }
414
+ }
415
+ // Same-filesystem rename makes the Markdown replacement atomic.
416
+ fs.renameSync(stagedMarkdown, filePath);
417
+ }
418
+ finally {
419
+ fs.rmSync(stagingDir, { recursive: true, force: true });
420
+ }
421
+ }
422
+ else {
423
+ await beforePublish?.();
424
+ fs.writeFileSync(filePath, fullContent, 'utf-8');
425
+ }
400
426
  return [{
401
427
  title: data.title,
402
428
  author: data.author || '-',
@@ -67,6 +67,9 @@ export declare class TimeoutError extends CliError {
67
67
  export declare class ArgumentError extends CliError {
68
68
  constructor(message: string, hint?: string);
69
69
  }
70
+ export declare class AdapterCoordinationError extends CliError {
71
+ constructor(code: string, message: string, temporary?: boolean, hint?: string);
72
+ }
70
73
  export declare class EmptyResultError extends CliError {
71
74
  constructor(command: string, hint?: string);
72
75
  }
@@ -86,6 +86,11 @@ export class ArgumentError extends CliError {
86
86
  super('ARGUMENT', message, hint, EXIT_CODES.USAGE_ERROR);
87
87
  }
88
88
  }
89
+ export class AdapterCoordinationError extends CliError {
90
+ constructor(code, message, temporary = false, hint) {
91
+ super(code, message, hint, temporary ? EXIT_CODES.TEMPFAIL : EXIT_CODES.USAGE_ERROR);
92
+ }
93
+ }
89
94
  export class EmptyResultError extends CliError {
90
95
  constructor(command, hint) {
91
96
  super('EMPTY_RESULT', `${command} returned no data`, hint ?? 'The page structure may have changed, or you may need to log in', EXIT_CODES.EMPTY_RESULT);
@@ -22,6 +22,8 @@ export declare function executeCommand(cmd: CliCommand, rawKwargs: CommandArgs,
22
22
  keepTab?: string;
23
23
  windowMode?: string;
24
24
  siteSession?: string;
25
+ adapterSession?: string;
26
+ adapterQueueTimeout?: string;
25
27
  onTraceExport?: (trace: ObservationExportResult) => void;
26
28
  }): Promise<unknown>;
27
29
  export declare function prepareCommandArgs(cmd: CliCommand, rawKwargs: CommandArgs): CommandArgs;