@petercjl/boardmixcli 0.0.0-stage → 0.1.6
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/CONTRACT.md +83 -0
- package/INSTALL.md +11 -0
- package/LICENSE +21 -0
- package/README.md +39 -2
- package/UPDATE.md +11 -0
- package/bin/boardmixcli.mjs +48 -0
- package/capabilities.json +289 -0
- package/lib/assets.mjs +18 -0
- package/lib/batch.mjs +45 -0
- package/lib/bridge.mjs +61 -0
- package/lib/browser-process.mjs +61 -0
- package/lib/browser-session.mjs +32 -0
- package/lib/client.mjs +11 -0
- package/lib/commands.mjs +50 -0
- package/lib/desktop.mjs +59 -0
- package/lib/image-codecs.mjs +23 -0
- package/lib/maintenance.mjs +170 -0
- package/lib/page-adapter.js +155 -0
- package/lib/protocol.mjs +35 -0
- package/lib/video.mjs +33 -0
- package/package.json +43 -4
- package/plugin/main.js +51 -0
- package/scripts/install-notice.mjs +1 -0
- package/scripts/packed-smoke.mjs +32 -0
- package/scripts/sync-version.mjs +13 -0
- package/scripts/validate-package.mjs +11 -0
- package/skills/boardmixcli/SKILL.md +31 -0
- package/skills/boardmixcli/adapters/generic.json +44 -0
- package/skills/boardmixcli/agents/openai.yaml +3 -0
- package/skills/boardmixcli/capabilities.json +98 -0
- package/skills/boardmixcli/manifest.json +5 -0
package/lib/bridge.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {inspectVideo} from './video.mjs';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import {randomBytes, randomUUID} from 'node:crypto';
|
|
4
|
+
import {readFile} from 'node:fs/promises';
|
|
5
|
+
import {validate} from './protocol.mjs';
|
|
6
|
+
import {inspectImage} from './assets.mjs';
|
|
7
|
+
|
|
8
|
+
const main = await readFile(new URL('../plugin/main.js', import.meta.url), 'utf8');
|
|
9
|
+
const pageAdapter = await readFile(new URL('./page-adapter.js',import.meta.url),'utf8');
|
|
10
|
+
export async function startBridge() {
|
|
11
|
+
const token = randomBytes(32).toString('hex');
|
|
12
|
+
const queue = [], pending = new Map(), assets=new Map(),clients=new Map(),outcomes=new Map();
|
|
13
|
+
let lastSeen = 0;
|
|
14
|
+
function reply(res,status,data) { res.writeHead(status,{'Content-Type':'application/json','Access-Control-Allow-Origin':'*','Cache-Control':'no-store'}); res.end(JSON.stringify(data)); }
|
|
15
|
+
async function body(req) { let s=''; for await (const c of req) {s+=c; if(s.length>1024*1024) throw new Error('BODY_TOO_LARGE');} return JSON.parse(s || '{}'); }
|
|
16
|
+
const server = http.createServer(async(req,res)=>{
|
|
17
|
+
const host = `127.0.0.1:${server.address().port}`;
|
|
18
|
+
if(req.headers.host !== host) return reply(res,403,{error:'INVALID_HOST'});
|
|
19
|
+
if(req.method==='OPTIONS') {res.writeHead(204,{'Access-Control-Allow-Origin':'*','Access-Control-Allow-Headers':'Authorization,Content-Type','Access-Control-Allow-Methods':'GET,POST,OPTIONS','Access-Control-Allow-Private-Network':'true'});return res.end();}
|
|
20
|
+
if(req.headers.authorization !== `Bearer ${token}`) return reply(res,401,{error:'UNAUTHORIZED'});
|
|
21
|
+
try {
|
|
22
|
+
if(req.method==='GET' && req.url==='/status') return reply(res,200,{connected:Date.now()-lastSeen<5000,pending:pending.size,clients:Array.from(clients,([fileKey,time])=>({fileKey,connected:Date.now()-time<5000}))});
|
|
23
|
+
if(req.method==='GET' && req.url==='/bootstrap') return reply(res,200,{script:bootstrap()});
|
|
24
|
+
if(req.method==='POST'&&req.url==='/register-asset'){
|
|
25
|
+
const input=await body(req);if(typeof input.file!=='string')throw Error('FILE_REQUIRED');
|
|
26
|
+
const a=await (input.kind==='video'?inspectVideo:inspectImage)(input.file);if(a.sha256!==input.sha256)throw Error('ASSET_CHANGED');assets.set(a.sha256,a);return reply(res,200,{assetId:a.sha256,...a});
|
|
27
|
+
}
|
|
28
|
+
if(req.method==='GET'&&req.url.startsWith('/asset-info/')){const a=assets.get(req.url.slice('/asset-info/'.length));if(!a)return reply(res,404,{error:'ASSET_NOT_REGISTERED'});const {file,...publicInfo}=a;return reply(res,200,publicInfo);}
|
|
29
|
+
if(req.method==='GET'&&req.url.startsWith('/asset/')){const a=assets.get(req.url.slice('/asset/'.length));if(!a)return reply(res,404,{error:'ASSET_NOT_REGISTERED'});const current=await (a.kind==='video'?inspectVideo:inspectImage)(a.file);if(current.sha256!==a.sha256)throw Error('ASSET_CHANGED');res.writeHead(200,{'Content-Type':a.mime,'Access-Control-Allow-Origin':'*','Cache-Control':'no-store'});return res.end(await readFile(a.file));}
|
|
30
|
+
if(req.method==='GET'&&req.url.startsWith('/outcome/'))return reply(res,200,outcomes.get(req.url.slice('/outcome/'.length))||{error:'OUTCOME_NOT_FOUND'});
|
|
31
|
+
if(req.method==='GET' && req.url.startsWith('/next')) {lastSeen=Date.now();const key=new URL(req.url,'http://localhost').searchParams.get('fileKey');if(key)clients.set(key,lastSeen);const i=queue.findIndex(x=>!key||x.fileKey===key);return reply(res,200,{request:i<0?null:queue.splice(i,1)[0]});}
|
|
32
|
+
if(req.method==='POST' && req.url==='/result') {
|
|
33
|
+
const r=await body(req); const task=pending.get(r.id);
|
|
34
|
+
if(task){lastSeen=Date.now();clients.set(task.fileKey,lastSeen);}
|
|
35
|
+
outcomes.set(r.id,r);if(task) {clearTimeout(task.timer);pending.delete(r.id);reply(task.res,200,r);}
|
|
36
|
+
return reply(res,200,{accepted:!!task});
|
|
37
|
+
}
|
|
38
|
+
if(req.method==='POST' && req.url==='/request') {
|
|
39
|
+
const input=validate(await body(req));
|
|
40
|
+
if(input.op==='image.import.batch'&&input.params.items.some(i=>!assets.has(i.assetId)))throw Error('ASSET_NOT_REGISTERED');
|
|
41
|
+
if(['image.import','video.import'].includes(input.op)&&!assets.has(input.params.assetId))throw Error('ASSET_NOT_REGISTERED');
|
|
42
|
+
const operationId=['image.import.batch','video.import'].includes(input.op)?input.params.operationId:null;
|
|
43
|
+
if(operationId&&outcomes.has(operationId))return reply(res,200,outcomes.get(operationId));
|
|
44
|
+
if(operationId&&pending.has(operationId))return reply(res,409,{error:'OPERATION_PENDING'});
|
|
45
|
+
if(clients.size&&Date.now()-(clients.get(input.fileKey)||0)>=5000)return reply(res,409,{error:'TARGET_BOARD_NOT_CONNECTED'});
|
|
46
|
+
if(Date.now()-lastSeen>=5000) return reply(res,409,{error:'PLUGIN_NOT_CONNECTED'});
|
|
47
|
+
if(pending.size) return reply(res,409,{error:'BUSY'});
|
|
48
|
+
const id=operationId||randomUUID(); const task={id,channel:'boardmixcli-v1',...input};
|
|
49
|
+
const timer=setTimeout(()=>{pending.delete(id);const i=queue.findIndex(x=>x.id===id);if(i>=0)queue.splice(i,1);reply(res,504,{error:'OUTCOME_UNKNOWN',id,retrySafe:['info','board.info','object.get','object.get.batch','object.list'].includes(input.op)});},['video.import','image.import','image.import.batch'].includes(input.op)?150000:30000);
|
|
50
|
+
pending.set(id,{res,timer,fileKey:input.fileKey});queue.push(task);return;
|
|
51
|
+
}
|
|
52
|
+
reply(res,404,{error:'NOT_FOUND'});
|
|
53
|
+
} catch(error) {reply(res,400,{error:error.message});}
|
|
54
|
+
});
|
|
55
|
+
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});
|
|
56
|
+
const endpoint=`http://127.0.0.1:${server.address().port}`;
|
|
57
|
+
function bootstrap() {
|
|
58
|
+
return `(${pageAdapter})(${JSON.stringify({endpoint,token})},${JSON.stringify(main)})`;
|
|
59
|
+
}
|
|
60
|
+
return {server,endpoint,token,bootstrap};
|
|
61
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFile, spawn } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const exec = promisify(execFile);
|
|
8
|
+
export const profile = path.join(os.homedir(), '.dianshang-chrome-profile');
|
|
9
|
+
const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
10
|
+
const cdp = 'http://127.0.0.1:9223';
|
|
11
|
+
function fail(code, message) { return Object.assign(new Error(message), { code }); }
|
|
12
|
+
|
|
13
|
+
export function matchesProcess(command, expectedProfile = profile) {
|
|
14
|
+
return command.startsWith(`${chrome} `)
|
|
15
|
+
&& /(?:^|\s)--remote-debugging-port=9223(?:\s|$)/.test(command)
|
|
16
|
+
&& (command.includes(`--user-data-dir=${expectedProfile} `) || command.endsWith(`--user-data-dir=${expectedProfile}`))
|
|
17
|
+
&& !command.includes('--no-sandbox');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function status() {
|
|
21
|
+
if (process.platform !== 'darwin') throw fail('UNSUPPORTED_PLATFORM', '此安装版只支持 macOS');
|
|
22
|
+
let listeners;
|
|
23
|
+
try { listeners = (await exec('/usr/sbin/lsof', ['-b', '-nP', '-t', '-iTCP:9223', '-sTCP:LISTEN'], { timeout: 5000 })).stdout.trim(); }
|
|
24
|
+
catch (error) { if (error.code === 1 && !error.stderr) listeners = ''; else throw error; }
|
|
25
|
+
if (!listeners) return { ok: false, state: 'stopped', cdp, profile };
|
|
26
|
+
const pids = [...new Set(listeners.split(/\s+/))];
|
|
27
|
+
for (const pid of pids) {
|
|
28
|
+
const command = (await exec('/bin/ps', ['-p', pid, '-o', 'command='])).stdout.trim();
|
|
29
|
+
if (!matchesProcess(command)) throw fail('PORT_CONFLICT', '9223 端口不是指定的安全电商浏览器;未停止或修改任何进程');
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(`${cdp}/json/version`, { signal: AbortSignal.timeout(1500), redirect: 'error' });
|
|
33
|
+
const value = await response.json();
|
|
34
|
+
if (!response.ok || !value.Browser?.includes('Chrome/') || !value.webSocketDebuggerUrl) throw new Error('CDP 无效');
|
|
35
|
+
return { ok: true, state: 'ready', cdp, profile, pids, browser: value.Browser };
|
|
36
|
+
} catch { return { ok: false, state: 'starting', cdp, profile, pids }; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function open(url, deps = {status, access:fs.access, mkdir:fs.mkdir, spawn, fetch, pause:ms=>new Promise(r=>setTimeout(r,ms))}) {
|
|
40
|
+
let current = await deps.status();
|
|
41
|
+
const started = current.state === 'stopped';
|
|
42
|
+
if (started) {
|
|
43
|
+
await deps.access(chrome);
|
|
44
|
+
await deps.mkdir(profile, { recursive: true, mode: 0o700 });
|
|
45
|
+
const child = deps.spawn(chrome, ['--remote-debugging-address=127.0.0.1', '--remote-debugging-port=9223', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', 'about:blank'], { detached: true, stdio: 'ignore' });
|
|
46
|
+
await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
|
|
47
|
+
child.unref();
|
|
48
|
+
}
|
|
49
|
+
const deadline = Date.now() + 15000;
|
|
50
|
+
while (!current.ok && Date.now() < deadline) {
|
|
51
|
+
await deps.pause(300);
|
|
52
|
+
current = await deps.status();
|
|
53
|
+
}
|
|
54
|
+
if (!current.ok) throw fail('NOT_READY', '浏览器尚未就绪;未清理资料、锁文件或终止其他浏览器');
|
|
55
|
+
if (url) {
|
|
56
|
+
const response = await deps.fetch(`${cdp}/json/new?${encodeURIComponent(url)}`, { method: 'PUT', signal: AbortSignal.timeout(3000), redirect: 'error' });
|
|
57
|
+
if (!response.ok) throw fail('TAB_OPEN_FAILED', `浏览器已就绪,打开页面失败 HTTP ${response.status}`);
|
|
58
|
+
}
|
|
59
|
+
return { ...current, started };
|
|
60
|
+
}
|
|
61
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {open as start, status as processStatus} from './browser-process.mjs';
|
|
2
|
+
import {rpc} from './desktop.mjs';
|
|
3
|
+
export const browserEndpoint='http://127.0.0.1:9223';
|
|
4
|
+
const home='https://boardmix.cn/app/';
|
|
5
|
+
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
|
6
|
+
export function classifyAuth({url,text,hasUserAvatar=false}) {
|
|
7
|
+
const u=new URL(url); if(u.origin!=='https://boardmix.cn')return 'unknown';
|
|
8
|
+
if(/(?:^|\/)(?:login|signin)(?:\/|$)/i.test(u.pathname)||/扫码登录|验证码登录|密码登录|微信登录|登录\s*[//]\s*注册/.test(text))return 'login_required';
|
|
9
|
+
if((/^\/app\/team\//.test(u.pathname)||u.pathname==='/app/recent-file'&&hasUserAvatar)&&['最近打开','我的收藏','全部项目'].every(x=>text.includes(x)))return 'ready';
|
|
10
|
+
return 'unknown';
|
|
11
|
+
}
|
|
12
|
+
async function list(){const r=await fetch(browserEndpoint+'/json/list',{signal:AbortSignal.timeout(2000),redirect:'error'});if(!r.ok)throw Error('BROWSER_UNAVAILABLE');return r.json();}
|
|
13
|
+
async function read(t){const c=await rpc(browserEndpoint,t);try{const r=await c.send('Runtime.evaluate',{expression:'({url:location.href,text:document.body?.innerText||"",hasUserAvatar:!!document.querySelector("nav .ed-avatar--container")})',returnByValue:true});if(r.exceptionDetails)throw Error('AUTH_PROBE_FAILED');return r.result.value;}finally{c.close();}}
|
|
14
|
+
function candidate(t){try{const u=new URL(t.url);return t.type==='page'&&u.origin==='https://boardmix.cn'&&!u.pathname.startsWith('/app/editor/');}catch{return false;}}
|
|
15
|
+
export async function authStatus(deps={list,read,processStatus}) {
|
|
16
|
+
const browser=await deps.processStatus();if(!browser.ok)return {ok:false,state:'browser_unavailable',browser};
|
|
17
|
+
const pages=(await deps.list()).filter(candidate); let login=null;
|
|
18
|
+
for(const t of pages){const state=classifyAuth(await deps.read(t));if(state==='ready')return {ok:true,state,endpoint:browserEndpoint,tabId:t.id,url:t.url};if(state==='login_required')login=t;}
|
|
19
|
+
return {ok:false,state:login?'login_required':'unknown',code:login?'AUTH_REQUIRED':'AUTH_STATE_UNKNOWN',endpoint:browserEndpoint,tabId:login?.id,url:login?.url||home,prompt:login?'请在已打开的 Chrome Boardmix 页面完成登录,然后继续检测。':'Boardmix 登录状态尚未确认,请等待页面加载或检查页面。',resume:'boardmixcli browser.ensure --wait-ms 60000'};
|
|
20
|
+
}
|
|
21
|
+
async function enterLogin(result){
|
|
22
|
+
const t=(await list()).find(t=>t.id===result.tabId);if(!t)return;
|
|
23
|
+
const c=await rpc(browserEndpoint,t);try{await c.send('Page.bringToFront');await c.send('Runtime.evaluate',{expression:`(()=>{const b=[...document.querySelectorAll('button.loginEntry--btn')].find(e=>/登录\\s*[//]\\s*注册/.test(e.textContent));if(b)b.click();})()`});}finally{c.close();}
|
|
24
|
+
}
|
|
25
|
+
export async function ensureBrowser({waitMs=15000}={},deps={start,list,authStatus,pause,enterLogin}) {
|
|
26
|
+
if(!Number.isInteger(waitMs)||waitMs<0||waitMs>60000)throw Error('INVALID_WAIT_MS');
|
|
27
|
+
const browser=await deps.start();
|
|
28
|
+
if(!(await deps.list()).some(candidate))await deps.start(home);
|
|
29
|
+
const deadline=Date.now()+waitMs;let result,loginOpened=false;
|
|
30
|
+
do{result=await deps.authStatus();if(result.state==='login_required'&&!loginOpened){await deps.enterLogin?.(result);loginOpened=true;}if(result.ok||result.state==='login_required'&&waitMs<=15000)break;if(Date.now()>=deadline)break;await deps.pause(500);}while(true);
|
|
31
|
+
return {...result,browserStarted:browser.started,profile:browser.profile};
|
|
32
|
+
}
|
package/lib/client.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
export async function connect(sessionPath){
|
|
3
|
+
if(!sessionPath)throw Error('SESSION_PATH_REQUIRED');
|
|
4
|
+
const {endpoint,token}=JSON.parse(await readFile(sessionPath,'utf8'));
|
|
5
|
+
if(!/^http:\/\/127\.0\.0\.1:\d+$/.test(endpoint)||typeof token!=='string')throw Error('INVALID_SESSION');
|
|
6
|
+
async function call(route,data){
|
|
7
|
+
const r=await fetch(endpoint+route,{method:data?'POST':'GET',headers:{Authorization:'Bearer '+token,'Content-Type':'application/json'},body:data?JSON.stringify(data):undefined,signal:AbortSignal.timeout(190000)});
|
|
8
|
+
const result=await r.json();if(!r.ok||result.ok===false){const e=Error(result.error||'REQUEST_FAILED');e.details=result;throw e;}return result;
|
|
9
|
+
}
|
|
10
|
+
return {endpoint,call,request:(op,fileKey,params={})=>call('/request',{op,fileKey,params})};
|
|
11
|
+
}
|
package/lib/commands.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {inspectVideo,downloadVideo,importVideo} from './video.mjs';
|
|
2
|
+
import {readFile} from 'node:fs/promises';
|
|
3
|
+
import {inspectBatch,importBatch} from './batch.mjs';
|
|
4
|
+
import {parse} from './maintenance.mjs';
|
|
5
|
+
import {connect} from './client.mjs';
|
|
6
|
+
import {downloadImage} from './image-codecs.mjs';
|
|
7
|
+
import {inspectImage,listImages} from './assets.mjs';
|
|
8
|
+
export async function atomicCommand(command,args){
|
|
9
|
+
const extras={
|
|
10
|
+
'video.inspect':['--file'],'video.download':['--url','--output'],'video.import':['--file','--receipt'],
|
|
11
|
+
'image.inspect.batch':['--input'],'image.import.batch':['--input','--receipt'],'object.get.batch':['--input'],'object.place.batch':['--input'],
|
|
12
|
+
'image.download':['--url','--output'],'frame.create':['--x','--y','--width','--height','--name'],'text.create':['--x','--y','--width','--height','--text','--font-size'],'image.list':['--directory'],'image.inspect':['--file'],'image.import':['--file'],
|
|
13
|
+
'board.create':['--team-id','--project-id','--folder-id'],'board.rename':['--name'],
|
|
14
|
+
'board.open':['--target-file-key'],'object.get':['--session-id','--local-id'],
|
|
15
|
+
'object.move':['--session-id','--local-id','--x','--y'],
|
|
16
|
+
'object.resize':['--session-id','--local-id','--width','--height'],'view.fit':['--ids']
|
|
17
|
+
};
|
|
18
|
+
const flags=[...(['video.inspect','video.download','image.list','image.inspect','image.download','image.inspect.batch'].includes(command)?[]:['--session','--file-key']),...(extras[command]||[])];
|
|
19
|
+
parse(args,Object.fromEntries(flags.map(k=>[k,'string'])));
|
|
20
|
+
function option(name,required=true){const i=args.indexOf(name);const v=i<0?undefined:args[i+1];if(required&&(!v||v.startsWith('--')))throw Error(name+'_REQUIRED');return v;}
|
|
21
|
+
function number(name){const v=Number(option(name));if(!Number.isFinite(v))throw Error('INVALID_'+name);return v;}
|
|
22
|
+
if(command==='image.inspect.batch')return inspectBatch(JSON.parse(await readFile(option('--input'),'utf8')));
|
|
23
|
+
if(command==='video.inspect')return inspectVideo(option('--file'));
|
|
24
|
+
if(command==='video.download')return downloadVideo(option('--url'),option('--output'));
|
|
25
|
+
if(command==='image.list')return {images:await listImages(option('--directory'))};
|
|
26
|
+
if(command==='image.inspect')return inspectImage(option('--file'));
|
|
27
|
+
if(command==='image.download'){const r=await downloadImage(option('--url'),option('--output'));return {...await inspectImage(r.file),sourceUrl:r.sourceUrl};}
|
|
28
|
+
const client=await connect(option('--session')),fileKey=option('--file-key');
|
|
29
|
+
if(command==='video.import')return importVideo(client,fileKey,option('--file'),option('--receipt'));
|
|
30
|
+
if(command==='image.import.batch')return importBatch(client,fileKey,JSON.parse(await readFile(option('--input'),'utf8')),option('--receipt'));
|
|
31
|
+
if(command==='object.get.batch')return client.request(command,fileKey,{guids:JSON.parse(await readFile(option('--input'),'utf8'))});
|
|
32
|
+
if(command==='object.place.batch')return client.request(command,fileKey,{items:JSON.parse(await readFile(option('--input'),'utf8'))});
|
|
33
|
+
if(['frame.create','text.create'].includes(command))return client.request(command,fileKey,{x:number('--x'),y:number('--y'),width:number('--width'),height:number('--height'),content:option(command==='frame.create'?'--name':'--text'),...(command==='text.create'?{fontSize:Number(option('--font-size',false)||28)}:{})});
|
|
34
|
+
if(command==='board.info')return client.request(command,fileKey);
|
|
35
|
+
if(command==='board.create')return client.request(command,fileKey,{teamId:number('--team-id'),projectId:number('--project-id'),folderId:Number(option('--folder-id',false)||0)});
|
|
36
|
+
if(command==='board.rename')return client.request(command,fileKey,{name:option('--name')});
|
|
37
|
+
if(command==='board.open')return client.request(command,fileKey,{targetFileKey:option('--target-file-key')});
|
|
38
|
+
if(command==='board.close')return client.request(command,fileKey);
|
|
39
|
+
if(command==='object.selection')return client.request(command,fileKey);
|
|
40
|
+
if(command==='object.get')return client.request(command,fileKey,{sessionID:number('--session-id'),localID:number('--local-id')});
|
|
41
|
+
if(command==='object.move')return client.request(command,fileKey,{sessionID:number('--session-id'),localID:number('--local-id'),x:number('--x'),y:number('--y')});
|
|
42
|
+
if(command==='object.resize')return client.request(command,fileKey,{sessionID:number('--session-id'),localID:number('--local-id'),width:number('--width'),height:number('--height')});
|
|
43
|
+
if(command==='view.fit')return client.request(command,fileKey,{guids:JSON.parse(option('--ids'))});
|
|
44
|
+
if(command==='image.import'){
|
|
45
|
+
const asset=await inspectImage(option('--file'));
|
|
46
|
+
const registered=await client.call('/register-asset',{file:asset.file,sha256:asset.sha256});
|
|
47
|
+
return client.request(command,fileKey,{assetId:registered.assetId});
|
|
48
|
+
}
|
|
49
|
+
throw Error('UNKNOWN_COMMAND');
|
|
50
|
+
}
|
package/lib/desktop.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {connect} from './client.mjs';
|
|
2
|
+
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
|
3
|
+
export const defaultEndpoint='http://127.0.0.1:19427';
|
|
4
|
+
export function endpoint(value=defaultEndpoint){
|
|
5
|
+
if(!/^http:\/\/127\.0\.0\.1:\d+$/.test(value))throw Error('LOOPBACK_ENDPOINT_REQUIRED');
|
|
6
|
+
const port=Number(new URL(value).port);if(port<1024||port>65535)throw Error('INVALID_DEBUG_PORT');return value;
|
|
7
|
+
}
|
|
8
|
+
export function editorKey(url){try{const u=new URL(url);return u.origin==='https://boardmix.cn'&&/^\/app\/editor\/[A-Za-z0-9_-]{8,128}$/.test(u.pathname)?u.pathname.split('/').pop():null;}catch{return null;}}
|
|
9
|
+
function boardmixTarget(t){try{const u=new URL(t.url);return t.type==='page'&&(u.origin==='https://boardmix.cn'&&u.pathname.startsWith('/app/')||u.protocol==='file:'&&u.pathname.endsWith('/boardmix.app/Contents/Resources/app.asar/packages/renderer/dist/index.html'));}catch{return false;}}
|
|
10
|
+
export async function targets(base=defaultEndpoint){
|
|
11
|
+
endpoint(base);let r;try{r=await fetch(base+'/json/list',{signal:AbortSignal.timeout(2000),redirect:'error'});}catch{throw Error('DESKTOP_CONNECTION_UNAVAILABLE');}
|
|
12
|
+
if(!r.ok)throw Error('DESKTOP_CONNECTION_UNAVAILABLE');const list=await r.json();if(!Array.isArray(list))throw Error('INVALID_DESKTOP_RESPONSE');
|
|
13
|
+
return list.filter(boardmixTarget).map(t=>({id:t.id,title:t.title,url:t.url,fileKey:editorKey(t.url),webSocketDebuggerUrl:t.webSocketDebuggerUrl}));
|
|
14
|
+
}
|
|
15
|
+
export async function rpc(base,target){
|
|
16
|
+
const u=new URL(target.webSocketDebuggerUrl),b=new URL(endpoint(base));
|
|
17
|
+
if(u.protocol!=='ws:'||u.hostname!=='127.0.0.1'||u.port!==b.port||!u.pathname.startsWith('/devtools/page/')||u.username||u.password)throw Error('UNTRUSTED_DEBUG_TARGET');
|
|
18
|
+
const ws=new WebSocket(u),pending=new Map();let serial=0;
|
|
19
|
+
await new Promise((resolve,reject)=>{const t=setTimeout(()=>{ws.close();reject(Error('DESKTOP_CONNECT_TIMEOUT'));},3000);ws.addEventListener('open',()=>{clearTimeout(t);resolve();},{once:true});ws.addEventListener('error',()=>{clearTimeout(t);reject(Error('DESKTOP_CONNECT_FAILED'));},{once:true});});
|
|
20
|
+
ws.addEventListener('message',e=>{let m;try{m=JSON.parse(e.data);}catch{return;}const p=pending.get(m.id);if(!p)return;pending.delete(m.id);clearTimeout(p.timer);m.error?p.reject(Error('CDP_REQUEST_FAILED')):p.resolve(m.result);});
|
|
21
|
+
ws.addEventListener('close',()=>{for(const p of pending.values()){clearTimeout(p.timer);p.reject(Error('DESKTOP_DISCONNECTED'));}pending.clear();});
|
|
22
|
+
return {close:()=>ws.close(),send(method,params={}){return new Promise((resolve,reject)=>{const id=++serial,timer=setTimeout(()=>{pending.delete(id);reject(Error('DESKTOP_REQUEST_TIMEOUT'));},20000);pending.set(id,{resolve,reject,timer});ws.send(JSON.stringify({id,method,params}));});}};
|
|
23
|
+
}
|
|
24
|
+
async function evaluate(c,expression){const r=await c.send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(r.exceptionDetails)throw Error('DESKTOP_ADAPTER_FAILED');return r.result?.value;}
|
|
25
|
+
function validKey(k){if(typeof k!=='string'||!/^[A-Za-z0-9_-]{8,128}$/.test(k))throw Error('FILE_KEY_REQUIRED');}
|
|
26
|
+
export async function attachDesktop({session,fileKey,base=defaultEndpoint,browser=false},deps={targets,rpc}){
|
|
27
|
+
validKey(fileKey);endpoint(base);const client=await connect(session),status=await client.call('/status');
|
|
28
|
+
const found=(await deps.targets(base)).filter(t=>t.fileKey===fileKey);
|
|
29
|
+
if(found.length!==1)throw Error(found.length?'AMBIGUOUS_EDITOR':'EDITOR_NOT_OPEN');
|
|
30
|
+
if(status.pending)throw Error('BRIDGE_BUSY');
|
|
31
|
+
const c=await deps.rpc(base,found[0]);try{
|
|
32
|
+
const identity=await evaluate(c,'({url:location.href,desktop:window.desktopManagerApi?.version,bridge:window.__boardmixcliPageBridge?.endpoint,active:window.__boardmixcliPageBridge?.active})');
|
|
33
|
+
if(editorKey(identity?.url)!==fileKey||!browser&&identity.desktop!=='1.0.0')throw Error('DESKTOP_IDENTITY_MISMATCH');
|
|
34
|
+
if(identity.active&&identity.bridge===client.endpoint&&status.clients?.some(c=>c.fileKey===fileKey&&c.connected))return {connected:true,fileKey,reused:true,visibleDevTools:false};
|
|
35
|
+
const readyUntil=Date.now()+10000;let ready=false;
|
|
36
|
+
while(Date.now()<readyUntil){if(await evaluate(c,"Array.from(document.scripts).some(s=>s.src.includes('/assets/index-')&&s.src.endsWith('.js'))")){ready=true;break;}await pause(200);}
|
|
37
|
+
if(!ready)throw Error('EDITOR_LOADING');
|
|
38
|
+
const {script}=await client.call('/bootstrap');const result=await evaluate(c,script);
|
|
39
|
+
if(!result?.connected||result.fileKey!==fileKey)throw Error('ATTACH_READBACK_MISMATCH');
|
|
40
|
+
const deadline=Date.now()+5000;
|
|
41
|
+
while(Date.now()<deadline){const s=await client.call('/status');if(s.clients?.some(x=>x.fileKey===fileKey&&x.connected))return {connected:true,fileKey,reused:false,visibleDevTools:false};await pause(100);}
|
|
42
|
+
throw Error('ATTACH_NOT_CONFIRMED');
|
|
43
|
+
}finally{c.close();}
|
|
44
|
+
}
|
|
45
|
+
export async function closeBrowser({session,fileKey,base}){
|
|
46
|
+
validKey(fileKey);endpoint(base);const found=(await targets(base)).filter(t=>t.fileKey===fileKey);if(found.length!==1)throw Error(found.length?'AMBIGUOUS_EDITOR':'EDITOR_NOT_OPEN');
|
|
47
|
+
const client=await connect(session),{result:info}=await client.request('board.info',fileKey);
|
|
48
|
+
if(info.fileKey!==fileKey||info.uploading||info.localSavePending||info.coopStatus!==1)throw Error('SAVE_OR_UPLOAD_PENDING');
|
|
49
|
+
const c=await rpc(base,found[0]);try{await evaluate(c,'window.__boardmixcliPageBridge?.stop()');const result=await c.send('Target.closeTarget',{targetId:found[0].id});if(!result.success)throw Error('BROWSER_CLOSE_FAILED');}finally{c.close();}
|
|
50
|
+
const deadline=Date.now()+5000;while(Date.now()<deadline){if(!(await targets(base)).some(t=>t.id===found[0].id))return {closed:true,fileKey};await pause(100);}throw Error('BROWSER_CLOSE_PENDING');
|
|
51
|
+
}
|
|
52
|
+
export async function openDesktop({fileKey,base=defaultEndpoint,browser=false}){
|
|
53
|
+
validKey(fileKey);const list=await targets(base),existing=list.filter(t=>t.fileKey===fileKey);
|
|
54
|
+
if(existing.length>1)throw Error('AMBIGUOUS_EDITOR');if(existing.length===1)return {opened:true,fileKey,reused:true};
|
|
55
|
+
const host=list.find(t=>t.url.startsWith('https://boardmix.cn/app/'));
|
|
56
|
+
if(!host)throw Error('BOARDMIX_HOME_REQUIRED');const c=await rpc(base,host);
|
|
57
|
+
try{if(browser)await c.send('Target.createTarget',{url:'https://boardmix.cn/app/editor/'+fileKey,background:false});else{const ok=await evaluate(c,`(()=>{if(window.desktopManagerApi?.version!=='1.0.0')return false;window.desktopManagerApi.openFile(${JSON.stringify(fileKey)});return true;})()`);if(!ok)throw Error('DESKTOP_IDENTITY_MISMATCH');}}finally{c.close();}
|
|
58
|
+
const deadline=Date.now()+15000;while(Date.now()<deadline){if((await targets(base)).some(t=>t.fileKey===fileKey))return {opened:true,fileKey,reused:false};await pause(250);}throw Error('EDITOR_OPEN_PENDING');
|
|
59
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {lstat,writeFile} from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function imageMetadata(b){
|
|
4
|
+
if(b.length>=24&&b.subarray(0,8).toString('hex')==='89504e470d0a1a0a')return {mime:'image/png',width:b.readUInt32BE(16),height:b.readUInt32BE(20)};
|
|
5
|
+
if(b.length>4&&b[0]===255&&b[1]===216){
|
|
6
|
+
let i=2;while(i+4<=b.length){if(b[i++]!==255)throw Error('INVALID_JPEG');while(b[i]===255)i++;const marker=b[i++];if(marker===217||marker===218)break;if(marker===1||marker>=208&&marker<=215)continue;const len=b.readUInt16BE(i);if(len<2||i+len>b.length)throw Error('INVALID_JPEG');if([192,193,194,195,197,198,199,201,202,203,205,206,207].includes(marker)){if(len<7)throw Error('INVALID_JPEG');return {mime:'image/jpeg',height:b.readUInt16BE(i+3),width:b.readUInt16BE(i+5)};}i+=len;}
|
|
7
|
+
}
|
|
8
|
+
if(b.length>=30&&b.toString('ascii',0,4)==='RIFF'&&b.toString('ascii',8,12)==='WEBP'){
|
|
9
|
+
const kind=b.toString('ascii',12,16);
|
|
10
|
+
if(kind==='VP8X')return {mime:'image/webp',width:1+b.readUIntLE(24,3),height:1+b.readUIntLE(27,3)};
|
|
11
|
+
if(kind==='VP8 '&&b.subarray(23,26).toString('hex')==='9d012a')return {mime:'image/webp',width:b.readUInt16LE(26)&16383,height:b.readUInt16LE(28)&16383};
|
|
12
|
+
if(kind==='VP8L'&&b[20]===47){const v=b.readUInt32LE(21);return {mime:'image/webp',width:(v&16383)+1,height:((v>>>14)&16383)+1};}
|
|
13
|
+
}
|
|
14
|
+
throw Error('UNSUPPORTED_IMAGE_FORMAT_PNG_REQUIRED_OR_JPEG_WEBP');
|
|
15
|
+
}
|
|
16
|
+
export async function downloadImage(url,output){
|
|
17
|
+
const u=new URL(url);if(!['https:','http:'].includes(u.protocol)||u.username||u.password)throw Error('INVALID_IMAGE_URL');
|
|
18
|
+
const target=path.resolve(output);try{await lstat(target);throw Error('TARGET_EXISTS');}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
19
|
+
const r=await fetch(u,{signal:AbortSignal.timeout(30000)});if(!r.ok)throw Error('IMAGE_DOWNLOAD_HTTP_'+r.status);
|
|
20
|
+
const chunks=[];let size=0;for await(const c of r.body){size+=c.length;if(size>100*1024*1024)throw Error('IMAGE_TOO_LARGE');chunks.push(c);}
|
|
21
|
+
const data=Buffer.concat(chunks),m=imageMetadata(data);if(!m.width||!m.height)throw Error('INVALID_IMAGE');
|
|
22
|
+
await writeFile(target,data,{flag:'wx',mode:0o600});return {file:target,sourceUrl:u.href,...m,size};
|
|
23
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {readFile,writeFile,mkdir,readdir,lstat,cp,rename,rm,mkdtemp,chmod} from 'node:fs/promises';
|
|
2
|
+
import {createHash,randomUUID} from 'node:crypto';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {homedir,tmpdir} from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {spawn} from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
export const root=fileURLToPath(new URL('../',import.meta.url));
|
|
9
|
+
export const meta=JSON.parse(await readFile(path.join(root,'package.json'),'utf8'));
|
|
10
|
+
const capabilityManifest=JSON.parse(await readFile(path.join(root,'capabilities.json'),'utf8'));
|
|
11
|
+
const components=['bin','lib','plugin','scripts','package.json','capabilities.json','CONTRACT.md','README.md','INSTALL.md','UPDATE.md','LICENSE'];
|
|
12
|
+
const receiptName='.boardmixcli-receipt.json';
|
|
13
|
+
const fail=(code,details)=>{throw Object.assign(Error(code),{details});};
|
|
14
|
+
const json=async p=>JSON.parse(await readFile(p,'utf8'));
|
|
15
|
+
async function exists(p){try{return await lstat(p);}catch(e){if(e.code==='ENOENT')return null;throw e;}}
|
|
16
|
+
export function parse(args,allowed){
|
|
17
|
+
const result={};
|
|
18
|
+
for(let i=0;i<args.length;i++){
|
|
19
|
+
const k=args[i]; if(!Object.hasOwn(allowed,k)||Object.hasOwn(result,k))fail('INVALID_ARGUMENT',{argument:k});
|
|
20
|
+
if(allowed[k]==='boolean')result[k]=true;
|
|
21
|
+
else {const v=args[++i];if(!v||v.startsWith('--'))fail('MISSING_ARGUMENT',{argument:k});result[k]=v;}
|
|
22
|
+
}return result;
|
|
23
|
+
}
|
|
24
|
+
function targetOf(o){
|
|
25
|
+
if(!o['--agent']||! /^[a-z][a-z0-9-]{0,63}$/.test(o['--agent']))fail('INVALID_AGENT');
|
|
26
|
+
if(!o['--destination']||!path.isAbsolute(o['--destination']))fail('DESTINATION_REQUIRED',{recovery:'Resolve the current Agent exact Skill directory and pass --destination absolute-path.'});
|
|
27
|
+
const t=path.resolve(o['--destination']);
|
|
28
|
+
const runtimeSelf=path.resolve(root)===path.join(t,'.runtime');
|
|
29
|
+
if(t===path.parse(t).root||t===homedir()||t===path.resolve(root)||(!runtimeSelf&&root.startsWith(t+path.sep))||t.startsWith(root+path.sep))fail('UNSAFE_DESTINATION');
|
|
30
|
+
return t;
|
|
31
|
+
}
|
|
32
|
+
async function tree(dir,prefix=''){
|
|
33
|
+
const out={};
|
|
34
|
+
for(const e of await readdir(dir,{withFileTypes:true})){
|
|
35
|
+
const rel=prefix+e.name,p=path.join(dir,e.name);
|
|
36
|
+
if(e.isSymbolicLink())fail('SYMLINK_UNSUPPORTED',{path:p});
|
|
37
|
+
if(e.isDirectory())Object.assign(out,await tree(p,rel+'/'));
|
|
38
|
+
else if(e.isFile())out[rel]=createHash('sha256').update(await readFile(p)).digest('hex');
|
|
39
|
+
}return out;
|
|
40
|
+
}
|
|
41
|
+
async function expected(){
|
|
42
|
+
const out=await tree(path.join(root,'skills/boardmixcli'));
|
|
43
|
+
for(const name of components){
|
|
44
|
+
const p=path.join(root,name),s=await lstat(p);
|
|
45
|
+
if(s.isDirectory()){for(const [k,v] of Object.entries(await tree(p)))if(!k.includes('.bak'))out['.runtime/'+name+'/'+k]=v;}
|
|
46
|
+
else out['.runtime/'+name]=createHash('sha256').update(await readFile(p)).digest('hex');
|
|
47
|
+
}
|
|
48
|
+
// The isolated runtime also carries its own canonical Skill for lifecycle commands.
|
|
49
|
+
for(const [k,v] of Object.entries(await tree(path.join(root,'skills/boardmixcli'))))out['.runtime/skills/boardmixcli/'+k]=v;
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
export async function status(o){
|
|
53
|
+
const target=targetOf(o),s=await exists(target);
|
|
54
|
+
const base={name:'boardmixcli',package:meta.name,version:meta.version,target,agent:o['--agent'],installed:!!s,managed:false,current:false,edited:[],verification:{verified:false,algorithm:'sha256'},recovery:{action:'skill install with the same explicit target'},hostDiscovery:'pending-host-verification'};
|
|
55
|
+
if(!s)return base;
|
|
56
|
+
if(!s.isDirectory()||s.isSymbolicLink())fail('UNMANAGED_TARGET',{target});
|
|
57
|
+
const r=await json(path.join(target,receiptName)).catch(()=>null);
|
|
58
|
+
if(!r||r.package!==meta.name||r.agent!==o['--agent']||!r.hashes||r.target!==target)return base;
|
|
59
|
+
base.managed=true;const actual=await tree(target);delete actual[receiptName];
|
|
60
|
+
const wanted=await expected();
|
|
61
|
+
// UI metadata is host-owned after first installation; preserve it across reconciliation.
|
|
62
|
+
const hostOwned=k=>k.startsWith('agents/');
|
|
63
|
+
base.edited=Object.keys(actual).filter(k=>!hostOwned(k)&&(!r.hashes[k]||actual[k]!==r.hashes[k]));
|
|
64
|
+
const exact=Object.entries(wanted).every(([k,h])=>hostOwned(k)||actual[k]===h);
|
|
65
|
+
base.current=r.version===meta.version&&exact&&base.edited.length===0;
|
|
66
|
+
base.verification={verified:base.current,algorithm:'sha256',installedVersion:r.version};
|
|
67
|
+
base.recovery={action:base.edited.length?'Preserve local edits and resolve ownership before updating.':base.current?'none':'skill update with this explicit target'};
|
|
68
|
+
return base;
|
|
69
|
+
}
|
|
70
|
+
export async function install(o,{renameTarget=rename}={}){
|
|
71
|
+
const target=targetOf(o),prior=await status(o);
|
|
72
|
+
if(prior.installed&&!prior.managed)fail('UNMANAGED_TARGET',{target});
|
|
73
|
+
if(prior.edited.length)fail('EDITED_TARGET',{target,edited:prior.edited});
|
|
74
|
+
if(prior.current)return {...prior,changed:false};
|
|
75
|
+
await mkdir(path.dirname(target),{recursive:true});
|
|
76
|
+
const stage=await mkdtemp(path.join(path.dirname(target),'.boardmixcli-stage-'));
|
|
77
|
+
let backup;
|
|
78
|
+
try{
|
|
79
|
+
for(const e of await readdir(path.join(root,'skills/boardmixcli')))await cp(path.join(root,'skills/boardmixcli',e),path.join(stage,e),{recursive:true,errorOnExist:true,force:false});
|
|
80
|
+
const runtime=path.join(stage,'.runtime');await mkdir(runtime);
|
|
81
|
+
for(const name of components)await cp(path.join(root,name),path.join(runtime,name),{recursive:true,filter:p=>!p.includes('.bak'),force:false,errorOnExist:true});
|
|
82
|
+
await cp(path.join(root,'skills/boardmixcli'),path.join(runtime,'skills/boardmixcli'),{recursive:true});
|
|
83
|
+
if(prior.installed&&await exists(path.join(target,'agents'))){await rm(path.join(stage,'agents'),{recursive:true,force:true});await cp(path.join(target,'agents'),path.join(stage,'agents'),{recursive:true});}
|
|
84
|
+
const hashes=await tree(stage);
|
|
85
|
+
await writeFile(path.join(stage,receiptName),JSON.stringify({package:meta.name,version:meta.version,agent:o['--agent'],target,hashes,sourceVersion:meta.version},null,2)+'\n',{flag:'wx',mode:0o600});
|
|
86
|
+
let inPlace=false;
|
|
87
|
+
if(prior.installed){
|
|
88
|
+
backup=target+'.backup-'+randomUUID();
|
|
89
|
+
try{await renameTarget(target,backup);}
|
|
90
|
+
catch(e){
|
|
91
|
+
if(!['EBUSY','EPERM','EACCES'].includes(e.code))throw e;
|
|
92
|
+
await cp(target,backup,{recursive:true,force:false,errorOnExist:true});
|
|
93
|
+
inPlace=true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if(inPlace){
|
|
97
|
+
// Locked-directory update: snapshot first, copy complete resources, commit receipt last.
|
|
98
|
+
try{
|
|
99
|
+
for(const e of await readdir(stage))if(e!==receiptName)await cp(path.join(stage,e),path.join(target,e),{recursive:true,force:true});
|
|
100
|
+
await cp(path.join(stage,receiptName),path.join(target,receiptName),{force:true});
|
|
101
|
+
}catch(e){fail('SKILL_SYNC_FAILED',{target,backup,phase:'in-place-sync',recovery:'Restore the complete preserved backup after releasing host file locks.'});}
|
|
102
|
+
}else try{await rename(stage,target);}catch(e){if(backup)await rename(backup,target);throw e;}
|
|
103
|
+
const result=await status(o);if(!result.current)fail('INSTALL_VERIFICATION_FAILED',{target,backup});
|
|
104
|
+
return {...result,changed:true,...backup?{backup}:{}};
|
|
105
|
+
}finally{await rm(stage,{recursive:true,force:true});}
|
|
106
|
+
}
|
|
107
|
+
const newerVersion=(candidate,current)=>{const a=candidate.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)$/),b=current.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)/);if(!a||!b)return false;for(let i=1;i<4;i++){if(Number(a[i])!==Number(b[i]))return Number(a[i])>Number(b[i]);}return false;};
|
|
108
|
+
const cacheDir=()=>process.env.BOARDMIXCLI_CACHE_DIR||path.join(homedir(),'.cache','boardmixcli');
|
|
109
|
+
export async function checkUpdate({automatic=false}={}){
|
|
110
|
+
if(process.env.BOARDMIXCLI_UPDATE_CHECK==='0')return {enabled:false};
|
|
111
|
+
const p=path.join(cacheDir(),'update.json');
|
|
112
|
+
const cached=await json(p).catch(()=>null);
|
|
113
|
+
if(automatic&&cached&&Date.now()-cached.checkedAt<24*3600e3)return {...cached,cached:true};
|
|
114
|
+
let result;
|
|
115
|
+
try{
|
|
116
|
+
const r=await fetch('https://registry.npmjs.org/@petercjl%2fboardmixcli/latest',{signal:AbortSignal.timeout(automatic?1200:10000)});
|
|
117
|
+
if(!r.ok)throw Error('REGISTRY_'+r.status);
|
|
118
|
+
const published=await r.json();result={checkedAt:Date.now(),version:meta.version,latest:published.version,available:newerVersion(published.version,meta.version)};
|
|
119
|
+
}catch(e){result={checkedAt:Date.now(),version:meta.version,available:false,error:e.message,recovery:'Retry update check after network recovery.'};}
|
|
120
|
+
try{
|
|
121
|
+
await mkdir(cacheDir(),{recursive:true});
|
|
122
|
+
const prior=await exists(p);
|
|
123
|
+
if(!prior)await writeFile(p,JSON.stringify({...result,owner:meta.name}),{flag:'wx',mode:0o600});
|
|
124
|
+
else if(!prior.isSymbolicLink()&&cached?.owner===meta.name){
|
|
125
|
+
const next=p+'.'+randomUUID();await writeFile(next,JSON.stringify({...result,owner:meta.name}),{flag:'wx',mode:0o600});await rename(next,p);
|
|
126
|
+
}
|
|
127
|
+
}catch{}
|
|
128
|
+
if(automatic&&result.available)process.stderr.write(`boardmixcli: ${result.latest} available; ask your Agent to update CLI and its selected Skill.\n`);
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
async function run(cmd,args){return new Promise((resolve,reject)=>{const c=spawn(cmd,args,{stdio:['ignore','pipe','pipe'],shell:false});let out='',err='';c.stdout.on('data',x=>out+=x);c.stderr.on('data',x=>err+=x);c.on('error',reject);c.on('exit',code=>code===0?resolve(out):reject(Object.assign(Error('UPDATE_COMMAND_FAILED'),{details:{phase:'candidate-install',code,stderr:err.slice(-2000)}})));});}
|
|
132
|
+
export async function updateInstall(o){
|
|
133
|
+
const before=await status(o);if(before.installed&&(!before.managed||before.edited.length))fail('TARGET_PROTECTED',before);
|
|
134
|
+
if(!o['--yes'])fail('CONFIRMATION_REQUIRED');
|
|
135
|
+
// Stage into a private managed prefix. Replace neither the npm package nor selected Skill on a download failure.
|
|
136
|
+
const latest=await checkUpdate();if(latest.error||!latest.latest)fail('UPDATE_CHECK_FAILED',latest);
|
|
137
|
+
if(latest.latest!==meta.version&&!newerVersion(latest.latest,meta.version))return {...await install(o),packageChanged:false,installedAhead:true,registryVersion:latest.latest};
|
|
138
|
+
const stage=await mkdtemp(path.join(tmpdir(),'boardmixcli-update-'));
|
|
139
|
+
const npmCli=process.env.npm_execpath||path.join(path.dirname(process.execPath),'node_modules/npm/bin/npm-cli.js');
|
|
140
|
+
try{
|
|
141
|
+
if(!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(latest.latest))fail('INVALID_REGISTRY_VERSION');
|
|
142
|
+
const npmArgs=['install','--prefix',stage,'--ignore-scripts','--no-audit','--no-fund',meta.name+'@'+latest.latest];
|
|
143
|
+
if(process.platform==='win32'){
|
|
144
|
+
if(!await exists(npmCli))fail('NPM_RUNTIME_UNAVAILABLE',{recovery:'Use npm-managed execution or configure npm_execpath to npm-cli.js.'});
|
|
145
|
+
await run(process.execPath,[npmCli,...npmArgs]);
|
|
146
|
+
}else await run('npm',npmArgs);
|
|
147
|
+
const candidate=path.join(stage,'node_modules/@petercjl/boardmixcli/bin/boardmixcli.mjs');
|
|
148
|
+
const result=JSON.parse(await run(process.execPath,[candidate,'skill','update','--agent',o['--agent'],'--destination',o['--destination'],'--json']));
|
|
149
|
+
if(!result.current)fail('SKILL_SYNC_FAILED',result);
|
|
150
|
+
return {...result,entry:path.join(o['--destination'],'.runtime/bin/boardmixcli.mjs'),packageChanged:meta.version!==latest.latest,recovery:{action:'Use the selected Skill target-owned entry; refresh host discovery.'}};
|
|
151
|
+
}finally{await rm(stage,{recursive:true,force:true});}
|
|
152
|
+
}
|
|
153
|
+
export async function maintenance(command,args){
|
|
154
|
+
if(command==='doctor'){
|
|
155
|
+
parse(args,{'--json':'boolean'});
|
|
156
|
+
return {ok:Number(process.versions.node.split('.')[0])>=22,package:meta.name,version:meta.version,node:process.version,platform:process.platform,adapterBuild:'index-DjSD2oPg.js',service:{connected:false,required:'Run browser.ensure for managed Chrome startup and login, then browser.connect; inspect status --session FILE.'},skill:{source:path.join(root,'skills/boardmixcli'),registration:'host-owned'}};
|
|
157
|
+
}
|
|
158
|
+
const action=args.shift();
|
|
159
|
+
if(command==='skill'){
|
|
160
|
+
if(action==='source'){parse(args,{'--json':'boolean'});return {name:'boardmixcli',package:meta.name,version:meta.version,source:path.join(root,'skills/boardmixcli'),maintenanceContract:capabilityManifest.maintenanceContract,commandContracts:capabilityManifest.commandContracts};}
|
|
161
|
+
if(!['status','install','update'].includes(action))fail('UNKNOWN_COMMAND');
|
|
162
|
+
if(args.includes('--help')){parse(args,{'--help':'boolean','--json':'boolean'});return {command:'skill '+action,...capabilityManifest.commandContracts['skill '+action]};}
|
|
163
|
+
const o=parse(args,{'--agent':'string','--destination':'string','--json':'boolean'});
|
|
164
|
+
return action==='status'?status(o):install(o);
|
|
165
|
+
}
|
|
166
|
+
if(command==='update'){
|
|
167
|
+
if(action==='check'){parse(args,{'--json':'boolean'});return checkUpdate();}
|
|
168
|
+
if(action==='install')return updateInstall(parse(args,{'--agent':'string','--destination':'string','--yes':'boolean','--json':'boolean'}));
|
|
169
|
+
}fail('UNKNOWN_COMMAND');
|
|
170
|
+
}
|