@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/CONTRACT.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
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. For a CDP-connected Chrome, `browser.connect` attaches each exact editor without opening DevTools. Desktop console attachment remains a fallback. 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
|
+
## Added capability operations
|
|
36
|
+
|
|
37
|
+
`frame.create --session FILE --file-key KEY --x X --y Y --width W --height H --name TEXT` creates one native Frame, normalizes its top-left position, sets and verifies its name.
|
|
38
|
+
|
|
39
|
+
`text.create` uses the same geometry options with `--text TEXT [--font-size N]` (default 28). Native text uses auto-fit bounds; returned width and height are actual measured values, not a fixed requested rectangle. Verify position and inspect visible content; current text readback exposes font size, not the rich-text content.
|
|
40
|
+
|
|
41
|
+
`image.download --url URL --output NEW_FILE` downloads only the explicit HTTP(S) source, validates PNG/JPEG/WebP metadata and creates a private new file with SHA256. Existing targets refuse overwrite. `image.inspect`, `image.list` and `image.import` accept the same formats and preserve source bytes. Download failure does not submit anything to Boardmix.
|
|
42
|
+
|
|
43
|
+
Frame/text creation has the same uncertain-outcome rules as image import. Geometric containment inside Frames is distinct from native parent-child membership; the latter is not exposed by this contract.
|
|
44
|
+
|
|
45
|
+
## Distribution status
|
|
46
|
+
|
|
47
|
+
npm-distributed capability package with CLI-managed isolated Skill installation. Source changes beyond the registry version require an explicitly selected development entry until their release. 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.
|
|
48
|
+
|
|
49
|
+
## Batch operations (contract version 1)
|
|
50
|
+
|
|
51
|
+
- `image.inspect.batch --input FILE`: JSON array of 1–1000 absolute file paths; returns `images` with dimensions, SHA256 and MD5. Local inspection uses bounded concurrency.
|
|
52
|
+
- `image.import.batch --session SESSION --file-key KEY --input FILE --receipt NEW_FILE`: JSON array of 1–20 `{key,file,sha256?}` entries. Imports through one native uploader call; returns `items` containing the caller key, source metadata and distinct object GUID, plus `uploadFinished`. Native resource hashes are matched to source MD5, independently of completion order. Duplicate bytes receive distinct GUIDs.
|
|
53
|
+
- `object.get.batch --session SESSION --file-key KEY --input FILE`: JSON array of 1–1000 `{sessionID,localID}` values; returns `objects` in input order.
|
|
54
|
+
- `object.place.batch --session SESSION --file-key KEY --input FILE`: JSON array of 1–200 `{sessionID,localID,x,y,width,height}` entries. Validates the whole request, then applies exact dimensions and absolute top-left positions, restores original aspect locks and waits for matching readback. Returns `objects` in input order. It is not a transactional rollback operation: failure can leave partial placement; inspect and repeat explicit placement for the same GUIDs.
|
|
55
|
+
|
|
56
|
+
Import receipts are private, durable files reserved before submission. Reusing the same input, file key and receipt reads the recorded result or the running bridge outcome; it never submits the import again. Changed inputs return RECEIPT_CONFLICT. BATCH_OUTCOME_UNRESOLVED requires inspection of the existing operation and canvas; retain the receipt, source files and session, and do not create a fresh receipt to retry an uncertain import. Bridge outcomes are in memory; a completed receipt survives bridge restarts. Batch writes on one editor are serialized, while local file reads and native upload scheduling can run concurrently.
|
|
57
|
+
|
|
58
|
+
Frame creation can return rounded native dimensions. Callers requiring exact fractional geometry apply `object.place.batch` or `object.resize` using the returned GUID. Preserve the GUID before validating final layout so recovery can finish placement without recreating the Frame.
|
|
59
|
+
|
|
60
|
+
## Browser connection (contract version 1)
|
|
61
|
+
|
|
62
|
+
The caller supplies an existing, authorized Chrome CDP endpoint as `--endpoint http://127.0.0.1:PORT`. No browser profile is created and no login is copied. Only Boardmix app targets are enumerated. This route does not launch or restart the desktop app. A normal desktop without a CDP endpoint retains the explicit console fallback; a failed browser connection must not silently switch hosts.
|
|
63
|
+
|
|
64
|
+
- `browser.targets --endpoint URL`: read-only target inventory with title, URL, exact file key and target ID.
|
|
65
|
+
- `browser.open --endpoint URL --file-key KEY`: opens an existing Boardmix file in a browser tab, or reuses its unique existing tab. Requires an existing Boardmix app/home tab. It does not create a Boardmix file. Call `board.create` separately through a connected editor.
|
|
66
|
+
- `browser.connect --endpoint URL --session SESSION --file-key KEY`: matches one exact editor, waits for its app script, injects the versioned adapter and verifies the authenticated local bridge. Reuses only a live adapter matching that session; pending writes refuse replacement. Returns connected/reused/fileKey/visibleDevTools. No visible console is opened.
|
|
67
|
+
- `browser.close --endpoint URL --session SESSION --file-key KEY`: reads upload/save/sync status first, closes only the exact tab and checks that its target disappears. It preserves the cloud file. BROWSER_CLOSE_PENDING requires target inspection rather than a blind repeated close.
|
|
68
|
+
|
|
69
|
+
Missing endpoint/target, duplicate matching tabs, unverified adapter build and identity mismatch stop with structured errors. Login remains the user's existing browser session. Editor writes still require an explicit file key and the existing capability authorization. Source and CDN image bytes follow the same upload contract as desktop mode. Browser attachment has been tested on macOS Chrome; native Windows/Linux execution remains unverified.
|
|
70
|
+
|
|
71
|
+
## Managed browser and login
|
|
72
|
+
|
|
73
|
+
`browser.ensure --json` starts/reuses macOS Google Chrome at http://127.0.0.1:9223 using the persistent `~/.dianshang-chrome-profile`, opens Boardmix home if absent, and returns `state:ready` only after workspace login markers are visible. No pre-opened Boardmix tab is required. The packaged launcher has no ecommerce CLI dependency.
|
|
74
|
+
|
|
75
|
+
`auth.status --json` is read-only. `auth.wait --wait-ms 60000 --json` resumes preparation and polls login for up to 60 seconds; `browser.ensure` defaults to 15 seconds and returns immediately on an identified login page. AUTH_REQUIRED includes a human login prompt and resume command. Unknown/load states remain AUTH_STATE_UNKNOWN. CLI exits nonzero until ready. Human login/verification stays in Chrome; the CLI does not read or print credentials. Repeat calls reuse the profile and home tab.
|
|
76
|
+
|
|
77
|
+
Startup verifies the listening process, Chrome executable, debugging port and exact profile. PORT_CONFLICT refuses to change other processes. Missing Chrome and unsupported launch platforms are explicit errors. Windows/Linux launch is not implemented. Existing explicit-endpoint browser commands remain compatible. Browser readiness does not grant file write authorization; continue with the caller-selected file and bridge session.
|
|
78
|
+
|
|
79
|
+
## Native video contract v1
|
|
80
|
+
|
|
81
|
+
`video.inspect --file FILE` returns MP4 width, height, duration, SHA256 and MD5 using ffprobe on PATH. `video.download --url HTTPS_URL --output NEW_FILE` preserves source bytes and refuses existing files. Maximum source size is 100 MiB.
|
|
82
|
+
|
|
83
|
+
`video.import --session SESSION --file-key KEY --file FILE --receipt NEW_RECEIPT` creates one native video object, awaits the provider upload, and verifies object resourceHash equals source MD5, mediaType is video/mp4, and mediaLoading is false. Returns asset, object GUID/geometry, and uploadFinished. Placement is a separate explicit operation. The receipt is reserved before submission. Repeating the same file hash and board with the same receipt retrieves the known result; changed intent returns RECEIPT_CONFLICT, unknown outcome returns VIDEO_OUTCOME_UNRESOLVED without resubmission. Use object.get after recovery to verify the object still exists. No automatic transcoding or poster substitution. Tested on macOS Chrome; other hosts require ffprobe and runtime verification.
|
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,40 @@
|
|
|
1
|
-
#
|
|
1
|
+
# BoardmixCLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Boardmix 桌面画板的元命令能力包。提供文件新建、命名、打开和关闭,PNG/JPEG/WebP 导入、Frame 与文字创建、图元读取、精确尺寸与位置调整,返回 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/download/import`、`frame.create`、`text.create`、`object.selection/get/move/resize`、`view.fit`。图元移动使用绝对左上角画板坐标;尺寸调整保留宽高比锁定状态。创建和导入超时后先核查结果再决定后续动作。
|
|
27
|
+
|
|
28
|
+
运行 `npm test` 与 `npm run check` 检查包、命令与安装生命周期。真实画板证据、私人会话和备份均不进入公开包。
|
|
29
|
+
|
|
30
|
+
Batch contract v1 supports up to 20 images per native import, durable import receipts, bulk object reads and explicit bulk placement. See CONTRACT.md for JSON inputs and recovery behavior.
|
|
31
|
+
|
|
32
|
+
Existing logged-in Chrome sessions can connect through `browser.targets/open/connect/close` using an explicit loopback CDP endpoint. This route keeps DevTools closed; no desktop restart or additional profile is required. See CONTRACT.md.
|
|
33
|
+
|
|
34
|
+
## Managed browser and login
|
|
35
|
+
|
|
36
|
+
`browser.ensure --json` starts/reuses macOS Google Chrome at http://127.0.0.1:9223 using the persistent `~/.dianshang-chrome-profile`, opens Boardmix home if absent, and returns `state:ready` only after workspace login markers are visible. No pre-opened Boardmix tab is required. The packaged launcher has no ecommerce CLI dependency.
|
|
37
|
+
|
|
38
|
+
`auth.status --json` is read-only. `auth.wait --wait-ms 60000 --json` resumes preparation and polls login for up to 60 seconds; `browser.ensure` defaults to 15 seconds and returns immediately on an identified login page. AUTH_REQUIRED includes a human login prompt and resume command. Unknown/load states remain AUTH_STATE_UNKNOWN. CLI exits nonzero until ready. Human login/verification stays in Chrome; the CLI does not read or print credentials. Repeat calls reuse the profile and home tab.
|
|
39
|
+
|
|
40
|
+
Startup verifies the listening process, Chrome executable, debugging port and exact profile. PORT_CONFLICT refuses to change other processes. Missing Chrome and unsupported launch platforms are explicit errors. Windows/Linux launch is not implemented. Existing explicit-endpoint browser commands remain compatible. Browser readiness does not grant file write authorization; continue with the caller-selected file and bridge session.
|
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,48 @@
|
|
|
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
|
+
import {targets,attachDesktop,openDesktop,closeBrowser} from '../lib/desktop.mjs';
|
|
9
|
+
import {ensureBrowser,authStatus} from '../lib/browser-session.mjs';
|
|
10
|
+
const args=process.argv.slice(2), command=args.shift();
|
|
11
|
+
const output=x=>process.stdout.write(JSON.stringify(x,null,2)+'\n');
|
|
12
|
+
function flag(k){const i=args.indexOf(k);return i<0?undefined:args[i+1];}
|
|
13
|
+
async function absent(p){try{await stat(p);throw Error('TARGET_EXISTS: '+p);}catch(e){if(e.code!=='ENOENT')throw e;}}
|
|
14
|
+
try {
|
|
15
|
+
if(['browser.ensure','auth.status','auth.wait'].includes(command)){
|
|
16
|
+
const o=parse(args,{'--wait-ms':'string','--json':'boolean'});
|
|
17
|
+
const r=command==='auth.status'?await authStatus():await ensureBrowser({waitMs:Number(o['--wait-ms']??(command==='auth.wait'?60000:15000))});output(r);if(!r.ok)process.exitCode=1;
|
|
18
|
+
} else if(command?.startsWith('browser.')) {
|
|
19
|
+
const o=parse(args,{'--session':'string','--endpoint':'string','--file-key':'string','--json':'boolean'});
|
|
20
|
+
const options={session:o['--session'],base:o['--endpoint'],fileKey:o['--file-key'],browser:command.startsWith('browser.')};
|
|
21
|
+
if(options.browser&&!options.base)throw Error('BROWSER_ENDPOINT_REQUIRED');
|
|
22
|
+
if(command==='browser.targets')output({targets:await targets(options.base)});
|
|
23
|
+
else if(command==='browser.connect')output(await attachDesktop(options));
|
|
24
|
+
else if(command==='browser.close')output(await closeBrowser(options));
|
|
25
|
+
else if(command==='browser.open')output(await openDesktop(options));
|
|
26
|
+
else throw Error('UNKNOWN_DESKTOP_COMMAND');
|
|
27
|
+
} else if(['skill','update','doctor'].includes(command))output({ok:true,...await maintenance(command,args)});
|
|
28
|
+
else if(['video.inspect','video.download','video.import','image.inspect.batch','image.import.batch','object.get.batch','object.place.batch','frame.create','text.create','image.download','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));}
|
|
29
|
+
else if(command==='version'||command==='--version') {parse(args,{'--json':'boolean'});output({package:meta.name,version:meta.version});}
|
|
30
|
+
else if(command==='capabilities') output(JSON.parse(await readFile(new URL('../capabilities.json',import.meta.url),'utf8')));
|
|
31
|
+
else if(command==='serve') {
|
|
32
|
+
const target=flag('--session');if(!target)throw Error('SESSION_PATH_REQUIRED');
|
|
33
|
+
const session=path.resolve(target), boot=session+'.bootstrap.js';await absent(session);await absent(boot);
|
|
34
|
+
const bridge=await startBridge();
|
|
35
|
+
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});}
|
|
36
|
+
catch(e){bridge.server.close();throw e;}
|
|
37
|
+
output({listening:bridge.endpoint,session,bootstrap:boot});
|
|
38
|
+
} else if(['status','request'].includes(command)) {
|
|
39
|
+
const target=flag('--session');if(!target)throw Error('SESSION_PATH_REQUIRED');
|
|
40
|
+
const {endpoint,token}=JSON.parse(await readFile(target,'utf8'));
|
|
41
|
+
if(!/^http:\/\/127\.0\.0\.1:\d+$/.test(endpoint)||typeof token!=='string')throw Error('INVALID_SESSION');
|
|
42
|
+
const headers={Authorization:'Bearer '+token,'Content-Type':'application/json'};
|
|
43
|
+
let request;
|
|
44
|
+
if(command==='request'){const file=flag('--input');if(!file)throw Error('INPUT_PATH_REQUIRED');request=validate(JSON.parse(await readFile(file,'utf8')));}
|
|
45
|
+
const r=await fetch(endpoint+(command==='status'?'/status':'/request'),{method:request?'POST':'GET',headers,body:request?JSON.stringify(request):undefined,signal:AbortSignal.timeout(20000)});
|
|
46
|
+
const result=await r.json();output(result);if(!r.ok||result.ok===false)process.exitCode=1;
|
|
47
|
+
} else output({usage:['browser.ensure [--wait-ms 0..60000]','auth.status | auth.wait [--wait-ms 0..60000]','browser.targets --endpoint URL','browser.open --file-key KEY --endpoint URL','browser.connect | browser.close --session FILE --file-key KEY --endpoint URL','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','image.inspect.batch --input FILES_JSON','image.import.batch --input ITEMS_JSON --receipt NEW_RECEIPT','object.get.batch | object.place.batch --input ITEMS_JSON','object.selection | object.get | object.move | object.resize','view.fit --ids <JSON-GUID-array>','version','capabilities'],stage:'atomic-command-prototype'});
|
|
48
|
+
} catch(error) {output({ok:false,error:error.message,...error.details?{details:error.details}:{}});process.exitCode=1;}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"package": "@petercjl/boardmixcli",
|
|
4
|
+
"version": "0.1.6",
|
|
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
|
+
"frame.create",
|
|
23
|
+
"text.create",
|
|
24
|
+
"image.download",
|
|
25
|
+
"image.inspect.batch",
|
|
26
|
+
"image.import.batch",
|
|
27
|
+
"object.get.batch",
|
|
28
|
+
"object.place.batch",
|
|
29
|
+
"browser.targets",
|
|
30
|
+
"browser.open",
|
|
31
|
+
"browser.connect",
|
|
32
|
+
"browser.close",
|
|
33
|
+
"browser.ensure",
|
|
34
|
+
"auth.status",
|
|
35
|
+
"auth.wait",
|
|
36
|
+
"video.inspect",
|
|
37
|
+
"video.download",
|
|
38
|
+
"video.import"
|
|
39
|
+
],
|
|
40
|
+
"coordinateContract": "object.move uses absolute top-left board coordinates",
|
|
41
|
+
"imageFormats": [
|
|
42
|
+
"image/png",
|
|
43
|
+
"image/jpeg",
|
|
44
|
+
"image/webp"
|
|
45
|
+
],
|
|
46
|
+
"authentication": "Managed Chrome launch, human Boardmix login, resumable auth check and ephemeral bridge token",
|
|
47
|
+
"adapterBuild": "index-DjSD2oPg.js",
|
|
48
|
+
"requiredCapabilities": [
|
|
49
|
+
"Node.js >=22",
|
|
50
|
+
"filesystem access",
|
|
51
|
+
"loopback HTTP",
|
|
52
|
+
"authorized Boardmix browser CDP endpoint, or explicit desktop console fallback"
|
|
53
|
+
],
|
|
54
|
+
"agentEligibility": "Any Agent satisfying required capabilities",
|
|
55
|
+
"platformEvidence": {
|
|
56
|
+
"macos": "Boardmix 1.7.2 real atomic operations tested",
|
|
57
|
+
"windows": "portable lifecycle CI required; Boardmix runtime unverified",
|
|
58
|
+
"linux": "portable lifecycle CI required; Boardmix runtime unverified"
|
|
59
|
+
},
|
|
60
|
+
"unresolved": [
|
|
61
|
+
"Boardmix runtime validation on Windows and Linux"
|
|
62
|
+
],
|
|
63
|
+
"installationLifecycle": {
|
|
64
|
+
"agentNeutral": true,
|
|
65
|
+
"targetRequired": true,
|
|
66
|
+
"hostRegistrationRequired": true,
|
|
67
|
+
"installationMode": "copy",
|
|
68
|
+
"sharedLinksAllowed": false,
|
|
69
|
+
"isolatedRuntimeRequired": true,
|
|
70
|
+
"skillRequired": true,
|
|
71
|
+
"updateRequiresSkillSync": true,
|
|
72
|
+
"required_components": [
|
|
73
|
+
"cli",
|
|
74
|
+
"skill"
|
|
75
|
+
],
|
|
76
|
+
"installGuide": "INSTALL.md",
|
|
77
|
+
"updateGuide": "UPDATE.md",
|
|
78
|
+
"install": "Resolve own target; skill install with explicit agent and destination; copy runtime",
|
|
79
|
+
"update": "Stage candidate with npm; reconcile only selected Skill including same version",
|
|
80
|
+
"verification": "sha256 plus package, Skill and receipt version agreement; host discovery verified by host",
|
|
81
|
+
"installerIntegration": "postinstall notice and scripts-disabled INSTALL.md handoff"
|
|
82
|
+
},
|
|
83
|
+
"automaticUpdates": {
|
|
84
|
+
"enabledByDefault": true,
|
|
85
|
+
"intervalHours": 24,
|
|
86
|
+
"timeoutMs": 1200,
|
|
87
|
+
"trigger": "ordinary atomic command entry",
|
|
88
|
+
"configuration": "BOARDMIXCLI_CACHE_DIR selects cache",
|
|
89
|
+
"disable": "BOARDMIXCLI_UPDATE_CHECK=0",
|
|
90
|
+
"recovery": "24 hour offline backoff or explicit update check",
|
|
91
|
+
"defaultMode": "notify"
|
|
92
|
+
},
|
|
93
|
+
"commandContracts": {
|
|
94
|
+
"skill source": {
|
|
95
|
+
"options": {
|
|
96
|
+
"--json": {
|
|
97
|
+
"type": "boolean"
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"sideEffects": "none"
|
|
101
|
+
},
|
|
102
|
+
"skill status": {
|
|
103
|
+
"options": {
|
|
104
|
+
"--json": {
|
|
105
|
+
"type": "boolean"
|
|
106
|
+
},
|
|
107
|
+
"--agent": {
|
|
108
|
+
"type": "string"
|
|
109
|
+
},
|
|
110
|
+
"--destination": {
|
|
111
|
+
"type": "string"
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"sideEffects": "none"
|
|
115
|
+
},
|
|
116
|
+
"skill install": {
|
|
117
|
+
"options": {
|
|
118
|
+
"--json": {
|
|
119
|
+
"type": "boolean"
|
|
120
|
+
},
|
|
121
|
+
"--agent": {
|
|
122
|
+
"type": "string"
|
|
123
|
+
},
|
|
124
|
+
"--destination": {
|
|
125
|
+
"type": "string"
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"sideEffects": "local-write"
|
|
129
|
+
},
|
|
130
|
+
"skill update": {
|
|
131
|
+
"options": {
|
|
132
|
+
"--json": {
|
|
133
|
+
"type": "boolean"
|
|
134
|
+
},
|
|
135
|
+
"--agent": {
|
|
136
|
+
"type": "string"
|
|
137
|
+
},
|
|
138
|
+
"--destination": {
|
|
139
|
+
"type": "string"
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
"sideEffects": "local-write"
|
|
143
|
+
},
|
|
144
|
+
"doctor": {
|
|
145
|
+
"options": {
|
|
146
|
+
"--json": {
|
|
147
|
+
"type": "boolean"
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
"sideEffects": "none"
|
|
151
|
+
},
|
|
152
|
+
"update check": {
|
|
153
|
+
"options": {
|
|
154
|
+
"--json": {
|
|
155
|
+
"type": "boolean"
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
"sideEffects": "local-write"
|
|
159
|
+
},
|
|
160
|
+
"update install": {
|
|
161
|
+
"options": {
|
|
162
|
+
"--json": {
|
|
163
|
+
"type": "boolean"
|
|
164
|
+
},
|
|
165
|
+
"--agent": {
|
|
166
|
+
"type": "string"
|
|
167
|
+
},
|
|
168
|
+
"--destination": {
|
|
169
|
+
"type": "string"
|
|
170
|
+
},
|
|
171
|
+
"--yes": {
|
|
172
|
+
"type": "boolean"
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
"sideEffects": "package-and-skill-update"
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
"maintenanceContract": {
|
|
179
|
+
"schemaVersion": 1,
|
|
180
|
+
"agentEligibility": "valid-name-with-generic-fallback",
|
|
181
|
+
"hostDiscoveryAuthority": "host",
|
|
182
|
+
"targetOptions": {
|
|
183
|
+
"--destination": {
|
|
184
|
+
"kind": "skill-directory"
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
"source": {
|
|
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
|
+
"path": {
|
|
201
|
+
"field": "source",
|
|
202
|
+
"type": "string"
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
"status": {
|
|
206
|
+
"name": {
|
|
207
|
+
"field": "name",
|
|
208
|
+
"type": "string"
|
|
209
|
+
},
|
|
210
|
+
"package": {
|
|
211
|
+
"field": "package",
|
|
212
|
+
"type": "string"
|
|
213
|
+
},
|
|
214
|
+
"version": {
|
|
215
|
+
"field": "version",
|
|
216
|
+
"type": "string"
|
|
217
|
+
},
|
|
218
|
+
"target": {
|
|
219
|
+
"field": "target",
|
|
220
|
+
"type": "string"
|
|
221
|
+
},
|
|
222
|
+
"installed": {
|
|
223
|
+
"field": "installed",
|
|
224
|
+
"type": "boolean"
|
|
225
|
+
},
|
|
226
|
+
"managed": {
|
|
227
|
+
"field": "managed",
|
|
228
|
+
"type": "boolean"
|
|
229
|
+
},
|
|
230
|
+
"current": {
|
|
231
|
+
"field": "current",
|
|
232
|
+
"type": "boolean"
|
|
233
|
+
},
|
|
234
|
+
"edited": {
|
|
235
|
+
"field": "edited",
|
|
236
|
+
"type": "string[]"
|
|
237
|
+
},
|
|
238
|
+
"verification": {
|
|
239
|
+
"field": "verification",
|
|
240
|
+
"type": "object"
|
|
241
|
+
},
|
|
242
|
+
"recovery": {
|
|
243
|
+
"field": "recovery",
|
|
244
|
+
"type": "object"
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
"batchContract": {
|
|
249
|
+
"version": 1,
|
|
250
|
+
"imageImportLimit": 20,
|
|
251
|
+
"objectPlaceLimit": 200,
|
|
252
|
+
"objectReadLimit": 1000,
|
|
253
|
+
"mapping": "caller key + source MD5 to native resourceHash",
|
|
254
|
+
"recovery": "--receipt is reserved before submit; repeat only returns recorded or bridge outcome, never reimports"
|
|
255
|
+
},
|
|
256
|
+
"browserContract": {
|
|
257
|
+
"version": 2,
|
|
258
|
+
"endpoint": "explicit http://127.0.0.1:PORT",
|
|
259
|
+
"requires": "Chrome installed; browser.ensure owns macOS launch and login readiness",
|
|
260
|
+
"operations": [
|
|
261
|
+
"targets",
|
|
262
|
+
"open",
|
|
263
|
+
"connect",
|
|
264
|
+
"close",
|
|
265
|
+
"ensure",
|
|
266
|
+
"auth.status",
|
|
267
|
+
"auth.wait"
|
|
268
|
+
],
|
|
269
|
+
"visibleDevTools": false,
|
|
270
|
+
"exactFileKey": true,
|
|
271
|
+
"creationOwnedBy": "board.create",
|
|
272
|
+
"desktopLaunch": "not provided; existing desktop uses console fallback",
|
|
273
|
+
"tested": "macOS Chrome with Boardmix index-DjSD2oPg.js",
|
|
274
|
+
"managedEndpoint": "http://127.0.0.1:9223",
|
|
275
|
+
"launchPlatforms": [
|
|
276
|
+
"macos"
|
|
277
|
+
]
|
|
278
|
+
},
|
|
279
|
+
"videoContract": {
|
|
280
|
+
"version": 1,
|
|
281
|
+
"formats": [
|
|
282
|
+
"video/mp4"
|
|
283
|
+
],
|
|
284
|
+
"maxBytes": 104857600,
|
|
285
|
+
"requires": "ffprobe on PATH",
|
|
286
|
+
"import": "Native video object; await upload and verify MD5, MIME and ready state",
|
|
287
|
+
"receipt": "Required; repeated receipt queries outcome and never resubmits"
|
|
288
|
+
}
|
|
289
|
+
}
|
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
|
+
import {imageMetadata} from './image-codecs.mjs';
|
|
5
|
+
|
|
6
|
+
export async function inspectImage(file) {
|
|
7
|
+
const absolute=path.resolve(file), metadata=await stat(absolute);
|
|
8
|
+
if(!metadata.isFile())throw Error('IMAGE_FILE_REQUIRED');
|
|
9
|
+
const bytes=await readFile(absolute);
|
|
10
|
+
const {width,height,mime}=imageMetadata(bytes);
|
|
11
|
+
if(!width||!height||bytes.length>100*1024*1024)throw Error('INVALID_IMAGE');
|
|
12
|
+
return {file:absolute,name:path.basename(absolute),mime,size:bytes.length,width,height,sha256:createHash('sha256').update(bytes).digest('hex'),md5:createHash('md5').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|jpe?g|webp)$/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/batch.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import {inspectImage} from './assets.mjs';
|
|
4
|
+
import {validate} from './protocol.mjs';
|
|
5
|
+
|
|
6
|
+
export async function inspectBatch(files){
|
|
7
|
+
if(!Array.isArray(files)||!files.length||files.length>1000||files.some(f=>typeof f!=='string'))throw Error('INVALID_FILE_LIST');
|
|
8
|
+
const images=[];
|
|
9
|
+
for(let i=0;i<files.length;i+=4)images.push(...await Promise.all(files.slice(i,i+4).map(inspectImage)));
|
|
10
|
+
return {images};
|
|
11
|
+
}
|
|
12
|
+
// A receipt is reserved before submitting. Repeating it only reads a known outcome.
|
|
13
|
+
export async function importBatch(client,fileKey,items,receiptPath){
|
|
14
|
+
if(!Array.isArray(items)||!items.length||items.length>20)throw Error('INVALID_BATCH');
|
|
15
|
+
const keys=new Set();
|
|
16
|
+
for(const i of items){if(!i||Object.keys(i).some(k=>!['key','file','sha256'].includes(k))||typeof i.key!=='string'||!i.key||i.key.length>200||keys.has(i.key)||typeof i.file!=='string'||i.sha256!==undefined&&!/^[a-f0-9]{64}$/.test(i.sha256))throw Error('INVALID_BATCH_ITEM');keys.add(i.key);}
|
|
17
|
+
const identity=crypto.createHash('sha256').update(JSON.stringify({fileKey,items})).digest('hex');
|
|
18
|
+
let old;
|
|
19
|
+
try{old=JSON.parse(await fs.readFile(receiptPath,'utf8'));}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
20
|
+
if(old){
|
|
21
|
+
if(old.owner!=='@petercjl/boardmixcli'||old.identity!==identity)throw Error('RECEIPT_CONFLICT');
|
|
22
|
+
if(old.result)return {ok:true,result:old.result,recovered:true};
|
|
23
|
+
const outcome=await client.call('/outcome/'+old.operationId);
|
|
24
|
+
if(!outcome.ok||!outcome.result)throw Object.assign(Error('BATCH_OUTCOME_UNRESOLVED'),{details:{operationId:old.operationId,receipt:receiptPath,outcome}});
|
|
25
|
+
await saveReceipt(receiptPath,{...old,result:outcome.result,status:'completed'});
|
|
26
|
+
return {...outcome,recovered:true};
|
|
27
|
+
}
|
|
28
|
+
const inspected=(await inspectBatch(items.map(i=>i.file))).images;
|
|
29
|
+
for(let i=0;i<items.length;i++)if(items[i].sha256&&inspected[i].sha256!==items[i].sha256)throw Error('ASSET_CHANGED');
|
|
30
|
+
const params={operationId:crypto.randomUUID(),items:items.map((i,n)=>({key:i.key,assetId:inspected[n].sha256}))};
|
|
31
|
+
validate({op:'image.import.batch',fileKey,params});
|
|
32
|
+
for(let i=0;i<items.length;i+=4)await Promise.all(inspected.slice(i,i+4).map(a=>client.call('/register-asset',{file:a.file,sha256:a.sha256})));
|
|
33
|
+
const receipt={owner:'@petercjl/boardmixcli',schemaVersion:1,identity,fileKey,operationId:params.operationId,status:'submitted',createdAt:new Date().toISOString()};
|
|
34
|
+
await fs.writeFile(receiptPath,JSON.stringify(receipt,null,2),{flag:'wx',mode:0o600});
|
|
35
|
+
const response=await client.request('image.import.batch',fileKey,params);
|
|
36
|
+
await saveReceipt(receiptPath,{...receipt,status:'completed',result:response.result});
|
|
37
|
+
return response;
|
|
38
|
+
}
|
|
39
|
+
async function saveReceipt(file,value){
|
|
40
|
+
const old=JSON.parse(await fs.readFile(file,'utf8'));
|
|
41
|
+
if(old.owner!==value.owner||old.identity!==value.identity)throw Error('RECEIPT_CONFLICT');
|
|
42
|
+
await fs.copyFile(file,file+'.'+crypto.randomUUID()+'.backup',fs.constants.COPYFILE_EXCL);
|
|
43
|
+
const stage=file+'.'+crypto.randomUUID()+'.tmp';
|
|
44
|
+
await fs.writeFile(stage,JSON.stringify(value,null,2),{flag:'wx',mode:0o600});await fs.rename(stage,file);
|
|
45
|
+
}
|