@coboot/headless 0.2.2-rc.0

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/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # Coboot Headless
2
+
3
+ Node.js 20+ 的无浏览器后台运行包。首次启动只需一次性设备 token:
4
+
5
+ ```bash
6
+ npx @coboot/headless <device-token>
7
+ ```
8
+
9
+ 命令会把运行时安装到 `/data/coboot/.coboot`,启动 detached daemon 后退出。
10
+ 不需要设置 `BACKEND_URL`、`CLIENT_UID`,也不需要安装 Chromium。
11
+
12
+ ```bash
13
+ npx @coboot/headless status
14
+ npx @coboot/headless logs
15
+ npx @coboot/headless restart
16
+ npx @coboot/headless stop
17
+ ```
18
+
19
+ 默认数据目录可通过 `HEADLESS_DATA_DIR` 修改。daemon 默认禁用 updater 和浏览器预热。
20
+
21
+ 已有设备凭证无效或需要切换设备身份时,显式重新注册:
22
+
23
+ ```bash
24
+ npx @coboot/headless --force-enroll <device-token>
25
+ ```
26
+
27
+ 命令会先停止 daemon 并临时备份旧凭证;新凭证和客户端均就绪后才完成替换,失败时恢复旧凭证。
28
+
29
+ ## 运行环境
30
+
31
+ - Edition:`coboot`,在构建期写入,启动时不能切换。
32
+ - 默认 HTTP 服务:`https://www.coboot.com`。
33
+ - 默认 WebSocket 服务:`wss://www.coboot.com/wsschat`。
34
+ - 默认数据目录:`/data/coboot`。
35
+ - 包不携带浏览器、Chromium、`playwright-core` 或 updater,不能用于浏览器工具。
36
+
37
+ 使用其他服务环境时,必须同时设置与设备 token 匹配的地址:
38
+
39
+ ```bash
40
+ BACKEND_URL=https://example.com \
41
+ WS_URL=wss://example.com/wsschat \
42
+ npx @coboot/headless <device-token>
43
+ ```
44
+
45
+ 代码读取的 WebSocket 覆盖变量名是 `WS_URL`,不是 `WSS_URL`。
46
+
47
+ ## 维护者构建
48
+
49
+ 在 `frontend/packages/app-desktop` 下执行:
50
+
51
+ ```bash
52
+ CU_EDITION=coboot \
53
+ PKG_NAME=@coboot/headless \
54
+ PKG_BIN_NAME=coboot-headless \
55
+ PKG_VERSION=0.2.2 \
56
+ pnpm build:headless:npm
57
+ ```
58
+
59
+ 发布前核对包内清单、edition 和哈希:
60
+
61
+ ```bash
62
+ npm pack --dry-run --json ./dist-headless
63
+ tar -xOf dist-headless/coboot-headless-0.2.2-rc.0.tgz package/package.json
64
+ sha256sum dist-headless/coboot-headless-0.2.2-rc.0.tgz
65
+ ```
66
+
67
+ `.tgz` 属于本地构建产物,不提交 Git。只发布已经完成上述核对的 tarball。
package/cli.mjs ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import{execFileSync as C,spawn as A}from"child_process";import i from"fs";import o from"path";import{fileURLToPath as P}from"url";var N=new Set(["help","start","stop","restart","status","logs"]);function j(e){if(e==="coboot")return{edition:e,packageName:"@coboot/headless",productName:"Coboot",dataRoot:"/data/coboot",stateDirectory:".coboot"};if(e!=="siboot")throw new Error(`unsupported headless edition: ${e}`);return{edition:"siboot",packageName:"siboot-headless",productName:"Siboot",dataRoot:"/data/siboot",stateDirectory:".siboot"}}var v="coboot",a=j(v);function w(){console.log(`Usage:
3
+ ${a.packageName} <device-token>
4
+ ${a.packageName} --force-enroll <device-token>
5
+ ${a.packageName} start|stop|restart|status|logs
6
+
7
+ Environment:
8
+ HEADLESS_DATA_DIR Persistent data root (default: ${a.dataRoot})`)}function p(e){let n=e[0]?.trim()||"";if(!n||n==="--help"||n==="-h"||n==="help")return{command:"help"};if(n==="--force-enroll"){let r=e[1]?.trim()||"";if(!r||e.length!==2)throw new Error("--force-enroll requires one device token");return{command:"install",token:r,forceEnroll:!0}}return N.has(n)?{command:n}:{command:"install",token:n}}import l from"fs";var x=e=>new Promise(n=>setTimeout(n,e));function g(e){if(!Number.isInteger(e)||e<=0)return!1;try{return process.kill(e,0),!0}catch{return!1}}function _(e){try{return JSON.parse(l.readFileSync(e,"utf8"))}catch{return null}}function $(e){let n=_(e);return!!(n?.deviceId?.trim()&&n?.credentialId?.trim()&&n?.ownerUid?.trim()&&n?.deviceName?.trim()&&n?.deviceType>0&&n?.publicKey?.trim()&&n?.privateKeyPem?.trim())}function b(e){return[e.credential,e.authSession]}function O(e){for(let n of e.entries){try{l.unlinkSync(n.file)}catch{}n.existed&&l.renameSync(n.backup,n.file)}}function R(e){let n=`${process.pid}-${Date.now()}`,r=b(e).map(s=>({file:s,backup:`${s}.force-enroll-${n}.bak`,existed:l.existsSync(s)})),t=[];try{for(let s of r)s.existed&&(l.renameSync(s.file,s.backup),t.push(s))}catch(s){for(let c of t.reverse())l.renameSync(c.backup,c.file);throw s}return{entries:r}}function L(e){for(let n of e.entries)n.existed&&l.unlinkSync(n.backup)}function H(e){O(e)}async function F(e,n){let r=Date.now()+n,t=0;for(;Date.now()<r;){let s=_(`${e.run}/status.json`);if($(e.credential)&&g(s?.client_pid)){if(t||=Date.now(),Date.now()-t>=1e3)return}else t=0;let c=Number.parseInt(l.readFileSync(`${e.run}/daemon.pid`,"utf8"),10);if(s?.client_exit_code!=null&&!s?.client_pid)throw new Error(`client exited during force enrollment with code ${s.client_exit_code}`);if(c&&!g(c))throw new Error("daemon exited during force enrollment");await x(100)}throw new Error(`force enrollment did not become ready within ${n}ms`)}async function D(e,n,r,t={}){let s=R(e);try{let c=await n();return await F(e,t.timeoutMs||15e3),L(s),c}catch(c){await r().catch(()=>{});try{H(s)}catch(d){throw new AggregateError([c,d],"force enrollment and rollback failed")}throw c}}var k=P(import.meta.url),E=a.dataRoot;function U(e){let n=o.resolve(e||E);if(!o.isAbsolute(e||E)||n===o.parse(n).root)throw new Error(`HEADLESS_DATA_DIR must be a non-root absolute path: ${e}`);return n}function B(e=process.env.SIBOOT_DATA_ROOT||process.env.HEADLESS_DATA_DIR){let n=U(e||E),r=o.join(n,a.stateDirectory);return{root:n,home:r,releases:o.join(r,"releases"),current:o.join(r,"current"),scripts:o.join(r,"scripts"),run:o.join(r,"run"),logs:o.join(r,"logs"),workspace:o.join(n,"workspace"),credential:o.join(r,"headless-device-credential.json"),authSession:o.join(r,"headless-auth-session.json")}}function I(e){for(let n of[e.home,e.releases,e.scripts,e.run,e.workspace,o.join(e.logs,"daemon"),o.join(e.logs,"client"),o.join(e.logs,"update")])i.mkdirSync(n,{recursive:!0,mode:448})}function J(e){let n=JSON.parse(i.readFileSync(o.join(e,"package.json"),"utf8")),r=String(n.version||"").trim();if(!r||!/^[0-9A-Za-z._-]+$/.test(r))throw new Error("package version is missing or invalid");return r}function V(e,n){let r=o.relative(e.home,n),t=`${e.current}.next-${process.pid}`;try{i.unlinkSync(t)}catch{}i.symlinkSync(r,t),i.renameSync(t,e.current)}function y(e,n,r){let t=`${n}.next-${process.pid}`;i.copyFileSync(e,t),i.chmodSync(t,r),i.renameSync(t,n)}function K(e,n,r){let t=`${e}.next-${process.pid}`;i.writeFileSync(t,n,{mode:r}),i.renameSync(t,e)}function M(e,n){I(n);let r=J(e),t=o.join(n.releases,r);i.mkdirSync(t,{recursive:!0,mode:448});let s=o.join(e,"entry.mjs"),c=o.join(e,"daemon.mjs");if(!i.existsSync(s)||!i.existsSync(c))throw new Error("package is missing entry.mjs or daemon.mjs");return y(s,o.join(t,"entry.mjs"),320),y(c,o.join(n.scripts,"daemon.mjs"),320),K(o.join(t,"manifest.json"),`${JSON.stringify({type:"headless-npm-daemon",version:r,browserRuntime:"disabled"},null,2)}
9
+ `,256),V(n,t),{version:r,releaseDir:t}}function h(e){try{let n=Number.parseInt(i.readFileSync(e,"utf8").trim(),10);return Number.isInteger(n)&&n>0?n:null}catch{return null}}function m(e){if(!e)return!1;try{return process.kill(e,0),!0}catch{return!1}}function W(e,n){let r="";try{r=i.readFileSync(`/proc/${e}/cmdline`,"utf8").replaceAll("\0"," ").trim()}catch{}if(!r)try{r=C("ps",["-ww","-p",String(e),"-o","command="],{encoding:"utf8"}).trim()}catch{return!1}return[n,S(n)].some(t=>r.includes(t))}var T=e=>new Promise(n=>setTimeout(n,e));async function f(e){let n=o.join(e.run,"daemon.pid"),r=h(n);if(!m(r)){try{i.unlinkSync(n)}catch{}return!1}let t=o.join(e.scripts,"daemon.mjs");if(!W(r,t))throw new Error(`refusing to stop unrelated process from stale daemon.pid: ${r}`);process.kill(r,"SIGTERM");for(let s=0;s<50&&m(r);s+=1)await T(100);if(m(r))throw new Error(`daemon did not stop within 5 seconds: ${r}`);return!0}function Q(e,n,r=process.env){let t={...r,NO_ELECTRON:"1",CLIENT_TYPE:"anydev",SIBOOT_BASE_DIR:e.home,SIBOOT_DATA_ROOT:e.root,HEADLESS_DATA_DIR:e.root,SIBOOT_DATA_DIR:e.root,HEADLESS_DEVICE_CREDENTIAL_PATH:e.credential,HEADLESS_AUTH_SESSION_PATH:e.authSession,HEADLESS_DEVICE_REQUIRE_AUTH:"1",HEADLESS_PREWARM_DEFAULT_SESSION:"0",WORKSPACE:e.workspace,ANYDEV_UPDATE_ENABLED:"0"};return n?t.HEADLESS_DEVICE_TOKEN=n:delete t.HEADLESS_DEVICE_TOKEN,delete t.HEADLESS_DEVICE_FORCE_ENROLL,t}async function Y(e,n=""){if(I(e),!i.existsSync(e.credential)&&!n)throw new Error(`device credential is missing; run ${a.packageName} <device-token> first`);let r=o.join(e.scripts,"daemon.mjs");if(!i.existsSync(o.join(e.current,"entry.mjs"))||!i.existsSync(r))throw new Error(`runtime is not installed; run ${a.packageName} <device-token> first`);let t=i.existsSync(e.credential)?"":n,s=o.join(e.logs,"daemon",`${new Date().toISOString().slice(0,10)}.log`),c=i.openSync(s,"a",384);A(process.execPath,[r],{detached:!0,env:Q(e,t),stdio:["ignore",c,c]}).unref(),i.closeSync(c),await T(500);let u=h(o.join(e.run,"daemon.pid"));if(!m(u))throw new Error(`daemon failed to start; check ${s}`);return{pid:u,logFile:s}}function q(e){let n=h(o.join(e.run,"daemon.pid")),r=null;try{r=JSON.parse(i.readFileSync(o.join(e.run,"status.json"),"utf8"))}catch{}console.log(JSON.stringify({running:m(n),pid:n,current:i.existsSync(e.current),status:r},null,2))}async function z(e){let n=o.join(e.logs,"daemon",`${new Date().toISOString().slice(0,10)}.log`);if(!i.existsSync(n))throw new Error(`log file not found: ${n}`);let r=A("tail",["-n","100","-f",n],{stdio:"inherit"});await new Promise((t,s)=>{r.once("error",s),r.once("exit",c=>c===0?t():s(new Error(`tail exited ${c}`)))})}async function G(){let e=p(process.argv.slice(2));if(e.command==="help")return w();let n=B();if(e.command==="status")return q(n);if(e.command==="logs")return z(n);if(e.command==="stop"){let d=await f(n);console.log(d?`${a.productName} Headless stopped`:`${a.productName} Headless is not running`);return}let r=o.dirname(k);await f(n);let t=async()=>{let d=M(r,n),u=await Y(n,e.token||"");return{installed:d,started:u}},{installed:s,started:c}=e.forceEnroll?await D(n,t,()=>f(n)):await t();console.log(`${a.productName} Headless ${s.version} started in background (pid=${c.pid})`),e.forceEnroll&&console.log("Device credential replaced by force enrollment"),console.log(`Logs: ${c.logFile}`)}function S(e){try{return i.realpathSync(e)}catch{return o.resolve(e)}}process.argv[1]&&S(process.argv[1])===S(k)&&G().catch(e=>{console.error(`${a.packageName}: ${e instanceof Error?e.message:String(e)}`),process.exitCode=1});export{Q as createDaemonEnv,M as installHeadlessRuntime,p as parseHeadlessCliArgs,B as resolveHeadlessLayout,Y as startHeadlessRuntime,f as stopHeadlessRuntime};
package/daemon.mjs ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import r from"fs";import B from"os";import n from"path";import{spawn as $}from"child_process";var d=process.env.SIBOOT_BASE_DIR,I=n.join(d,"current"),O=process.env.WORKSPACE||"/data/siboot/workspace",E=n.join(d,"run"),_=n.join(d,"logs"),f=n.join(I,"entry.mjs"),T=n.join(d,"scripts","updater.mjs"),j=n.join(E,"daemon.pid"),A=n.join(E,"node.pid"),N=n.join(E,"updater.pid"),Y=n.join(E,"status.json"),L=Number.parseInt(process.env.ANYDEV_CLIENT_RESTART_DELAY_MS||"1000",10),F=Number.parseInt(process.env.ANYDEV_UPDATER_RESTART_DELAY_MS||"10000",10),R=Number.parseInt(process.env.ANYDEV_LOG_RETENTION_DAYS||"4",10),s=null,c=null,l=!1,y=0,v=0,U=new Date().toISOString();function G(){for(let e of[E,O,n.join(_,"daemon"),n.join(_,"client"),n.join(_,"update")])r.mkdirSync(e,{recursive:!0})}function M(){return new Date().toISOString().slice(0,10)}function h(e){return n.join(_,e,`${M()}.log`)}function o(e,t){let i=`${new Date().toISOString()} [${e}] ${t}
3
+ `;r.appendFile(h(e),i,()=>{}),e==="daemon"&&process.stdout.write(i)}function V(e){try{return JSON.parse(r.readFileSync(e,"utf8"))}catch{return null}}function b(e,t){r.mkdirSync(n.dirname(e),{recursive:!0}),r.writeFileSync(e,JSON.stringify(t,null,2),"utf8")}function S(e){try{r.unlinkSync(e)}catch{}}function m(){let e=V(n.join(I,"manifest.json"));if(typeof e?.version=="string"&&e.version.trim())return e.version.trim();try{return n.basename(r.realpathSync(I))}catch{return process.env.APP_VERSION||"unknown"}}function k(){let e=process.env.HEADLESS_DEVICE_CREDENTIAL_PATH||process.env.SIBOOT_DEVICE_CREDENTIAL_PATH;if(e&&e.trim())return n.resolve(e.trim());let t=process.env.HEADLESS_DATA_DIR||process.env.SIBOOT_DATA_DIR||n.join(B.homedir(),".siboot");return n.join(t,"headless-device-credential.json")}function H(){try{return r.existsSync(k())}catch{return!1}}function u(e={}){b(Y,{pid:process.pid,client_pid:s?.pid||null,updater_pid:c?.pid||null,client_start_count:y,updater_start_count:v,version:m(),current_dir:I,state:l?"stopping":"running",started_at:U,updated_at:new Date().toISOString(),...e})}function J(e,t){let i=p=>{let a=Buffer.from(p);r.appendFile(h(t),a,()=>{})};e.stdout?.on("data",i),e.stderr?.on("data",i)}function P(){if(!Number.isFinite(R)||R<=0)return;let e=Date.now();for(let t of["daemon","client","update"]){let i=n.join(_,t);if(r.existsSync(i))for(let p of r.readdirSync(i)){let a=n.join(i,p),D=null;try{D=r.statSync(a)}catch{continue}if(!D.isFile())continue;e-D.mtimeMs>R*24*60*60*1e3&&S(a)}}}function g(){if(l||s)return;if(!r.existsSync(f)){o("daemon",`client entry missing entry=${f}`),u({state:"client_entry_missing"}),setTimeout(g,L).unref?.();return}y+=1;let e={...process.env,APP_VERSION:m(),CLIENT_TYPE:process.env.CLIENT_TYPE||"anydev",SIBOOT_BASE_DIR:d,SIBOOT_CURRENT_DIR:I,SIBOOT_RUN_DIR:E,SIBOOT_LOG_DIR:_};H()&&(delete e.HEADLESS_DEVICE_TOKEN,delete process.env.HEADLESS_DEVICE_TOKEN),o("daemon",`starting client entry=${f} cwd=${O} version=${m()} start_count=${y}`),s=$(process.execPath,[f],{cwd:O,env:e,stdio:["ignore","pipe","pipe"]}),r.writeFileSync(A,String(s.pid)),J(s,"client"),u(),s.on("error",t=>{o("daemon",`client spawn error error=${t.message}`)}),s.on("exit",(t,i)=>{o("daemon",`client exited pid=${s?.pid||""} code=${t} signal=${i}`),s=null,S(A),u({client_exit_code:t,client_exit_signal:i}),l||setTimeout(g,L).unref?.()})}function x(){if(!(l||c||process.env.ANYDEV_UPDATE_ENABLED==="0")){if(!r.existsSync(T)){o("daemon",`updater entry missing entry=${T}`);return}v+=1,o("daemon",`starting updater entry=${T} start_count=${v}`),c=$(process.execPath,[T],{cwd:d,env:{...process.env,CLIENT_TYPE:"anydev",SIBOOT_BASE_DIR:d,SIBOOT_CURRENT_DIR:I,SIBOOT_RUN_DIR:E,SIBOOT_LOG_DIR:_},stdio:["ignore","inherit","inherit"]}),r.writeFileSync(N,String(c.pid)),u(),c.on("error",e=>{o("daemon",`updater spawn error error=${e.message}`)}),c.on("exit",(e,t)=>{o("daemon",`updater exited pid=${c?.pid||""} code=${e} signal=${t}`),c=null,S(N),u({updater_exit_code:e,updater_exit_signal:t}),l||setTimeout(x,F).unref?.()})}}function w(e,t){return e?.pid?(o("daemon",`stopping ${t} pid=${e.pid}`),new Promise(i=>{let p=!1,a=()=>{p||(p=!0,i())};e.once("exit",a);try{e.kill("SIGTERM")}catch{a();return}setTimeout(()=>{if(!p){try{e.kill("SIGKILL")}catch{}a()}},5e3).unref?.()})):Promise.resolve()}async function C(e){l||(l=!0,o("daemon",`shutdown signal=${e}`),u({state:"stopping",signal:e}),await Promise.all([w(c,"updater"),w(s,"client")]),S(N),S(A),S(j),u({state:"stopped",signal:e}),process.exit(0))}function K(){G(),r.writeFileSync(j,String(process.pid)),process.on("SIGTERM",()=>{C("SIGTERM")}),process.on("SIGINT",()=>{C("SIGINT")}),o("daemon",`started base=${d} version=${m()}`),u({state:"starting"}),P(),g(),x(),setInterval(P,3600*1e3).unref?.()}K();