publishport-opencli 1.0.9 → 1.0.10
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/clis/_shared/file-inject.js +41 -0
- package/clis/_shared/video-publish.js +4 -4
- package/clis/bilibili/upload.js +1 -1
- package/clis/chatgpt/utils.js +5 -5
- package/clis/claude/utils.js +1 -1
- package/clis/deepseek/utils.js +7 -7
- package/clis/douyin/draft.js +6 -6
- package/clis/douyin/publish-image.js +10 -31
- package/clis/facebook/post.js +9 -36
- package/clis/instagram/post.js +19 -66
- package/clis/instagram/reel.js +11 -11
- package/clis/threads/post.js +15 -34
- package/clis/twitter/post.js +15 -42
- package/clis/twitter/utils.js +4 -36
- package/clis/wechat-channels/publish.js +29 -57
- package/clis/weibo/publish.js +8 -8
- package/clis/weixin/create-draft.js +7 -7
- package/clis/xianyu/publish.js +2 -2
- package/clis/xiaohongshu/publish-video.js +9 -27
- package/clis/xiaohongshu/publish.js +32 -72
- package/dist/src/browser/bridge-readiness.d.ts +1 -1
- package/dist/src/browser/daemon-client.d.ts +72 -1
- package/dist/src/browser/daemon-client.js +1 -1
- package/dist/src/browser/daemon-version.d.ts +1 -1
- package/dist/src/browser/errors.d.ts +2 -0
- package/dist/src/browser/errors.js +3 -3
- package/dist/src/browser/network-interceptor.d.ts +11 -0
- package/dist/src/browser/network-interceptor.js +1 -0
- package/dist/src/cli.js +24 -24
- package/dist/src/commands/daemon.js +1 -1
- package/dist/src/daemon-utils.d.ts +1 -0
- package/dist/src/daemon-utils.js +1 -1
- package/dist/src/daemon.js +1 -1
- package/dist/src/doctor.d.ts +1 -1
- package/dist/src/doctor.js +7 -7
- package/dist/src/errors.d.ts +8 -0
- package/dist/src/errors.js +1 -1
- package/dist/src/execution.js +2 -2
- package/dist/src/session-lease.d.ts +135 -0
- package/dist/src/session-lease.js +1 -0
- package/dist/src/session-lease.test.d.ts +1 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{fetchDaemonStatus as M,requestDaemonShutdown as X}from"../browser/daemon-
|
|
1
|
+
import{fetchDaemonStatus as M,requestDaemonShutdown as X}from"../browser/daemon-transport.js";import{restartDaemon as Y}from"../browser/daemon-lifecycle.js";import{formatDuration as Z}from"../download/progress.js";import{log as C}from"../logger.js";import{PKG_VERSION as U}from"../version.js";import{formatDaemonVersion as W,isDaemonStale as $}from"../browser/daemon-version.js";import{browserSession as q,getBrowserFactory as T}from"../runtime.js";export async function daemonStatus(){const k=await M();if(!k){console.log("Daemon: not running");return}let z;if(k.extensionConnected)z=k.extensionVersion?`connected (v${k.extensionVersion})`:"connected (version unknown)";else if(k.profileRequired){const A=k.profiles?.length??0;z=`${A} ${A===1?"profile":"profiles"} connected, none selected — run \`ppcli profile use <name>\``}else if(k.profileDisconnected)z="requested profile not connected — run `ppcli profile use <name>`";else z="disconnected";const H=W(k),J=$(k,U);console.log(`Daemon: ${J?"stale":"running"} (PID ${k.pid})`);console.log(`Version: ${H}${J?` (CLI v${U}; run: ppcli daemon restart)`:""}`);console.log(`Uptime: ${Z(Math.round(k.uptime*1000))}`);console.log(`Extension: ${z}`);if(k.profiles&&k.profiles.length>0)console.log(`Profiles: ${k.profiles.map((A)=>{const Q=A.extensionVersion?` v${A.extensionVersion}`:"";return`${A.contextId}${Q}`}).join(", ")}`);console.log(`Memory: ${k.memoryMB} MB`);console.log(`Port: ${k.port}`)}export async function daemonStop(){if(!await M()){C.info("Daemon is not running.");return}if(await X())C.success("Daemon stopped.");else{C.error("Failed to stop daemon.");process.exitCode=1}}export async function daemonRestart(){const k=await M();if(k?.profiles&&k.profiles.length>0)C.warn(`Restarting daemon will disconnect ${k.profiles.length} browser profile(s); the extension should reconnect automatically.`);const z=await Y();if(!z.stopped){C.error("Failed to stop daemon before restart.");process.exitCode=1;return}if(!z.status){C.error("Daemon restart timed out before the new daemon reported status.");process.exitCode=1;return}const H=z.previousStatus?"restarted":"started",J=W(z.status);C.success(`Daemon ${H} on port ${z.status.port} (${J}).`);if(z.status.extensionConnected){const A=z.status.profiles?.length??0,Q=A>0?`; profiles connected: ${A}`:"";C.status(`Extension connected${Q}.`)}else C.warn("Daemon is running, but the Browser Bridge extension has not connected yet.")}export async function daemonWarm(k){const z=await M();if(!z){C.warn("Daemon/extension not ready; skip warm (retry after the browser extension connects).");return}if(!z.extensionConnected){C.warn("Browser extension not connected yet; skip warm.");return}const H=k&&/^https?:\/\//i.test(k)?k:"about:blank",J=T("__warm__");try{await q(J,async(A)=>{await A.goto(H).catch(()=>{})},{session:"site:__warm__",windowMode:"background",surface:"adapter",siteSession:"persistent"});C.success(`Automation window warmed (${H}, kept alive for reuse).`)}catch(A){C.warn(`Warm failed (non-fatal): ${A instanceof Error?A.message:String(A)}`)}}
|
|
@@ -14,5 +14,6 @@ export declare function buildExtensionDisconnectFailure(input: {
|
|
|
14
14
|
action: string;
|
|
15
15
|
dispatched: boolean;
|
|
16
16
|
}): DaemonFailureContract;
|
|
17
|
+
export declare function buildCommandTimeoutFailure(action: string, timeoutMs: number): DaemonFailureContract;
|
|
17
18
|
export declare function buildCommandDispatchFailure(contextId: string): DaemonFailureContract;
|
|
18
19
|
export declare function getResponseCorsHeaders(pathname: string, origin?: string): Record<string, string> | undefined;
|
package/dist/src/daemon-utils.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const COMMAND_RESULT_UNKNOWN_CODE="command_result_unknown",COMMAND_RESULT_UNKNOWN_HINT="Inspect the browser/session state before retrying. Do not blindly retry write commands such as navigate, click, type, or eval.",PROFILE_DISCONNECTED_HINT="Open that Chrome profile and make sure the ppcli extension is enabled, or choose another profile with ppcli profile use <name>.";export function commandResultUnknownMessage(e){return`Browser connection dropped after the ${e} command was dispatched; it may have completed.`}export function buildExtensionDisconnectFailure(e){if(e.dispatched)return{message:commandResultUnknownMessage(e.action),errorCode:COMMAND_RESULT_UNKNOWN_CODE,errorHint:COMMAND_RESULT_UNKNOWN_HINT,status:503,countAsCommandResultUnknown:!0};return buildCommandDispatchFailure(e.contextId)}export function buildCommandDispatchFailure(e){return{message:`Browser profile "${e}" disconnected before command dispatch`,errorCode:"profile_disconnected",errorHint:PROFILE_DISCONNECTED_HINT,status:503,countAsCommandResultUnknown:!1}}export function getResponseCorsHeaders(e,n){if(e!=="/ping")return;if(!n||!n.startsWith("chrome-extension://"))return;return{"Access-Control-Allow-Origin":n,Vary:"Origin"}}
|
|
1
|
+
export const COMMAND_RESULT_UNKNOWN_CODE="command_result_unknown",COMMAND_RESULT_UNKNOWN_HINT="Inspect the browser/session state before retrying. Do not blindly retry write commands such as navigate, click, type, or eval.",PROFILE_DISCONNECTED_HINT="Open that Chrome profile and make sure the ppcli extension is enabled, or choose another profile with ppcli profile use <name>.";export function commandResultUnknownMessage(e){return`Browser connection dropped after the ${e} command was dispatched; it may have completed.`}export function buildExtensionDisconnectFailure(e){if(e.dispatched)return{message:commandResultUnknownMessage(e.action),errorCode:COMMAND_RESULT_UNKNOWN_CODE,errorHint:COMMAND_RESULT_UNKNOWN_HINT,status:503,countAsCommandResultUnknown:!0};return buildCommandDispatchFailure(e.contextId)}export function buildCommandTimeoutFailure(e,n){return{message:`Browser ${e} command timed out after ${Math.round(n/1000)}s; it may still complete in the browser.`,errorCode:COMMAND_RESULT_UNKNOWN_CODE,errorHint:COMMAND_RESULT_UNKNOWN_HINT,status:408,countAsCommandResultUnknown:!0}}export function buildCommandDispatchFailure(e){return{message:`Browser profile "${e}" disconnected before command dispatch`,errorCode:"profile_disconnected",errorHint:PROFILE_DISCONNECTED_HINT,status:503,countAsCommandResultUnknown:!1}}export function getResponseCorsHeaders(e,n){if(e!=="/ping")return;if(!n||!n.startsWith("chrome-extension://"))return;return{"Access-Control-Allow-Origin":n,Vary:"Origin"}}
|
package/dist/src/daemon.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createServer as b}from"node:http";import{WebSocketServer as u,WebSocket as v}from"ws";import{DEFAULT_DAEMON_PORT as p,PUBLISHPORT_DAEMON_PORT as g,unsupportedDaemonPortEnvMessage as c}from"./constants.js";import{EXIT_CODES as S}from"./errors.js";import{log as G}from"./logger.js";import{PKG_VERSION as l}from"./version.js";import{DEFAULT_CONTEXT_ID as d}from"./browser/profile.js";import{buildCommandDispatchFailure as a,buildExtensionDisconnectFailure as i,getResponseCorsHeaders as t}from"./daemon-utils.js";import*as L from"./browser/trace.js";const B=p,h=g;if(process.env.OPENCLI_DAEMON_PORT){G.error(c(process.env.OPENCLI_DAEMON_PORT));process.exit(S.USAGE_ERROR)}const W=new Map,N=new Map;let I=0;const n=200,k=[];class E extends Error{errorCode;errorHint;status;constructor(J,K,Z,z=400){super(J);this.errorCode=K;this.errorHint=Z;this.status=z;this.name="DaemonCommandFailure"}}function s(J){k.push(J);if(k.length>n)k.shift()}function j(){return[...W.values()].filter((J)=>J.ws.readyState===v.OPEN)}function q(J){const K=typeof J==="string"&&J.trim()?J.trim():void 0;if(K){const z=W.get(K);if(z?.ws.readyState===v.OPEN)return{connection:z};const Q=j();if(Q.length===1){G.warn(`[daemon] Requested profile "${K}" not connected; falling back to the only connected profile "${Q[0].contextId}"`);return{connection:Q[0]}}return{errorCode:"profile_disconnected",error:`Browser profile "${K}" is not connected.`,errorHint:"Open that Chrome profile and make sure the ppcli extension is enabled, or choose another profile with ppcli profile use <name>."}}const Z=j();if(Z.length===1)return{connection:Z[0]};if(Z.length>1)return{errorCode:"profile_required",error:"Multiple Browser Bridge profiles are connected; choose one with --profile.",errorHint:"Run ppcli profile list, then use ppcli --profile <name> ... or ppcli profile use <name>."};return{errorCode:"extension_not_connected",error:"Extension not connected. Please install the ppcli Browser Bridge extension."}}function o(J,K){const Z=typeof K==="string"&&K.trim()?K.trim():d,z=W.get(Z);if(z&&z.ws!==J)z.ws.close();const Q=[...W.entries()].find(([,A])=>A.ws===J);if(Q&&Q[0]!==Z)W.delete(Q[0]);const $=W.get(Z),V={contextId:Z,ws:J,extensionVersion:$?.ws===J?$.extensionVersion:null,extensionCompatRange:$?.ws===J?$.extensionCompatRange:null,lastSeenAt:Date.now()};W.set(Z,V);return V}function x(J){for(const[K,Z]of W.entries()){if(Z.ws!==J)continue;W.delete(K);L.traceExtDisconnect(K);for(const[z,Q]of N){if(Q.contextId!==K)continue;clearTimeout(Q.timer);const $=i({contextId:K,action:Q.action,dispatched:Q.dispatched});if($.countAsCommandResultUnknown){I++;G.warn(`[daemon] Command result unknown after extension disconnect (id=${z}, action=${Q.action}, context=${K})`)}L.traceResult(z,!1,Date.now()-Q.dispatchTs,$.errorCode);Q.reject(new E($.message,$.errorCode,$.errorHint,$.status));N.delete(z)}}}const w=33554432;class P extends Error{status=413;constructor(){super(`请求体超过 ${w/1024/1024} MB 上限(正文内联图片过多/过大?可减少单次发布的图片体积)`);this.name="BodyTooLargeError"}}function r(J){return new Promise((K,Z)=>{const z=[];let Q=0,$=!1;J.on("data",(V)=>{if($)return;Q+=V.length;if(Q>w){$=!0;z.length=0;Z(new P);return}z.push(V)});J.on("end",()=>{if(!$)K(Buffer.concat(z).toString("utf-8"))});J.on("error",(V)=>{if(!$)Z(V)})})}function Y(J,K,Z,z){J.writeHead(K,{"Content-Type":"application/json",...z});J.end(JSON.stringify(Z))}async function f(J,K){const Z=J.headers.origin;if(Z&&!Z.startsWith("chrome-extension://")){Y(K,403,{ok:!1,error:"Forbidden: cross-origin request blocked"});return}if(J.method==="OPTIONS"){K.writeHead(204);K.end();return}const z=J.url??"/",Q=z.split("?")[0];if(J.method==="GET"&&Q==="/ping"){Y(K,200,{ok:!0},t(Q,Z));return}if(!J.headers["x-opencli"]){Y(K,403,{ok:!1,error:"Forbidden: missing X-OpenCLI header"});return}if(J.method==="GET"&&Q==="/status"){const $=process.uptime(),V=process.memoryUsage(),F=new URL(z,`http://localhost:${B}`).searchParams.get("contextId")?.trim()||void 0,X=q(F),_=j().map((M)=>({contextId:M.contextId,extensionConnected:!0,extensionVersion:M.extensionVersion??void 0,extensionCompatRange:M.extensionCompatRange??void 0,pending:[...N.values()].filter((H)=>H.contextId===M.contextId).length,lastSeenAt:M.lastSeenAt}));Y(K,200,{ok:!0,pid:process.pid,uptime:$,daemonVersion:l,extensionConnected:!!X.connection,extensionVersion:X.connection?.extensionVersion??void 0,extensionCompatRange:X.connection?.extensionCompatRange??void 0,contextId:X.connection?.contextId??F,profileRequired:X.errorCode==="profile_required",profileDisconnected:X.errorCode==="profile_disconnected",profiles:_,pending:N.size,commandResultUnknown:I,memoryMB:Math.round(V.rss/1024/1024*10)/10,port:B});return}if(J.method==="GET"&&Q==="/logs"){const V=new URL(z,`http://localhost:${B}`).searchParams.get("level"),A=V?k.filter((F)=>F.level===V):k;Y(K,200,{ok:!0,logs:A});return}if(J.method==="DELETE"&&Q==="/logs"){k.length=0;Y(K,200,{ok:!0});return}if(J.method==="POST"&&Q==="/shutdown"){Y(K,200,{ok:!0,message:"Shutting down"});setTimeout(()=>R(),100);return}if(J.method==="POST"&&z==="/command"){try{const $=JSON.parse(await r(J));if(!$.id){Y(K,400,{ok:!1,error:"Missing command id"});return}const V=q(typeof $.contextId==="string"?$.contextId:void 0);if(!V.connection){Y(K,V.errorCode==="profile_required"?409:503,{id:$.id,ok:!1,errorCode:V.errorCode,error:V.error,...V.errorHint?{errorHint:V.errorHint}:{}});return}const A=typeof $.timeout==="number"&&$.timeout>0?$.timeout*1000:120000;if(N.has($.id)){Y(K,409,{id:$.id,ok:!1,error:"Duplicate command id already pending; retry"});return}const F=await new Promise((X,_)=>{const M=setTimeout(()=>{N.delete($.id);L.traceTimeout($.id,A,typeof $.action==="string"?$.action:void 0);_(Error(`Command timeout (${A/1000}s)`))},A),H={contextId:V.connection.contextId,action:typeof $.action==="string"?$.action:"unknown",dispatched:!1,dispatchTs:Date.now(),resolve:X,reject:_,timer:M};N.set($.id,H);const y=(U)=>{if(N.get($.id)!==H)return;const D=a(H.contextId);clearTimeout(M);N.delete($.id);_(new E(D.message,D.errorCode,D.errorHint,D.status));G.warn(`[daemon] Failed to dispatch command ${$.id}: ${U instanceof Error?U.message:String(U)}`)};try{V.connection.ws.send(JSON.stringify($),(U)=>{if(U&&!H.dispatched)y(U)});H.dispatched=!0;L.traceDispatch($)}catch(U){y(U)}});Y(K,200,F)}catch($){const V=$ instanceof E?$:null;if($ instanceof P){Y(K,413,{ok:!1,error:$.message,errorCode:"body_too_large"});return}Y(K,V?.status??($ instanceof Error&&$.message.includes("timeout")?408:400),{ok:!1,error:$ instanceof Error?$.message:"Invalid request",...V?.errorCode?{errorCode:V.errorCode}:{},...V?.errorHint?{errorHint:V.errorHint}:{}})}return}Y(K,404,{error:"Not found"})}const O=b((J,K)=>{f(J,K).catch(()=>{K.writeHead(500);K.end()})}),T=b((J,K)=>{f(J,K).catch(()=>{K.writeHead(500);K.end()})}),C=new u({noServer:!0});function m(J,K,Z){if((J.url??"/").split("?")[0]!=="/ext"){K.destroy();return}const Q=J.headers.origin;if(Q&&!Q.startsWith("chrome-extension://")){K.destroy();return}C.handleUpgrade(J,K,Z,($)=>{C.emit("connection",$,J)})}O.on("upgrade",m);T.on("upgrade",m);C.on("connection",(J)=>{G.info("[daemon] Extension connected");let K=0;const Z=setInterval(()=>{if(J.readyState!==v.OPEN){clearInterval(Z);return}if(K>=2){G.warn("[daemon] Extension heartbeat lost, closing connection");clearInterval(Z);J.terminate();return}K++;J.ping()},15000);J.on("pong",()=>{K=0});J.on("message",(z)=>{try{const Q=JSON.parse(z.toString());if(Q.type==="hello"){if(Q.brand!=="publishport"){G.warn(`[daemon] 拒绝非 PublishPort 官方扩展连接(brand=${JSON.stringify(Q.brand)})`);J.close(4003,"Only the official PublishPort extension is supported");return}const V=o(J,Q.contextId);V.extensionVersion=typeof Q.version==="string"?Q.version:null;V.extensionCompatRange=typeof Q.compatRange==="string"?Q.compatRange:null;V.lastSeenAt=Date.now();G.info(`[daemon] Extension profile connected: ${V.contextId}`);L.traceExtConnect(V.contextId,V.extensionVersion);return}if(Q.type==="log"){if(Q.level==="error")G.error(`[ext] ${Q.msg}`);else if(Q.level==="warn")G.warn(`[ext] ${Q.msg}`);else G.info(`[ext] ${Q.msg}`);s({level:Q.level,msg:Q.msg,ts:Q.ts??Date.now()});L.traceExtLog(Q.level,Q.msg);return}const $=N.get(Q.id);if($){clearTimeout($.timer);N.delete(Q.id);L.traceResult(Q.id,!!Q.ok,Date.now()-$.dispatchTs,typeof Q.errorCode==="string"?Q.errorCode:void 0,typeof Q.page==="string"?Q.page:void 0);$.resolve(Q)}}catch(Q){const $=z.toString().slice(0,200);G.warn(`[daemon] Ignoring malformed WS message from extension: ${Q instanceof Error?Q.message:String(Q)} (first 200 chars: ${JSON.stringify($)})`)}});J.on("close",()=>{G.info("[daemon] Extension disconnected");clearInterval(Z);x(J)});J.on("error",()=>{clearInterval(Z);x(J)})});O.listen(B,"127.0.0.1",()=>{G.info(`[daemon] Listening on http://127.0.0.1:${B}`)});O.on("error",(J)=>{if(J.code==="EADDRINUSE"){G.error(`[daemon] Port ${B} already in use — another daemon is likely running. Exiting.`);process.exit(S.SERVICE_UNAVAIL)}G.error(`[daemon] Server error: ${J.message}`);process.exit(S.GENERIC_ERROR)});T.listen(h,"127.0.0.1",()=>{G.info(`[daemon] Listening on http://127.0.0.1:${h} (PublishPort extension)`)});T.on("error",(J)=>{G.warn(`[daemon] PublishPort port ${h} unavailable (${J.message}) — extension will fall back to ${B}`)});function R(){for(const[,J]of N){clearTimeout(J.timer);J.reject(Error("Daemon shutting down"))}N.clear();for(const J of W.values())J.ws.close();O.close();T.close();process.exit(S.SUCCESS)}process.on("SIGTERM",R);process.on("SIGINT",R);
|
|
1
|
+
import{createServer as p}from"node:http";import{WebSocketServer as a,WebSocket as P}from"ws";import{DEFAULT_DAEMON_PORT as t,PUBLISHPORT_DAEMON_PORT as n,unsupportedDaemonPortEnvMessage as s}from"./constants.js";import{EXIT_CODES as E}from"./errors.js";import{log as Y}from"./logger.js";import{PKG_VERSION as o}from"./version.js";import{DEFAULT_CONTEXT_ID as r}from"./browser/profile.js";import{buildCommandDispatchFailure as g,buildCommandTimeoutFailure as e,buildExtensionDisconnectFailure as JJ,getResponseCorsHeaders as QJ}from"./daemon-utils.js";import*as F from"./browser/trace.js";import{SessionLeaseRegistry as $J,buildSessionBusyFailure as VJ,getSessionLeaseKey as ZJ,isSessionLeaseCommand as zJ}from"./session-lease.js";const T=t,I=n;if(process.env.OPENCLI_DAEMON_PORT){Y.error(s(process.env.OPENCLI_DAEMON_PORT));process.exit(E.USAGE_ERROR)}const U=new Map,A=new Map,C=new $J;function m(Q){for(const $ of A.values())if($.runId===Q)return!0;return!1}function j(Q,$,Z){clearTimeout($.timer);A.delete(Q);if($.leaseKey&&$.runId)C.heartbeat($.leaseKey,$.runId,Date.now());for(const z of $.settlers)if(Z.error)z.reject(Z.error);else z.resolve(Z.data)}let q=0;const GJ=200,D=[];class S extends Error{errorCode;errorHint;status;constructor(Q,$,Z,z=400){super(Q);this.errorCode=$;this.errorHint=Z;this.status=z;this.name="DaemonCommandFailure"}}function YJ(Q){D.push(Q);if(D.length>GJ)D.shift()}function x(){return[...U.values()].filter((Q)=>Q.ws.readyState===P.OPEN)}function c(Q){const $=typeof Q==="string"&&Q.trim()?Q.trim():void 0;if($){const z=U.get($);if(z?.ws.readyState===P.OPEN)return{connection:z};const V=x();if(V.length===1){Y.warn(`[daemon] Requested profile "${$}" not connected; falling back to the only connected profile "${V[0].contextId}"`);return{connection:V[0]}}return{errorCode:"profile_disconnected",error:`Browser profile "${$}" is not connected.`,errorHint:"Open that Chrome profile and make sure the ppcli extension is enabled, or choose another profile with ppcli profile use <name>."}}const Z=x();if(Z.length===1)return{connection:Z[0]};if(Z.length>1)return{errorCode:"profile_required",error:"Multiple Browser Bridge profiles are connected; choose one with --profile.",errorHint:"Run ppcli profile list, then use ppcli --profile <name> ... or ppcli profile use <name>."};return{errorCode:"extension_not_connected",error:"Extension not connected. Please install the ppcli Browser Bridge extension."}}function NJ(Q,$){const Z=typeof $==="string"&&$.trim()?$.trim():r,z=U.get(Z);if(z&&z.ws!==Q)z.ws.close();const V=[...U.entries()].find(([,H])=>H.ws===Q);if(V&&V[0]!==Z)U.delete(V[0]);const J=U.get(Z),G={contextId:Z,ws:Q,extensionVersion:J?.ws===Q?J.extensionVersion:null,extensionCompatRange:J?.ws===Q?J.extensionCompatRange:null,lastSeenAt:Date.now()};U.set(Z,G);return G}function u(Q){for(const[$,Z]of U.entries()){if(Z.ws!==Q)continue;U.delete($);F.traceExtDisconnect($);for(const[z,V]of A){if(V.contextId!==$)continue;const J=JJ({contextId:$,action:V.action,dispatched:V.dispatched});if(J.countAsCommandResultUnknown){q++;Y.warn(`[daemon] Command result unknown after extension disconnect (id=${z}, action=${V.action}, context=${$})`)}F.traceResult(z,!1,Date.now()-V.dispatchTs,J.errorCode);j(z,V,{error:new S(J.message,J.errorCode,J.errorHint,J.status)})}}}const l=33554432;class f extends Error{status=413;constructor(){super(`请求体超过 ${l/1024/1024} MB 上限(正文内联图片过多/过大?可减少单次发布的图片体积)`);this.name="BodyTooLargeError"}}function KJ(Q){return new Promise(($,Z)=>{const z=[];let V=0,J=!1;Q.on("data",(G)=>{if(J)return;V+=G.length;if(V>l){J=!0;z.length=0;Z(new f);return}z.push(G)});Q.on("end",()=>{if(!J)$(Buffer.concat(z).toString("utf-8"))});Q.on("error",(G)=>{if(!J)Z(G)})})}function N(Q,$,Z,z){Q.writeHead($,{"Content-Type":"application/json",...z});Q.end(JSON.stringify(Z))}async function d(Q,$){const Z=Q.headers.origin;if(Z&&!Z.startsWith("chrome-extension://")){N($,403,{ok:!1,error:"Forbidden: cross-origin request blocked"});return}if(Q.method==="OPTIONS"){$.writeHead(204);$.end();return}const z=Q.url??"/",V=z.split("?")[0];if(Q.method==="GET"&&V==="/ping"){N($,200,{ok:!0},QJ(V,Z));return}if(!Q.headers["x-opencli"]){N($,403,{ok:!1,error:"Forbidden: missing X-OpenCLI header"});return}if(Q.method==="GET"&&V==="/status"){const J=process.uptime(),G=process.memoryUsage(),L=new URL(z,`http://localhost:${T}`).searchParams.get("contextId")?.trim()||void 0,W=c(L),R=x().map((O)=>({contextId:O.contextId,extensionConnected:!0,extensionVersion:O.extensionVersion??void 0,extensionCompatRange:O.extensionCompatRange??void 0,pending:[...A.values()].filter((B)=>B.contextId===O.contextId).length,lastSeenAt:O.lastSeenAt}));N($,200,{ok:!0,pid:process.pid,uptime:J,daemonVersion:o,extensionConnected:!!W.connection,extensionVersion:W.connection?.extensionVersion??void 0,extensionCompatRange:W.connection?.extensionCompatRange??void 0,contextId:W.connection?.contextId??L,profileRequired:W.errorCode==="profile_required",profileDisconnected:W.errorCode==="profile_disconnected",profiles:R,pending:A.size,sessionLeases:C.list(Date.now(),m),commandResultUnknown:q,memoryMB:Math.round(G.rss/1024/1024*10)/10,port:T});return}if(Q.method==="GET"&&V==="/logs"){const G=new URL(z,`http://localhost:${T}`).searchParams.get("level"),H=G?D.filter((L)=>L.level===G):D;N($,200,{ok:!0,logs:H});return}if(Q.method==="DELETE"&&V==="/logs"){D.length=0;N($,200,{ok:!0});return}if(Q.method==="POST"&&V==="/shutdown"){N($,200,{ok:!0,message:"Shutting down"});setTimeout(()=>b(),100);return}if(Q.method==="POST"&&z==="/command"){try{const J=JSON.parse(await KJ(Q));if(!J.id){N($,400,{ok:!1,error:"Missing command id"});return}if(J.action==="lease-release"){if(typeof J.runId==="string")C.releaseByRunId(J.runId);N($,200,{id:J.id,ok:!0});return}const G=c(typeof J.contextId==="string"?J.contextId:void 0);if(!G.connection){N($,G.errorCode==="profile_required"?409:503,{id:J.id,ok:!1,errorCode:G.errorCode,error:G.error,...G.errorHint?{errorHint:G.errorHint}:{}});return}let H,L;if(zJ(J)){const B=Date.now(),k=ZJ(G.connection.contextId,J.surface,J.session),_=C.touch(k,{runId:J.runId,command:typeof J.command==="string"&&J.command?J.command:J.action,now:B,hasPendingWork:m});if(!_.granted){const X=VJ(J.session,_.holder,B);Y.warn(`[daemon] Session ${k} busy — rejected ${J.command??J.action} (runId=${J.runId}); held by ${_.holder.command} (runId=${_.holder.runId})`);N($,X.status,{id:J.id,ok:!1,errorCode:X.errorCode,error:X.message,errorHint:X.errorHint});return}H=k;L=J.runId}const W=typeof J.deadlineAt==="number"&&J.deadlineAt>0?Math.max(1000,J.deadlineAt-Date.now()):typeof J.timeout==="number"&&J.timeout>0?J.timeout*1000:120000,R=A.get(J.id);if(R){const B=await new Promise((k,_)=>{R.settlers.push({resolve:k,reject:_})});N($,200,B);return}const O=await new Promise((B,k)=>{const _=setTimeout(()=>{const K=A.get(J.id);if(!K)return;F.traceTimeout(J.id,W,typeof J.action==="string"?J.action:void 0);const M=e(K.action,W);if(M.countAsCommandResultUnknown&&K.dispatched){q++;Y.warn(`[daemon] Command timed out after dispatch (id=${J.id}, action=${K.action}, timeout=${W}ms)`)}j(J.id,K,{error:new S(M.message,M.errorCode,M.errorHint,M.status)})},W),X={contextId:G.connection.contextId,action:typeof J.action==="string"?J.action:"unknown",dispatched:!1,dispatchTs:Date.now(),settlers:[{resolve:B,reject:k}],timer:_,...H&&L?{leaseKey:H,runId:L}:{}};A.set(J.id,X);const y=(K)=>{if(A.get(J.id)!==X)return;const M=g(X.contextId);j(J.id,X,{error:new S(M.message,M.errorCode,M.errorHint,M.status)});Y.warn(`[daemon] Failed to dispatch command ${J.id}: ${K instanceof Error?K.message:String(K)}`)};try{G.connection.ws.send(JSON.stringify(J),(K)=>{if(K&&!X.dispatched)y(K)});X.dispatched=!0;F.traceDispatch(J)}catch(K){y(K)}});N($,200,O)}catch(J){const G=J instanceof S?J:null;if(J instanceof f){N($,413,{ok:!1,error:J.message,errorCode:"body_too_large"});return}N($,G?.status??(J instanceof Error&&J.message.includes("timeout")?408:400),{ok:!1,error:J instanceof Error?J.message:"Invalid request",...G?.errorCode?{errorCode:G.errorCode}:{},...G?.errorHint?{errorHint:G.errorHint}:{}})}return}N($,404,{error:"Not found"})}const v=p((Q,$)=>{d(Q,$).catch(()=>{$.writeHead(500);$.end()})}),h=p((Q,$)=>{d(Q,$).catch(()=>{$.writeHead(500);$.end()})}),w=new a({noServer:!0});function i(Q,$,Z){if((Q.url??"/").split("?")[0]!=="/ext"){$.destroy();return}const V=Q.headers.origin;if(V&&!V.startsWith("chrome-extension://")){$.destroy();return}w.handleUpgrade(Q,$,Z,(J)=>{w.emit("connection",J,Q)})}v.on("upgrade",i);h.on("upgrade",i);w.on("connection",(Q)=>{Y.info("[daemon] Extension connected");let $=0;const Z=setInterval(()=>{if(Q.readyState!==P.OPEN){clearInterval(Z);return}if($>=2){Y.warn("[daemon] Extension heartbeat lost, closing connection");clearInterval(Z);Q.terminate();return}$++;Q.ping()},15000);Q.on("pong",()=>{$=0});Q.on("message",(z)=>{try{const V=JSON.parse(z.toString());if(V.type==="hello"){if(V.brand!=="publishport"){Y.warn(`[daemon] 拒绝非 PublishPort 官方扩展连接(brand=${JSON.stringify(V.brand)})`);Q.close(4003,"Only the official PublishPort extension is supported");return}const G=NJ(Q,V.contextId);G.extensionVersion=typeof V.version==="string"?V.version:null;G.extensionCompatRange=typeof V.compatRange==="string"?V.compatRange:null;G.lastSeenAt=Date.now();Y.info(`[daemon] Extension profile connected: ${G.contextId}`);F.traceExtConnect(G.contextId,G.extensionVersion);return}if(V.type==="log"){if(V.level==="error")Y.error(`[ext] ${V.msg}`);else if(V.level==="warn")Y.warn(`[ext] ${V.msg}`);else Y.info(`[ext] ${V.msg}`);YJ({level:V.level,msg:V.msg,ts:V.ts??Date.now()});F.traceExtLog(V.level,V.msg);return}if(V.type==="ping")return;const J=A.get(V.id);if(J){F.traceResult(V.id,!!V.ok,Date.now()-J.dispatchTs,typeof V.errorCode==="string"?V.errorCode:void 0,typeof V.page==="string"?V.page:void 0);j(V.id,J,{data:V})}}catch(V){const J=z.toString().slice(0,200);Y.warn(`[daemon] Ignoring malformed WS message from extension: ${V instanceof Error?V.message:String(V)} (first 200 chars: ${JSON.stringify(J)})`)}});Q.on("close",()=>{Y.info("[daemon] Extension disconnected");clearInterval(Z);u(Q)});Q.on("error",()=>{clearInterval(Z);u(Q)})});v.listen(T,"127.0.0.1",()=>{Y.info(`[daemon] Listening on http://127.0.0.1:${T}`)});v.on("error",(Q)=>{if(Q.code==="EADDRINUSE"){Y.error(`[daemon] Port ${T} already in use — another daemon is likely running. Exiting.`);process.exit(E.SERVICE_UNAVAIL)}Y.error(`[daemon] Server error: ${Q.message}`);process.exit(E.GENERIC_ERROR)});h.listen(I,"127.0.0.1",()=>{Y.info(`[daemon] Listening on http://127.0.0.1:${I} (PublishPort extension)`)});h.on("error",(Q)=>{Y.warn(`[daemon] PublishPort port ${I} unavailable (${Q.message}) — extension will fall back to ${T}`)});function b(){for(const[Q,$]of A){const Z=$.dispatched?new S("Daemon shutting down before the command completed.","daemon_shutting_down","The daemon is being replaced; a journaling extension replays the command result on retry.",503):(()=>{const z=g($.contextId);return new S(z.message,z.errorCode,z.errorHint,z.status)})();j(Q,$,{error:Z})}A.clear();for(const Q of U.values())Q.ws.close();h.close();v.close(()=>process.exit(E.SUCCESS));setTimeout(()=>{v.closeIdleConnections?.();h.closeIdleConnections?.();setTimeout(()=>process.exit(E.SUCCESS),500).unref()},100).unref()}process.on("SIGTERM",b);process.on("SIGINT",b);
|
package/dist/src/doctor.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Simplified for the daemon-based architecture.
|
|
5
5
|
*/
|
|
6
|
-
import type { BrowserProfileStatus } from './browser/daemon-
|
|
6
|
+
import type { BrowserProfileStatus } from './browser/daemon-transport.js';
|
|
7
7
|
import { type AdapterShadow } from './adapter-shadow.js';
|
|
8
8
|
export type DoctorOptions = {
|
|
9
9
|
yes?: boolean;
|
package/dist/src/doctor.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import{DEFAULT_DAEMON_PORT as
|
|
2
|
-
This usually means the daemon crashed or exited right after serving the live probe.`);else if(!K)J.push("Daemon is not running. It should start automatically when you run an ppcli browser command.");if(Q&&q.cliVersion)J.push(
|
|
3
|
-
This usually means the Browser Bridge service worker is reconnecting slowly or Chrome suspended it.`);else if(K
|
|
1
|
+
import{DEFAULT_DAEMON_PORT as M}from"./constants.js";import{BrowserBridge as k}from"./browser/index.js";import{setDaemonCommandTimeoutSeconds as P}from"./browser/daemon-client.js";import{getDaemonHealth as _}from"./browser/daemon-transport.js";import{getErrorMessage as F}from"./errors.js";import{getRuntimeLabel as E}from"./runtime-detect.js";import{aliasForContextId as I,loadProfileConfig as O}from"./browser/profile.js";import{formatDaemonVersion as N,isDaemonStale as T,staleDaemonIssue as b}from"./browser/daemon-version.js";import{findShadowedUserAdapters as j,formatAdapterShadowIssue as y}from"./adapter-shadow.js";const w=8,V="__doctor__";function U(q){const z=q.replace(/^v/,"").split("-")[0].split(".").map(Number);if(z.length<3||z.some(isNaN))return null;return[z[0],z[1],z[2]]}function D(q,z){const G=z.match(/^(>=?)\s*(\S+)$/);if(!G)return!0;const[,K,W]=G,Y=U(q),Z=U(W);if(!Y||!Z)return!0;const Q=Y[0]-Z[0]||Y[1]-Z[1]||Y[2]-Z[2];return K===">="?Q>=0:Q>0}export async function checkConnectivity(q){const z=Date.now(),G=q?.timeout??w;P(G);try{const K=new k,W=await K.connect({timeout:G,session:V,surface:"browser"});try{await W.evaluate("1 + 1");await W.closeWindow?.()}finally{await K.close()}return{ok:!0,durationMs:Date.now()-z}}catch(K){return{ok:!1,error:F(K),durationMs:Date.now()-z}}finally{P(null)}}export async function runBrowserDoctor(q={}){const z=await checkConnectivity(),G=await _(),K=G.state!=="stopped",W=G.state==="ready",Y=z.ok&&!K,Z=z.ok&&K&&!W,Q=T(G.status,q.cliVersion),$=G.status?.profiles,X=G.status?.extensionVersion,H=j(),J=[];if(Y)J.push(`Daemon connectivity is unstable. The live browser test succeeded, but the daemon was no longer running immediately afterward.
|
|
2
|
+
This usually means the daemon crashed or exited right after serving the live probe.`);else if(!K)J.push("Daemon is not running. It should start automatically when you run an ppcli browser command.");if(Q&&q.cliVersion)J.push(b(G.status,q.cliVersion));if(Z)J.push(`Extension connection is unstable. The live browser test succeeded, but the daemon reported the extension disconnected immediately afterward.
|
|
3
|
+
This usually means the Browser Bridge service worker is reconnecting slowly or Chrome suspended it.`);else if(K&&!W)if(G.state==="profile-required")J.push(`Multiple Chrome profiles are connected to the daemon, but no default profile was selected.
|
|
4
4
|
Run ppcli profile list, then ppcli profile use <name>, or pass --profile <name>.`);else if(G.state==="profile-disconnected")J.push(`Selected browser profile is not connected: ${G.status?.contextId??"unknown"}.
|
|
5
5
|
Open that Chrome profile and make sure the ppcli extension is enabled.`);else J.push(`Daemon is running but the Chrome/Chromium extension is not connected.
|
|
6
6
|
If the extension is already installed, try: ppcli daemon restart
|
|
7
7
|
If the extension is not installed, install the PublishPort browser extension
|
|
8
|
-
from the PublishPort desktop app.`);if(
|
|
8
|
+
from the PublishPort desktop app.`);if(W&&!X)J.push(`Extension is connected but did not report a version.
|
|
9
9
|
This usually means an outdated Browser Bridge extension.
|
|
10
|
-
Reload or reinstall the PublishPort browser extension via the PublishPort desktop app.`);if(!z.ok)J.push(`Browser connectivity test failed: ${z.error??"unknown"}`);const B=G.status?.extensionCompatRange;if(
|
|
11
|
-
Update via the PublishPort desktop app (or: npm install -g publishport-ppcli).`)}else if(
|
|
12
|
-
Update the PublishPort browser extension via the PublishPort desktop app.`)}if(H.length>0)J.push(
|
|
10
|
+
Reload or reinstall the PublishPort browser extension via the PublishPort desktop app.`);if(!z.ok)J.push(`Browser connectivity test failed: ${z.error??"unknown"}`);const B=G.status?.extensionCompatRange;if(X&&q.cliVersion&&B){if(!D(q.cliVersion,B))J.push(`CLI version incompatible with extension: extension v${X} requires CLI ${B}, but CLI is v${q.cliVersion}
|
|
11
|
+
Update via the PublishPort desktop app (or: npm install -g publishport-ppcli).`)}else if(X&&q.cliVersion){const A=X.split(".")[0],L=q.cliVersion.split(".")[0];if(A!==L)J.push(`Extension major version mismatch: extension v${X} ≠ CLI v${q.cliVersion}
|
|
12
|
+
Update the PublishPort browser extension via the PublishPort desktop app.`)}if(H.length>0)J.push(y(H));return{cliVersion:q.cliVersion,daemonRunning:K,daemonFlaky:Y,daemonStale:Q,daemonVersion:G.status?.daemonVersion,extensionConnected:W,extensionFlaky:Z,extensionVersion:X,connectivity:z,profiles:$,adapterShadows:H,issues:J}}export function renderBrowserDoctorReport(q){const z=[`ppcli v${q.cliVersion??"unknown"} doctor (${E()})`,""],G=q.daemonFlaky?"[WARN]":q.daemonStale?"[WARN]":q.daemonRunning?"[OK]":"[MISSING]",K=q.daemonFlaky?"unstable (running during live check, then stopped)":q.daemonRunning?`running on port ${M} (${q.daemonStale?`${N(q)}, stale; CLI v${q.cliVersion??"unknown"}`:N(q)})`:"not running";z.push(`${G} Daemon: ${K}`);const W=q.extensionFlaky||q.extensionConnected&&!q.extensionVersion?"[WARN]":q.extensionConnected?"[OK]":"[MISSING]",Y=!q.extensionConnected?"":q.extensionVersion?` (v${q.extensionVersion})`:" (version unknown)",Z=q.extensionFlaky?"unstable (connected during live check, then disconnected)":q.extensionConnected?"connected":"not connected";z.push(`${W} Extension: ${Z}${Y}`);if(q.profiles&&q.profiles.length>0){const Q=O();z.push("","Profiles:");for(const $ of q.profiles){const X=I(Q,$.contextId),H=X?` (${X})`:"",J=Q.defaultContextId===$.contextId?", default":"",B=$.extensionVersion?`v${$.extensionVersion}`:"version unknown";z.push(` • ${$.contextId}${H}: connected ${B}${J}`)}}if(q.connectivity){const Q=q.connectivity.ok?"[OK]":"[FAIL]",$=q.connectivity.ok?`connected in ${(q.connectivity.durationMs/1000).toFixed(1)}s`:`failed (${q.connectivity.error??"unknown"})`;z.push(`${Q} Connectivity: ${$}`)}if(q.issues.length){z.push("","Issues:");for(const Q of q.issues)z.push(` • ${Q}`)}else if(q.daemonRunning&&q.extensionConnected)z.push("","Everything looks good!");return z.join(`
|
|
13
13
|
`)}
|
package/dist/src/errors.d.ts
CHANGED
|
@@ -67,6 +67,14 @@ export declare class RateLimitError extends CliError {
|
|
|
67
67
|
export declare class ArgumentError extends CliError {
|
|
68
68
|
constructor(message: string, hint?: string);
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Thrown when a write command cannot run because another write command already
|
|
72
|
+
* holds the same persistent site session (see session-lease.ts). Uses TEMPFAIL
|
|
73
|
+
* so callers/scripts treat it as "retry later" rather than a permanent failure.
|
|
74
|
+
*/
|
|
75
|
+
export declare class SessionBusyError extends CliError {
|
|
76
|
+
constructor(message: string, hint?: string);
|
|
77
|
+
}
|
|
70
78
|
export declare class EmptyResultError extends CliError {
|
|
71
79
|
constructor(command: string, hint?: string);
|
|
72
80
|
}
|
package/dist/src/errors.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const EXIT_CODES={SUCCESS:0,GENERIC_ERROR:1,USAGE_ERROR:2,EMPTY_RESULT:66,SERVICE_UNAVAIL:69,TEMPFAIL:75,NOPERM:77,CONFIG_ERROR:78,INTERRUPTED:130};export class CliError extends Error{code;hint;exitCode;constructor(j,q,F,G=EXIT_CODES.GENERIC_ERROR){super(q);this.name=new.target.name;this.code=j;this.hint=F;this.exitCode=G}}const J=Symbol.for("ppcli.traceReceipt");export function attachTraceReceipt(j,q){if(!j||typeof j!=="object"&&typeof j!=="function")return;try{Object.defineProperty(j,J,{value:q,enumerable:!1,configurable:!0})}catch{}}export function getTraceReceipt(j){if(!j||typeof j!=="object"&&typeof j!=="function")return;return j[J]}export class BrowserConnectError extends CliError{kind;constructor(j,q,F="unknown"){super("BROWSER_CONNECT",j,q,EXIT_CODES.SERVICE_UNAVAIL);this.kind=F}}export class CommandExecutionError extends CliError{constructor(j,q){super("COMMAND_EXEC",j,q,EXIT_CODES.GENERIC_ERROR)}}export class ConfigError extends CliError{constructor(j,q){super("CONFIG",j,q,EXIT_CODES.CONFIG_ERROR)}}export class AuthRequiredError extends CliError{domain;constructor(j,q){super("AUTH_REQUIRED",q??`Not logged in to ${j}`,`Please open Chrome or Chromium and log in to https://${j}`,EXIT_CODES.NOPERM);this.domain=j}}export class TimeoutError extends CliError{constructor(j,q,F){super("TIMEOUT",`${j} timed out after ${q}s`,F??"Try again, or increase timeout with --timeout <seconds> (or OPENCLI_BROWSER_COMMAND_TIMEOUT for the global default)",EXIT_CODES.TEMPFAIL)}}export class RateLimitError extends CliError{constructor(j,q,F){const G=N(q);super("RATE_LIMITED",`Rate limit exceeded for ${j}`,`Retry after ${G}. To change this limit, edit ${F}.`,EXIT_CODES.TEMPFAIL)}}export class ArgumentError extends CliError{constructor(j,q){super("ARGUMENT",j,q,EXIT_CODES.USAGE_ERROR)}}export class EmptyResultError extends CliError{constructor(j,q){super("EMPTY_RESULT",`${j} returned no data`,q??"The page structure may have changed, or you may need to log in",EXIT_CODES.EMPTY_RESULT)}}export function adapterLoadError(j,q){return new CliError("ADAPTER_LOAD",j,q,EXIT_CODES.SERVICE_UNAVAIL)}export function selectorError(j,q){return new CliError("SELECTOR",`Could not find element: ${j}`,q??"The page UI may have changed. Please report this issue.",EXIT_CODES.GENERIC_ERROR)}export class PluginError extends CliError{constructor(j,q){super("PLUGIN",j,q,EXIT_CODES.GENERIC_ERROR)}}function N(j){const q=Math.max(0,Math.ceil(j));if(q<1000)return`${q}ms`;const F=Math.ceil(q/1000);if(F<60)return`${F}s`;const G=Math.ceil(F/60);if(G<60)return`${G}m`;return`${Math.ceil(G/60)}h`}export class LoginWallError extends CliError{status;url;bodyPreview;constructor(j,q,F,G,H){super("LOGIN_WALL",j,H??"The server returned an HTML page instead of JSON — likely a login wall, rate limit, or WAF challenge. Try re-logging in via your browser, or wait a few minutes before retrying.",EXIT_CODES.NOPERM);this.status=q;this.url=F;this.bodyPreview=G}}export function getErrorMessage(j){return j instanceof Error?j.message:String(j)}function K(j,q=0){if(q>10)return"(cause chain truncated)";if(j instanceof Error){const F=[j.message];if(j.cause)F.push(` caused by: ${K(j.cause,q+1)}`);return F.join(`
|
|
1
|
+
export const EXIT_CODES={SUCCESS:0,GENERIC_ERROR:1,USAGE_ERROR:2,EMPTY_RESULT:66,SERVICE_UNAVAIL:69,TEMPFAIL:75,NOPERM:77,CONFIG_ERROR:78,INTERRUPTED:130};export class CliError extends Error{code;hint;exitCode;constructor(j,q,F,G=EXIT_CODES.GENERIC_ERROR){super(q);this.name=new.target.name;this.code=j;this.hint=F;this.exitCode=G}}const J=Symbol.for("ppcli.traceReceipt");export function attachTraceReceipt(j,q){if(!j||typeof j!=="object"&&typeof j!=="function")return;try{Object.defineProperty(j,J,{value:q,enumerable:!1,configurable:!0})}catch{}}export function getTraceReceipt(j){if(!j||typeof j!=="object"&&typeof j!=="function")return;return j[J]}export class BrowserConnectError extends CliError{kind;constructor(j,q,F="unknown"){super("BROWSER_CONNECT",j,q,EXIT_CODES.SERVICE_UNAVAIL);this.kind=F}}export class CommandExecutionError extends CliError{constructor(j,q){super("COMMAND_EXEC",j,q,EXIT_CODES.GENERIC_ERROR)}}export class ConfigError extends CliError{constructor(j,q){super("CONFIG",j,q,EXIT_CODES.CONFIG_ERROR)}}export class AuthRequiredError extends CliError{domain;constructor(j,q){super("AUTH_REQUIRED",q??`Not logged in to ${j}`,`Please open Chrome or Chromium and log in to https://${j}`,EXIT_CODES.NOPERM);this.domain=j}}export class TimeoutError extends CliError{constructor(j,q,F){super("TIMEOUT",`${j} timed out after ${q}s`,F??"Try again, or increase timeout with --timeout <seconds> (or OPENCLI_BROWSER_COMMAND_TIMEOUT for the global default)",EXIT_CODES.TEMPFAIL)}}export class RateLimitError extends CliError{constructor(j,q,F){const G=N(q);super("RATE_LIMITED",`Rate limit exceeded for ${j}`,`Retry after ${G}. To change this limit, edit ${F}.`,EXIT_CODES.TEMPFAIL)}}export class ArgumentError extends CliError{constructor(j,q){super("ARGUMENT",j,q,EXIT_CODES.USAGE_ERROR)}}export class SessionBusyError extends CliError{constructor(j,q){super("SESSION_BUSY",j,q,EXIT_CODES.TEMPFAIL)}}export class EmptyResultError extends CliError{constructor(j,q){super("EMPTY_RESULT",`${j} returned no data`,q??"The page structure may have changed, or you may need to log in",EXIT_CODES.EMPTY_RESULT)}}export function adapterLoadError(j,q){return new CliError("ADAPTER_LOAD",j,q,EXIT_CODES.SERVICE_UNAVAIL)}export function selectorError(j,q){return new CliError("SELECTOR",`Could not find element: ${j}`,q??"The page UI may have changed. Please report this issue.",EXIT_CODES.GENERIC_ERROR)}export class PluginError extends CliError{constructor(j,q){super("PLUGIN",j,q,EXIT_CODES.GENERIC_ERROR)}}function N(j){const q=Math.max(0,Math.ceil(j));if(q<1000)return`${q}ms`;const F=Math.ceil(q/1000);if(F<60)return`${F}s`;const G=Math.ceil(F/60);if(G<60)return`${G}m`;return`${Math.ceil(G/60)}h`}export class LoginWallError extends CliError{status;url;bodyPreview;constructor(j,q,F,G,H){super("LOGIN_WALL",j,H??"The server returned an HTML page instead of JSON — likely a login wall, rate limit, or WAF challenge. Try re-logging in via your browser, or wait a few minutes before retrying.",EXIT_CODES.NOPERM);this.status=q;this.url=F;this.bodyPreview=G}}export function getErrorMessage(j){return j instanceof Error?j.message:String(j)}function K(j,q=0){if(q>10)return"(cause chain truncated)";if(j instanceof Error){const F=[j.message];if(j.cause)F.push(` caused by: ${K(j.cause,q+1)}`);return F.join(`
|
|
2
2
|
`)}return String(j)}export function toEnvelope(j){const q=j instanceof Error&&j.cause?K(j.cause):void 0,F=getTraceReceipt(j),G=F?{traceId:F.traceId,dir:F.traceDir,summaryPath:F.summaryPath,receiptPath:F.receiptPath,status:F.status}:void 0;if(j instanceof CliError)return{ok:!1,error:{code:j.code,message:j.message,...j.hint?{help:j.hint}:{},exitCode:j.exitCode,...q?{cause:q}:{}},...G?{trace:G}:{}};return{ok:!1,error:{code:"UNKNOWN",message:getErrorMessage(j),exitCode:EXIT_CODES.GENERIC_ERROR,...q?{cause:q}:{}},...G?{trace:G}:{}}}
|
package/dist/src/execution.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{getRegistry as
|
|
2
|
-
`);try{X?.(Z)}catch(J){
|
|
1
|
+
import{getRegistry as qq,fullName as y}from"./registry.js";import{pathToFileURL as Qq}from"node:url";import*as Xq from"node:crypto";import*as u from"node:fs";import*as Yq from"node:os";import{executePipeline as l}from"./pipeline/index.js";import{adapterLoadError as Zq,ArgumentError as F,CommandExecutionError as A,SessionBusyError as $q,attachTraceReceipt as Jq,getErrorMessage as c}from"./errors.js";import{shouldUseBrowserSession as Gq}from"./capabilityRouting.js";import{getBrowserFactory as Hq,browserSession as Vq,runWithTimeout as i,DEFAULT_BROWSER_COMMAND_TIMEOUT as zq}from"./runtime.js";import{withBrowserSessionLockIf as Lq}from"./browser-session-lock.js";import{resolveProfile as Wq}from"./browser/profile.js";import{ensureManagedChrome as Fq}from"./browser/managed-chrome.js";import{generateFingerprintJs as Kq}from"./browser/fingerprint.js";import{CDPBridge as Bq}from"./browser/index.js";import{clearDaemonRunContext as yq,generateRunId as Iq,isUnknownOutcomeError as m,releaseSiteSessionLease as n,setDaemonCommandTimeoutSeconds as jq,setDaemonRunContext as d}from"./browser/daemon-client.js";import{emitHook as D}from"./hooks.js";import{log as t}from"./logger.js";import{isElectronApp as _q}from"./electron-apps.js";import{probeCDP as Oq,resolveElectronEndpoint as Uq}from"./launcher.js";import{ObservationSession as Cq,exportObservationSession as Nq}from"./observation/index.js";import{resolveAdapterSourcePath as Aq}from"./adapter-source.js";const O=new Map,k=new Map,xq=`${Yq.homedir()}/.ppcli/clis/`;function Tq(q){if(q===void 0||q===null||q===""||q==="off")return"off";if(q==="on"||q==="retain-on-failure")return q;throw new F(`--trace must be one of: off, on, retain-on-failure. Received: "${String(q)}"`)}export function coerceAndValidateArgs(q,Q){const Y={...Q};for(const X of q){const Z=Y[X.name];if(X.required&&(Z===void 0||Z===null||Z===""))throw new F(`Argument "${X.name}" is required.`,X.help??`Provide a value for --${X.name}`);if(Z!==void 0&&Z!==null){if(X.type==="int"||X.type==="number"){const z=Number(Z);if(Number.isNaN(z))throw new F(`Argument "${X.name}" must be a valid number. Received: "${Z}"`);Y[X.name]=z}else if(X.type==="boolean"||X.type==="bool")if(typeof Z==="string"){const z=Z.toLowerCase();if(z==="true"||z==="1")Y[X.name]=!0;else if(z==="false"||z==="0")Y[X.name]=!1;else throw new F(`Argument "${X.name}" must be a boolean (true/false). Received: "${Z}"`)}else Y[X.name]=Boolean(Z);const J=Y[X.name];if(X.choices&&X.choices.length>0){if(!X.choices.map(String).includes(String(J)))throw new F(`Argument "${X.name}" must be one of: ${X.choices.join(", ")}. Received: "${J}"`)}}else if(X.default!==void 0)Y[X.name]=X.default}return Y}async function S(q,Q,Y,X){const Z=q;if(Z._lazy&&Z._modulePath){const J=Z._modulePath;if(J.startsWith(xq)&&O.has(J))try{const L=u.statSync(J),$=k.get(J);if($!==void 0&&L.mtimeMs!==$)O.delete(J)}catch{}if(!O.has(J)){const L=Qq(J).href,_=import(k.has(J)?`${L}?t=${Date.now()}`:L).then(()=>{try{k.set(J,u.statSync(J).mtimeMs)}catch{}},(K)=>{O.delete(J);throw Zq(`Failed to load adapter module ${J}: ${c(K)}`,"Check that the adapter file exists and has no syntax errors.")});O.set(J,_)}await O.get(J);const W=qq().get(y(q));if(W?.func)return s(W,Q,Y,X);if(W?.pipeline)return l(Q,W.pipeline,{args:Y,debug:X})}if(q.func)return s(q,Q,Y,X);if(q.pipeline)return l(Q,q.pipeline,{args:Y,debug:X});throw new A(`Command ${y(q)} has no func or pipeline`,"This is likely a bug in the adapter definition. Please report this issue.")}function s(q,Q,Y,X){if(q.browser===!1)return q.func(Y,X);if(!Q)throw new A(`Command ${y(q)} requires a browser session but none was provided`);return q.func(Q,Y,X)}function Rq(q){if(q.navigateBefore===!1)return null;if(typeof q.navigateBefore==="string")return q.navigateBefore;return null}function Mq(q,Q){if(!q||!Q)return!1;try{const Y=new URL(q).hostname;return Y===Q||Y.endsWith(`.${Q}`)}catch{return!1}}function Pq(q,Q){if(!Q)return!1;try{const Y=new URL(q),X=Y.hostname===Q||Y.hostname.endsWith(`.${Q}`),Z=Y.pathname===""||Y.pathname==="/";return X&&Z&&Y.search===""&&Y.hash===""}catch{return!1}}async function Dq(q,Q,Y,X){if(Y!=="persistent"||!q.domain)return!0;if(!Pq(X,q.domain))return!0;const Z=await Q.getCurrentUrl?.().catch(()=>null);return!Mq(Z,q.domain)}export async function executeCommand(q,Q,Y=!1,X={}){let Z;try{Z=X.prepared?Q:prepareCommandArgs(q,Q)}catch($){if($ instanceof F)throw $;throw new F(c($))}const J=wq(q,Z);jq(J);const z=Tq(X.trace),W={command:y(q),args:Z,startedAt:Date.now()};await D("onBeforeExecute",W);let L;try{if(Gq(q)){const $=_q(q.site),_=Wq(X.profile),K=!$&&_.kind==="managed"?_:null;let N;const x=vq(q.defaultWindowMode??"background",X.windowMode);let b,f;if(K){N=await Fq(K.name,K.entry,{windowMode:x});const H=K.entry.fingerprint;if(H?.enabled)b=Kq(H);const G=K.entry.proxy;if(G?.username)f={username:G.username,password:G.password??""}}else if($){const H=process.env.OPENCLI_CDP_ENDPOINT;if(H){const G=Number(new URL(H).port);if(!await Oq(G))throw new A(`CDP not reachable at ${H}`,"Check that the app is running with --remote-debugging-port and the endpoint is correct.");N=H}else N=await Uq(q.site)}const r=K?Bq:Hq(q.site),v=!K&&_.kind==="extension"?_.contextId:void 0,e=q,U=hq(q,X.siteSession),T=Eq(q,U),w=fq(U,X.keepTab),I=U==="persistent"&&q.access==="write"?{runId:Iq(),session:T}:null;if(I)d({runId:I.runId,command:y(q),access:"write"});let p,g=!1,R=null;try{L=await Lq(x!=="foreground",()=>Vq(r,async(H)=>{const G=z==="off"?null:new Cq({scope:{contextId:v??K?.name,session:T,target:H.getActivePage?.(),site:q.site,command:y(q),adapterSourcePath:Aq(e)}});if(G){G.record({stream:"action",name:"command",phase:"start",data:{args:Z}});await H.startNetworkCapture?.().catch(()=>!1)}const B=Rq(q);if(B&&await Dq(q,H,U,B)){G?.record({stream:"action",name:"pre_navigate",phase:"start",data:{url:B}});try{await H.goto(B);G?.record({stream:"action",name:"pre_navigate",phase:"end",data:{url:B}})}catch(V){if(V instanceof $q)throw V;G?.record({stream:"action",name:"pre_navigate",phase:"error",data:{url:B,error:V instanceof Error?V.message:String(V)}});const j=new A(`Pre-navigation to ${B} failed: ${V instanceof Error?V.message:V}`,"Check that the site is reachable and the browser extension is running.");j.cause=V;if(G&&(z==="on"||z==="retain-on-failure")){G.record({stream:"error",message:j.message,stack:j.stack,code:j.code,hint:j.hint});await h(G,H).catch(()=>{});E(G,"failure",j,X.onTraceExport)}throw j}}const C=J!==null?J+o:zq,M=S(q,H,Z,Y);R=M;let P=!1;M.then(()=>{P=!0},()=>{P=!0});try{const V=await i(M,{timeout:C,label:y(q)});G?.record({stream:"action",name:"command",phase:"end"});if(G&&z==="on"){await h(G,H).catch(()=>{});E(G,"success",void 0,X.onTraceExport)}if(!w)await H.closeWindow?.().catch(()=>{});return V}catch(V){if(!P)g=!0;if(G){G.record({stream:"action",name:"command",phase:"error",data:{error:V instanceof Error?V.message:String(V)}});G.record({stream:"error",message:V instanceof Error?V.message:String(V),stack:V instanceof Error?V.stack:void 0});if(z==="on"||z==="retain-on-failure"){await h(G,H).catch(()=>{});E(G,"failure",V,X.onTraceExport)}}if(!w)await H.closeWindow?.().catch(()=>{});throw V}},{session:T,cdpEndpoint:N,contextId:v,windowMode:x,surface:"adapter",siteSession:U,initScript:b,proxyAuth:f}),K?.name)}catch(H){p=H;throw H}finally{if(I)if(g&&R){const{runId:H,session:G}=I,B=(C)=>{yq(H);if(!m(C))n({runId:H,session:G,surface:"adapter"})};R.then(()=>B(),(C)=>B(C))}else{d(null);if(!m(p))await n({runId:I.runId,session:I.session,surface:"adapter"})}}}else if(J!==null){const $=J+o;L=await i(S(q,null,Z,Y),{timeout:$,label:y(q),hint:`Pass a higher --timeout value (currently ${J}s)`})}else L=await S(q,null,Z,Y)}catch($){W.error=$;W.finishedAt=Date.now();await D("onAfterExecute",W);throw $}W.finishedAt=Date.now();await D("onAfterExecute",W,L);return L}async function h(q,Q){const Y=Q.getActivePage?.()??q.scope.target,[X,Z,J,z,W]=await Promise.all([Q.getCurrentUrl?.().catch(()=>null)??Promise.resolve(null),Q.snapshot().catch(()=>{return}),Q.readNetworkCapture?.().catch(()=>[])??Promise.resolve([]),Q.consoleMessages("all").catch(()=>[]),Q.screenshot({format:"png"}).catch(()=>{return})]);if(Z!==void 0||X!==void 0)q.record({stream:"state",url:X,target:Y,snapshot:Z,label:"final"});for(const L of Array.isArray(J)?J:[]){const $=L;q.record({stream:"network",url:String($.url??""),method:typeof $.method==="string"?$.method:void 0,status:typeof $.responseStatus==="number"?$.responseStatus:void 0,contentType:typeof $.responseContentType==="string"?$.responseContentType:void 0,size:typeof $.responseBodyFullSize==="number"?$.responseBodyFullSize:void 0,requestHeaders:$.requestHeaders,responseHeaders:$.responseHeaders,requestBody:$.requestBodyPreview,responseBody:$.responsePreview,ts:typeof $.timestamp==="number"?$.timestamp:void 0})}for(const L of Array.isArray(z)?z:[])if(L&&typeof L==="object"){const $=L;q.record({stream:"console",level:String($.type??$.level??"log"),text:String($.text??$.message??""),ts:typeof $.timestamp==="number"?$.timestamp:void 0})}else q.record({stream:"console",level:"log",text:String(L)});if(typeof W==="string"&&W)q.record({stream:"screenshot",format:"png",data:W,label:"final"})}function E(q,Q,Y,X){try{const Z=Nq(q,{error:Y,status:Q});if(Q==="failure"&&Y!==void 0)Jq(Y,Z.receipt);else process.stderr.write(`ppcli trace artifact: ${Z.dir}
|
|
2
|
+
`);try{X?.(Z)}catch(J){t.warn(`[trace] Trace export callback failed: ${J instanceof Error?J.message:String(J)}`)}return Z}catch(Z){t.warn(`[trace] Failed to export trace artifact: ${Z instanceof Error?Z.message:String(Z)}`);return}}export function prepareCommandArgs(q,Q){const Y=coerceAndValidateArgs(q.args,Q);q.validateArgs?.(Y);return Y}const o=30;function kq(q){if(q===void 0||q===null||q==="")return null;if(q==="ephemeral"||q==="persistent")return q;throw new F(`--site-session must be one of: ephemeral, persistent. Received: "${String(q)}"`)}function Sq(){const q=process.env.PUBLISHPORT_SITE_SESSION;if(q==="persistent"||q==="ephemeral")return q;return null}function hq(q,Q){return kq(Q)??Sq()??q.siteSession??"persistent"}function Eq(q,Q){if(Q==="persistent")return`site:${q.site}`;return`site:${q.site}:${Xq.randomUUID()}`}function bq(q,Q){if(Q===void 0||Q==="")return null;if(Q==="true")return!0;if(Q==="false")return!1;throw new F(`${q} must be one of: true, false. Received: "${String(Q)}"`)}function fq(q,Q){if(q==="persistent")return!0;return bq("--keep-tab",Q)??!1}function a(q,Q){if(Q===void 0||Q==="")return null;if(Q==="foreground"||Q==="background")return Q;throw new F(`${q} must be one of: foreground, background. Received: "${String(Q)}"`)}function vq(q="background",Q){return a("--window",Q)??a("OPENCLI_WINDOW",process.env.OPENCLI_WINDOW)??q}function wq(q,Q){if(!q.args.some((Z)=>Z.name==="timeout"))return null;const Y=Q.timeout;if(Y===void 0||Y===null||Y==="")throw new F(`Argument "timeout" must be a positive integer. Received: "${String(Y)}"`);const X=Number(Y);if(!Number.isInteger(X)||X<=0)throw new F(`Argument "timeout" must be a positive integer. Received: "${String(Y)}"`);return X}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-(contextId, surface, session) write-command arbitration for the daemon.
|
|
3
|
+
*
|
|
4
|
+
* A long adapter write command (e.g. `chatgpt ask`, 10-20 min) is hundreds of
|
|
5
|
+
* short 'exec' round-trips against ONE persistent site session. When an outer
|
|
6
|
+
* agent times out and retries while the first process is still alive, both
|
|
7
|
+
* processes drive the same Chrome tab, multiplying renderer load — there is no
|
|
8
|
+
* arbitration at the exec level because two interleaved asks rarely have an
|
|
9
|
+
* exec in flight at the same instant.
|
|
10
|
+
*
|
|
11
|
+
* This registry grants ONE logical write lease per (contextId, surface,
|
|
12
|
+
* session) that spans the whole CLI command run. A second concurrent write
|
|
13
|
+
* fails fast, and a lease whose holder died (kill -9, crash) self-expires
|
|
14
|
+
* after TTL of inactivity so a retry succeeds within a bounded time. Each exec
|
|
15
|
+
* that flows through refreshes the lease, and a holder whose single exec
|
|
16
|
+
* outlives the TTL (e.g. a slow navigate) is still protected while that exec
|
|
17
|
+
* is in flight (see `hasPendingWork`), so a live long-running holder keeps the
|
|
18
|
+
* lease indefinitely.
|
|
19
|
+
*
|
|
20
|
+
* The daemon is the arbiter because it is the single local process that sees
|
|
21
|
+
* every CLI client; keeping the logic here (pure, no I/O) makes it testable
|
|
22
|
+
* without Chrome.
|
|
23
|
+
*/
|
|
24
|
+
/** Inactivity window after which a lease is considered abandoned. */
|
|
25
|
+
export declare const SESSION_LEASE_TTL_MS = 45000;
|
|
26
|
+
/** Machine-readable error code for the fast-fail busy response. */
|
|
27
|
+
export declare const SESSION_BUSY_CODE = "session_busy";
|
|
28
|
+
export interface SessionLeaseHolder {
|
|
29
|
+
/** Stable per logical CLI command run (NOT the per-exec command id). */
|
|
30
|
+
runId: string;
|
|
31
|
+
/** Human command name, e.g. `chatgpt ask`. */
|
|
32
|
+
command: string;
|
|
33
|
+
/** CLI process pid recovered from the runId, for the "kill it" hint. */
|
|
34
|
+
pid: number | null;
|
|
35
|
+
/** When the current holder first acquired the lease. */
|
|
36
|
+
startedAt: number;
|
|
37
|
+
/** Last time an exec from the holder refreshed the lease (heartbeat). */
|
|
38
|
+
lastSeenAt: number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Lease key = `${contextId}␟${surface}␟${encodeURIComponent(session)}`. The
|
|
42
|
+
* Chrome profile (contextId) is part of the key because a persistent site
|
|
43
|
+
* session name like `site:chatgpt` is only unique WITHIN a profile — the same
|
|
44
|
+
* adapter running in two profiles drives two different browsers and must never
|
|
45
|
+
* self-block. The unit separator (U+241F) cannot appear in a contextId or
|
|
46
|
+
* surface value; the session segment matches the extension's own lease-key
|
|
47
|
+
* encoding so both layers partition sessions the same way.
|
|
48
|
+
*/
|
|
49
|
+
export declare function getSessionLeaseKey(contextId: string, surface: string, session: string): string;
|
|
50
|
+
/** CLI runIds are `run_<pid>_<ts>_<rand>`; recover the pid for the busy hint. */
|
|
51
|
+
export declare function parsePidFromRunId(runId: string): number | null;
|
|
52
|
+
export type SessionLeaseCommand = {
|
|
53
|
+
surface?: unknown;
|
|
54
|
+
siteSession?: unknown;
|
|
55
|
+
access?: unknown;
|
|
56
|
+
session?: unknown;
|
|
57
|
+
runId?: unknown;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* A command is subject to lease arbitration only when it is an adapter write
|
|
61
|
+
* against a persistent site session and carries the identity needed to own a
|
|
62
|
+
* lease. Read commands, ephemeral sessions, and non-adapter surfaces are never
|
|
63
|
+
* arbitrated — a user mid-ask must still be able to check state.
|
|
64
|
+
*/
|
|
65
|
+
export declare function isSessionLeaseCommand<T extends SessionLeaseCommand>(command: T): command is T & {
|
|
66
|
+
surface: 'adapter';
|
|
67
|
+
siteSession: 'persistent';
|
|
68
|
+
access: 'write';
|
|
69
|
+
session: string;
|
|
70
|
+
runId: string;
|
|
71
|
+
};
|
|
72
|
+
export interface LeaseTouchResult {
|
|
73
|
+
granted: boolean;
|
|
74
|
+
holder: SessionLeaseHolder;
|
|
75
|
+
}
|
|
76
|
+
export declare class SessionLeaseRegistry {
|
|
77
|
+
private readonly ttlMs;
|
|
78
|
+
private readonly leases;
|
|
79
|
+
constructor(ttlMs?: number);
|
|
80
|
+
/**
|
|
81
|
+
* Acquire or refresh the lease for `key`.
|
|
82
|
+
*
|
|
83
|
+
* - Free key, or the current holder's lease has gone stale (holder died
|
|
84
|
+
* without releasing): the caller takes it — `granted: true`.
|
|
85
|
+
* - Same runId as the current holder: refresh (heartbeat) — `granted: true`.
|
|
86
|
+
* - A different runId while the holder is still alive: `granted: false`, and
|
|
87
|
+
* `holder` describes who to wait for or kill.
|
|
88
|
+
*
|
|
89
|
+
* Liveness is TTL-based, but a TTL-stale holder with a command still in
|
|
90
|
+
* flight is NOT dead — a single exec can legitimately outlast the TTL (e.g.
|
|
91
|
+
* a slow navigate produces no heartbeat until it settles). `hasPendingWork`
|
|
92
|
+
* lets the daemon report that, keeping the registry pure.
|
|
93
|
+
*/
|
|
94
|
+
touch(key: string, input: {
|
|
95
|
+
runId: string;
|
|
96
|
+
command: string;
|
|
97
|
+
now: number;
|
|
98
|
+
hasPendingWork?: (runId: string) => boolean;
|
|
99
|
+
}): LeaseTouchResult;
|
|
100
|
+
/**
|
|
101
|
+
* Refresh the holder's liveness without acquiring: called when one of the
|
|
102
|
+
* holder's in-flight commands settles, so the TTL clock restarts cleanly
|
|
103
|
+
* after an exec that outlived it. A non-owner runId never resurrects or
|
|
104
|
+
* steals a lease here.
|
|
105
|
+
*/
|
|
106
|
+
heartbeat(key: string, runId: string, now: number): void;
|
|
107
|
+
/**
|
|
108
|
+
* Release every lease held by `runId` (idempotent). Keyless on purpose: the
|
|
109
|
+
* release path must not depend on re-resolving the profile route — the
|
|
110
|
+
* profile may have disconnected by the time the CLI releases — and runIds
|
|
111
|
+
* are globally unique, so the runId alone identifies the lease.
|
|
112
|
+
*/
|
|
113
|
+
releaseByRunId(runId: string): void;
|
|
114
|
+
/** Active (non-expired) holder for `key`, lazily evicting a stale one. */
|
|
115
|
+
get(key: string, now: number): SessionLeaseHolder | undefined;
|
|
116
|
+
/**
|
|
117
|
+
* Snapshot of active holders for status surfaces (who owns each session).
|
|
118
|
+
* Uses the same aliveness rule as `touch()`: a TTL-stale holder with a
|
|
119
|
+
* command still in flight is alive, not dead, so `hasPendingWork` keeps it
|
|
120
|
+
* listed. Without it, `/status` would show no holder while challengers are
|
|
121
|
+
* still being rejected — misleading during a single long exec. Read-only:
|
|
122
|
+
* never lazily evicts.
|
|
123
|
+
*/
|
|
124
|
+
list(now: number, hasPendingWork?: (runId: string) => boolean): Array<{
|
|
125
|
+
key: string;
|
|
126
|
+
} & SessionLeaseHolder>;
|
|
127
|
+
}
|
|
128
|
+
export interface SessionBusyFailure {
|
|
129
|
+
message: string;
|
|
130
|
+
errorCode: string;
|
|
131
|
+
errorHint: string;
|
|
132
|
+
status: number;
|
|
133
|
+
}
|
|
134
|
+
/** Build the fast-fail response naming the holder, its pid, and hold time. */
|
|
135
|
+
export declare function buildSessionBusyFailure(session: string, holder: SessionLeaseHolder, now: number): SessionBusyFailure;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SESSION_LEASE_TTL_MS=45000,SESSION_BUSY_CODE="session_busy";export function getSessionLeaseKey(q,b,j){return`${q}␟${b}␟${encodeURIComponent(j)}`}export function parsePidFromRunId(q){const b=/^run_(\d+)_/.exec(q);if(!b)return null;const j=Number(b[1]);return Number.isInteger(j)&&j>0?j:null}export function isSessionLeaseCommand(q){return q.surface==="adapter"&&q.siteSession==="persistent"&&q.access==="write"&&typeof q.session==="string"&&q.session.length>0&&typeof q.runId==="string"&&q.runId.length>0}export class SessionLeaseRegistry{ttlMs;leases=new Map;constructor(q=SESSION_LEASE_TTL_MS){this.ttlMs=q}touch(q,b){const j=this.leases.get(q),z=j!==void 0&&(b.now-j.lastSeenAt<=this.ttlMs||b.hasPendingWork?.(j.runId)===!0);if(j!==void 0&&z&&j.runId!==b.runId)return{granted:!1,holder:j};const G=j!==void 0&&j.runId===b.runId?{...j,command:b.command,lastSeenAt:b.now}:{runId:b.runId,command:b.command,pid:parsePidFromRunId(b.runId),startedAt:b.now,lastSeenAt:b.now};this.leases.set(q,G);return{granted:!0,holder:G}}heartbeat(q,b,j){const z=this.leases.get(q);if(z!==void 0&&z.runId===b)z.lastSeenAt=j}releaseByRunId(q){for(const[b,j]of this.leases)if(j.runId===q)this.leases.delete(b)}get(q,b){const j=this.leases.get(q);if(j===void 0)return;if(b-j.lastSeenAt>this.ttlMs){this.leases.delete(q);return}return j}list(q,b){const j=[];for(const[z,G]of this.leases)if(q-G.lastSeenAt<=this.ttlMs||b?.(G.runId)===!0)j.push({key:z,...G});return j}}export function buildSessionBusyFailure(q,b,j){const z=Math.max(0,Math.round((j-b.startedAt)/1000)),G=b.pid!=null?`${b.command} (pid ${b.pid})`:b.command,H=b.pid!=null?`Wait for it to finish, or stop it with \`kill ${b.pid}\` if it is stuck.`:"Wait for it to finish, or stop that process if it is stuck.";return{message:`Session "${q}" is busy: ${G} has been driving it for ${z}s.`,errorCode:SESSION_BUSY_CODE,errorHint:`${H} Read-only commands are not blocked.`,status:409}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|