@petercjl/boardmixcli 0.0.0-bootstrap.0 → 0.1.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/CONTRACT.md +37 -0
- package/INSTALL.md +11 -0
- package/LICENSE +21 -0
- package/README.md +27 -2
- package/UPDATE.md +11 -0
- package/bin/boardmixcli.mjs +34 -0
- package/capabilities.json +230 -0
- package/lib/assets.mjs +18 -0
- package/lib/bridge.mjs +55 -0
- package/lib/client.mjs +11 -0
- package/lib/commands.mjs +35 -0
- package/lib/maintenance.mjs +168 -0
- package/lib/page-adapter.js +83 -0
- package/lib/protocol.mjs +23 -0
- package/package.json +35 -6
- package/plugin/main.js +40 -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 +26 -0
- package/skills/boardmixcli/adapters/generic.json +31 -0
- package/skills/boardmixcli/agents/openai.yaml +3 -0
- package/skills/boardmixcli/capabilities.json +67 -0
- package/skills/boardmixcli/manifest.json +5 -0
package/CONTRACT.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Capability contract
|
|
2
|
+
|
|
3
|
+
The CLI executes explicit operations. The calling Agent or application Skill owns content, file choice, sorting, coordinates, layout and acceptance decisions. The desktop/web adapter owns provider integration and translates native coordinate and ratio-lock behavior.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
Every connected command requires `--session PATH --file-key KEY`. `KEY` is the connected source context; a new target must be opened and attached before operating on it. Results are JSON. Failed commands exit nonzero. Successful native mutations include readback when available.
|
|
8
|
+
|
|
9
|
+
| Command | Additional input | Result / side effect |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `board.info` | none | File name, team/project/folder, upload and connection status |
|
|
12
|
+
| `board.create` | `--team-id N --project-id N [--folder-id N]` | Creates one unnamed file; returns its key; opens separately |
|
|
13
|
+
| `board.rename` | `--name TEXT` | Changes name and reads it back from the service |
|
|
14
|
+
| `board.open` | `--target-file-key KEY` | Requests a desktop tab; attachment and UI verification follow |
|
|
15
|
+
| `board.close` | none | Rejects pending uploads, pending local save or disconnected cloud sync; returns close request receipt |
|
|
16
|
+
| `image.list` | `--directory PATH` only | PNG metadata inventory in natural filename order; no connection needed |
|
|
17
|
+
| `image.inspect` | `--file PATH` only | PNG dimensions, size and SHA-256; no connection needed |
|
|
18
|
+
| `image.import` | `--file PATH` | Imports one PNG through the application uploader; returns asset digest, resource hash, new GUID and actual geometry |
|
|
19
|
+
| `object.selection` | none | Returns currently selected objects; no selection change |
|
|
20
|
+
| `object.get` | `--session-id N --local-id N` | Reads one object |
|
|
21
|
+
| `object.move` | GUID plus `--x N --y N` | Absolute **top-left board coordinates**; translates native center-point API and verifies |
|
|
22
|
+
| `object.resize` | GUID plus `--width N --height N` | Exact target size; temporarily releases ratio lock, restores it and verifies |
|
|
23
|
+
| `view.fit` | `--ids JSON_GUID_ARRAY` | Fits only explicit GUIDs into the viewport |
|
|
24
|
+
|
|
25
|
+
Image import preserves source bytes. Boardmix may choose a smaller initial display size; explicit resizing is a separate caller decision. Resizing does not resample or replace the uploaded source image.
|
|
26
|
+
|
|
27
|
+
## Transport and recovery
|
|
28
|
+
|
|
29
|
+
`serve --session NEW_PATH` creates a loopback service, private session token and console bootstrap script. Bootstrap attachment is currently performed once per opened view by an Agent. Close the developer-tools window after attachment before executing operations. On desktop 1.7.2 an open developer-tools window prevented the native close request from removing the tab in one test; repeating with developer tools closed removed the tab and passed UI verification. The source file path never goes to Boardmix: only the selected file bytes and base name enter the application's normal uploader. The local service exposes only explicitly registered image digests under authenticated routes.
|
|
30
|
+
|
|
31
|
+
Each request targets one file key, carries a correlation ID and is dispatched once. The bridge routes to the matching attached view. Do not automatically retry file creation or image import after timeout. Inspect the canvas and request outcome first. In-memory outcomes are available at authenticated `/outcome/ID`; durable operation receipts are the caller's responsibility in this prototype.
|
|
32
|
+
|
|
33
|
+
The browser adapter currently validates the observed `index-DjSD2oPg.js` build. An unverified build fails explicitly. Readback error is a mutation outcome requiring inspection, not proof that no change happened. `board.close` acknowledges a close request; the calling Agent verifies that the tab actually disappeared. Cloud-save persistence is verified by reopening and reading objects, independently of the close receipt. Native `view.fit` animates asynchronously; its immediate viewport snapshot can precede the final settled view. The calling Agent confirms the displayed fit independently.
|
|
34
|
+
|
|
35
|
+
## Distribution status
|
|
36
|
+
|
|
37
|
+
Local development prototype. No publication or managed CLI/Skill installation is claimed. Any Agent with Node.js, filesystem access and an authorized Boardmix connection can call the CLI. This machine's macOS desktop runtime has been exercised; other systems remain unverified. Application-layer Skills will be authored separately.
|
package/INSTALL.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Install for the current Agent
|
|
2
|
+
|
|
3
|
+
Send to your current Agent:
|
|
4
|
+
|
|
5
|
+
> 安装 @petercjl/boardmixcli,并验证当前 Agent 的配套 Skill 可用。
|
|
6
|
+
|
|
7
|
+
Agent execution contract: install the npm CLI, inspect `boardmixcli capabilities --json` and `boardmixcli skill source --json`, resolve your own advertised/configured Skill discovery directory, and pass the exact new Skill directory to `boardmixcli skill install --agent YOUR-VALID-AGENT-NAME --destination ABSOLUTE-SKILL-DIRECTORY --json`. Any valid Agent name uses the generic adapter. A missing destination stops before mutation. Unmanaged targets and local edits are protected. This step copies all resources and an isolated `.runtime` into the selected target. Installation does not access Boardmix or modify another Agent.
|
|
8
|
+
|
|
9
|
+
Verify `skill status` using the same target and Agent: installed, managed, current and verification.verified must be true. Execute the target-owned `.runtime/bin/boardmixcli.mjs` with Node.js and verify its version. Load SKILL.md through your host's native registration/discovery mechanism; refresh the host session when necessary. The CLI reports host discovery pending because it cannot verify the host catalog. Report completion only when the host actually discovers/loads the Skill. Otherwise report the pending phase and recovery action.
|
|
10
|
+
|
|
11
|
+
With npm scripts disabled, follow this identical handoff explicitly; the postinstall notice is guidance, not configuration. If npm succeeds but Skill sync fails, keep the installed CLI, resolve ownership/destination and retry `skill install/update`. Credentials remain in the already logged-in Boardmix application. Node.js >=22 and authorized per-editor developer-console access are external prerequisites. Desktop automation is tested on macOS Boardmix 1.7.2; other desktop platforms are unverified.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 petercjl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
# BoardmixCLI
|
|
1
|
+
# BoardmixCLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Boardmix 桌面画板的元命令能力包。提供文件新建、命名、打开和关闭,PNG 导入、图元读取、精确尺寸与位置调整,返回 JSON 结果。当前 Agent 负责材料选择、顺序、布局与业务验收。
|
|
4
|
+
|
|
5
|
+
> 安装 @petercjl/boardmixcli,并验证当前 Agent 的配套 Skill 可用。
|
|
6
|
+
|
|
7
|
+
> 更新 @petercjl/boardmixcli,并验证当前 Agent 的配套 Skill 已同步。
|
|
8
|
+
|
|
9
|
+
> 使用 boardmixcli 新建一个画板,命名为“图片整理”,将我提供的 PNG 图片按指定顺序纵向无间隙排列,保存后退出画板。
|
|
10
|
+
|
|
11
|
+
安装与更新的完整 Agent 交接见 [INSTALL.md](INSTALL.md)、[UPDATE.md](UPDATE.md)。npm 安装后还需同步当前 Agent 的配套 Skill;包管理入口提供物理隔离的资源与运行时,并由当前 Agent 确认宿主发现状态。适用于具备 Node.js、文件访问、本地 HTTP 和授权桌面控制能力的 Agent。
|
|
12
|
+
|
|
13
|
+
## 开发者参考
|
|
14
|
+
|
|
15
|
+
需要 Node.js >=22、已登录的 Boardmix,以及每个打开画板的 Console 连接。连接使用随机 loopback 端口和临时令牌;安装不授予服务访问权限。此包为独立开发的实验性工具,使用官方插件图元能力和版本锁定的内部桌面适配器。macOS Boardmix 1.7.2 / 前端 `index-DjSD2oPg.js` 已验证;其他桌面平台尚未验证,前端更新可能需要适配。
|
|
16
|
+
|
|
17
|
+
运行 `boardmixcli capabilities --json`、`boardmixcli doctor --json` 发现合同;`boardmixcli skill source --json` 定位配套手册。参数、坐标、错误与恢复详见 [CONTRACT.md](CONTRACT.md)。业务状态保留在 Boardmix,会话和本地材料留在调用者本机。
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
boardmixcli serve --session /absolute/new-session.json
|
|
21
|
+
boardmixcli board.info --session /absolute/new-session.json --file-key EXPLICIT_BOARD_KEY
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`serve` 创建新的私有会话文件与 `.bootstrap.js`。在目标编辑器 Console 中执行启动脚本,然后关闭开发者工具;每个新打开的画板需要单独连接。会话文件包含临时令牌,勿公开。CLI 使用 App 现有登录态,不读取或导出登录凭证。
|
|
25
|
+
|
|
26
|
+
可用操作:`board.info/create/rename/open/close`、`image.list/inspect/import`、`object.selection/get/move/resize`、`view.fit`。图元移动使用绝对左上角画板坐标;尺寸调整保留宽高比锁定状态。创建和导入超时后先核查结果再决定后续动作。
|
|
27
|
+
|
|
28
|
+
运行 `npm test` 与 `npm run check` 检查包、命令与安装生命周期。真实画板证据、私人会话和备份均不进入公开包。
|
package/UPDATE.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Update for the current Agent
|
|
2
|
+
|
|
3
|
+
Send to your current Agent:
|
|
4
|
+
|
|
5
|
+
> 更新 @petercjl/boardmixcli,并验证当前 Agent 的配套 Skill 已同步。
|
|
6
|
+
|
|
7
|
+
Resolve the selected Agent's exact Skill destination before replacement. Run `skill status` and preserve local edits; unmanaged targets refuse reconciliation. `update check --json` checks the stable registry version. `update install --agent YOUR-VALID-AGENT-NAME --destination ABSOLUTE-SKILL-DIRECTORY --yes --json` downloads a candidate with real npm into a private temporary prefix, synchronizes only the selected target, verifies hashes and returns its target-owned runtime entry. This managed update does not replace an unrelated global npm installation. Use the returned entry as the current installation, or explicitly upgrade the global npm CLI and repeat selected-target synchronization when a global entry is required.
|
|
8
|
+
|
|
9
|
+
Every package upgrade requires Skill synchronization, including same-version missing/stale repair. A verified current target takes a read-only no-op branch. Existing managed targets receive a recoverable adjacent backup before replacement. Host UI metadata is preserved. Missing canonical files may be repaired; edited files require reconciliation by the user. Package download failure leaves the target unchanged. A synchronization failure reports its phase and recovery; retry against the same target after resolving it. Backups provide rollback by restoring the preserved directory after checking ownership. Refresh host discovery and verify CLI version, Skill manifest/receipt version and `skill status` hashes. Host registration remains a separate host-owned phase.
|
|
10
|
+
|
|
11
|
+
Ordinary atomic task entry checks for updates with a 1200 ms network timeout, persistent 24-hour throttling including offline backoff, and notification only. Set `BOARDMIXCLI_UPDATE_CHECK=0` to disable detection or `BOARDMIXCLI_CACHE_DIR` to choose a cache directory. Automatic installation requires an explicit update request. No other Agent is synchronized.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {readFile,writeFile,stat} from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {startBridge} from '../lib/bridge.mjs';
|
|
5
|
+
import {operations,validate} from '../lib/protocol.mjs';
|
|
6
|
+
import {atomicCommand} from '../lib/commands.mjs';
|
|
7
|
+
import {maintenance,meta,parse,checkUpdate} from '../lib/maintenance.mjs';
|
|
8
|
+
const args=process.argv.slice(2), command=args.shift();
|
|
9
|
+
const output=x=>process.stdout.write(JSON.stringify(x,null,2)+'\n');
|
|
10
|
+
function flag(k){const i=args.indexOf(k);return i<0?undefined:args[i+1];}
|
|
11
|
+
async function absent(p){try{await stat(p);throw Error('TARGET_EXISTS: '+p);}catch(e){if(e.code!=='ENOENT')throw e;}}
|
|
12
|
+
try {
|
|
13
|
+
if(['skill','update','doctor'].includes(command))output({ok:true,...await maintenance(command,args)});
|
|
14
|
+
else if(['board.info','board.create','board.rename','board.open','board.close','image.list','image.inspect','image.import','object.selection','object.get','object.move','object.resize','view.fit'].includes(command)){await checkUpdate({automatic:true});output(await atomicCommand(command,args));}
|
|
15
|
+
else if(command==='version'||command==='--version') {parse(args,{'--json':'boolean'});output({package:meta.name,version:meta.version});}
|
|
16
|
+
else if(command==='capabilities') output(JSON.parse(await readFile(new URL('../capabilities.json',import.meta.url),'utf8')));
|
|
17
|
+
else if(command==='serve') {
|
|
18
|
+
const target=flag('--session');if(!target)throw Error('SESSION_PATH_REQUIRED');
|
|
19
|
+
const session=path.resolve(target), boot=session+'.bootstrap.js';await absent(session);await absent(boot);
|
|
20
|
+
const bridge=await startBridge();
|
|
21
|
+
try{await writeFile(session,JSON.stringify({endpoint:bridge.endpoint,token:bridge.token}),{flag:'wx',mode:0o600});await writeFile(boot,bridge.bootstrap(),{flag:'wx',mode:0o600});}
|
|
22
|
+
catch(e){bridge.server.close();throw e;}
|
|
23
|
+
output({listening:bridge.endpoint,session,bootstrap:boot});
|
|
24
|
+
} else if(['status','request'].includes(command)) {
|
|
25
|
+
const target=flag('--session');if(!target)throw Error('SESSION_PATH_REQUIRED');
|
|
26
|
+
const {endpoint,token}=JSON.parse(await readFile(target,'utf8'));
|
|
27
|
+
if(!/^http:\/\/127\.0\.0\.1:\d+$/.test(endpoint)||typeof token!=='string')throw Error('INVALID_SESSION');
|
|
28
|
+
const headers={Authorization:'Bearer '+token,'Content-Type':'application/json'};
|
|
29
|
+
let request;
|
|
30
|
+
if(command==='request'){const file=flag('--input');if(!file)throw Error('INPUT_PATH_REQUIRED');request=validate(JSON.parse(await readFile(file,'utf8')));}
|
|
31
|
+
const r=await fetch(endpoint+(command==='status'?'/status':'/request'),{method:request?'POST':'GET',headers,body:request?JSON.stringify(request):undefined,signal:AbortSignal.timeout(20000)});
|
|
32
|
+
const result=await r.json();output(result);if(!r.ok||result.ok===false)process.exitCode=1;
|
|
33
|
+
} else output({usage:['serve --session <new-private-session-file>','status --session <file>','request --session <file> --input <json-file>','board.info | board.create | board.rename | board.open | board.close','image.list | image.inspect | image.import','object.selection | object.get | object.move | object.resize','view.fit --ids <JSON-GUID-array>','version','capabilities'],stage:'atomic-command-prototype'});
|
|
34
|
+
} catch(error) {output({ok:false,error:error.message,...error.details?{details:error.details}:{}});process.exitCode=1;}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"package": "@petercjl/boardmixcli",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"stage": "experimental",
|
|
6
|
+
"releaseReady": true,
|
|
7
|
+
"responsibility": "Explicit capability operations; business workflow and layout remain with the calling Agent or application Skill",
|
|
8
|
+
"commands": [
|
|
9
|
+
"board.info",
|
|
10
|
+
"board.create",
|
|
11
|
+
"board.rename",
|
|
12
|
+
"board.open",
|
|
13
|
+
"board.close",
|
|
14
|
+
"image.list",
|
|
15
|
+
"image.inspect",
|
|
16
|
+
"image.import",
|
|
17
|
+
"object.selection",
|
|
18
|
+
"object.get",
|
|
19
|
+
"object.move",
|
|
20
|
+
"object.resize",
|
|
21
|
+
"view.fit"
|
|
22
|
+
],
|
|
23
|
+
"coordinateContract": "object.move uses absolute top-left board coordinates",
|
|
24
|
+
"imageFormats": [
|
|
25
|
+
"image/png"
|
|
26
|
+
],
|
|
27
|
+
"authentication": "ephemeral loopback token and existing Boardmix app login",
|
|
28
|
+
"adapterBuild": "index-DjSD2oPg.js",
|
|
29
|
+
"requiredCapabilities": [
|
|
30
|
+
"Node.js >=22",
|
|
31
|
+
"filesystem access",
|
|
32
|
+
"loopback HTTP",
|
|
33
|
+
"authorized Boardmix editor and console bootstrap"
|
|
34
|
+
],
|
|
35
|
+
"agentEligibility": "Any Agent satisfying required capabilities",
|
|
36
|
+
"platformEvidence": {
|
|
37
|
+
"macos": "Boardmix 1.7.2 real atomic operations tested",
|
|
38
|
+
"windows": "portable lifecycle CI required; Boardmix runtime unverified",
|
|
39
|
+
"linux": "portable lifecycle CI required; Boardmix runtime unverified"
|
|
40
|
+
},
|
|
41
|
+
"unresolved": [
|
|
42
|
+
"Automatic attachment per opened editor view",
|
|
43
|
+
"Boardmix runtime validation on Windows and Linux"
|
|
44
|
+
],
|
|
45
|
+
"installationLifecycle": {
|
|
46
|
+
"agentNeutral": true,
|
|
47
|
+
"targetRequired": true,
|
|
48
|
+
"hostRegistrationRequired": true,
|
|
49
|
+
"installationMode": "copy",
|
|
50
|
+
"sharedLinksAllowed": false,
|
|
51
|
+
"isolatedRuntimeRequired": true,
|
|
52
|
+
"skillRequired": true,
|
|
53
|
+
"updateRequiresSkillSync": true,
|
|
54
|
+
"required_components": [
|
|
55
|
+
"cli",
|
|
56
|
+
"skill"
|
|
57
|
+
],
|
|
58
|
+
"installGuide": "INSTALL.md",
|
|
59
|
+
"updateGuide": "UPDATE.md",
|
|
60
|
+
"install": "Resolve own target; skill install with explicit agent and destination; copy runtime",
|
|
61
|
+
"update": "Stage candidate with npm; reconcile only selected Skill including same version",
|
|
62
|
+
"verification": "sha256 plus package, Skill and receipt version agreement; host discovery verified by host",
|
|
63
|
+
"installerIntegration": "postinstall notice and scripts-disabled INSTALL.md handoff"
|
|
64
|
+
},
|
|
65
|
+
"automaticUpdates": {
|
|
66
|
+
"enabledByDefault": true,
|
|
67
|
+
"intervalHours": 24,
|
|
68
|
+
"timeoutMs": 1200,
|
|
69
|
+
"trigger": "ordinary atomic command entry",
|
|
70
|
+
"configuration": "BOARDMIXCLI_CACHE_DIR selects cache",
|
|
71
|
+
"disable": "BOARDMIXCLI_UPDATE_CHECK=0",
|
|
72
|
+
"recovery": "24 hour offline backoff or explicit update check",
|
|
73
|
+
"defaultMode": "notify"
|
|
74
|
+
},
|
|
75
|
+
"commandContracts": {
|
|
76
|
+
"skill source": {
|
|
77
|
+
"options": {
|
|
78
|
+
"--json": {
|
|
79
|
+
"type": "boolean"
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"sideEffects": "none"
|
|
83
|
+
},
|
|
84
|
+
"skill status": {
|
|
85
|
+
"options": {
|
|
86
|
+
"--json": {
|
|
87
|
+
"type": "boolean"
|
|
88
|
+
},
|
|
89
|
+
"--agent": {
|
|
90
|
+
"type": "string"
|
|
91
|
+
},
|
|
92
|
+
"--destination": {
|
|
93
|
+
"type": "string"
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"sideEffects": "none"
|
|
97
|
+
},
|
|
98
|
+
"skill install": {
|
|
99
|
+
"options": {
|
|
100
|
+
"--json": {
|
|
101
|
+
"type": "boolean"
|
|
102
|
+
},
|
|
103
|
+
"--agent": {
|
|
104
|
+
"type": "string"
|
|
105
|
+
},
|
|
106
|
+
"--destination": {
|
|
107
|
+
"type": "string"
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"sideEffects": "local-write"
|
|
111
|
+
},
|
|
112
|
+
"skill update": {
|
|
113
|
+
"options": {
|
|
114
|
+
"--json": {
|
|
115
|
+
"type": "boolean"
|
|
116
|
+
},
|
|
117
|
+
"--agent": {
|
|
118
|
+
"type": "string"
|
|
119
|
+
},
|
|
120
|
+
"--destination": {
|
|
121
|
+
"type": "string"
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
"sideEffects": "local-write"
|
|
125
|
+
},
|
|
126
|
+
"doctor": {
|
|
127
|
+
"options": {
|
|
128
|
+
"--json": {
|
|
129
|
+
"type": "boolean"
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
"sideEffects": "none"
|
|
133
|
+
},
|
|
134
|
+
"update check": {
|
|
135
|
+
"options": {
|
|
136
|
+
"--json": {
|
|
137
|
+
"type": "boolean"
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
"sideEffects": "local-write"
|
|
141
|
+
},
|
|
142
|
+
"update install": {
|
|
143
|
+
"options": {
|
|
144
|
+
"--json": {
|
|
145
|
+
"type": "boolean"
|
|
146
|
+
},
|
|
147
|
+
"--agent": {
|
|
148
|
+
"type": "string"
|
|
149
|
+
},
|
|
150
|
+
"--destination": {
|
|
151
|
+
"type": "string"
|
|
152
|
+
},
|
|
153
|
+
"--yes": {
|
|
154
|
+
"type": "boolean"
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
"sideEffects": "package-and-skill-update"
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
"maintenanceContract": {
|
|
161
|
+
"schemaVersion": 1,
|
|
162
|
+
"agentEligibility": "valid-name-with-generic-fallback",
|
|
163
|
+
"hostDiscoveryAuthority": "host",
|
|
164
|
+
"targetOptions": {
|
|
165
|
+
"--destination": {
|
|
166
|
+
"kind": "skill-directory"
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
"source": {
|
|
170
|
+
"name": {
|
|
171
|
+
"field": "name",
|
|
172
|
+
"type": "string"
|
|
173
|
+
},
|
|
174
|
+
"package": {
|
|
175
|
+
"field": "package",
|
|
176
|
+
"type": "string"
|
|
177
|
+
},
|
|
178
|
+
"version": {
|
|
179
|
+
"field": "version",
|
|
180
|
+
"type": "string"
|
|
181
|
+
},
|
|
182
|
+
"path": {
|
|
183
|
+
"field": "source",
|
|
184
|
+
"type": "string"
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
"status": {
|
|
188
|
+
"name": {
|
|
189
|
+
"field": "name",
|
|
190
|
+
"type": "string"
|
|
191
|
+
},
|
|
192
|
+
"package": {
|
|
193
|
+
"field": "package",
|
|
194
|
+
"type": "string"
|
|
195
|
+
},
|
|
196
|
+
"version": {
|
|
197
|
+
"field": "version",
|
|
198
|
+
"type": "string"
|
|
199
|
+
},
|
|
200
|
+
"target": {
|
|
201
|
+
"field": "target",
|
|
202
|
+
"type": "string"
|
|
203
|
+
},
|
|
204
|
+
"installed": {
|
|
205
|
+
"field": "installed",
|
|
206
|
+
"type": "boolean"
|
|
207
|
+
},
|
|
208
|
+
"managed": {
|
|
209
|
+
"field": "managed",
|
|
210
|
+
"type": "boolean"
|
|
211
|
+
},
|
|
212
|
+
"current": {
|
|
213
|
+
"field": "current",
|
|
214
|
+
"type": "boolean"
|
|
215
|
+
},
|
|
216
|
+
"edited": {
|
|
217
|
+
"field": "edited",
|
|
218
|
+
"type": "string[]"
|
|
219
|
+
},
|
|
220
|
+
"verification": {
|
|
221
|
+
"field": "verification",
|
|
222
|
+
"type": "object"
|
|
223
|
+
},
|
|
224
|
+
"recovery": {
|
|
225
|
+
"field": "recovery",
|
|
226
|
+
"type": "object"
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
package/lib/assets.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {readFile,readdir,stat} from 'node:fs/promises';
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export async function inspectImage(file) {
|
|
6
|
+
const absolute=path.resolve(file), metadata=await stat(absolute);
|
|
7
|
+
if(!metadata.isFile())throw Error('IMAGE_FILE_REQUIRED');
|
|
8
|
+
const bytes=await readFile(absolute);
|
|
9
|
+
if(bytes.length<24||bytes.subarray(0,8).toString('hex')!=='89504e470d0a1a0a')throw Error('PNG_REQUIRED_IN_THIS_ADAPTER');
|
|
10
|
+
const width=bytes.readUInt32BE(16),height=bytes.readUInt32BE(20);
|
|
11
|
+
if(!width||!height||bytes.length>100*1024*1024)throw Error('INVALID_IMAGE');
|
|
12
|
+
return {file:absolute,name:path.basename(absolute),mime:'image/png',size:bytes.length,width,height,sha256:createHash('sha256').update(bytes).digest('hex')};
|
|
13
|
+
}
|
|
14
|
+
export async function listImages(directory) {
|
|
15
|
+
const entries=await readdir(directory,{withFileTypes:true});
|
|
16
|
+
const names=entries.filter(e=>e.isFile()&&/\.png$/i.test(e.name)).map(e=>e.name).sort((a,b)=>a.localeCompare(b,'en',{numeric:true}));
|
|
17
|
+
return Promise.all(names.map(name=>inspectImage(path.join(directory,name))));
|
|
18
|
+
}
|
package/lib/bridge.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import {randomBytes, randomUUID} from 'node:crypto';
|
|
3
|
+
import {readFile} from 'node:fs/promises';
|
|
4
|
+
import {validate} from './protocol.mjs';
|
|
5
|
+
import {inspectImage} from './assets.mjs';
|
|
6
|
+
|
|
7
|
+
const main = await readFile(new URL('../plugin/main.js', import.meta.url), 'utf8');
|
|
8
|
+
const pageAdapter = await readFile(new URL('./page-adapter.js',import.meta.url),'utf8');
|
|
9
|
+
export async function startBridge() {
|
|
10
|
+
const token = randomBytes(32).toString('hex');
|
|
11
|
+
const queue = [], pending = new Map(), assets=new Map(),clients=new Map(),outcomes=new Map();
|
|
12
|
+
let lastSeen = 0;
|
|
13
|
+
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)); }
|
|
14
|
+
async function body(req) { let s=''; for await (const c of req) {s+=c; if(s.length>16384) throw new Error('BODY_TOO_LARGE');} return JSON.parse(s || '{}'); }
|
|
15
|
+
const server = http.createServer(async(req,res)=>{
|
|
16
|
+
const host = `127.0.0.1:${server.address().port}`;
|
|
17
|
+
if(req.headers.host !== host) return reply(res,403,{error:'INVALID_HOST'});
|
|
18
|
+
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();}
|
|
19
|
+
if(req.headers.authorization !== `Bearer ${token}`) return reply(res,401,{error:'UNAUTHORIZED'});
|
|
20
|
+
try {
|
|
21
|
+
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}))});
|
|
22
|
+
if(req.method==='GET' && req.url==='/bootstrap') return reply(res,200,{script:bootstrap()});
|
|
23
|
+
if(req.method==='POST'&&req.url==='/register-asset'){
|
|
24
|
+
const input=await body(req);if(typeof input.file!=='string')throw Error('FILE_REQUIRED');
|
|
25
|
+
const a=await 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});
|
|
26
|
+
}
|
|
27
|
+
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);}
|
|
28
|
+
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 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));}
|
|
29
|
+
if(req.method==='GET'&&req.url.startsWith('/outcome/'))return reply(res,200,outcomes.get(req.url.slice('/outcome/'.length))||{error:'OUTCOME_NOT_FOUND'});
|
|
30
|
+
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]});}
|
|
31
|
+
if(req.method==='POST' && req.url==='/result') {
|
|
32
|
+
const r=await body(req); const task=pending.get(r.id);
|
|
33
|
+
outcomes.set(r.id,r);if(task) {clearTimeout(task.timer);pending.delete(r.id);reply(task.res,200,r);}
|
|
34
|
+
return reply(res,200,{accepted:!!task});
|
|
35
|
+
}
|
|
36
|
+
if(req.method==='POST' && req.url==='/request') {
|
|
37
|
+
const input=validate(await body(req));
|
|
38
|
+
if(input.op==='image.import'&&!assets.has(input.params.assetId))throw Error('ASSET_NOT_REGISTERED');
|
|
39
|
+
if(clients.size&&Date.now()-(clients.get(input.fileKey)||0)>=5000)return reply(res,409,{error:'TARGET_BOARD_NOT_CONNECTED'});
|
|
40
|
+
if(Date.now()-lastSeen>=5000) return reply(res,409,{error:'PLUGIN_NOT_CONNECTED'});
|
|
41
|
+
if(pending.size) return reply(res,409,{error:'BUSY'});
|
|
42
|
+
const id=randomUUID(); const task={id,channel:'boardmixcli-v1',...input};
|
|
43
|
+
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.list'].includes(input.op)});},input.op==='image.import'?150000:30000);
|
|
44
|
+
pending.set(id,{res,timer});queue.push(task);return;
|
|
45
|
+
}
|
|
46
|
+
reply(res,404,{error:'NOT_FOUND'});
|
|
47
|
+
} catch(error) {reply(res,400,{error:error.message});}
|
|
48
|
+
});
|
|
49
|
+
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});
|
|
50
|
+
const endpoint=`http://127.0.0.1:${server.address().port}`;
|
|
51
|
+
function bootstrap() {
|
|
52
|
+
return `(${pageAdapter})(${JSON.stringify({endpoint,token})},${JSON.stringify(main)})`;
|
|
53
|
+
}
|
|
54
|
+
return {server,endpoint,token,bootstrap};
|
|
55
|
+
}
|
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 {call,request:(op,fileKey,params={})=>call('/request',{op,fileKey,params})};
|
|
11
|
+
}
|
package/lib/commands.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {parse} from './maintenance.mjs';
|
|
2
|
+
import {connect} from './client.mjs';
|
|
3
|
+
import {inspectImage,listImages} from './assets.mjs';
|
|
4
|
+
export async function atomicCommand(command,args){
|
|
5
|
+
const extras={
|
|
6
|
+
'image.list':['--directory'],'image.inspect':['--file'],'image.import':['--file'],
|
|
7
|
+
'board.create':['--team-id','--project-id','--folder-id'],'board.rename':['--name'],
|
|
8
|
+
'board.open':['--target-file-key'],'object.get':['--session-id','--local-id'],
|
|
9
|
+
'object.move':['--session-id','--local-id','--x','--y'],
|
|
10
|
+
'object.resize':['--session-id','--local-id','--width','--height'],'view.fit':['--ids']
|
|
11
|
+
};
|
|
12
|
+
const flags=[...(command==='image.list'||command==='image.inspect'?[]:['--session','--file-key']),...(extras[command]||[])];
|
|
13
|
+
parse(args,Object.fromEntries(flags.map(k=>[k,'string'])));
|
|
14
|
+
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;}
|
|
15
|
+
function number(name){const v=Number(option(name));if(!Number.isFinite(v))throw Error('INVALID_'+name);return v;}
|
|
16
|
+
if(command==='image.list')return {images:await listImages(option('--directory'))};
|
|
17
|
+
if(command==='image.inspect')return inspectImage(option('--file'));
|
|
18
|
+
const client=await connect(option('--session')),fileKey=option('--file-key');
|
|
19
|
+
if(command==='board.info')return client.request(command,fileKey);
|
|
20
|
+
if(command==='board.create')return client.request(command,fileKey,{teamId:number('--team-id'),projectId:number('--project-id'),folderId:Number(option('--folder-id',false)||0)});
|
|
21
|
+
if(command==='board.rename')return client.request(command,fileKey,{name:option('--name')});
|
|
22
|
+
if(command==='board.open')return client.request(command,fileKey,{targetFileKey:option('--target-file-key')});
|
|
23
|
+
if(command==='board.close')return client.request(command,fileKey);
|
|
24
|
+
if(command==='object.selection')return client.request(command,fileKey);
|
|
25
|
+
if(command==='object.get')return client.request(command,fileKey,{sessionID:number('--session-id'),localID:number('--local-id')});
|
|
26
|
+
if(command==='object.move')return client.request(command,fileKey,{sessionID:number('--session-id'),localID:number('--local-id'),x:number('--x'),y:number('--y')});
|
|
27
|
+
if(command==='object.resize')return client.request(command,fileKey,{sessionID:number('--session-id'),localID:number('--local-id'),width:number('--width'),height:number('--height')});
|
|
28
|
+
if(command==='view.fit')return client.request(command,fileKey,{guids:JSON.parse(option('--ids'))});
|
|
29
|
+
if(command==='image.import'){
|
|
30
|
+
const asset=await inspectImage(option('--file'));
|
|
31
|
+
const registered=await client.call('/register-asset',{file:asset.file,sha256:asset.sha256});
|
|
32
|
+
return client.request(command,fileKey,{assetId:registered.assetId});
|
|
33
|
+
}
|
|
34
|
+
throw Error('UNKNOWN_COMMAND');
|
|
35
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
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 cacheDir=()=>process.env.BOARDMIXCLI_CACHE_DIR||path.join(homedir(),'.cache','boardmixcli');
|
|
108
|
+
export async function checkUpdate({automatic=false}={}){
|
|
109
|
+
if(process.env.BOARDMIXCLI_UPDATE_CHECK==='0')return {enabled:false};
|
|
110
|
+
const p=path.join(cacheDir(),'update.json');
|
|
111
|
+
const cached=await json(p).catch(()=>null);
|
|
112
|
+
if(automatic&&cached&&Date.now()-cached.checkedAt<24*3600e3)return {...cached,cached:true};
|
|
113
|
+
let result;
|
|
114
|
+
try{
|
|
115
|
+
const r=await fetch('https://registry.npmjs.org/@petercjl%2fboardmixcli/latest',{signal:AbortSignal.timeout(1200)});
|
|
116
|
+
if(!r.ok)throw Error('REGISTRY_'+r.status);
|
|
117
|
+
const published=await r.json();result={checkedAt:Date.now(),version:meta.version,latest:published.version,available:published.version!==meta.version};
|
|
118
|
+
}catch(e){result={checkedAt:Date.now(),version:meta.version,available:false,error:e.message,recovery:'Retry update check after network recovery.'};}
|
|
119
|
+
try{
|
|
120
|
+
await mkdir(cacheDir(),{recursive:true});
|
|
121
|
+
const prior=await exists(p);
|
|
122
|
+
if(!prior)await writeFile(p,JSON.stringify({...result,owner:meta.name}),{flag:'wx',mode:0o600});
|
|
123
|
+
else if(!prior.isSymbolicLink()&&cached?.owner===meta.name){
|
|
124
|
+
const next=p+'.'+randomUUID();await writeFile(next,JSON.stringify({...result,owner:meta.name}),{flag:'wx',mode:0o600});await rename(next,p);
|
|
125
|
+
}
|
|
126
|
+
}catch{}
|
|
127
|
+
if(automatic&&result.available)process.stderr.write(`boardmixcli: ${result.latest} available; ask your Agent to update CLI and its selected Skill.\n`);
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
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)}})));});}
|
|
131
|
+
export async function updateInstall(o){
|
|
132
|
+
const before=await status(o);if(before.installed&&(!before.managed||before.edited.length))fail('TARGET_PROTECTED',before);
|
|
133
|
+
if(!o['--yes'])fail('CONFIRMATION_REQUIRED');
|
|
134
|
+
// Stage into a private managed prefix. Replace neither the npm package nor selected Skill on a download failure.
|
|
135
|
+
const latest=await checkUpdate();if(latest.error||!latest.latest)fail('UPDATE_CHECK_FAILED',latest);
|
|
136
|
+
const stage=await mkdtemp(path.join(tmpdir(),'boardmixcli-update-'));
|
|
137
|
+
const npmCli=process.env.npm_execpath||path.join(path.dirname(process.execPath),'node_modules/npm/bin/npm-cli.js');
|
|
138
|
+
try{
|
|
139
|
+
if(!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(latest.latest))fail('INVALID_REGISTRY_VERSION');
|
|
140
|
+
const npmArgs=['install','--prefix',stage,'--ignore-scripts','--no-audit','--no-fund',meta.name+'@'+latest.latest];
|
|
141
|
+
if(process.platform==='win32'){
|
|
142
|
+
if(!await exists(npmCli))fail('NPM_RUNTIME_UNAVAILABLE',{recovery:'Use npm-managed execution or configure npm_execpath to npm-cli.js.'});
|
|
143
|
+
await run(process.execPath,[npmCli,...npmArgs]);
|
|
144
|
+
}else await run('npm',npmArgs);
|
|
145
|
+
const candidate=path.join(stage,'node_modules/@petercjl/boardmixcli/bin/boardmixcli.mjs');
|
|
146
|
+
const result=JSON.parse(await run(process.execPath,[candidate,'skill','update','--agent',o['--agent'],'--destination',o['--destination'],'--json']));
|
|
147
|
+
if(!result.current)fail('SKILL_SYNC_FAILED',result);
|
|
148
|
+
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.'}};
|
|
149
|
+
}finally{await rm(stage,{recursive:true,force:true});}
|
|
150
|
+
}
|
|
151
|
+
export async function maintenance(command,args){
|
|
152
|
+
if(command==='doctor'){
|
|
153
|
+
parse(args,{'--json':'boolean'});
|
|
154
|
+
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:'Existing Boardmix login and explicit per-editor console bootstrap; inspect status --session FILE.'},skill:{source:path.join(root,'skills/boardmixcli'),registration:'host-owned'}};
|
|
155
|
+
}
|
|
156
|
+
const action=args.shift();
|
|
157
|
+
if(command==='skill'){
|
|
158
|
+
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};}
|
|
159
|
+
if(!['status','install','update'].includes(action))fail('UNKNOWN_COMMAND');
|
|
160
|
+
if(args.includes('--help')){parse(args,{'--help':'boolean','--json':'boolean'});return {command:'skill '+action,...capabilityManifest.commandContracts['skill '+action]};}
|
|
161
|
+
const o=parse(args,{'--agent':'string','--destination':'string','--json':'boolean'});
|
|
162
|
+
return action==='status'?status(o):install(o);
|
|
163
|
+
}
|
|
164
|
+
if(command==='update'){
|
|
165
|
+
if(action==='check'){parse(args,{'--json':'boolean'});return checkUpdate();}
|
|
166
|
+
if(action==='install')return updateInstall(parse(args,{'--agent':'string','--destination':'string','--yes':'boolean','--json':'boolean'}));
|
|
167
|
+
}fail('UNKNOWN_COMMAND');
|
|
168
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Versioned adapter for the Boardmix web bundle observed in desktop 1.7.2.
|
|
2
|
+
// Called with explicit local endpoint/token; no business layout decisions here.
|
|
3
|
+
async function installPageAdapter(config,mainScript){
|
|
4
|
+
if(window.__boardmixcliPageBridge)window.__boardmixcliPageBridge.stop();
|
|
5
|
+
const src=Array.from(document.scripts).map(s=>s.src).find(s=>/\/assets\/index-[^/]+\.js/.test(s));
|
|
6
|
+
if(!src||!src.endsWith('/index-DjSD2oPg.js'))throw Error('UNVERIFIED_BOARDMIX_BUILD');
|
|
7
|
+
const module=await import(src), manager=module.z(),files=module.bm(),importer=module.eb;
|
|
8
|
+
if(typeof manager.renameFile!=='function'||typeof files.httpCreateFile!=='function'||typeof importer.importPureRefFiles!=='function')throw Error('ADAPTER_CONTRACT_MISMATCH');
|
|
9
|
+
const headers={Authorization:'Bearer '+config.token,'Content-Type':'application/json'};
|
|
10
|
+
let stopped=false,timer,pendingReply;
|
|
11
|
+
async function send(route,data){const r=await fetch(config.endpoint+route,{method:data?'POST':'GET',headers,body:data?JSON.stringify(data):undefined});if(!r.ok)throw Error('LOCAL_BRIDGE_'+r.status);return r.json();}
|
|
12
|
+
const runner=module.lr;
|
|
13
|
+
await runner.run({id:'boardmixcli-atomic',name:'boardmixcli',main:'main.js'},mainScript,'<!doctype html><meta charset="utf-8"><p>boardmixcli 已连接</p>');
|
|
14
|
+
function onReply(e){const r=e.data?.pluginMessage;if(r?.channel==='boardmixcli-v1'&&typeof r.ok==='boolean'&&pendingReply?.id===r.id){const done=pendingReply.resolve;pendingReply=null;done(r);}}
|
|
15
|
+
window.addEventListener('message',onReply);
|
|
16
|
+
async function plugin(op,params={}){
|
|
17
|
+
if(pendingReply)throw Error('PLUGIN_BUSY');
|
|
18
|
+
const id=crypto.randomUUID();
|
|
19
|
+
return new Promise((resolve,reject)=>{
|
|
20
|
+
const t=setTimeout(()=>{if(pendingReply?.id===id)pendingReply=null;reject(Error('PLUGIN_TIMEOUT'));},10000);
|
|
21
|
+
pendingReply={id,resolve:r=>{clearTimeout(t);r.ok?resolve(r.result):reject(Error(r.error));}};
|
|
22
|
+
window.postMessage({pluginMessage:{channel:'boardmixcli-v1',id,op,fileKey:manager.fileKey,params}},location.origin);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function boardInfo(){const f=manager.fileInfo;return {fileKey:manager.fileKey,name:f.name,teamId:f.team_id,projectId:f.project_id,folderId:f.folder_id,localSavePending:manager.isLocalSpaceActive&&manager.isEditorNeedSave,coopStatus:module.rl().coopStatus,uploading:importer.progressMap?.size||0};}
|
|
26
|
+
async function execute(job){
|
|
27
|
+
if(job.fileKey!==manager.fileKey)throw Error('FILE_MISMATCH');
|
|
28
|
+
const p=job.params;
|
|
29
|
+
if(job.op==='board.info')return boardInfo();
|
|
30
|
+
if(job.op==='board.create'){
|
|
31
|
+
const r=await files.httpCreateFile(p.teamId,p.projectId,p.folderId,undefined);
|
|
32
|
+
return {fileKey:r.file_key,name:r.name,teamId:r.team_id,projectId:r.project_id};
|
|
33
|
+
}
|
|
34
|
+
if(job.op==='board.rename'){await manager.renameFile(p.name);const read=await files.httpGetFile(job.fileKey);if(read.name!==p.name)throw Error('RENAME_READBACK_MISMATCH');return {fileKey:job.fileKey,name:read.name};}
|
|
35
|
+
if(job.op==='board.open'){if(!window.desktopManagerApi?.openFile)throw Error('DESKTOP_OPEN_UNAVAILABLE');window.desktopManagerApi.openFile(p.targetFileKey);return {requested:true,targetFileKey:p.targetFileKey};}
|
|
36
|
+
if(job.op==='board.close'){
|
|
37
|
+
if(importer.progressMap?.size||(manager.isLocalSpaceActive&&manager.isEditorNeedSave)||(!manager.isLocalSpaceActive&&module.rl().coopStatus!==1))throw Error('SAVE_OR_UPLOAD_PENDING');
|
|
38
|
+
if(!window.desktopManagerApi?.closeView)throw Error('DESKTOP_CLOSE_UNAVAILABLE');return {closeRequested:true,fileKey:job.fileKey};
|
|
39
|
+
}
|
|
40
|
+
if(job.op==='object.resize'){
|
|
41
|
+
const guid={sessionID:p.sessionID,localID:p.localID},original=await plugin('object.get',guid);
|
|
42
|
+
const locked=original.aspectRatioLocked;
|
|
43
|
+
let matched=false;
|
|
44
|
+
try{
|
|
45
|
+
if(locked){module.mK({nodes:[{guid,privateInfo:{lockAspectRatio:false}}]});await new Promise(r=>setTimeout(r,150));}
|
|
46
|
+
module.mK({nodes:[{guid,width:p.width,height:p.height}]});
|
|
47
|
+
const deadline=Date.now()+3000;
|
|
48
|
+
while(Date.now()<deadline){await new Promise(r=>setTimeout(r,100));const shape=await plugin('object.get',guid);if(Math.abs(shape.width-p.width)<0.01&&Math.abs(shape.height-p.height)<0.01){matched=true;break;}}
|
|
49
|
+
if(!matched)throw Error('RESIZE_READBACK_MISMATCH');
|
|
50
|
+
}finally{if(locked){module.mK({nodes:[{guid,privateInfo:{lockAspectRatio:true}}]});await new Promise(r=>setTimeout(r,150));}}
|
|
51
|
+
return plugin('object.get',guid);
|
|
52
|
+
}
|
|
53
|
+
if(job.op==='image.import'){
|
|
54
|
+
const a=await send('/asset-info/'+p.assetId),before=await plugin('object.selection');
|
|
55
|
+
const r=await fetch(config.endpoint+'/asset/'+p.assetId,{headers});if(!r.ok)throw Error('ASSET_FETCH_FAILED');
|
|
56
|
+
const blob=await r.blob(),file=new File([blob],a.name,{type:a.mime});
|
|
57
|
+
const submitted=await importer.importPureRefFiles([{file}]);if(submitted!==1)throw Error('IMPORT_REJECTED');
|
|
58
|
+
const known=new Set(before.objects.map(x=>x.guid.sessionID+':'+x.guid.localID));
|
|
59
|
+
const deadline=Date.now()+120000;
|
|
60
|
+
while(Date.now()<deadline){
|
|
61
|
+
await new Promise(r=>setTimeout(r,500));
|
|
62
|
+
const current=await plugin('object.selection'),added=current.objects.filter(x=>!known.has(x.guid.sessionID+':'+x.guid.localID));
|
|
63
|
+
if(added.length>1)throw Error('CONCURRENT_CANVAS_MUTATION');
|
|
64
|
+
if(added.length===1&&!(importer.progressMap?.size)&&added[0].width>0&&added[0].height>0)return {asset:{name:a.name,sha256:a.sha256,width:a.width,height:a.height},object:added[0],uploadFinished:true};
|
|
65
|
+
}
|
|
66
|
+
throw Error('IMPORT_OUTCOME_UNKNOWN');
|
|
67
|
+
}
|
|
68
|
+
return plugin(job.op,p);
|
|
69
|
+
}
|
|
70
|
+
async function poll(){
|
|
71
|
+
if(stopped)return;
|
|
72
|
+
try{
|
|
73
|
+
const next=await send('/next?fileKey='+encodeURIComponent(manager.fileKey));
|
|
74
|
+
if(next.request){const job=next.request;let response;try{response={id:job.id,ok:true,result:await execute(job)};}catch(e){response={id:job.id,ok:false,error:String(e.message||e)};}
|
|
75
|
+
await send('/result',response);
|
|
76
|
+
if(job.op==='board.close'&&response.ok){window.__boardmixcliPageBridge.stop();setTimeout(()=>window.desktopManagerApi.closeView(),100);return;}
|
|
77
|
+
}
|
|
78
|
+
}catch(e){console.warn('boardmixcli connection',e.message);}
|
|
79
|
+
timer=setTimeout(poll,350);
|
|
80
|
+
}
|
|
81
|
+
window.__boardmixcliPageBridge={stop(){stopped=true;clearTimeout(timer);window.removeEventListener('message',onReply);}};
|
|
82
|
+
poll();return {connected:true,...boardInfo()};
|
|
83
|
+
}
|
package/lib/protocol.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const operations = ['info','frame.create','object.get','object.selection','object.move','object.resize','view.fit','board.info','board.create','board.rename','board.open','board.close','image.import'];
|
|
2
|
+
export function validate(input) {
|
|
3
|
+
if (!input || !operations.includes(input.op)) throw new Error('UNSUPPORTED_OPERATION');
|
|
4
|
+
if (typeof input.fileKey !== 'string' || !/^[A-Za-z0-9_-]{8,100}$/.test(input.fileKey)) throw new Error('INVALID_FILE_KEY');
|
|
5
|
+
const p = input.params ?? {};
|
|
6
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) throw new Error('INVALID_PARAMS');
|
|
7
|
+
const fields = ({'frame.create':['x','y','width','height','content'],'object.get':['sessionID','localID'],'object.move':['sessionID','localID','x','y'],'object.resize':['sessionID','localID','width','height'],'view.fit':['guids'],'board.create':['teamId','projectId','folderId'],'board.rename':['name'],'board.open':['targetFileKey'],'image.import':['assetId']})[input.op]||[];
|
|
8
|
+
if (Object.keys(p).some(k => !fields.includes(k))) throw new Error('UNKNOWN_PARAMETER');
|
|
9
|
+
if (input.op === 'frame.create') {
|
|
10
|
+
for (const k of ['x','y','width','height']) if (!Number.isFinite(p[k]) || Math.abs(p[k]) > 1e6) throw new Error('INVALID_GEOMETRY');
|
|
11
|
+
if (p.width <= 0 || p.height <= 0) throw new Error('INVALID_GEOMETRY');
|
|
12
|
+
if (p.content !== undefined && (typeof p.content !== 'string' || p.content.length > 1000)) throw new Error('INVALID_CONTENT');
|
|
13
|
+
}
|
|
14
|
+
if (['object.get','object.move','object.resize'].includes(input.op)) for (const k of ['sessionID','localID']) if (!Number.isSafeInteger(p[k]) || p[k] < 0) throw new Error('INVALID_GUID');
|
|
15
|
+
if(input.op==='object.move')for(const k of ['x','y'])if(!Number.isFinite(p[k])||Math.abs(p[k])>1e7)throw Error('INVALID_GEOMETRY');
|
|
16
|
+
if(input.op==='object.resize')for(const k of ['width','height'])if(!Number.isFinite(p[k])||p[k]<=0||p[k]>1e6)throw Error('INVALID_GEOMETRY');
|
|
17
|
+
if(input.op==='board.create')for(const k of ['teamId','projectId','folderId'])if(!Number.isSafeInteger(p[k])||p[k]<0)throw Error('INVALID_DESTINATION');
|
|
18
|
+
if(input.op==='board.rename'&&(typeof p.name!=='string'||!p.name.trim()||p.name.length>100))throw Error('INVALID_NAME');
|
|
19
|
+
if(input.op==='board.open'&&(typeof p.targetFileKey!=='string'||!/^[A-Za-z0-9_-]{8,100}$/.test(p.targetFileKey)))throw Error('INVALID_FILE_KEY');
|
|
20
|
+
if(input.op==='image.import'&&(typeof p.assetId!=='string'||!/^[a-f0-9]{64}$/.test(p.assetId)))throw Error('INVALID_ASSET_ID');
|
|
21
|
+
if(input.op==='view.fit'&&(!Array.isArray(p.guids)||!p.guids.length||p.guids.length>1000||p.guids.some(g=>!Number.isSafeInteger(g.sessionID)||g.sessionID<0||!Number.isSafeInteger(g.localID)||g.localID<0)))throw Error('INVALID_GUID_LIST');
|
|
22
|
+
return {op:input.op,fileKey:input.fileKey,params:p};
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@petercjl/boardmixcli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Atomic Boardmix desktop operations and portable Agent capability manual",
|
|
5
6
|
"license": "MIT",
|
|
6
|
-
"publishConfig": {
|
|
7
|
-
"access": "public"
|
|
8
|
-
},
|
|
9
7
|
"repository": {
|
|
10
8
|
"type": "git",
|
|
11
9
|
"url": "git+https://github.com/petercjl/boardmixcli.git"
|
|
12
10
|
},
|
|
11
|
+
"homepage": "https://github.com/petercjl/boardmixcli#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/petercjl/boardmixcli/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"boardmixcli": "bin/boardmixcli.mjs"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/*.test.mjs",
|
|
23
|
+
"check": "node scripts/validate-package.mjs",
|
|
24
|
+
"prepack": "node scripts/validate-package.mjs",
|
|
25
|
+
"postinstall": "node scripts/install-notice.mjs",
|
|
26
|
+
"smoke": "node scripts/packed-smoke.mjs"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
13
31
|
"files": [
|
|
14
|
-
"
|
|
32
|
+
"bin/*.mjs",
|
|
33
|
+
"lib/*.mjs",
|
|
34
|
+
"lib/page-adapter.js",
|
|
35
|
+
"plugin/main.js",
|
|
36
|
+
"scripts/*.mjs",
|
|
37
|
+
"skills/boardmixcli/**",
|
|
38
|
+
"README.md",
|
|
39
|
+
"INSTALL.md",
|
|
40
|
+
"UPDATE.md",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"CONTRACT.md",
|
|
43
|
+
"capabilities.json"
|
|
15
44
|
]
|
|
16
45
|
}
|
package/plugin/main.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Boardmix plugin sandbox. Only named operations are accepted.
|
|
2
|
+
function compact(r) {
|
|
3
|
+
return {guid:r.guid,name:r.name,x:r.x,y:r.y,width:r.width,height:r.height,objectType:r.objectType,aspectRatioLocked:r.lockAspectRatio,resourceHash:r.imageInfo&&r.imageInfo.hash,cropped:r.imageInfo&&r.imageInfo.cropped,imageNum:r.imageNum};
|
|
4
|
+
}
|
|
5
|
+
boardmix.ui.on('message', function(message) {
|
|
6
|
+
if (!message || message.channel !== 'boardmixcli-v1' || typeof message.op !== 'string') return;
|
|
7
|
+
var response = {channel:'boardmixcli-v1', id:message.id};
|
|
8
|
+
try {
|
|
9
|
+
if (message.fileKey !== boardmix.fileKey) throw new Error('FILE_MISMATCH');
|
|
10
|
+
var p = message.params || {};
|
|
11
|
+
if (message.op === 'info') response.result = {fileKey:boardmix.fileKey, apiVersion:boardmix.apiVersion, view:boardmix.viewport.getView(), zoom:boardmix.viewport.getZoom(), operations:['info','frame.create','object.get']};
|
|
12
|
+
else if (message.op === 'frame.create') {
|
|
13
|
+
var created = boardmix.createFrame(p);
|
|
14
|
+
var g = created.guid;
|
|
15
|
+
response.result = {created:compact(created), readback:compact(boardmix.getById(g.sessionID,g.localID))};
|
|
16
|
+
} else if (message.op === 'object.get') response.result = compact(boardmix.getById(p.sessionID,p.localID));
|
|
17
|
+
else if(message.op==='object.selection'){
|
|
18
|
+
var selection=boardmix.getSelect();response.result={objects:selection.guids.map(function(g){return compact(boardmix.getById(g.sessionID,g.localID));})};
|
|
19
|
+
}
|
|
20
|
+
else if(message.op==='view.fit'){
|
|
21
|
+
boardmix.viewport.zoomTo({guids:p.guids});response.result={guids:p.guids,zoom:boardmix.viewport.getZoom(),view:boardmix.viewport.getView()};
|
|
22
|
+
}
|
|
23
|
+
else if(message.op==='object.move'){
|
|
24
|
+
var original=boardmix.getById(p.sessionID,p.localID);
|
|
25
|
+
boardmix.moveTo(p.sessionID,p.localID,p.x+original.width/2,p.y+original.height/2);
|
|
26
|
+
var moved=compact(boardmix.getById(p.sessionID,p.localID));
|
|
27
|
+
if(Math.abs(moved.x-p.x)>0.01||Math.abs(moved.y-p.y)>0.01)throw new Error('MOVE_READBACK_MISMATCH:'+JSON.stringify(moved));
|
|
28
|
+
response.result=moved;
|
|
29
|
+
}
|
|
30
|
+
else if(message.op==='object.resize'){
|
|
31
|
+
throw new Error('HOST_ADAPTER_REQUIRED_FOR_EXACT_RESIZE');
|
|
32
|
+
}
|
|
33
|
+
else throw new Error('UNSUPPORTED_OPERATION');
|
|
34
|
+
response.ok = true;
|
|
35
|
+
} catch (error) { response.ok = false; response.error = String(error.message || error); }
|
|
36
|
+
boardmix.ui.postMessage(response);
|
|
37
|
+
});
|
|
38
|
+
// Boardmix routes sandbox replies through its plugin window wrapper.
|
|
39
|
+
boardmix.showUI({title:'boardmixcli',width:280,height:90});
|
|
40
|
+
boardmix.ui.hide();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
console.log('@petercjl/boardmixcli: CLI installation is one phase. Ask your current Agent to read INSTALL.md / UPDATE.md, resolve its own Skill destination, run skill install/update, verify hashes and refresh host discovery. No service access is granted by installation.');
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {mkdtemp,readFile,rm,writeFile,stat} from 'node:fs/promises';
|
|
2
|
+
import {execFileSync} from 'node:child_process';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
const root=fileURLToPath(new URL('../',import.meta.url)),scratch=await mkdtemp(path.join(tmpdir(),'boardmix-packed-'));
|
|
8
|
+
const npmCli=process.env.npm_execpath||fileURLToPath(new URL('../../../npm/bin/npm-cli.js',import.meta.url));
|
|
9
|
+
function npm(args){return execFileSync(process.execPath,[npmCli,...args],{cwd:root,encoding:'utf8',env:{...process.env,BOARDMIXCLI_UPDATE_CHECK:'0'}});}
|
|
10
|
+
function cli(entry,args){return JSON.parse(execFileSync(process.execPath,[entry,...args],{encoding:'utf8',env:{...process.env,BOARDMIXCLI_UPDATE_CHECK:'0'}}));}
|
|
11
|
+
try{
|
|
12
|
+
const packed=JSON.parse(npm(['pack','--silent','--json','--pack-destination',scratch]))[0];
|
|
13
|
+
assert.ok(packed.files.every(f=>!/(evidence|\.bak-|\.session\.|bootstrap\.js)/.test(f.path)));
|
|
14
|
+
const bootstrap=path.join(scratch,'bootstrap');npm(['install','--prefix',bootstrap,'--ignore-scripts','--no-audit','--no-fund',path.join(scratch,packed.filename)]);
|
|
15
|
+
const entry=path.join(bootstrap,'node_modules/@petercjl/boardmixcli/bin/boardmixcli.mjs');
|
|
16
|
+
const version=cli(entry,['version']).version;
|
|
17
|
+
for(const name of ['host-a','host-b']){
|
|
18
|
+
const target=path.join(scratch,name),args=['--agent',name,'--destination',target,'--json'];
|
|
19
|
+
assert.equal(cli(entry,['skill','status',...args]).installed,false);
|
|
20
|
+
assert.equal(cli(entry,['skill','install',...args]).current,true);
|
|
21
|
+
assert.equal(cli(entry,['skill','update',...args]).changed,false);
|
|
22
|
+
}
|
|
23
|
+
const a=path.join(scratch,'host-a/.runtime/bin/boardmixcli.mjs'),b=path.join(scratch,'host-b/.runtime/bin/boardmixcli.mjs');
|
|
24
|
+
if(process.platform!=='win32')assert.notEqual((await stat(a)).ino,(await stat(b)).ino);
|
|
25
|
+
await rm(bootstrap,{recursive:true});await rm(path.join(scratch,'host-a'),{recursive:true});
|
|
26
|
+
assert.equal(cli(b,['version']).version,version);
|
|
27
|
+
const png=Buffer.alloc(24);Buffer.from('89504e470d0a1a0a','hex').copy(png);png.write('IHDR',12);png.writeUInt32BE(750,16);png.writeUInt32BE(1475,20);
|
|
28
|
+
const image=path.join(scratch,'图片.png');await writeFile(image,png);
|
|
29
|
+
const result=cli(b,['image.inspect','--file',image]);assert.equal(result.width,750);assert.equal(result.height,1475);
|
|
30
|
+
assert.equal(cli(b,['skill','status','--agent','host-b','--destination',path.join(scratch,'host-b'),'--json']).current,true);
|
|
31
|
+
console.log(JSON.stringify({ok:true,version,packedFiles:packed.files.length,isolation:'bootstrap and host-a removed; host-b version, image.inspect and receipt verified',historicalUpdater:'first release; registry absence checked before bootstrap publication'}));
|
|
32
|
+
}finally{await rm(scratch,{recursive:true,force:true});}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Release-only identity synchronization; prepack remains read-only.
|
|
2
|
+
import {readFile,writeFile,copyFile,stat} from 'node:fs/promises';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {randomUUID} from 'node:crypto';
|
|
6
|
+
const root=fileURLToPath(new URL('../',import.meta.url));
|
|
7
|
+
const pkg=JSON.parse(await readFile(path.join(root,'package.json'),'utf8'));
|
|
8
|
+
for(const name of ['capabilities.json','skills/boardmixcli/manifest.json']){
|
|
9
|
+
const p=path.join(root,name);await stat(p);const x=JSON.parse(await readFile(p,'utf8'));
|
|
10
|
+
if(x.version===pkg.version&&x.package===pkg.name)continue;
|
|
11
|
+
await copyFile(p,p+'.bak-'+randomUUID());x.version=pkg.version;x.package=pkg.name;
|
|
12
|
+
await writeFile(p,JSON.stringify(x,null,2)+'\n');
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const root=fileURLToPath(new URL('../',import.meta.url));
|
|
5
|
+
const p=JSON.parse(await readFile(path.join(root,'package.json'),'utf8'));
|
|
6
|
+
for(const f of ['capabilities.json','skills/boardmixcli/manifest.json']){
|
|
7
|
+
const x=JSON.parse(await readFile(path.join(root,f),'utf8'));
|
|
8
|
+
if(x.version!==p.version||x.package!==p.name)throw Error('VERSION_DRIFT: '+f);
|
|
9
|
+
}
|
|
10
|
+
for(const f of ['INSTALL.md','UPDATE.md','CONTRACT.md','skills/boardmixcli/SKILL.md'])if(!(await readFile(path.join(root,f),'utf8')).trim())throw Error('MISSING_RESOURCE: '+f);
|
|
11
|
+
console.error('Package and Skill identity verified: '+p.name+'@'+p.version);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: boardmixcli
|
|
3
|
+
description: Operate Boardmix desktop boards through explicit atomic CLI commands for files, PNG images, object geometry and viewport. Use for authorized Boardmix automation; the caller owns content and layout decisions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# BoardmixCLI capability manual
|
|
7
|
+
|
|
8
|
+
Input: explicit operations, Boardmix file keys, local PNG paths and object GUIDs, authorized by the user. Strategy: discover the installed contract, connect the selected editor, execute atomic operations and verify readback. Output: JSON receipts and verified Boardmix state. The calling Agent or application Skill owns business methods, material selection, ordering and layout.
|
|
9
|
+
|
|
10
|
+
## Main line
|
|
11
|
+
|
|
12
|
+
1. Resolve this Skill directory from the host's actual loaded resource path. Use Node.js with its target-owned `.runtime/bin/boardmixcli.mjs`; never infer a different Agent's installation path. Read this Skill’s `capabilities.json` and `adapters/generic.json` to resolve the logical execution and desktop features from the current host. Run `version`, `capabilities --json`, and `doctor --json`. In a source npm checkout, resolve the canonical runtime through the package CLI instead. Node.js >=22, local filesystem, loopback HTTP and authorized editor-console access are required. Any Agent with these capabilities may use the generic adapter. Native desktop execution is tested on macOS Boardmix 1.7.2; other desktop platforms remain unverified.
|
|
13
|
+
2. For local inspection, `image.list --directory ABSOLUTE_DIRECTORY` returns PNG filenames in natural numeric order; `image.inspect --file ABSOLUTE_PNG` returns source dimensions and SHA256. Caller decides whether this order suits the task. For editor actions, read the runtime's `CONTRACT.md` before the first write to obtain exact flags and response/error contracts.
|
|
14
|
+
3. Start `serve --session NEW_ABSOLUTE_PRIVATE_FILE`. Use the host's authorized native desktop tools to open the intended Boardmix editor's developer Console and execute the generated bootstrap script. This is an explicit provider adapter prerequisite: each opened editor requires its own connection. Close developer tools before file-close operations. Verify `status --session FILE` and `board.info --session FILE --file-key EXACT_KEY` identify the intended view. A mismatched front-end build stops for adapter repair.
|
|
15
|
+
4. Execute only caller-selected operations: file create/name/open/close; one PNG import; object get/move/resize; explicit viewport fit. Every editor command requires `--session` and `--file-key`. GUID sessionID/localID are separate numeric flags. Move uses absolute top-left board coordinates. Resize sets both dimensions and preserves aspect-ratio lock. Inspect each receipt, then read object/file state to verify the requested change before proceeding.
|
|
16
|
+
5. Report actual file identity, operations, verified state and unresolved outcomes. For close, verify the file is absent from the desktop's open-file tabs after the close receipt. Fit animates asynchronously, so an immediate viewport value may precede the final visual state.
|
|
17
|
+
|
|
18
|
+
## Recovery and boundaries
|
|
19
|
+
|
|
20
|
+
- Missing Node, desktop controls, console access, login or supported front-end build: return CAPABILITY_UNAVAILABLE / AUTH_REQUIRED / FEATURE_UNSUPPORTED with the prerequisite. Resume at step 1 or 3 after resolution; do not invent a different adapter.
|
|
21
|
+
- File create or image import timeout: outcome may be unknown. Inspect the operation receipt/outcome and actual file/object state before any new mutation. Avoid duplicate creates/imports. Resume at step 4 only when the outcome is known.
|
|
22
|
+
- Protected local destination: preserve unmanaged targets and edits; use CLI-managed skill status/install/update with the current Agent's explicit destination. Installation does not authorize Boardmix writes or paid service actions.
|
|
23
|
+
- Package update: read `.runtime/UPDATE.md`, reconcile only this selected installation, verify CLI/Skill/receipt versions and hashes, then refresh host discovery. A current verified receipt takes a read-only completion branch. Host discovery requires host evidence.
|
|
24
|
+
- New recurring integration failures belong in a separately authorized capability-package repair with tests. Business quality and layout rules remain owned by the application layer.
|
|
25
|
+
|
|
26
|
+
The runtime CONTRACT.md and capabilities.json are authoritative versioned operation references. This mechanical manual directly internalizes tested command behavior; it has no nested Skill or private knowledge-base dependency. Clean-context regression is separate from ordinary operation.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "portable-skill-adapter",
|
|
3
|
+
"schema_version": "1.0.0",
|
|
4
|
+
"platform": "generic",
|
|
5
|
+
"mappings": [
|
|
6
|
+
{
|
|
7
|
+
"capability_id": "command.execute",
|
|
8
|
+
"status": "implemented",
|
|
9
|
+
"implementation": {
|
|
10
|
+
"kind": "instruction",
|
|
11
|
+
"discovery": "Resolve command execution and native desktop controls from the current Agent advertised tools. CLI is target-owned .runtime/bin/boardmixcli.mjs; run with Node >=22. Console and tab inspection use host-authorized native desktop control. Return CAPABILITY_UNAVAILABLE when missing."
|
|
12
|
+
},
|
|
13
|
+
"features": [
|
|
14
|
+
"node22",
|
|
15
|
+
"json-results"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"capability_id": "desktop.console",
|
|
20
|
+
"status": "implemented",
|
|
21
|
+
"implementation": {
|
|
22
|
+
"kind": "instruction",
|
|
23
|
+
"discovery": "Resolve command execution and native desktop controls from the current Agent advertised tools. CLI is target-owned .runtime/bin/boardmixcli.mjs; run with Node >=22. Console and tab inspection use host-authorized native desktop control. Return CAPABILITY_UNAVAILABLE when missing."
|
|
24
|
+
},
|
|
25
|
+
"features": [
|
|
26
|
+
"console-paste",
|
|
27
|
+
"inspect-open-tabs"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "portable-skill-capabilities",
|
|
3
|
+
"schema_version": "1.0.0",
|
|
4
|
+
"skill": "boardmixcli",
|
|
5
|
+
"target_platforms": [
|
|
6
|
+
"generic"
|
|
7
|
+
],
|
|
8
|
+
"host_resolution": {
|
|
9
|
+
"fallback_adapter": "generic",
|
|
10
|
+
"destination": "host-supplied explicit Skill directory"
|
|
11
|
+
},
|
|
12
|
+
"capabilities": [
|
|
13
|
+
{
|
|
14
|
+
"id": "command.execute",
|
|
15
|
+
"purpose": "Execute target-owned Node CLI",
|
|
16
|
+
"required": true,
|
|
17
|
+
"required_features": [
|
|
18
|
+
"node22",
|
|
19
|
+
"json-results"
|
|
20
|
+
],
|
|
21
|
+
"input_fields": [
|
|
22
|
+
"argv"
|
|
23
|
+
],
|
|
24
|
+
"output_fields": [
|
|
25
|
+
"stdout",
|
|
26
|
+
"exitCode"
|
|
27
|
+
],
|
|
28
|
+
"side_effects": {
|
|
29
|
+
"local_process": true,
|
|
30
|
+
"authorization": "Explicit caller operation",
|
|
31
|
+
"external_mutation": false,
|
|
32
|
+
"may_cost_money": false
|
|
33
|
+
},
|
|
34
|
+
"errors": [
|
|
35
|
+
"CAPABILITY_UNAVAILABLE",
|
|
36
|
+
"FEATURE_UNSUPPORTED"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "desktop.console",
|
|
41
|
+
"purpose": "Connect authorized Boardmix editor",
|
|
42
|
+
"required": true,
|
|
43
|
+
"required_features": [
|
|
44
|
+
"console-paste",
|
|
45
|
+
"inspect-open-tabs"
|
|
46
|
+
],
|
|
47
|
+
"input_fields": [
|
|
48
|
+
"bootstrap-file",
|
|
49
|
+
"editor-file-key"
|
|
50
|
+
],
|
|
51
|
+
"output_fields": [
|
|
52
|
+
"connected",
|
|
53
|
+
"visible-file-state"
|
|
54
|
+
],
|
|
55
|
+
"side_effects": {
|
|
56
|
+
"application_access": true,
|
|
57
|
+
"authorization": "User authorized Boardmix access",
|
|
58
|
+
"external_mutation": true,
|
|
59
|
+
"may_cost_money": false
|
|
60
|
+
},
|
|
61
|
+
"errors": [
|
|
62
|
+
"CAPABILITY_UNAVAILABLE",
|
|
63
|
+
"FEATURE_UNSUPPORTED"
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|