publishport-opencli 1.0.14 → 1.0.16

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.
@@ -1,6 +1,6 @@
1
- import{CommandExecutionError as Y}from"@jackwener/opencli/errors";import{PAGE_RUNTIME as Z}from"./page-runtime.js";import{gotoWritePage as $}from"./publish.js";export function buildCheckAuthJs(z){return`(async () => {
1
+ import{CommandExecutionError as X}from"@jackwener/opencli/errors";import{PAGE_RUNTIME as Z}from"./page-runtime.js";import{gotoWritePage as $}from"./publish.js";export function buildCheckAuthJs(B){return`(async () => {
2
2
  `+Z+`
3
- const __checkAuth = (`+z+`);
3
+ const __checkAuth = (`+B+`);
4
4
  try {
5
5
  const r = await __checkAuth(PP);
6
6
  return {
@@ -11,6 +11,6 @@ try {
11
11
  error: (r && r.error) || "",
12
12
  };
13
13
  } catch (e) {
14
- return { isAuthenticated: false, userId: "", username: "", avatar: "", error: String((e && e.message) || e) };
14
+ return { isAuthenticated: false, probeFailed: true, userId: "", username: "", avatar: "", error: String((e && e.message) || e) };
15
15
  }
16
- })()`}export async function checkLogin(z,v){if(!v||!v.home)throw Error("checkLogin: profile.home is required");if(typeof v.checkAuth!=="function")throw Error("checkLogin: profile.checkAuth must be a function");try{await $(z,v.home,v.originRe)}catch(V){return{isAuthenticated:!1,userId:"",username:"",avatar:"",error:"未登录(无法进入需登录的页面,疑似被重定向到登录页)"}}const D=buildCheckAuthJs(v.checkAuth.toString());return await z.evaluate(D)||{isAuthenticated:!1,userId:"",username:"",avatar:"",error:"no result"}}export async function requireLogin(z,v,D){const B=await checkLogin(z,v);if(!B.isAuthenticated)throw new Y(`未登录${D||""}。请先在已连接 ppcli 浏览器桥的 Chrome 里登录该平台。`+(B.error?`(${B.error})`:""));return B}export function cookieQuickCheck(z,v,D=[]){const B=new Set(v);return async(V)=>{return{logged_in:(await V.getCookies({url:z})).some((K)=>K.value&&(B.has(K.name)||D.some((X)=>K.name.startsWith(X))))}}}export const __test__={buildCheckAuthJs,checkLogin,requireLogin,cookieQuickCheck};
16
+ })()`}export async function checkLogin(B,z){if(!z||!z.home)throw Error("checkLogin: profile.home is required");if(typeof z.checkAuth!=="function")throw Error("checkLogin: profile.checkAuth must be a function");try{await $(B,z.home,z.originRe)}catch(D){if(!D||!D.landedOffSite)throw D;return{isAuthenticated:!1,userId:"",username:"",avatar:"",error:"未登录(访问需登录的页面时被重定向到 "+(D.lastHref||"其它页面")+")"}}const K=buildCheckAuthJs(z.checkAuth.toString()),v=await B.evaluate(K);if(!v)throw new X("登录检测未返回结果(页面内脚本没有执行成功),无法判定登录状态。");if(v.probeFailed)throw new X("登录检测失败(页面内请求异常:"+(v.error||"未知错误")+")。这不代表未登录,请稍后重试。");return v}export async function requireLogin(B,z,K){const v=await checkLogin(B,z);if(!v.isAuthenticated)throw new X(`未登录${K||""}。请先在已连接 ppcli 浏览器桥的 Chrome 里登录该平台。`+(v.error?`(${v.error})`:""));return v}export function cookieQuickCheck(B,z,K=[]){const v=new Set(z);return async(D)=>{return{logged_in:(await D.getCookies({url:B})).some((V)=>V.value&&(v.has(V.name)||K.some((Y)=>V.name.startsWith(Y))))}}}export const __test__={buildCheckAuthJs,checkLogin,requireLogin,cookieQuickCheck};
@@ -1,9 +1,9 @@
1
- import{CommandExecutionError as O}from"@jackwener/opencli/errors";import{normalizeContent as H}from"./format.js";import{assertLiteralContent as C}from"../content-guard.js";import{inlineLocalImages as U,isLocalImagePath as I,localImageToDataUri as E}from"./images.js";import{PAGE_RUNTIME as S}from"./page-runtime.js";export function selectContent(K,Q,X="auto"){const Z=H(K,{format:X});return Q.outputFormat==="markdown"?Z.markdown:Z.html}export function originReFromHome(K){let Q="";try{Q=new URL(K).host}catch(Z){Q=""}return"^https?://([^/]*\\.)?"+Q.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"(/|$)"}export async function gotoWritePage(K,Q,X){const Z=X||originReFromHome(Q);for(let G=0;G<8;G++){await K.goto(Q);await K.wait({time:1});const D=await K.evaluate(`(() => {
1
+ import{CommandExecutionError as _}from"@jackwener/opencli/errors";import{normalizeContent as k}from"./format.js";import{assertLiteralContent as C}from"../content-guard.js";import{inlineLocalImages as w,isLocalImagePath as I,localImageToDataUri as E}from"./images.js";import{PAGE_RUNTIME as S}from"./page-runtime.js";export function selectContent(K,Q,X="auto"){const Z=k(K,{format:X});return Q.outputFormat==="markdown"?Z.markdown:Z.html}export function originReFromHome(K){let Q="";try{Q=new URL(K).host}catch(Z){Q=""}return"^https?://([^/]*\\.)?"+Q.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"(/|$)"}export async function gotoWritePage(K,Q,X){const Z=X||originReFromHome(Q);let O=null;for(let W=0;W<8;W++){await K.goto(Q);await K.wait({time:1});const $=await K.evaluate(`(() => {
2
2
  var href = ''; try { href = location.href; } catch (e) {}
3
3
  var onSite = new RegExp(${JSON.stringify(Z)}).test(href);
4
4
  var cookieOk = true; try { void document.cookie; } catch (e) { cookieOk = false; }
5
5
  return { href: href, onSite: onSite, cookieOk: cookieOk };
6
- })()`);if(D&&D.onSite&&D.cookieOk)return;await K.wait({time:1})}throw new O(`无法在浏览器中打开已登录的写作页(页面停留在空白页,未落到 ${Q})。`+"请确认登录该平台的 Chrome 已连接 ppcli 浏览器桥。")}export function buildPublishJs(K,Q,X){return`(async () => {
6
+ })()`);if($&&$.onSite&&$.cookieOk)return;if($)O=$;await K.wait({time:1})}const G=O&&O.href||"",q=new _(`无法在浏览器中打开已登录的写作页(未落到 ${Q}${G?`,当前停留在 ${G}`:",页面停留在空白页"})。`+"请确认登录该平台的 Chrome 已连接 ppcli 浏览器桥。");q.lastHref=G;q.landedOffSite=/^https?:/i.test(G);throw q}export function buildPublishJs(K,Q,X){return`(async () => {
7
7
  `+S+`
8
8
  const I = `+JSON.stringify(K)+`;
9
9
  const __publish = (`+Q+`);
@@ -31,4 +31,4 @@ if (!__pub || __pub.ok === false) {
31
31
  return { ok: false, stage: (__pub && __pub.stage) || "publish", status: __pub && __pub.status, message: (__pub && __pub.message) || "publish failed", detail: __pub && __pub.detail, uploaded: __upN, failed: __failN };
32
32
  }
33
33
  return { ok: true, id: String(__pub.id == null ? "" : __pub.id), url: __pub.url || "", draft: !!__pub.draft, uploaded: __upN, failed: __failN };
34
- })()`}export async function publishArticle(K,Q){const{title:X,body:Z,format:G="auto",draftOnly:D=!1,profile:q,publishParams:k=null}=Q;if(!q)throw Error("publishArticle: profile is required");if(!q.home)throw Error("publishArticle: profile.home is required");if(typeof q.publish!=="function")throw Error("publishArticle: profile.publish must be a function");C(Z,{fileFlag:"--file"});const W=H(Z,{format:G}),w=q.outputFormat==="markdown"||q.needsMarkdown===!0,L=q.outputFormat==="html"||q.needsHtml===!0,_=w?await U(W.markdown):{content:null,missing:[]},z=L?await U(W.html):{content:null,missing:[]},A=_.content,J=z.content,y=q.outputFormat==="markdown"?A:J,N=[],T=new Set;for(const V of[..._.missing,...z.missing]){if(T.has(V.src))continue;T.add(V.src);N.push(V)}let Y=k;if(Y&&typeof Y.cover==="string"&&Y.cover){const V=Y.cover.trim();if(/["<>\s]/.test(V))throw new O("封面图路径/URL 含非法字符(引号/尖括号/空白):"+V.slice(0,120));if(I(V))try{const{dataUri:$}=await E(V);Y={...Y,cover:$}}catch($){throw new O("封面图读取失败:"+V+"("+String($&&$.message||$)+")")}else Y={...Y,cover:V}}await gotoWritePage(K,q.home,q.originRe);const R={title:X,content:y,markdown:q.needsMarkdown===!0&&q.outputFormat!=="markdown"?A:null,html:q.needsHtml===!0&&q.outputFormat!=="html"?J:null,draftOnly:!!D,outputFormat:q.outputFormat,preprocessConfig:q.preprocessConfig||null,imageSpec:q.image?.spec||null,imageSkip:q.image?.skip||[],imageAllowRelative:q.image?.allowRelative===!0,publishParams:Y},j=typeof q.image?.uploadFn==="function"?q.image.uploadFn.toString():null,x=buildPublishJs(R,q.publish.toString(),j),B=await K.evaluate(x);if(!B||B.ok===!1){const V=B?.stage||"publish",$=B?.status!=null?` (HTTP ${B.status})`:"",M=B?.detail!=null?`;接口原始响应:${typeof B.detail==="string"?B.detail:JSON.stringify(B.detail)}`:"";throw new O(`[${V}] ${B?.message||"发布失败"}${$}${M}`)}return{id:B.id,url:B.url,draft:B.draft,images:{uploaded:B.uploaded||[],failed:(B.failed||[]).concat(N)}}}export const __test__={selectContent,originReFromHome,buildPublishJs,publishArticle};
34
+ })()`}export async function publishArticle(K,Q){const{title:X,body:Z,format:O="auto",draftOnly:G=!1,profile:q,publishParams:W=null}=Q;if(!q)throw Error("publishArticle: profile is required");if(!q.home)throw Error("publishArticle: profile.home is required");if(typeof q.publish!=="function")throw Error("publishArticle: profile.publish must be a function");C(Z,{fileFlag:"--file"});const $=k(Z,{format:O}),L=q.outputFormat==="markdown"||q.needsMarkdown===!0,y=q.outputFormat==="html"||q.needsHtml===!0,z=L?await w($.markdown):{content:null,missing:[]},A=y?await w($.html):{content:null,missing:[]},J=z.content,N=A.content,R=q.outputFormat==="markdown"?J:N,T=[],U=new Set;for(const V of[...z.missing,...A.missing]){if(U.has(V.src))continue;U.add(V.src);T.push(V)}let Y=W;if(Y&&typeof Y.cover==="string"&&Y.cover){const V=Y.cover.trim();if(/["<>\s]/.test(V))throw new _("封面图路径/URL 含非法字符(引号/尖括号/空白):"+V.slice(0,120));if(I(V))try{const{dataUri:D}=await E(V);Y={...Y,cover:D}}catch(D){throw new _("封面图读取失败:"+V+"("+String(D&&D.message||D)+")")}else Y={...Y,cover:V}}await gotoWritePage(K,q.home,q.originRe);const j={title:X,content:R,markdown:q.needsMarkdown===!0&&q.outputFormat!=="markdown"?J:null,html:q.needsHtml===!0&&q.outputFormat!=="html"?N:null,draftOnly:!!G,outputFormat:q.outputFormat,preprocessConfig:q.preprocessConfig||null,imageSpec:q.image?.spec||null,imageSkip:q.image?.skip||[],imageAllowRelative:q.image?.allowRelative===!0,publishParams:Y},x=typeof q.image?.uploadFn==="function"?q.image.uploadFn.toString():null,H=buildPublishJs(j,q.publish.toString(),x),B=await K.evaluate(H);if(!B||B.ok===!1){const V=B?.stage||"publish",D=B?.status!=null?` (HTTP ${B.status})`:"",M=B?.detail!=null?`;接口原始响应:${typeof B.detail==="string"?B.detail:JSON.stringify(B.detail)}`:"";throw new _(`[${V}] ${B?.message||"发布失败"}${D}${M}`)}return{id:B.id,url:B.url,draft:B.draft,images:{uploaded:B.uploaded||[],failed:(B.failed||[]).concat(T)}}}export const __test__={selectContent,originReFromHome,buildPublishJs,publishArticle};
@@ -1 +1 @@
1
- import{CliError as W,CommandExecutionError as F}from"@jackwener/opencli/errors";import{cli as O,Strategy as _}from"@jackwener/opencli/registry";import{readFile as q,stat as T}from"node:fs/promises";import{publishArticle as S}from"../_shared/article/publish.js";export const oschinaProfile={home:"https://my.oschina.net",outputFormat:"markdown",image:{spec:{url:"https://apiv1.oschina.net/oschinapi/ai/creation/project/uploadDetail",method:"POST",bodyType:"binary-multipart",fileField:"file",fileName:"image",body:{},responsePath:"result"},skip:["apiv1.oschina.net","oscimg.oschina.net","static.oschina.net"]},checkAuth:async()=>{const G=await fetch("https://apiv1.oschina.net/oschinapi/user/myDetails",{credentials:"include"});if(!G.ok)return{isAuthenticated:!1,error:"HTTP "+G.status};let z=null;try{z=await G.json()}catch(V){return{isAuthenticated:!1,error:"解析响应失败"}}if(!z||!z.success||!z.result||!z.result.userId)return{isAuthenticated:!1,error:z&&z.message||"未登录"};const B=String(z.result.userId),K=z.result.userVo&&z.result.userVo.name||B,M=z.result.userVo&&z.result.userVo.portraitUrl||"";return{isAuthenticated:!0,userId:B,username:K,avatar:M}},publish:async(G)=>{const z=G.params||{};let B="",K="";try{const H=await fetch("https://apiv1.oschina.net/oschinapi/user/myDetails",{credentials:"include"});if(!H.ok)return{ok:!1,stage:"auth",status:H.status,message:"获取开源中国用户信息失败:HTTP "+H.status+"(登录态可能已失效,请在客户端重新登录)"};const L=await H.json();if(L&&L.success&&L.result&&L.result.userId)B=String(L.result.userId);else K=L&&L.message||"myDetails 未返回 userId(登录态可能已失效或为匿名)"}catch(H){return{ok:!1,stage:"auth",message:"校验开源中国登录态时请求异常:"+String(H&&H.message||H)}}if(!B)return{ok:!1,stage:"auth",message:"未登录开源中国,无法获取用户 ID"+(K?":"+K:"")};let M=0;if(z.category){let H=[];try{const Y=await(await fetch("https://apiv1.oschina.net/oschinapi/blog_catalog/list_by_user",{credentials:"include"})).json();H=Y&&Y.result||[]}catch(J){return{ok:!1,stage:"category",message:"获取开源中国博客分类失败:"+String(J&&J.message||J)}}const L=H.find((J)=>String(J.name)===String(z.category));if(!L)return{ok:!1,stage:"category",message:"未找到博客分类「"+z.category+"」,可选:"+(H.map((J)=>J.name).join(" / ")||"(你还没有任何分类)")};M=L.id}if(G.draftOnly){const H=await fetch("https://apiv1.oschina.net/oschinapi/api/draft/save_draft",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({title:G.title,user:Number(B),content:G.content,contentType:1,catalog:M,originUrl:"",privacy:!0,disableComment:!1})}),L=await H.text();let J=null;try{J=JSON.parse(L)}catch(j){}if(!H.ok||!J||!J.success||!J.result||!J.result.id)return{ok:!1,stage:"save_draft",status:H.status,message:J&&J.message||L.slice(0,300)};const Y=String(J.result.id);return{ok:!0,draft:!0,id:Y,url:"https://my.oschina.net/u/"+B+"/blog/ai-write/draft/"+Y}}const V=await fetch("https://apiv1.oschina.net/oschinapi/blog/web/add",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({title:G.title,content:G.content,contentType:1,type:"1",originUrl:"",catalog:M,privacy:!1,disableComment:!1,isAiRelated:!1,user:Number(B)})}),U=await V.text();let Q=null;try{Q=JSON.parse(U)}catch(H){}if(!V.ok||!Q||Q.code!==200||Q.result==null)return{ok:!1,stage:"publish",status:V.status,message:Q&&(Q.message||Q.msg)||U.slice(0,300)};const X=String(Q.result);return{ok:!0,draft:!1,id:X,url:"https://my.oschina.net/u/"+B+"/blog/"+X}}};function Z(G){if(!G.execute)throw new W("INVALID_INPUT","发布开源中国文章需要 --execute 参数。注意:本报错仅表示缺少 --execute,并未校验其余参数,不能当 dry-run 预览用")}function $(G,z,B,K,M={}){return[{status:"success",outcome:K,message:G,target_type:z,target:B,...M}]}async function A(G){const z=typeof G.text==="string"?G.text:void 0,B=typeof G.file==="string"?G.file:void 0;if(z&&B)throw new W("INVALID_INPUT","正文和文件路径只能二选一,不能同时指定");let K=z??"";if(B){let M;try{M=await T(B)}catch{throw new W("INVALID_INPUT","文件不存在:"+B)}if(!M.isFile())throw new W("INVALID_INPUT","必须指定可读的文本文件:"+B);let V;try{V=await q(B)}catch{throw new W("INVALID_INPUT","文件读取失败:"+B)}try{K=new TextDecoder("utf-8",{fatal:!0}).decode(V)}catch{throw new W("INVALID_INPUT","文件编码不是 UTF-8:"+B)}}if(!K.trim())throw new W("INVALID_INPUT","文章正文不能为空");return K}O({site:"oschina",name:"article",access:"write",description:"发布文章到开源中国博客。默认正式发布,加 --draft 仅存草稿。正文默认 Markdown,外链图片自动转存到开源中国图床。",domain:"my.oschina.net",strategy:_.COOKIE,browser:!0,args:[{name:"title",positional:!0,required:!0,help:"文章标题"},{name:"text",positional:!0,help:"文章正文(默认 Markdown;--html 表示原始 HTML)"},{name:"file",help:"从文件读取正文(UTF-8,默认 Markdown)"},{name:"html",type:"boolean",help:"将正文视为原始 HTML 而非 Markdown"},{name:"category",help:"个人博客分类名(精确匹配),合法值用 `oschina catalogs` 列举;可空=不分类"},{name:"draft",type:"boolean",help:"仅存草稿,不发布"},{name:"execute",type:"boolean",help:"真正执行写操作;不加此参数命令拒绝写入"}],columns:["status","outcome","message","target_type","target","created_target","created_url"],func:async(G,z)=>{if(!G)throw new F("开源中国文章发布需要浏览器会话");Z(z);const B=String(z.title??"").trim();if(!B)throw new W("INVALID_INPUT","文章标题不能为空");const K=await A(z),M=Boolean(z.draft),V={category:typeof z.category==="string"?z.category.trim():""},U=await S(G,{title:B,body:K,format:z.html?"html":"markdown",draftOnly:M,profile:oschinaProfile,publishParams:V}),Q=U.images.uploaded.length|0,X=U.images.failed.length|0;let H=U.draft?"已保存到开源中国草稿箱":"已正式发布到开源中国博客";if(Q||X)H+=`·图片:${Q} 张已转存${X?`,${X} 张失败`:""}`;return $(H,"article","",U.draft?"draft":"created",{created_target:(U.draft?"draft:":"article:")+U.id,created_url:U.url})}});export const __test__={oschinaProfile,requireExecute:Z,buildResultRow:$,resolvePayload:A};
1
+ import{CliError as W,CommandExecutionError as F}from"@jackwener/opencli/errors";import{cli as O,Strategy as _}from"@jackwener/opencli/registry";import{readFile as q,stat as T}from"node:fs/promises";import{publishArticle as j}from"../_shared/article/publish.js";export const oschinaProfile={home:"https://my.oschina.net",outputFormat:"markdown",image:{spec:{url:"https://apiv1.oschina.net/oschinapi/ai/creation/project/uploadDetail",method:"POST",bodyType:"binary-multipart",fileField:"file",fileName:"image",body:{},responsePath:"result"},skip:["apiv1.oschina.net","oscimg.oschina.net","static.oschina.net"]},checkAuth:async()=>{const G=await fetch("https://apiv1.oschina.net/oschinapi/user/myDetails",{credentials:"include"});if(!G.ok)return{isAuthenticated:!1,error:"HTTP "+G.status};let z=null;try{z=await G.json()}catch(V){return{isAuthenticated:!1,error:"解析响应失败"}}if(!z||!z.success||!z.result||!z.result.userId)return{isAuthenticated:!1,error:z&&z.message||"未登录"};const B=String(z.result.userId),K=z.result.userVo&&z.result.userVo.name||B,M=z.result.userVo&&z.result.userVo.portraitUrl||"";return{isAuthenticated:!0,userId:B,username:K,avatar:M}},publish:async(G)=>{const z=G.params||{};let B="",K="";try{const H=await fetch("https://apiv1.oschina.net/oschinapi/user/myDetails",{credentials:"include"});if(!H.ok)return{ok:!1,stage:"auth",status:H.status,message:"获取开源中国用户信息失败:HTTP "+H.status+"(登录态可能已失效,请在客户端重新登录)"};const L=await H.json();if(L&&L.success&&L.result&&L.result.userId)B=String(L.result.userId);else K=L&&L.message||"myDetails 未返回 userId(登录态可能已失效或为匿名)"}catch(H){return{ok:!1,stage:"auth",message:"校验开源中国登录态时请求异常:"+String(H&&H.message||H)}}if(!B)return{ok:!1,stage:"auth",message:"未登录开源中国,无法获取用户 ID"+(K?":"+K:"")};let M=0;if(z.category){let H=[];try{const Y=await(await fetch("https://apiv1.oschina.net/oschinapi/blog_catalog/list_by_user",{credentials:"include"})).json();H=Y&&Y.result||[]}catch(J){return{ok:!1,stage:"category",message:"获取开源中国博客分类失败:"+String(J&&J.message||J)}}const L=H.find((J)=>String(J.name)===String(z.category));if(!L)return{ok:!1,stage:"category",message:"未找到博客分类「"+z.category+"」,可选:"+(H.map((J)=>J.name).join(" / ")||"(你还没有任何分类)")};M=L.id}if(G.draftOnly){const H=await fetch("https://apiv1.oschina.net/oschinapi/api/draft/save_draft",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({title:G.title,user:Number(B),content:G.content,contentType:1,catalog:M,originUrl:"",privacy:!0,disableComment:!1})}),L=await H.text();let J=null;try{J=JSON.parse(L)}catch(S){}if(!H.ok||!J||!J.success||!J.result||!J.result.id)return{ok:!1,stage:"save_draft",status:H.status,message:J&&J.message||L.slice(0,300)};const Y=String(J.result.id);return{ok:!0,draft:!0,id:Y,url:"https://my.oschina.net/u/"+B+"/blog/ai-write/draft/"+Y}}const V=await fetch("https://apiv1.oschina.net/oschinapi/blog/web/add",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({title:G.title,content:G.content,contentType:1,type:"1",originUrl:"",catalog:M,privacy:!1,disableComment:!1,isAiRelated:!1,user:Number(B)})}),Q=await V.text();let U=null;try{U=JSON.parse(Q)}catch(H){}if(!V.ok||!U||U.code!==200||U.result==null)return{ok:!1,stage:"publish",status:V.status,message:U&&(U.message||U.msg)||Q.slice(0,300)};const X=String(U.result);return{ok:!0,draft:!1,id:X,url:"https://my.oschina.net/u/"+B+"/blog/"+X}}};function Z(G){if(!G.execute)throw new W("INVALID_INPUT","发布开源中国文章需要 --execute 参数。注意:本报错仅表示缺少 --execute,并未校验其余参数,不能当 dry-run 预览用")}function $(G,z,B,K,M={}){return[{status:"success",outcome:K,message:G,target_type:z,target:B,...M}]}async function A(G){const z=typeof G.text==="string"?G.text:void 0,B=typeof G.file==="string"?G.file:void 0;if(z&&B)throw new W("INVALID_INPUT","正文和文件路径只能二选一,不能同时指定");let K=z??"";if(B){let M;try{M=await T(B)}catch{throw new W("INVALID_INPUT","文件不存在:"+B)}if(!M.isFile())throw new W("INVALID_INPUT","必须指定可读的文本文件:"+B);let V;try{V=await q(B)}catch{throw new W("INVALID_INPUT","文件读取失败:"+B)}try{K=new TextDecoder("utf-8",{fatal:!0}).decode(V)}catch{throw new W("INVALID_INPUT","文件编码不是 UTF-8:"+B)}}if(!K.trim())throw new W("INVALID_INPUT","文章正文不能为空");return K}O({site:"oschina",name:"article",access:"write",description:"发布文章到开源中国博客。默认正式发布,加 --draft 仅存草稿。正文默认 Markdown,外链图片自动转存到开源中国图床。",domain:"my.oschina.net",strategy:_.COOKIE,browser:!0,args:[{name:"title",positional:!0,required:!0,help:"文章标题"},{name:"text",positional:!0,help:"文章正文(默认 Markdown;--html 表示原始 HTML)"},{name:"file",help:"从文件读取正文(UTF-8,默认 Markdown)"},{name:"html",type:"boolean",help:"将正文视为原始 HTML 而非 Markdown"},{name:"category",help:"个人博客分类名(精确匹配),合法值用 `oschina catalogs` 列举;可空=不分类"},{name:"draft",type:"boolean",help:"仅存草稿,不发布"},{name:"execute",type:"boolean",help:"真正执行写操作;不加此参数命令拒绝写入"}],columns:["status","outcome","message","target_type","target","created_target","created_url"],func:async(G,z)=>{if(!G)throw new F("开源中国文章发布需要浏览器会话");Z(z);const B=String(z.title??"").trim();if(!B)throw new W("INVALID_INPUT","文章标题不能为空");const K=await A(z),M=Boolean(z.draft),V={category:typeof z.category==="string"?z.category.trim():""},Q=await j(G,{title:B,body:K,format:z.html?"html":"markdown",draftOnly:M,profile:oschinaProfile,publishParams:V}),U=Q.images.uploaded.length|0,X=Q.images.failed.length|0;let H=Q.draft?"已保存到开源中国草稿箱":"已正式发布到开源中国博客";if(U||X)H+=`·图片:${U} 张已转存${X?`,${X} 张失败`:""}`;if(!Q.draft)H+="。已按公开发布提交;开源中国对新账号/新文章有站方审核,过审前它不会出现在公开列表,"+"此时 stats 的 is_private 会显示「是」——这是审核中,不是发布失败,请勿重发,稍后回查即可";return $(H,"article","",Q.draft?"draft":"created",{created_target:(Q.draft?"draft:":"article:")+Q.id,created_url:Q.url})}});export const __test__={oschinaProfile,requireExecute:Z,buildResultRow:$,resolvePayload:A};
@@ -51,4 +51,4 @@ import{cli as Y,Strategy as $}from"@jackwener/opencli/registry";import{CliError
51
51
  }
52
52
  return out;
53
53
  }
54
- `}function f(h){if(h&&typeof h==="object"&&!Array.isArray(h)&&"session"in h&&"data"in h)return h.data;return h}function U(h){return typeof h==="number"&&Number.isFinite(h)?h:""}function W(h,H,Q,T,q){const F=h.id!=null?String(h.id):"",N=T&&F?T.get(F):null,B=(z)=>h[z]!=null?h[z]:N?N[z]:void 0,K=Number(B("type")),O={};for(const[z,D]of Object.entries({reply_count:U(B("replyCount")),state:B("state"),as_recomm:h.asRecomm,detritus:B("detritus"),scheduled_time:h.scheduledTime,scheduled_state:h.scheduledState,summary:typeof B("detail")==="string"?String(B("detail")).slice(0,80):void 0}))if(D!==void 0&&D!==null&&D!=="")O[z]=D;return{id:F,type:"博客",title:h.title!=null?String(h.title):"",url:F&&H?`https://my.oschina.net/u/${H}/blog/${F}`:"",published_at:B("createTime")||"",views:U(B("viewCount")),likes:U(B("voteCount")),comments:U(B("commentCount")),collects:"",shares:"",origin:K===2?"转载":K===1?"原创":"",is_private:Q!=="已发布"?"":q||N?"否":"是",recommended:Number(B("recomm"))===1?"推荐":"",top:Number(B("asTop"))===1?"置顶":"",status:Q,extra:Object.keys(O).length?JSON.stringify(O):""}}Y({site:"oschina",name:"stats",access:"read",description:"开源中国博客数据(每篇阅读/点赞/评论等全量运营指标)",domain:"my.oschina.net",strategy:$.COOKIE,browser:!0,args:[{name:"limit",type:"number",default:20,help:"返回条数"},{name:"user",help:"查看指定用户 ID 的公开博客数据(默认当前登录账号,含私密与定时博客)"}],columns:["id","type","title","url","published_at","views","likes","comments","collects","shares","origin","is_private","recommended","top","status","extra"],func:async(h,H)=>{const Q=Number(H.limit)||20,T=H.user!=null&&String(H.user).trim()!==""?String(H.user).trim():"";await h.goto("https://my.oschina.net/");await h.wait(1);const q=f(await h.evaluate(Z(Q,T)));if(!q||typeof q!=="object")throw Error("开源中国页面取数失败:未拿到任何响应,请确认 Chrome 已打开且能访问 my.oschina.net。");if(q.notLoggedIn)throw Error("未登录开源中国:user/myDetails 未返回用户信息。请先在 Chrome 中登录 my.oschina.net(可用 ppcli oschina login)。");const F=String(q.targetUser||q.me||""),N=q.pub&&q.pub.rows||[];if(q.pub&&q.pub.error)throw Error("获取开源中国博客公开列表失败:"+q.pub.error);const B=new Map(N.filter((z)=>z&&z.id!=null).map((z)=>[String(z.id),z])),K=[],O=new Set;if(q.isSelf){if(q.mine&&q.mine.error)throw Error("获取开源中国「我的创作」博客列表失败:"+q.mine.error);for(const z of q.mine&&q.mine.rows||[]){const D=W(z,F,"已发布",B);if(D.id)O.add(D.id);K.push(D)}for(const z of N){const D=z&&z.id!=null?String(z.id):"";if(!D||O.has(D))continue;O.add(D);K.push(W(z,F,"已发布",null,!0))}for(const z of q.scheduled&&q.scheduled.rows||[])K.push(W(z,F,"已定时未发布",null,!1))}else for(const z of N)K.push(W(z,F,"已发布",null,!0));if(K.length===0)throw new I("EMPTY_RESULT",q.isSelf?"当前开源中国账号没有任何博客(已发布/定时均为空)。":`用户 ${F} 没有任何公开博客。`,q.isSelf?"发布过内容后再来查看数据(ppcli oschina article)。":"请确认用户 ID 是否正确。");return K.slice(0,Q)}});
54
+ `}function j(h){if(h&&typeof h==="object"&&!Array.isArray(h)&&"session"in h&&"data"in h)return h.data;return h}function U(h){return typeof h==="number"&&Number.isFinite(h)?h:""}function W(h,H,Q,T,q){const F=h.id!=null?String(h.id):"",N=T&&F?T.get(F):null,B=(z)=>h[z]!=null?h[z]:N?N[z]:void 0,K=Number(B("type")),O={};for(const[z,D]of Object.entries({reply_count:U(B("replyCount")),state:B("state"),as_recomm:h.asRecomm,detritus:B("detritus"),scheduled_time:h.scheduledTime,scheduled_state:h.scheduledState,summary:typeof B("detail")==="string"?String(B("detail")).slice(0,80):void 0}))if(D!==void 0&&D!==null&&D!=="")O[z]=D;if(Q==="已发布"&&!(q||N))O.visibility_note="未出现在公开列表:可能是站方审核中(新账号/新文章常见),也可能是该文设为私密;请稍后回查再判定";return{id:F,type:"博客",title:h.title!=null?String(h.title):"",url:F&&H?`https://my.oschina.net/u/${H}/blog/${F}`:"",published_at:B("createTime")||"",views:U(B("viewCount")),likes:U(B("voteCount")),comments:U(B("commentCount")),collects:"",shares:"",origin:K===2?"转载":K===1?"原创":"",is_private:Q!=="已发布"?"":q||N?"否":"是",recommended:Number(B("recomm"))===1?"推荐":"",top:Number(B("asTop"))===1?"置顶":"",status:Q,extra:Object.keys(O).length?JSON.stringify(O):""}}Y({site:"oschina",name:"stats",access:"read",description:"开源中国博客数据(每篇阅读/点赞/评论等全量运营指标)",domain:"my.oschina.net",strategy:$.COOKIE,browser:!0,args:[{name:"limit",type:"number",default:20,help:"返回条数"},{name:"user",help:"查看指定用户 ID 的公开博客数据(默认当前登录账号,含私密与定时博客)"}],columns:["id","type","title","url","published_at","views","likes","comments","collects","shares","origin","is_private","recommended","top","status","extra"],func:async(h,H)=>{const Q=Number(H.limit)||20,T=H.user!=null&&String(H.user).trim()!==""?String(H.user).trim():"";await h.goto("https://my.oschina.net/");await h.wait(1);const q=j(await h.evaluate(Z(Q,T)));if(!q||typeof q!=="object")throw Error("开源中国页面取数失败:未拿到任何响应,请确认 Chrome 已打开且能访问 my.oschina.net。");if(q.notLoggedIn)throw Error("未登录开源中国:user/myDetails 未返回用户信息。请先在 Chrome 中登录 my.oschina.net(可用 ppcli oschina login)。");const F=String(q.targetUser||q.me||""),N=q.pub&&q.pub.rows||[];if(q.pub&&q.pub.error)throw Error("获取开源中国博客公开列表失败:"+q.pub.error);const B=new Map(N.filter((z)=>z&&z.id!=null).map((z)=>[String(z.id),z])),K=[],O=new Set;if(q.isSelf){if(q.mine&&q.mine.error)throw Error("获取开源中国「我的创作」博客列表失败:"+q.mine.error);for(const z of q.mine&&q.mine.rows||[]){const D=W(z,F,"已发布",B);if(D.id)O.add(D.id);K.push(D)}for(const z of N){const D=z&&z.id!=null?String(z.id):"";if(!D||O.has(D))continue;O.add(D);K.push(W(z,F,"已发布",null,!0))}for(const z of q.scheduled&&q.scheduled.rows||[])K.push(W(z,F,"已定时未发布",null,!1))}else for(const z of N)K.push(W(z,F,"已发布",null,!0));if(K.length===0)throw new I("EMPTY_RESULT",q.isSelf?"当前开源中国账号没有任何博客(已发布/定时均为空)。":`用户 ${F} 没有任何公开博客。`,q.isSelf?"发布过内容后再来查看数据(ppcli oschina article)。":"请确认用户 ID 是否正确。");return K.slice(0,Q)}});
@@ -1,12 +1,9 @@
1
- import{spawn as C}from"node:child_process";import{fileURLToPath as F}from"node:url";import*as M from"node:fs";import*as K from"node:path";import{DEFAULT_DAEMON_PORT}from"../constants.js";import{BrowserConnectError as J}from"../errors.js";import{PKG_VERSION as N}from"../version.js";import{waitForBridgeReady as b}from"./bridge-readiness.js";import{fetchDaemonStatus as B,getDaemonHealth as g,requestDaemonShutdown as O}from"./daemon-transport.js";export function resolveDaemonLaunchSpec(){const z=K.dirname(F(import.meta.url)),Q=K.resolve(z,".."),W=K.join(Q,"daemon.ts"),Z=K.join(Q,"daemon.js"),Y=M.existsSync(W),X=Y?W:Z,$=!!process.versions?.bun,U=Y&&!$;return{binary:process.execPath,args:U?["--import","tsx/esm",X]:[X],scriptPath:X}}export function spawnDaemonProcess(){const z=resolveDaemonLaunchSpec(),Q=C(z.binary,z.args,{detached:!0,stdio:"ignore",env:{...process.env}});Q.unref();return Q}export async function waitForDaemonStop(z){const Q=Date.now()+z;while(Date.now()<Q){await x(200);if((await g()).state==="stopped")return!0}return!1}export async function waitForDaemonStatus(z){const Q=Date.now()+z;while(Date.now()<Q){const W=await B({timeout:Math.min(1000,Math.max(100,Q-Date.now()))});if(W)return W;await x(200)}return null}export const daemonLifecycleHooks={requestDaemonShutdown:O,spawnDaemonProcess,waitForDaemonStop};export async function restartDaemon(z={}){const Q=await B();let W=Q===null;if(Q){W=await O()&&await waitForDaemonStop(z.stopTimeoutMs??3000);if(!W)return{previousStatus:Q,status:Q,stopped:!1,spawned:!1}}spawnDaemonProcess();const Z=await waitForDaemonStatus(z.startTimeoutMs??5000);return{previousStatus:Q,status:Z,stopped:W,spawned:!0}}export async function ensureBrowserBridgeReady(z={}){const W=(z.timeoutSeconds&&z.timeoutSeconds>0?z.timeoutSeconds:10)*1000,Z=z.verbose??!0,Y=z.contextId,X=await g({contextId:Y}),$=X.status?.daemonVersion,U=!!X.status&&(!$||$!==N);let q=!1,k=null;if(U){const y=$?`v${$} ≠ v${N}`:`pre-version daemon, CLI is v${N}`;if(Z&&(process.env.OPENCLI_VERBOSE||process.stderr.isTTY))process.stderr.write(`⚠️ Stale daemon detected (${y}). Restarting...
2
- `);let j=await daemonLifecycleHooks.requestDaemonShutdown()&&await daemonLifecycleHooks.waitForDaemonStop(3000);if(!j){const G=X.status?.pid;if(typeof G==="number"&&Number.isInteger(G)&&G>0){try{process.kill(G,"SIGKILL")}catch{}j=await daemonLifecycleHooks.waitForDaemonStop(2000)}}if(!j)throw new J("Stale daemon could not be replaced",`A stale daemon (${y}) is running but did not shut down (graceful + SIGKILL both failed).
3
- Run manually: ppcli daemon stop`,"daemon-not-running");q=!0}if(!q&&X.state==="ready")return{health:X,spawnedProcess:k};if(!q&&X.state==="profile-required")throw V(X,Y);if(q||X.state==="stopped"){if(Z&&(process.env.OPENCLI_VERBOSE||process.stderr.isTTY))process.stderr.write(`⏳ Starting daemon...
1
+ import{spawn as V}from"node:child_process";import{fileURLToPath as b}from"node:url";import*as x from"node:fs";import*as K from"node:path";import{DEFAULT_DAEMON_PORT}from"../constants.js";import{BrowserConnectError as J}from"../errors.js";import{PKG_VERSION as j}from"../version.js";import{waitForBridgeReady as F}from"./bridge-readiness.js";import{fetchDaemonStatus as B,getDaemonHealth as y,requestDaemonShutdown as C}from"./daemon-transport.js";export function resolveDaemonLaunchSpec(){const z=K.dirname(b(import.meta.url)),Q=K.resolve(z,".."),W=K.join(Q,"daemon.ts"),Z=K.join(Q,"daemon.js"),Y=x.existsSync(W),X=Y?W:Z,$=!!process.versions?.bun,U=Y&&!$;return{binary:process.execPath,args:U?["--import","tsx/esm",X]:[X],scriptPath:X}}export function spawnDaemonProcess(){const z=resolveDaemonLaunchSpec(),Q=V(z.binary,z.args,{detached:!0,stdio:"ignore",env:{...process.env}});Q.unref();return Q}export async function waitForDaemonStop(z){const Q=Date.now()+z;while(Date.now()<Q){await O(200);if((await y()).state==="stopped")return!0}return!1}export async function waitForDaemonStatus(z){const Q=Date.now()+z;while(Date.now()<Q){const W=await B({timeout:Math.min(1000,Math.max(100,Q-Date.now()))});if(W)return W;await O(200)}return null}export const daemonLifecycleHooks={requestDaemonShutdown:C,spawnDaemonProcess,waitForDaemonStop};export async function restartDaemon(z={}){const Q=await B();let W=Q===null;if(Q){W=await C()&&await waitForDaemonStop(z.stopTimeoutMs??3000);if(!W)return{previousStatus:Q,status:Q,stopped:!1,spawned:!1}}spawnDaemonProcess();const Z=await waitForDaemonStatus(z.startTimeoutMs??5000);return{previousStatus:Q,status:Z,stopped:W,spawned:!0}}export async function ensureBrowserBridgeReady(z={}){const W=(z.timeoutSeconds&&z.timeoutSeconds>0?z.timeoutSeconds:10)*1000,Z=z.verbose??!0,Y=z.contextId,X=await y({contextId:Y}),$=X.status?.daemonVersion,U=!!X.status&&(!$||$!==j);let q=!1,k=null;if(U){const N=$?`v${$} ≠ v${j}`:`pre-version daemon, CLI is v${j}`;if(Z&&(process.env.OPENCLI_VERBOSE||process.stderr.isTTY))process.stderr.write(`⚠️ Stale daemon detected (${N}). Restarting...
2
+ `);let g=await daemonLifecycleHooks.requestDaemonShutdown()&&await daemonLifecycleHooks.waitForDaemonStop(3000);if(!g){const G=X.status?.pid;if(typeof G==="number"&&Number.isInteger(G)&&G>0){try{process.kill(G,"SIGKILL")}catch{}g=await daemonLifecycleHooks.waitForDaemonStop(2000)}}if(!g)throw new J("Stale daemon could not be replaced",`A stale daemon (${N}) is running but did not shut down (graceful + SIGKILL both failed).
3
+ Run manually: ppcli daemon stop`,"daemon-not-running");q=!0}if(!q&&X.state==="ready")return{health:X,spawnedProcess:k};if(!q&&X.state==="profile-required")throw M(X,Y);if(q||X.state==="stopped"){if(Z&&(process.env.OPENCLI_VERBOSE||process.stderr.isTTY))process.stderr.write(`⏳ Starting daemon...
4
4
  `);k=daemonLifecycleHooks.spawnDaemonProcess()}else if(Z&&(process.env.OPENCLI_VERBOSE||process.stderr.isTTY)){process.stderr.write(`⏳ Waiting for Chrome/Chromium extension to connect...
5
5
  `);process.stderr.write(` Make sure Chrome or Chromium is open and the ppcli extension is enabled.
6
- `)}const A=await b(g,{timeoutMs:W,contextId:Y});if(A.state==="ready")return{health:A,spawnedProcess:k};throw V(A,Y)}function V(z,Q){if(z.state==="profile-required")return new J("Multiple Browser Bridge profiles are connected",`Select one with --profile <name>, OPENCLI_PROFILE=<name>, or ppcli profile use <name>.
7
- Run ppcli profile list to see connected profiles.`,"profile-required");if(z.state==="profile-disconnected"){const W=Q??z.status.contextId??"unknown";return new J(`Browser profile "${W}" is not connected`,"Open the matching Chrome profile and make sure the ppcli extension is enabled, or choose another profile with ppcli profile use <name>.","profile-disconnected")}if(z.state==="no-extension")return new J("Browser Bridge extension not connected",`Make sure Chrome/Chromium is open and the ppcli extension is enabled.
8
- If not installed:
9
- 1. Download: https://github.com/jackwener/OpenCLI/releases
10
- `+" 2. Open chrome://extensions → Developer Mode → Load unpacked","extension-not-connected");return new J("Failed to start ppcli daemon",`Try running manually:
6
+ `)}const A=await F(y,{timeoutMs:W,contextId:Y});if(A.state==="ready")return{health:A,spawnedProcess:k};throw M(A,Y)}function M(z,Q){if(z.state==="profile-required")return new J("Multiple Browser Bridge profiles are connected",`Select one with --profile <name>, OPENCLI_PROFILE=<name>, or ppcli profile use <name>.
7
+ Run ppcli profile list to see connected profiles.`,"profile-required");if(z.state==="profile-disconnected"){const W=Q??z.status.contextId??"unknown";return new J(`浏览器环境 "${W}" 未连接`,`这台机器上名为 "${W}" 的浏览器环境当前没连上。请用户打开 PublishPort 客户端,`+"在「高级设置 → 环境」里确认该环境已启动;或改用其它环境(--profile <名字>),"+"不带 --profile 则用默认环境。这是设备侧环境问题,重试同一条命令不会好转,请先告知用户。","profile-disconnected")}if(z.state==="no-extension")return new J("浏览器桥未连接","这台机器上的 PublishPort 托管浏览器当前没在运行(或浏览器插件被禁用了)。"+"请用户打开 PublishPort 客户端,在平台列表里点一次「检测登录」,客户端会把托管浏览器拉起来;"+"若仍不行,重启一次 PublishPort 客户端。这是设备侧环境问题,重试同一条命令不会好转,请先告知用户。","extension-not-connected");return new J("Failed to start ppcli daemon",`Try running manually:
11
8
  node ${resolveDaemonLaunchSpec().scriptPath}
12
- Make sure port ${DEFAULT_DAEMON_PORT} is available.`,"daemon-not-running")}function x(z){return new Promise((Q)=>setTimeout(Q,z))}export{DEFAULT_DAEMON_PORT};
9
+ Make sure port ${DEFAULT_DAEMON_PORT} is available.`,"daemon-not-running")}function O(z){return new Promise((Q)=>setTimeout(Q,z))}export{DEFAULT_DAEMON_PORT};
@@ -1,5 +1,5 @@
1
- import{BrowserConnectError as H}from"../errors.js";import{DEFAULT_DAEMON_PORT as Q}from"../constants.js";const W=["Extension disconnected","Extension not connected","attach failed","Detached while handling command","Debugger is not attached to the tab","no longer exists","No tab with id","CDP connection","Daemon command failed","No window with id"],Y=["Inspected target navigated or closed"];function Z(q){return q instanceof Error?q.message:String(q)}const K={attach_failed:{kind:"extension-transient",retryable:!0,delayMs:1500},tab_gone:{kind:"extension-transient",retryable:!0,delayMs:1500},target_navigated:{kind:"target-navigation",retryable:!0,delayMs:200},detached_mid_command:{kind:"non-retryable",retryable:!1,delayMs:0},cdp_timeout:{kind:"non-retryable",retryable:!1,delayMs:0}};export function classifyBrowserError(q){const x=q&&typeof q==="object"?q.code:void 0;if(typeof x==="string"&&K[x])return K[x];const z=Z(q);if(W.some((J)=>z.includes(J)))return{kind:"extension-transient",retryable:!0,delayMs:1500};if(Y.some((J)=>z.includes(J)))return{kind:"target-navigation",retryable:!0,delayMs:200};if(z.includes("-32000")&&/target|context/i.test(z))return{kind:"target-navigation",retryable:!0,delayMs:200};return{kind:"non-retryable",retryable:!1,delayMs:0}}export function isTransientBrowserError(q){return classifyBrowserError(q).retryable}export function formatBrowserConnectError(q,x){switch(q){case"daemon-not-running":return new H("Cannot connect to ppcli daemon."+(x?`
1
+ import{BrowserConnectError as x}from"../errors.js";import{DEFAULT_DAEMON_PORT as K}from"../constants.js";const Q=["Extension disconnected","Extension not connected","attach failed","Detached while handling command","Debugger is not attached to the tab","no longer exists","No tab with id","CDP connection","Daemon command failed","No window with id"],W=["Inspected target navigated or closed"];function Y(h){return h instanceof Error?h.message:String(h)}const J={attach_failed:{kind:"extension-transient",retryable:!0,delayMs:1500},tab_gone:{kind:"extension-transient",retryable:!0,delayMs:1500},target_navigated:{kind:"target-navigation",retryable:!0,delayMs:200},detached_mid_command:{kind:"non-retryable",retryable:!1,delayMs:0},cdp_timeout:{kind:"non-retryable",retryable:!1,delayMs:0}};export function classifyBrowserError(h){const q=h&&typeof h==="object"?h.code:void 0;if(typeof q==="string"&&J[q])return J[q];const v=Y(h);if(Q.some((z)=>v.includes(z)))return{kind:"extension-transient",retryable:!0,delayMs:1500};if(W.some((z)=>v.includes(z)))return{kind:"target-navigation",retryable:!0,delayMs:200};if(v.includes("-32000")&&/target|context/i.test(v))return{kind:"target-navigation",retryable:!0,delayMs:200};return{kind:"non-retryable",retryable:!1,delayMs:0}}export function isTransientBrowserError(h){return classifyBrowserError(h).retryable}export function formatBrowserConnectError(h,q){switch(h){case"daemon-not-running":return new x("Cannot connect to ppcli daemon."+(q?`
2
2
 
3
- ${x}`:""),`Run \`ppcli doctor\` to diagnose, or \`ppcli daemon restart\` to force a fresh daemon. Default port is ${Q}.`,q);case"extension-not-connected":return new H("Browser Bridge extension is not connected."+(x?`
3
+ ${q}`:""),`Run \`ppcli doctor\` to diagnose, or \`ppcli daemon restart\` to force a fresh daemon. Default port is ${K}.`,h);case"extension-not-connected":return new x("浏览器桥未连接。"+(q?`
4
4
 
5
- ${x}`:""),"Install the extension from GitHub Releases, then reload.",q);case"command-failed":return new H(`Browser command failed: ${x??"unknown error"}`,void 0,q);default:return new H(x??"Failed to connect to browser",void 0,q)}}
5
+ ${q}`:""),"请用户打开 PublishPort 客户端,在平台列表里点一次「检测登录」把托管浏览器拉起来;仍不行就重启客户端。",h);case"command-failed":return new x(`Browser command failed: ${q??"unknown error"}`,void 0,h);default:return new x(q??"Failed to connect to browser",void 0,h)}}
@@ -7,8 +7,12 @@ export interface AuthStatusRow {
7
7
  status: AuthStatus;
8
8
  logged_in: boolean | '';
9
9
  identity: string;
10
- checked: AuthStatusMode | 'skipped' | 'navigation_required';
10
+ checked: AuthStatusMode | 'skipped' | 'navigation_required' | 'cached';
11
11
  error: string;
12
+ /** [pp-only] 该结论最后一次**真实探测**的时间(ISO)。缓存命中时指向原探测时刻。 */
13
+ checked_at?: string;
14
+ /** [pp-only] 该「已登录」是否经 whoami 实证(false = 仅按 cookie 存在推断)。 */
15
+ verified?: boolean;
12
16
  }
13
17
  interface AuthStatusOptions {
14
18
  sites?: string;
@@ -17,6 +21,12 @@ interface AuthStatusOptions {
17
21
  concurrency?: string | number;
18
22
  timeout?: string | number;
19
23
  profile?: string;
24
+ /** [pp-only] 复用不超过该秒数的本机缓存结论,命中就不再驱动浏览器。0 = 每次真探。 */
25
+ maxAge?: string | number;
26
+ /** [pp-only] 本轮最多用 whoami 实证几个「cookie 说已登录但尚未实证」的站点。 */
27
+ verify?: string | number;
28
+ statePath?: string;
29
+ now?: Date;
20
30
  }
21
31
  export interface AuthRefreshRow {
22
32
  site: string;
@@ -1,2 +1,3 @@
1
- import{mkdir as y,readFile as w,writeFile as g}from"node:fs/promises";import{homedir as h}from"node:os";import{dirname as l,join as u}from"node:path";import{pathToFileURL as n}from"node:url";import{InvalidArgumentError as I,Option as p}from"commander";import{AuthRequiredError as P,CliError as F,getErrorMessage as M}from"../errors.js";import{executeCommand as N}from"../execution.js";import{ensureBrowserBridgeReady as i}from"../browser/daemon-lifecycle.js";import{resolveProfile as a}from"../browser/profile.js";import{isElectronApp as U}from"../electron-apps.js";import{fullName as c,getRegistry as _}from"../registry.js";import{render as x}from"../output.js";const V=1,q=86400000;function j(G,J,X){if(G===void 0||G===null||G==="")return X;const Z=Number(G);if(!Number.isInteger(Z)||Z<=0)throw new I(`${J} must be a positive integer. Received: "${String(G)}"`);return Z}function C(G){if(!G||!G.trim())return null;const J=G.split(",").map((X)=>X.trim()).filter(Boolean);return J.length>0?new Set(J):null}function m(){return u(h(),".ppcli","auth-refresh.json")}function d(){return{version:V,sites:{}}}async function o(G){try{const J=JSON.parse(await w(G,"utf8"));if(J&&J.version===V&&J.sites&&typeof J.sites==="object")return{version:V,sites:J.sites}}catch(J){if(J.code!=="ENOENT")throw J}return d()}async function s(G,J){await y(l(G),{recursive:!0});await g(G,`${JSON.stringify(J,null,2)}
2
- `,"utf8")}function T(G){if(!G?.last_touched_at)return null;const J=Date.parse(G.last_touched_at);return Number.isFinite(J)?J:null}function r(G,J){const X=T(G);return X!==null&&J.getTime()-X<q}function B(G){const J=T(G);return J===null?"":new Date(J+q).toISOString()}function b(){const G=new Set;return[..._().values()].filter((J)=>{if(G.has(J))return!1;G.add(J);return J.name==="whoami"&&J.access==="read"}).sort((J,X)=>J.site.localeCompare(X.site))}async function O(G){const J=G;if(!J._lazy||!J._modulePath)return G;await import(n(J._modulePath).href);return _().get(c(G))??G}function z(G,J){const X=G.args.some((Z)=>Z.name==="timeout");return{...G,args:X?G.args:[...G.args,{name:"timeout",type:"int",default:J,help:"Per-site auth command timeout in seconds"}]}}function t(G,J){if(G.browser!==!0||typeof G.authStatus?.quickCheck!=="function")return null;return z({...G,func:G.authStatus.quickCheck,navigateBefore:!1,siteSession:"ephemeral",defaultWindowMode:"background"},J)}function v(G){if(typeof G==="boolean")return G;if(G&&typeof G==="object"&&!Array.isArray(G)){const J=G.logged_in;if(typeof J==="boolean")return J}return null}function k(G){if(G===void 0||G===null)return"";if(typeof G==="string"||typeof G==="number"||typeof G==="boolean")return String(G);return""}function S(G){if(!G||typeof G!=="object"||Array.isArray(G))return"";const J=G,X=/(?:email|phone|real.?name|first.?name|last.?name|cookie|token|session|secret|password|csrf|jwt|bearer|wt2)/i;for(const Z of["username","handle","user_id","id","name","nickname","user_type","url"]){if(X.test(Z))continue;const Y=k(J[Z]);if(Y)return Y}for(const[Z,Y]of Object.entries(J)){if(Z==="site"||Z==="logged_in"||X.test(Z))continue;const $=k(Y);if($)return $}return""}function H(G){return G instanceof P||typeof G==="object"&&G!==null&&G.code==="AUTH_REQUIRED"}function A(G,J,X){if(H(X))return{site:G,status:"not_logged_in",logged_in:!1,identity:"",checked:J,error:""};const Z=X instanceof F?X.code:"",Y=M(X);return{site:G,status:"error",logged_in:"",identity:"",checked:J,error:Z?`${Z}: ${Y}`:Y}}function e(G,J){if(G.browser!==!0)return null;let X=G.authStatus?.refresh;if(typeof X!=="function"){const Z=G.authStatus?.quickCheck;if(typeof Z!=="function"||!G.domain)return null;const Y=G.domain.startsWith("http://")||G.domain.startsWith("https://")?G.domain:`https://${G.domain}`;X=async($,D,Q)=>{await $.goto(Y);await $.wait(1);if(v(await Z($,D,Q))!==!0)throw new P(G.domain??G.site,`Auth refresh quickCheck failed for ${G.site}`);return{status:"touched"}}}return z({...G,func:X,navigateBefore:!1,siteSession:"persistent",defaultWindowMode:"background"},J)}function GG(G){if(G&&typeof G==="object"&&!Array.isArray(G)){const J=G;if(J.status==="refreshed"||J.refreshed===!0)return"refreshed"}return"touched"}function JG(G,J,X){if(H(X))return{site:G,status:"not_logged_in",last_touched_at:J?.last_touched_at??"",next_refresh_at:B(J),error:""};const Z=X instanceof F?X.code:"",Y=M(X);return{site:G,status:"error",last_touched_at:J?.last_touched_at??"",next_refresh_at:B(J),error:Z?`${Z}: ${Y}`:Y}}function E(){return process.env.PUBLISHPORT_SITE_SESSION==="ephemeral"?{siteSession:"ephemeral",keepTab:"false"}:{siteSession:"persistent",keepTab:"true"}}async function XG(G,J){try{const X=await O(G);if(X.browser!==!0){const D=z(X,J.timeoutSeconds),Q=await N(D,{timeout:J.timeoutSeconds},!1,{...J.profile?{profile:J.profile}:{}});return{site:G.site,status:"logged_in",logged_in:!0,identity:S(Q),checked:"quick",error:""}}if(X.authStatus?.quickCheckRequiresNavigation===!0)return{site:G.site,status:"unknown",logged_in:"",identity:"",checked:"navigation_required",error:""};const Z=t(X,J.timeoutSeconds);if(!Z)return{site:G.site,status:"unknown",logged_in:"",identity:"",checked:"skipped",error:"quickCheck not implemented; use --full to run whoami"};const Y=await N(Z,{timeout:J.timeoutSeconds},!1,{...E(),windowMode:"background",...J.profile?{profile:J.profile}:{}}),$=v(Y);if($===!0)return{site:G.site,status:"logged_in",logged_in:!0,identity:"",checked:"quick",error:""};if($===!1)return{site:G.site,status:"not_logged_in",logged_in:!1,identity:"",checked:"quick",error:""};return{site:G.site,status:"unknown",logged_in:"",identity:"",checked:"quick",error:"quickCheck returned no boolean logged_in signal"}}catch(X){return A(G.site,"quick",X)}}async function YG(G,J){try{const X=await O(G),Z=z(X,J.timeoutSeconds),Y=await N(Z,{timeout:J.timeoutSeconds},!1,{...E(),windowMode:"background",...J.profile?{profile:J.profile}:{}});return{site:G.site,status:"logged_in",logged_in:!0,identity:S(Y),checked:"full",error:""}}catch(X){return A(G.site,"full",X)}}function ZG(G,J){return J?`${G}@${J}`:G}async function $G(G,J){const X=ZG(G.site,J.profile),Z=J.state.sites[X];if(!J.force&&r(Z,J.now))return{site:G.site,status:"skipped",last_touched_at:Z?.last_touched_at??"",next_refresh_at:B(Z),error:""};const Y=J.now.toISOString();try{const $=await O(G),D=e($,J.timeoutSeconds);if(!D){J.state.sites[X]={...Z,last_attempt_at:Y,last_status:"unsupported"};return{site:G.site,status:"unsupported",last_touched_at:Z?.last_touched_at??"",next_refresh_at:B(Z),error:"refresh probe is not available for this site"}}const Q=await N(D,{timeout:J.timeoutSeconds},!1,{siteSession:"persistent",keepTab:"true",windowMode:"background",...J.profile?{profile:J.profile}:{}}),K=GG(Q);J.state.sites[X]={...Z,last_attempt_at:Y,last_touched_at:Y,last_status:K};return{site:G.site,status:K,last_touched_at:Y,next_refresh_at:new Date(J.now.getTime()+q).toISOString(),error:""}}catch($){const D=H($)?"not_logged_in":"error";J.state.sites[X]={...Z,last_attempt_at:Y,last_status:D};return JG(G.site,Z,$)}}async function R(G,J){const X=a(J);if(X.kind!=="extension")return null;if(!G.some((Y)=>Y.browser===!0&&!U(Y.site)))return null;try{await i({timeoutSeconds:15,contextId:X.contextId,verbose:!1});return null}catch(Y){return M(Y)}}function DG(G,J,X){return{site:G,status:"error",logged_in:"",identity:"",checked:J,error:`BROWSER_CONNECT: ${X}`}}async function f(G,J,X){const Z=Array(G.length);let Y=0;const $=Array.from({length:Math.min(J,G.length)},async()=>{while(Y<G.length){const D=Y++;Z[D]=await X(G[D])}});await Promise.all($);return Z}export async function collectAuthStatus(G){const J=C(G.sites),X=G.full?"full":"quick",Z=j(G.concurrency,"--concurrency",X==="full"?3:8),Y=j(G.timeout,"--timeout",X==="full"?20:8),$=String(G.only??"all");if(!["all","logged-in","not-logged-in","unknown","error"].includes($))throw new I("--only must be one of: all, logged-in, not-logged-in, unknown, error");const D=b().filter((W)=>!J||J.has(W.site)),Q=await R(D,G.profile),K=await f(D,Z,(W)=>Q&&W.browser===!0&&!U(W.site)?Promise.resolve(DG(W.site,X,Q)):X==="full"?YG(W,{timeoutSeconds:Y,profile:G.profile}):XG(W,{timeoutSeconds:Y,profile:G.profile})),L=$.replace(/-/g,"_");return L==="all"?K:K.filter((W)=>W.status===L)}export async function collectAuthRefresh(G){const J=C(G.sites),X=j(G.concurrency,"--concurrency",3),Z=j(G.timeout,"--timeout",20),Y=G.statePath??m(),$=G.now??new Date,D=await o(Y),Q=b().filter((W)=>!J||J.has(W.site)),K=await R(Q,G.profile),L=await f(Q,X,(W)=>K&&W.browser===!0&&!U(W.site)?Promise.resolve({site:W.site,status:"error",last_touched_at:D.sites[W.site]?.last_touched_at??"",next_refresh_at:B(D.sites[W.site]),error:`BROWSER_CONNECT: ${K}`}):$G(W,{timeoutSeconds:Z,profile:G.profile,now:$,state:D,force:G.all===!0}));await s(Y,D);return L}export function registerAuthCommands(G){const J=G.command("auth").description("Inspect website login status"),X=J.command("status").description("Show login status for sites with auth adapters").option("--site <sites>","Comma-separated site names to check, e.g. github,chatgpt").option("--full","Run full per-site whoami probes instead of quick no-navigation checks",!1).option("--concurrency <n>","Maximum sites to check at once").option("--timeout <seconds>","Per-site timeout in seconds").addOption(new p("--only <status>","Filter rows by status").choices(["all","logged-in","not-logged-in","unknown","error"]).default("all")).option("-f, --format <fmt>","Output format: table, plain, json, yaml, md, csv","table").action(async(Y)=>{const $=typeof X.optsWithGlobals==="function"?X.optsWithGlobals():{},D=await collectAuthStatus({sites:Y.site,full:Y.full===!0,concurrency:Y.concurrency,timeout:Y.timeout,only:Y.only,profile:typeof $.profile==="string"&&$.profile.trim()?$.profile.trim():void 0}),Q=typeof Y.format==="string"?Y.format:"table";x(D,{fmt:Q,fmtExplicit:X.getOptionValueSource("format")==="cli",columns:["site","status","identity","checked","error"],title:"ppcli/auth status",source:Y.full?"full whoami probe":"quick auth check"})}),Z=J.command("refresh").description("Touch logged-in site sessions to keep browser auth fresh").option("--site <sites>","Comma-separated site names to refresh, e.g. github,claude").option("--all","Ignore the 24h refresh throttle and force every selected site",!1).option("--concurrency <n>","Maximum sites to refresh at once").option("--timeout <seconds>","Per-site timeout in seconds").option("-f, --format <fmt>","Output format: table, plain, json, yaml, md, csv","table").action(async(Y)=>{const $=typeof Z.optsWithGlobals==="function"?Z.optsWithGlobals():{},D=await collectAuthRefresh({sites:Y.site,all:Y.all===!0,concurrency:Y.concurrency,timeout:Y.timeout,profile:typeof $.profile==="string"&&$.profile.trim()?$.profile.trim():void 0}),Q=typeof Y.format==="string"?Y.format:"table";x(D,{fmt:Q,fmtExplicit:Z.getOptionValueSource("format")==="cli",columns:["site","status","last_touched_at","next_refresh_at","error"],title:"ppcli/auth refresh",source:Y.all?"forced persistent touch":"persistent touch with 24h throttle"})});return J}
1
+ import{mkdir as l,readFile as u,rename as KG,writeFile as p}from"node:fs/promises";import{homedir as n}from"node:os";import{dirname as c,join as i}from"node:path";import{pathToFileURL as LG}from"node:url";import{InvalidArgumentError as k,Option as jG}from"commander";import{AuthRequiredError as m,CliError as a,getErrorMessage as b}from"../errors.js";import{executeCommand as H}from"../execution.js";import{ensureBrowserBridgeReady as zG}from"../browser/daemon-lifecycle.js";import{resolveProfile as qG}from"../browser/profile.js";import{isElectronApp as R}from"../electron-apps.js";import{fullName as UG,getRegistry as d}from"../registry.js";import{render as s}from"../output.js";const E=1,v=86400000;function M(G,J,X){if(G===void 0||G===null||G==="")return X;const $=Number(G);if(!Number.isInteger($)||$<=0)throw new k(`${J} must be a positive integer. Received: "${String(G)}"`);return $}function o(G,J,X){if(G===void 0||G===null||G==="")return X;const $=Number(G);if(!Number.isInteger($)||$<0)throw new k(`${J} must be a non-negative integer. Received: "${String(G)}"`);return $}function r(G){if(!G||!G.trim())return null;const J=G.split(",").map((X)=>X.trim()).filter(Boolean);return J.length>0?new Set(J):null}function VG(){return i(n(),".ppcli","auth-refresh.json")}const S=1,OG=600000;function NG(){return i(n(),".ppcli","auth-state.json")}async function BG(G){try{const J=JSON.parse(await u(G,"utf8"));if(J&&J.version===S&&J.profiles&&typeof J.profiles==="object")return{version:S,profiles:J.profiles}}catch{}return{version:S,profiles:{}}}async function HG(G,J){await l(c(G),{recursive:!0});const X=`${G}.${process.pid}.tmp`;await p(X,`${JSON.stringify(J,null,2)}
2
+ `,"utf8");await KG(X,G)}function MG(G,J){return{site:G,status:J.status,logged_in:J.logged_in,identity:J.identity,checked:"cached",error:J.error,checked_at:new Date(J.at).toISOString(),verified:J.verifiedAt!==void 0}}function IG(G,J,X){return{status:G.status,logged_in:G.logged_in,identity:G.identity,checked:G.checked,error:G.error,at:J,...X!==void 0?{verifiedAt:X}:{}}}function CG(){return{version:E,sites:{}}}async function xG(G){try{const J=JSON.parse(await u(G,"utf8"));if(J&&J.version===E&&J.sites&&typeof J.sites==="object")return{version:E,sites:J.sites}}catch(J){if(J.code!=="ENOENT")throw J}return CG()}async function _G(G,J){await l(c(G),{recursive:!0});await p(G,`${JSON.stringify(J,null,2)}
3
+ `,"utf8")}function t(G){if(!G?.last_touched_at)return null;const J=Date.parse(G.last_touched_at);return Number.isFinite(J)?J:null}function FG(G,J){const X=t(G);return X!==null&&J.getTime()-X<v}function O(G){const J=t(G);return J===null?"":new Date(J+v).toISOString()}function e(){const G=new Set;return[...d().values()].filter((J)=>{if(G.has(J))return!1;G.add(J);return J.name==="whoami"&&J.access==="read"}).sort((J,X)=>J.site.localeCompare(X.site))}async function A(G){const J=G;if(!J._lazy||!J._modulePath)return G;await import(LG(J._modulePath).href);return d().get(UG(G))??G}function I(G,J){const X=G.args.some(($)=>$.name==="timeout");return{...G,args:X?G.args:[...G.args,{name:"timeout",type:"int",default:J,help:"Per-site auth command timeout in seconds"}]}}function PG(G,J){if(G.browser!==!0||typeof G.authStatus?.quickCheck!=="function")return null;return I({...G,func:G.authStatus.quickCheck,navigateBefore:!1,siteSession:"ephemeral",defaultWindowMode:"background"},J)}function GG(G){if(typeof G==="boolean")return G;if(G&&typeof G==="object"&&!Array.isArray(G)){const J=G.logged_in;if(typeof J==="boolean")return J}return null}function TG(G){const J=Array.isArray(G)?G[0]:G;if(J&&typeof J==="object"&&!Array.isArray(J)){const X=J.logged_in;if(typeof X==="boolean")return X}return null}function kG(G){const J=Array.isArray(G)?G[0]:G;if(J&&typeof J==="object"&&!Array.isArray(J)){const X=J.error;if(typeof X==="string"&&X.trim())return X.trim()}return""}function JG(G){if(G===void 0||G===null)return"";if(typeof G==="string"||typeof G==="number"||typeof G==="boolean")return String(G);return""}function XG(G){const J=Array.isArray(G)?G[0]:G;if(!J||typeof J!=="object"||Array.isArray(J))return"";const X=J,$=/(?:email|phone|real.?name|first.?name|last.?name|cookie|token|session|secret|password|csrf|jwt|bearer|wt2)/i;for(const Z of["username","handle","user_id","id","name","nickname","user_type","url"]){if($.test(Z))continue;const Y=JG(X[Z]);if(Y)return Y}for(const[Z,Y]of Object.entries(X)){if(Z==="site"||Z==="logged_in"||$.test(Z))continue;const Q=JG(Y);if(Q)return Q}return""}function f(G){return G instanceof m||typeof G==="object"&&G!==null&&G.code==="AUTH_REQUIRED"}function ZG(G,J,X){if(f(X))return{site:G,status:"not_logged_in",logged_in:!1,identity:"",checked:J,error:""};const $=X instanceof a?X.code:"",Z=b(X);return{site:G,status:"error",logged_in:"",identity:"",checked:J,error:$?`${$}: ${Z}`:Z}}function bG(G,J){if(G.browser!==!0)return null;let X=G.authStatus?.refresh;if(typeof X!=="function"){const $=G.authStatus?.quickCheck;if(typeof $!=="function"||!G.domain)return null;const Z=G.domain.startsWith("http://")||G.domain.startsWith("https://")?G.domain:`https://${G.domain}`;X=async(Y,Q,K)=>{await Y.goto(Z);await Y.wait(1);if(GG(await $(Y,Q,K))!==!0)throw new m(G.domain??G.site,`Auth refresh quickCheck failed for ${G.site}`);return{status:"touched"}}}return I({...G,func:X,navigateBefore:!1,siteSession:"persistent",defaultWindowMode:"background"},J)}function RG(G){if(G&&typeof G==="object"&&!Array.isArray(G)){const J=G;if(J.status==="refreshed"||J.refreshed===!0)return"refreshed"}return"touched"}function EG(G,J,X){if(f(X))return{site:G,status:"not_logged_in",last_touched_at:J?.last_touched_at??"",next_refresh_at:O(J),error:""};const $=X instanceof a?X.code:"",Z=b(X);return{site:G,status:"error",last_touched_at:J?.last_touched_at??"",next_refresh_at:O(J),error:$?`${$}: ${Z}`:Z}}function $G(){return process.env.PUBLISHPORT_SITE_SESSION==="ephemeral"?{siteSession:"ephemeral",keepTab:"false"}:{siteSession:"persistent",keepTab:"true"}}async function vG(G,J){try{const X=await A(G);if(X.browser!==!0){const Q=I(X,J.timeoutSeconds),K=await H(Q,{timeout:J.timeoutSeconds},!1,{...J.profile?{profile:J.profile}:{}});return{site:G.site,status:"logged_in",logged_in:!0,identity:XG(K),checked:"quick",error:""}}if(X.authStatus?.quickCheckRequiresNavigation===!0)return{site:G.site,status:"unknown",logged_in:"",identity:"",checked:"navigation_required",error:""};const $=PG(X,J.timeoutSeconds);if(!$)return{site:G.site,status:"unknown",logged_in:"",identity:"",checked:"skipped",error:"quickCheck not implemented; use --full to run whoami"};const Z=await H($,{timeout:J.timeoutSeconds},!1,{...$G(),windowMode:"background",...J.profile?{profile:J.profile}:{}}),Y=GG(Z);if(Y===!0)return{site:G.site,status:"logged_in",logged_in:!0,identity:"",checked:"quick",error:""};if(Y===!1)return{site:G.site,status:"not_logged_in",logged_in:!1,identity:"",checked:"quick",error:""};return{site:G.site,status:"unknown",logged_in:"",identity:"",checked:"quick",error:"quickCheck returned no boolean logged_in signal"}}catch(X){return ZG(G.site,"quick",X)}}async function DG(G,J){try{const X=await A(G),$=I(X,J.timeoutSeconds),Z=await H($,{timeout:J.timeoutSeconds},!1,{...$G(),windowMode:"background",...J.profile?{profile:J.profile}:{}});if(TG(Z)===!1)return{site:G.site,status:"not_logged_in",logged_in:!1,identity:"",checked:"full",error:kG(Z)};return{site:G.site,status:"logged_in",logged_in:!0,identity:XG(Z),checked:"full",error:""}}catch(X){return ZG(G.site,"full",X)}}function SG(G,J){return J?`${G}@${J}`:G}async function AG(G,J){const X=SG(G.site,J.profile),$=J.state.sites[X];if(!J.force&&FG($,J.now))return{site:G.site,status:"skipped",last_touched_at:$?.last_touched_at??"",next_refresh_at:O($),error:""};const Z=J.now.toISOString();try{const Y=await A(G),Q=bG(Y,J.timeoutSeconds);if(!Q){J.state.sites[X]={...$,last_attempt_at:Z,last_status:"unsupported"};return{site:G.site,status:"unsupported",last_touched_at:$?.last_touched_at??"",next_refresh_at:O($),error:"refresh probe is not available for this site"}}const K=await H(Q,{timeout:J.timeoutSeconds},!1,{siteSession:"persistent",keepTab:"true",windowMode:"background",...J.profile?{profile:J.profile}:{}}),z=RG(K);J.state.sites[X]={...$,last_attempt_at:Z,last_touched_at:Z,last_status:z};return{site:G.site,status:z,last_touched_at:Z,next_refresh_at:new Date(J.now.getTime()+v).toISOString(),error:""}}catch(Y){const Q=f(Y)?"not_logged_in":"error";J.state.sites[X]={...$,last_attempt_at:Z,last_status:Q};return EG(G.site,$,Y)}}async function YG(G,J){const X=qG(J);if(X.kind!=="extension")return null;if(!G.some((Z)=>Z.browser===!0&&!R(Z.site)))return null;try{await zG({timeoutSeconds:15,contextId:X.contextId,verbose:!1});return null}catch(Z){return b(Z)}}function fG(G,J,X){return{site:G,status:"error",logged_in:"",identity:"",checked:J,error:`BROWSER_CONNECT: ${X}`}}async function g(G,J,X){const $=Array(G.length);let Z=0;const Y=Array.from({length:Math.min(J,G.length)},async()=>{while(Z<G.length){const Q=Z++;$[Q]=await X(G[Q])}});await Promise.all(Y);return $}export async function collectAuthStatus(G){const J=r(G.sites),X=G.full?"full":"quick",$=M(G.concurrency,"--concurrency",X==="full"?3:8),Z=M(G.timeout,"--timeout",X==="full"?20:8),Y=String(G.only??"all");if(!["all","logged-in","not-logged-in","unknown","error"].includes(Y))throw new k("--only must be one of: all, logged-in, not-logged-in, unknown, error");const Q=o(G.maxAge,"--max-age",0)*1000,K=o(G.verify,"--verify",0),z=G.statePath??NG(),q=(G.now??new Date).getTime(),j=G.profile??"",C=e().filter((D)=>!J||J.has(D.site)),x=await BG(z),_=x.profiles[j]??{},F=new Map,N=[];for(const D of C){const L=_[D.site];if(Q>0&&L&&q-L.at<Q)F.set(D.site,MG(D.site,L));else N.push(D)}const P=N.length>0?await YG(N,G.profile):null,QG=await g(N,$,(D)=>P&&D.browser===!0&&!R(D.site)?Promise.resolve(fG(D.site,X,P)):X==="full"?DG(D,{timeoutSeconds:Z,profile:G.profile}):vG(D,{timeoutSeconds:Z,profile:G.profile})),WG=new Map(C.map((D)=>[D.site,D])),U=new Map(F),T=new Map;for(const D of QG){U.set(D.site,D);if(X==="full"&&D.status!=="error")T.set(D.site,q)}const V=(D)=>{const L=T.get(D.site);if(L!==void 0)return L;const W=_[D.site];return W&&W.status===D.status?W.verifiedAt:void 0};if(K>0&&!P){const D=[...U.values()].filter((W)=>W.status==="logged_in"&&W.checked!=="full").filter((W)=>{const B=V(W);return B===void 0||q-B>=OG}).sort((W,B)=>(V(W)??0)-(V(B)??0)).slice(0,K).map((W)=>WG.get(W.site)).filter((W)=>W!==void 0),L=await g(D,Math.min($,3),(W)=>DG(W,{timeoutSeconds:Math.max(Z,20),profile:G.profile}));for(const W of L){if(W.status==="error")continue;U.set(W.site,W);T.set(W.site,q)}}const y={..._};for(const D of U.keys()){if(F.has(D))continue;const L=U.get(D);if(!L)continue;y[D]=IG(L,q,V(L))}x.profiles[j]=y;await HG(z,x).catch(()=>{});const h=C.map((D)=>U.get(D.site)).filter((D)=>D!==void 0).map((D)=>({...D,checked_at:D.checked_at??new Date(q).toISOString(),verified:D.verified??V(D)!==void 0})),w=Y.replace(/-/g,"_");return w==="all"?h:h.filter((D)=>D.status===w)}export async function collectAuthRefresh(G){const J=r(G.sites),X=M(G.concurrency,"--concurrency",3),$=M(G.timeout,"--timeout",20),Z=G.statePath??VG(),Y=G.now??new Date,Q=await xG(Z),K=e().filter((j)=>!J||J.has(j.site)),z=await YG(K,G.profile),q=await g(K,X,(j)=>z&&j.browser===!0&&!R(j.site)?Promise.resolve({site:j.site,status:"error",last_touched_at:Q.sites[j.site]?.last_touched_at??"",next_refresh_at:O(Q.sites[j.site]),error:`BROWSER_CONNECT: ${z}`}):AG(j,{timeoutSeconds:$,profile:G.profile,now:Y,state:Q,force:G.all===!0}));await _G(Z,Q);return q}export function registerAuthCommands(G){const J=G.command("auth").description("Inspect website login status"),X=J.command("status").description("Show login status for sites with auth adapters").option("--site <sites>","Comma-separated site names to check, e.g. github,chatgpt").option("--full","Run full per-site whoami probes instead of quick no-navigation checks",!1).option("--concurrency <n>","Maximum sites to check at once").option("--timeout <seconds>","Per-site timeout in seconds").option("--max-age <seconds>","Reuse a local cached verdict younger than this instead of probing").option("--verify <n>",'Re-confirm at most N cookie-only "logged in" verdicts with a real whoami').addOption(new jG("--only <status>","Filter rows by status").choices(["all","logged-in","not-logged-in","unknown","error"]).default("all")).option("-f, --format <fmt>","Output format: table, plain, json, yaml, md, csv","table").action(async(Z)=>{const Y=typeof X.optsWithGlobals==="function"?X.optsWithGlobals():{},Q=await collectAuthStatus({sites:Z.site,full:Z.full===!0,concurrency:Z.concurrency,timeout:Z.timeout,only:Z.only,maxAge:Z.maxAge,verify:Z.verify,profile:typeof Y.profile==="string"&&Y.profile.trim()?Y.profile.trim():void 0}),K=typeof Z.format==="string"?Z.format:"table";s(Q,{fmt:K,fmtExplicit:X.getOptionValueSource("format")==="cli",columns:["site","status","identity","checked","error"],title:"ppcli/auth status",source:Z.full?"full whoami probe":"quick auth check"})}),$=J.command("refresh").description("Touch logged-in site sessions to keep browser auth fresh").option("--site <sites>","Comma-separated site names to refresh, e.g. github,claude").option("--all","Ignore the 24h refresh throttle and force every selected site",!1).option("--concurrency <n>","Maximum sites to refresh at once").option("--timeout <seconds>","Per-site timeout in seconds").option("-f, --format <fmt>","Output format: table, plain, json, yaml, md, csv","table").action(async(Z)=>{const Y=typeof $.optsWithGlobals==="function"?$.optsWithGlobals():{},Q=await collectAuthRefresh({sites:Z.site,all:Z.all===!0,concurrency:Z.concurrency,timeout:Z.timeout,profile:typeof Y.profile==="string"&&Y.profile.trim()?Y.profile.trim():void 0}),K=typeof Z.format==="string"?Z.format:"table";s(Q,{fmt:K,fmtExplicit:$.getOptionValueSource("format")==="cli",columns:["site","status","last_touched_at","next_refresh_at","error"],title:"ppcli/auth refresh",source:Z.all?"forced persistent touch":"persistent touch with 24h throttle"})});return J}
@@ -1 +1 @@
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);
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:`浏览器环境 "${$}" 未连接。`,errorHint:"请用户在 PublishPort 客户端的「高级设置 → 环境」里确认该环境已启动,或改用其它环境(--profile <名字>)。"}}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:"浏览器桥未连接:这台机器上的 PublishPort 托管浏览器没在运行。",errorHint:"请用户打开 PublishPort 客户端,在平台列表里点一次「检测登录」把托管浏览器拉起来;仍不行就重启客户端。"}}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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "publishport-opencli",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": false