@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.
- package/cli-manifest.json +56 -3
- package/clis/ima/knowledge-list.js +48 -0
- package/clis/ima/knowledge.js +23 -0
- package/clis/ima/native-api.js +67 -10
- package/clis/ima/native-client.js +23 -7
- package/clis/ima/utils.js +1 -0
- package/clis/weixin/_wechat/article-artifact.js +55 -0
- package/clis/weixin/_wechat/article-identity.js +27 -0
- package/clis/weixin/_wechat/publish-analysis.js +8 -4
- package/clis/weixin/_wechat/publish-download.js +3 -1
- package/clis/weixin/download-publish-data.js +20 -0
- package/clis/weixin/download.js +15 -2
- package/dist/src/adapter-coordination.d.ts +26 -0
- package/dist/src/adapter-coordination.js +183 -0
- package/dist/src/adapter-coordination.test.d.ts +1 -0
- package/dist/src/adapter-execution-context.d.ts +6 -0
- package/dist/src/adapter-execution-context.js +8 -0
- package/dist/src/adapter-scheduler.d.ts +86 -0
- package/dist/src/adapter-scheduler.js +349 -0
- package/dist/src/adapter-scheduler.test.d.ts +1 -0
- package/dist/src/browser/daemon-client.d.ts +11 -0
- package/dist/src/browser/daemon-client.js +53 -1
- package/dist/src/browser/extension-capabilities.d.ts +1 -0
- package/dist/src/browser/extension-capabilities.js +18 -5
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +3 -0
- package/dist/src/build-manifest.js +1 -0
- package/dist/src/cli-argv-preprocess.d.ts +3 -0
- package/dist/src/cli-argv-preprocess.js +4 -0
- package/dist/src/commanderAdapter.js +11 -0
- package/dist/src/daemon.js +118 -0
- package/dist/src/discovery.js +1 -0
- package/dist/src/download/article-download.d.ts +2 -0
- package/dist/src/download/article-download.js +30 -4
- package/dist/src/errors.d.ts +3 -0
- package/dist/src/errors.js +5 -0
- package/dist/src/execution.d.ts +2 -0
- package/dist/src/execution.js +168 -105
- package/dist/src/help.d.ts +1 -0
- package/dist/src/help.js +40 -0
- package/dist/src/manifest-types.d.ts +4 -0
- package/dist/src/registry.d.ts +6 -0
- package/dist/src/registry.js +23 -0
- package/dist/src/serialization.d.ts +1 -0
- package/dist/src/serialization.js +1 -0
- package/dist/src/types.d.ts +2 -0
- 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
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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;
|
package/dist/src/browser/page.js
CHANGED
|
@@ -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;
|
|
@@ -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;
|
package/dist/src/daemon.js
CHANGED
|
@@ -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();
|
package/dist/src/discovery.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 || '-',
|
package/dist/src/errors.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/src/errors.js
CHANGED
|
@@ -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);
|
package/dist/src/execution.d.ts
CHANGED
|
@@ -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;
|