@ninkch/dsh-artcraft 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -24
- package/README.zh-CN.md +22 -21
- package/artcraft.config.json.example +7 -4
- package/client.js +2 -2
- package/index.js +180 -140
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -25,13 +25,13 @@ This plugin brings that surface into DeepSeek Harness, so an AI agent can *opera
|
|
|
25
25
|
not just talk about them:
|
|
26
26
|
|
|
27
27
|
- **In chat** — four agent tools let any Harness session check the toolchain, run CLI commands,
|
|
28
|
-
send control-channel commands to a running app, and
|
|
28
|
+
send control-channel commands to a running app, and do bridged file operations.
|
|
29
29
|
- **In the browser** — a same-origin HTTP API (`/artcraft/api/*`) that any Harness web UI can call,
|
|
30
30
|
no CORS, no keys. It was built for [无限画布 / infinite-canvas](../dsh-infinite_canvas/README.md),
|
|
31
31
|
a node-based AI short-drama storyboard canvas, but any front end can use it.
|
|
32
|
-
- **
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
- **Paths belong to the caller** — this plugin is a bridge and owns no data directory. Files read
|
|
33
|
+
or written go wherever the caller points: chat invocations default to the current conversation
|
|
34
|
+
workspace (or any path named in the prompt); the canvas writes under its own project folder.
|
|
35
35
|
|
|
36
36
|
A typical AI short-drama loop looks like this: generate storyboard frames with a model →
|
|
37
37
|
batch color-grade and sharpen them in PhotoCraft → render an animated title in EffectCraft →
|
|
@@ -55,7 +55,8 @@ Every step after "generate" runs through this plugin, headless.
|
|
|
55
55
|
headless edits cut · grade · mix Lottie export
|
|
56
56
|
└───────────────────────┴───────────────────────┘
|
|
57
57
|
▼
|
|
58
|
-
|
|
58
|
+
caller-decided locations (chat → conversation
|
|
59
|
+
workspace · canvas → its project folder)
|
|
59
60
|
```
|
|
60
61
|
|
|
61
62
|
Everything runs locally. No cloud round-trips, no accounts, no telemetry.
|
|
@@ -102,19 +103,19 @@ pnpm install --dir C:/Users/<you>/.dsh/profiles/desktop
|
|
|
102
103
|
> If your Harness build ships a plugin manager UI that installs from a folder, that works too —
|
|
103
104
|
> it ends up writing exactly the two lines above.
|
|
104
105
|
|
|
105
|
-
**3. Configure** `artcraft.config.json`
|
|
106
|
-
`artcraft.config.json.example
|
|
107
|
-
|
|
106
|
+
**3. Configure** `artcraft.config.json` (copy from
|
|
107
|
+
`artcraft.config.json.example`). Recommended location: `C:\Users\<you>\.dsh\artcraft.config.json`
|
|
108
|
+
(persists across plugin updates); placing it next to `index.js` also works (legacy). The file is
|
|
109
|
+
re-read on every request — no restart needed for path changes:
|
|
108
110
|
|
|
109
111
|
```jsonc
|
|
110
112
|
{
|
|
111
|
-
"dataRoot": "D:/craft-workspace", // where assets & renders live
|
|
112
113
|
"bins": {
|
|
113
114
|
// three accepted forms: a directory (the <name>-cli binary is appended),
|
|
114
115
|
// a full path to the executable, or a bare name looked up on PATH
|
|
115
116
|
"photocraft": "C:/craft/photocraft-0.6.0-windows-x64-portable",
|
|
116
117
|
"filmcraft": "C:/craft/filmcraft-0.5.0-windows-x64-portable",
|
|
117
|
-
"effectcraft": "C:/
|
|
118
|
+
"effectcraft": "C:/Program Files/EffectCraft"
|
|
118
119
|
},
|
|
119
120
|
"control": { "filmcraft": 9876, "effectcraft": 9877 }
|
|
120
121
|
}
|
|
@@ -139,10 +140,10 @@ artcraft_cli { bin:"photocraft", args:["batch","--actions","demo/craft/grade.js
|
|
|
139
140
|
|
|
140
141
|
| Tool | What it does |
|
|
141
142
|
|---|---|
|
|
142
|
-
| `artcraft_status` | Toolchain health check: which CLIs resolved,
|
|
143
|
+
| `artcraft_status` | Toolchain health check: which CLIs resolved, config location, control ports, running jobs |
|
|
143
144
|
| `artcraft_cli` | Run any CLI subcommand synchronously (`photocraft-cli run/batch`, `filmcraft-cli commands`, …) |
|
|
144
145
|
| `artcraft_control` | One command over the JSON control channel of a *running* FilmCraft / EffectCraft |
|
|
145
|
-
| `artcraft_fs` | list / stat / read / write / mkdir
|
|
146
|
+
| `artcraft_fs` | Bridged file ops — list / stat / read / write / mkdir at caller-decided absolute paths |
|
|
146
147
|
|
|
147
148
|
## Driving it from the browser
|
|
148
149
|
|
|
@@ -170,15 +171,16 @@ Full route list:
|
|
|
170
171
|
| Route | Purpose |
|
|
171
172
|
|---|---|
|
|
172
173
|
| `GET /artcraft/api/status` | Toolchain health (same payload as `artcraft_status`) |
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
175
|
-
| `GET /artcraft/api/
|
|
176
|
-
| `GET /artcraft/api/file?rel=…` | Download a file — streamed, up to 2 GB |
|
|
174
|
+
| `POST /artcraft/api/fs?action=write&path=…` | Write a file — absolute `path`, raw body (Blob upload) or JSON `{base, rel, text\|base64}` |
|
|
175
|
+
| `GET /artcraft/api/fs?op=list\|stat\|readtext&path=…` | Read directories, metadata, small text files |
|
|
176
|
+
| `GET /artcraft/api/file?path=…` | Download a file — streamed, up to 2 GB |
|
|
177
177
|
| `POST /artcraft/api/job` · `GET ?id=…` · `POST /artcraft/api/job/cancel` | Job lifecycle with output statting and tail logs |
|
|
178
178
|
| `POST /artcraft/api/control` | Synchronous control-channel call |
|
|
179
179
|
|
|
180
180
|
Uploads are capped at 320 MB per request (Blob direct, no base64 needed for the raw path);
|
|
181
|
-
downloads stream up to 2 GB.
|
|
181
|
+
downloads stream up to 2 GB. All path arguments are **absolute** (or `base` absolute directory +
|
|
182
|
+
relative `rel`) — the caller decides where files live: chat calls point at the conversation
|
|
183
|
+
workspace, the canvas points at its project folder.
|
|
182
184
|
|
|
183
185
|
## The control channel: drive a running app
|
|
184
186
|
|
|
@@ -259,14 +261,15 @@ a web UI.
|
|
|
259
261
|
loopback-only and unauthenticated, so nothing else will answer on that port.
|
|
260
262
|
- **A job hangs** — `GET /artcraft/api/job` lists everything with logs; cancel by id. Plugin
|
|
261
263
|
reloads kill all child jobs by design.
|
|
262
|
-
- **
|
|
264
|
+
- **"需要绝对路径" errors** — pass absolute paths (chat: the conversation workspace; canvas: the
|
|
265
|
+
project folder), or a `base` absolute directory + relative `rel`.
|
|
263
266
|
|
|
264
267
|
## Security & trust model
|
|
265
268
|
|
|
266
269
|
The HTTP surface has **no authentication** and rides on the Harness web server, which listens on
|
|
267
270
|
loopback only — treat the port as trusted-local and don't expose it to a network; put a reverse
|
|
268
|
-
proxy with auth in front if you must. Child processes are locked to the
|
|
269
|
-
|
|
271
|
+
proxy with auth in front if you must. Child processes are locked to the seven allow-listed
|
|
272
|
+
family CLIs; the control channel only ever dials `127.0.0.1`.
|
|
270
273
|
|
|
271
274
|
## Publishing
|
|
272
275
|
|
|
@@ -281,10 +284,10 @@ GitHub: this repository is self-contained; `README.md` is English-first with a
|
|
|
281
284
|
[简体中文](README.zh-CN.md) companion.
|
|
282
285
|
|
|
283
286
|
**Releases run through npm Trusted Publishing** (GitHub Actions OIDC — no tokens, no OTP):
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
land on `dev`, get tested there, and are only merged to `main` and
|
|
287
|
+
publishing is triggered by **creating a GitHub Release** (tag `vX.Y.Z` matching `package.json`);
|
|
288
|
+
`.github/workflows/publish.yml` verifies the release commit is on `main` and the versions align,
|
|
289
|
+
then publishes. Release flow and rules live in [RELEASING.md](RELEASING.md) — in short: changes
|
|
290
|
+
land on `dev`, get tested there, and are only merged to `main` and released with the maintainer's
|
|
288
291
|
explicit approval.
|
|
289
292
|
|
|
290
293
|
## License
|
package/README.zh-CN.md
CHANGED
|
@@ -24,12 +24,12 @@ Effects)等。它们有一个大多数桌面软件都不具备的共同基因
|
|
|
24
24
|
本插件把这套自动化面接进 DeepSeek Harness,让 AI 不只是"谈论"这些软件,而是真的**操作**它们:
|
|
25
25
|
|
|
26
26
|
- **对话里** —— 四个 Agent 工具,任何 Harness 会话都能检查工具链、执行 CLI 命令、向运行中的
|
|
27
|
-
App
|
|
27
|
+
App 发控制通道命令、做桥接文件操作。
|
|
28
28
|
- **浏览器里** —— 同源 HTTP API(`/artcraft/api/*`),任何 Harness 前端都能直接调用:无 CORS、
|
|
29
29
|
无密钥。它为 [无限画布 / infinite-canvas](../dsh-infinite_canvas/README.md)(节点式 AI 短剧
|
|
30
30
|
分镜画布)而建,但任何前端都可用。
|
|
31
|
-
-
|
|
32
|
-
|
|
31
|
+
- **路径由调用方决定** —— 本插件只是桥接,不持有数据目录:对话调用默认落在当前对话工作空间
|
|
32
|
+
(或提示词指定的任意位置);画布调用则写在自己项目目录下。
|
|
33
33
|
|
|
34
34
|
典型的 AI 短剧闭环:模型生成分镜图 → PhotoCraft 批量调色锐化 → EffectCraft 渲染动态片头 →
|
|
35
35
|
FilmCraft 剪辑、调色、混音、烧字幕 → 导出 H.264。"生成"之后的每一步都由本插件无头驱动。
|
|
@@ -51,7 +51,7 @@ FilmCraft 剪辑、调色、混音、烧字幕 → 导出 H.264。"生成"之后
|
|
|
51
51
|
(无头) 混音导出 Lottie 导出
|
|
52
52
|
└───────────────────────┴───────────────────────┘
|
|
53
53
|
▼
|
|
54
|
-
|
|
54
|
+
产物位置由调用方决定(对话 → 当前工作空间 · 画布 → 项目目录)
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
全流程本地运行:不联网、无账号、无遥测。
|
|
@@ -96,17 +96,17 @@ pnpm install --dir C:/Users/<你>/.dsh/profiles/desktop
|
|
|
96
96
|
|
|
97
97
|
> 若你的 Harness 版本带有"从文件夹安装插件"的管理界面,用它也行 —— 最终落盘的就是上面两行。
|
|
98
98
|
|
|
99
|
-
**3. 配置** `
|
|
100
|
-
|
|
99
|
+
**3. 配置** `artcraft.config.json`(从 `artcraft.config.json.example` 复制)。推荐放
|
|
100
|
+
`C:\Users\<你>\.dsh\artcraft.config.json`(持久,更新插件不丢);放在 `index.js` 旁也可(旧版兼容)。
|
|
101
|
+
文件已 gitignore,且每次请求都会重读 —— 改路径无需重启:
|
|
101
102
|
|
|
102
103
|
```jsonc
|
|
103
104
|
{
|
|
104
|
-
"dataRoot": "D:/craft-workspace", // 素材与产物的根目录
|
|
105
105
|
"bins": {
|
|
106
106
|
// 三种写法:目录(自动拼接 <名>-cli 可执行文件)、完整 exe 路径、或走 PATH 的裸命令名
|
|
107
107
|
"photocraft": "C:/craft/photocraft-0.6.0-windows-x64-portable",
|
|
108
108
|
"filmcraft": "C:/craft/filmcraft-0.5.0-windows-x64-portable",
|
|
109
|
-
"effectcraft": "C:/
|
|
109
|
+
"effectcraft": "C:/Program Files/EffectCraft"
|
|
110
110
|
},
|
|
111
111
|
"control": { "filmcraft": 9876, "effectcraft": 9877 }
|
|
112
112
|
}
|
|
@@ -129,10 +129,10 @@ artcraft_cli { bin:"photocraft", args:["batch","--actions","demo/craft/grade.js
|
|
|
129
129
|
|
|
130
130
|
| 工具 | 用途 |
|
|
131
131
|
|---|---|
|
|
132
|
-
| `artcraft_status` | 工具体检:CLI
|
|
132
|
+
| `artcraft_status` | 工具体检:CLI 是否找到、配置目录、控制端口、进行中的任务 |
|
|
133
133
|
| `artcraft_cli` | 同步执行任意 CLI 子命令(`photocraft-cli run/batch`、`filmcraft-cli commands` 等) |
|
|
134
134
|
| `artcraft_control` | 向**运行中**的 FilmCraft / EffectCraft 控制通道发一条命令 |
|
|
135
|
-
| `artcraft_fs` |
|
|
135
|
+
| `artcraft_fs` | 桥接文件操作 —— list / stat / 读 / 写 / 建目录,路径由调用方绝对指定 |
|
|
136
136
|
|
|
137
137
|
## 在浏览器里驱动
|
|
138
138
|
|
|
@@ -159,15 +159,15 @@ POST /artcraft/api/job/cancel { "id":"7f2c…" }
|
|
|
159
159
|
| 路由 | 用途 |
|
|
160
160
|
|---|---|
|
|
161
161
|
| `GET /artcraft/api/status` | 工具体检(与 `artcraft_status` 同数据) |
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
| `GET /artcraft/api/
|
|
165
|
-
| `GET /artcraft/api/file?rel=…` | 下载文件 —— 流式,上限 2 GB |
|
|
162
|
+
| `POST /artcraft/api/fs?action=write&path=…` | 写文件 —— 绝对 `path`,原始字节流(Blob 直传)或 JSON `{base, rel, text\|base64}` |
|
|
163
|
+
| `GET /artcraft/api/fs?op=list\|stat\|readtext&path=…` | 读目录、元数据、小文本 |
|
|
164
|
+
| `GET /artcraft/api/file?path=…` | 下载文件 —— 流式,上限 2 GB |
|
|
166
165
|
| `POST /artcraft/api/job` · `GET ?id=…` · `POST /artcraft/api/job/cancel` | 任务生命周期,含产物探测与日志尾部 |
|
|
167
166
|
| `POST /artcraft/api/control` | 控制通道同步调用 |
|
|
168
167
|
|
|
169
168
|
上传单次上限 320 MB(原始路径 Blob 直传,无需 base64);下载流式上限 2 GB。
|
|
170
|
-
|
|
169
|
+
所有路径参数为**绝对路径**(或 `base` 绝对目录 + 相对 `rel`)—— 位置由调用方决定:
|
|
170
|
+
对话调用指向当前工作空间,画布调用指向其项目目录。
|
|
171
171
|
|
|
172
172
|
## 控制通道:驱动正在运行的 App
|
|
173
173
|
|
|
@@ -244,13 +244,14 @@ NVENC 硬编)、ProRes、DNxHR,附 EBU R128 响度归一与字幕烧录。
|
|
|
244
244
|
不会有别的应答者。
|
|
245
245
|
- **任务卡住** —— `GET /artcraft/api/job` 带日志列出全部任务,按 id 取消;插件重载会按设计
|
|
246
246
|
终止所有子任务。
|
|
247
|
-
-
|
|
247
|
+
- **"需要绝对路径"报错** —— 传绝对路径即可:对话用当前工作空间下的路径,画布用项目目录下的
|
|
248
|
+
路径(或 `base` 绝对目录 + 相对 `rel`)。
|
|
248
249
|
|
|
249
250
|
## 安全与信任模型
|
|
250
251
|
|
|
251
252
|
HTTP 面无鉴权,挂在 Harness Web 服务之下(仅回环监听)—— 把端口当本机可信处理,**不要暴露
|
|
252
|
-
|
|
253
|
-
`
|
|
253
|
+
到网络**;确需远程请加带鉴权的反向代理。子进程锁定 7 个白名单家族 CLI;控制通道只会拨
|
|
254
|
+
`127.0.0.1`。
|
|
254
255
|
|
|
255
256
|
## 发布
|
|
256
257
|
|
|
@@ -264,9 +265,9 @@ GitHub 上传本仓库即可:`README.md` 英文优先,附
|
|
|
264
265
|
[简体中文](README.zh-CN.md) 对照。
|
|
265
266
|
|
|
266
267
|
**发版走 npm Trusted Publishing**(GitHub Actions OIDC —— 无令牌、无 OTP):
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
[RELEASING.md](RELEASING.md):改动先进 `dev` 测试,维护者明确批准后才合并 main
|
|
268
|
+
发布由 **GitHub Release** 触发(tag `vX.Y.Z` 与 package.json version 一致);`.github/workflows/publish.yml`
|
|
269
|
+
会校验发布提交在 main 上且版本对齐,然后自动发布。发版流程与红线见
|
|
270
|
+
[RELEASING.md](RELEASING.md):改动先进 `dev` 测试,维护者明确批准后才合并 main 并发 Release。
|
|
270
271
|
|
|
271
272
|
## 许可
|
|
272
273
|
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"dataRoot": "D:/craft-workspace",
|
|
3
2
|
"bins": {
|
|
4
|
-
"photocraft": "
|
|
5
|
-
"
|
|
6
|
-
"
|
|
3
|
+
"photocraft": "D:/craft/photocraft-0.6.0/photocraft-0.6.0-windows-x64-portable",
|
|
4
|
+
"vectorcraft": "C:/Program Files/VectorCraft",
|
|
5
|
+
"filmcraft": "D:/craft/filmcraft-0.5.0/filmcraft-0.5.0-windows-x64-portable",
|
|
6
|
+
"lightcraft": "C:/Program Files/LightCraft",
|
|
7
|
+
"pdfcraft": "C:/Program Files/PdfCraft",
|
|
8
|
+
"effectcraft": "C:/Program Files/EffectCraft",
|
|
9
|
+
"designcraft": "C:/Program Files/DesignCraft"
|
|
7
10
|
},
|
|
8
11
|
"control": {
|
|
9
12
|
"filmcraft": 9876,
|
package/client.js
CHANGED
|
@@ -120,8 +120,8 @@ window.__ModuleLoader__.load({
|
|
|
120
120
|
statusErr
|
|
121
121
|
? h('div', { className: 'dshac-muted' }, '未连接:' + statusErr + '(插件可能未注册或 Harness 需要重启)')
|
|
122
122
|
: h('div', null, Object.keys(LABELS).map(binRow),
|
|
123
|
-
status?.
|
|
124
|
-
'
|
|
123
|
+
status?.config?.path ? h('div', { className: 'dshac-row dshac-muted' },
|
|
124
|
+
'配置目录:', h('span', { className: 'dshac-mono' }, status.config.path)) : null),
|
|
125
125
|
h('div', { className: 'dshac-row', style: { marginTop: '10px' } },
|
|
126
126
|
h('span', { className: 'dshac-muted' },
|
|
127
127
|
'当前 v' + (status?.version ?? upd?.current ?? '?') +
|
package/index.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @ninkch/dsh-artcraft — DeepSeek Harness 插件(Host 半侧)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 本插件只是【桥接】:把 ArtCraft 家族软件的自动化接口转接给 Harness,不持有任何数据目录。
|
|
5
|
+
* 结果文件的位置由调用方决定 ——
|
|
6
|
+
* 对话调用:当前对话工作空间下的绝对路径(或提示词指定的任意位置)
|
|
7
|
+
* 画布调用:画布项目目录下的绝对路径(哪个项目使用就放哪个项目下)
|
|
8
|
+
*
|
|
9
|
+
* 两种形态:
|
|
5
10
|
* 1) Agent Tools(对话内直接驱动):
|
|
6
|
-
* artcraft_status ——
|
|
7
|
-
* artcraft_cli —— 运行
|
|
11
|
+
* artcraft_status —— 检查 7 个家族 CLI 是否可用、配置目录、控制通道配置
|
|
12
|
+
* artcraft_cli —— 运行 7 个家族 CLI 任意命令(cwd / 文件参数由调用方给绝对路径)
|
|
8
13
|
* artcraft_control —— 向运行中的 FilmCraft/EffectCraft JSON 控制通道发一条命令
|
|
9
|
-
* artcraft_fs ——
|
|
14
|
+
* artcraft_fs —— 桥接文件操作(list/stat/readtext/write/mkdir,绝对路径)
|
|
15
|
+
* artcraft_update —— 插件自身更新(检查/应用)
|
|
10
16
|
* 2) HTTP API(同源 /artcraft/api/*,供无限画布等前端调用,无 CORS 问题):
|
|
11
17
|
* GET /artcraft/api/status 环境自检
|
|
12
|
-
* GET /artcraft/api/projects 列出 dataRoot 下的项目目录
|
|
13
18
|
* GET /artcraft/api/fs list / stat / readtext
|
|
14
19
|
* POST /artcraft/api/fs write(JSON base64/text 或原始字节流)
|
|
15
20
|
* GET /artcraft/api/file 下载产物(流式,raw 或 text)
|
|
@@ -19,12 +24,10 @@
|
|
|
19
24
|
* POST /artcraft/api/control 控制通道同步调用(engine.execute / ui.*)
|
|
20
25
|
*
|
|
21
26
|
* 安全约定:
|
|
22
|
-
* -
|
|
23
|
-
* - 子进程只允许三个白名单键(photocraft/filmcraft/effectcraft),
|
|
24
|
-
* 具体可执行文件由 artcraft.config.json 指定;
|
|
27
|
+
* - 子进程只允许 7 个白名单键(家族 CLI),具体可执行文件由 artcraft.config.json 指定;
|
|
25
28
|
* - 控制通道只连 127.0.0.1,与 ArtCraft 官方协议一致(App 需以 --control 启动;
|
|
26
29
|
* PhotoCraft 的控制通道为鉴权模式,本插件不代理,故仅支持 filmcraft/effectcraft);
|
|
27
|
-
* - 本插件与 infinite-canvas 零代码耦合,仅通过 HTTP +
|
|
30
|
+
* - 本插件与 infinite-canvas 零代码耦合,仅通过 HTTP + 调用方给定路径协作。
|
|
28
31
|
*
|
|
29
32
|
* 信任模型:HTTP API 无鉴权,随 DSH Web GUI 仅监听回环;不要把 GUI 端口暴露到局域网。
|
|
30
33
|
*/
|
|
@@ -39,7 +42,7 @@ import crypto from 'node:crypto';
|
|
|
39
42
|
import { fileURLToPath } from 'node:url';
|
|
40
43
|
|
|
41
44
|
const NAME = 'artcraft';
|
|
42
|
-
const VERSION = '0.2.
|
|
45
|
+
const VERSION = '0.2.7';
|
|
43
46
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
44
47
|
|
|
45
48
|
const BINS = ['photocraft', 'vectorcraft', 'filmcraft', 'lightcraft', 'pdfcraft', 'effectcraft', 'designcraft'];
|
|
@@ -55,7 +58,6 @@ const LINE_CAP = 4000; // 单行截断
|
|
|
55
58
|
const KEEP_JOBS = 64; // 已结束任务保留条数
|
|
56
59
|
|
|
57
60
|
const DEFAULT_CONFIG = {
|
|
58
|
-
dataRoot: path.join(os.homedir(), 'craft-workspace'),
|
|
59
61
|
bins: {
|
|
60
62
|
photocraft: 'photocraft-cli',
|
|
61
63
|
vectorcraft: 'vectorcraft-cli',
|
|
@@ -72,44 +74,68 @@ const DEFAULT_CONFIG = {
|
|
|
72
74
|
|
|
73
75
|
async function loadConfig() {
|
|
74
76
|
const cfg = {
|
|
75
|
-
dataRoot: DEFAULT_CONFIG.dataRoot,
|
|
76
77
|
bins: { ...DEFAULT_CONFIG.bins },
|
|
77
78
|
control: { ...DEFAULT_PORTS },
|
|
79
|
+
configPath: null, // 实际生效的配置文件(供界面显示"配置目录")
|
|
78
80
|
};
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
// 配置查找顺序:~/.dsh(持久,推荐 —— 更新替换包目录也不丢)→ 包目录(旧版兼容)
|
|
82
|
+
const candidates = [
|
|
83
|
+
path.join(os.homedir(), '.dsh', 'artcraft.config.json'),
|
|
84
|
+
path.join(HERE, 'artcraft.config.json'),
|
|
85
|
+
];
|
|
86
|
+
for (const candidate of candidates) {
|
|
87
|
+
try {
|
|
88
|
+
const user = JSON.parse(await fs.readFile(candidate, 'utf8'));
|
|
89
|
+
if (user && user.bins) {
|
|
90
|
+
for (const k of BINS) {
|
|
91
|
+
if (typeof user.bins[k] === 'string' && user.bins[k]) cfg.bins[k] = user.bins[k];
|
|
92
|
+
}
|
|
85
93
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
94
|
+
if (user && user.control) {
|
|
95
|
+
for (const k of Object.keys(DEFAULT_PORTS)) {
|
|
96
|
+
const p = Number(user.control[k]);
|
|
97
|
+
if (Number.isInteger(p) && p > 0 && p < 65536) cfg.control[k] = p;
|
|
98
|
+
}
|
|
91
99
|
}
|
|
92
|
-
|
|
93
|
-
|
|
100
|
+
cfg.configPath = candidate;
|
|
101
|
+
break; // 第一个可解析的配置生效
|
|
102
|
+
} catch { /* 该候选不存在/非法 → 试下一个 */ }
|
|
103
|
+
}
|
|
94
104
|
return cfg;
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
/* ============================ 路径与进程 ============================ */
|
|
98
108
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
/**
|
|
110
|
+
* 桥接语义:路径由调用方决定(本插件只是桥梁,不设数据目录)——
|
|
111
|
+
* 对话调用:当前对话工作空间下的绝对路径(或提示词指定的任意位置)
|
|
112
|
+
* 画布调用:画布项目目录下的绝对路径
|
|
113
|
+
* 支持 ~ 展开;也接受 base(绝对目录) + rel(相对) 组合,方便批量调用。
|
|
114
|
+
*/
|
|
115
|
+
function resolveCallerPath(p) {
|
|
116
|
+
let s = String(p ?? '').trim();
|
|
117
|
+
if (!s) throw new Error('缺少路径参数');
|
|
118
|
+
if (s === '~' || s.startsWith('~/') || s.startsWith('~\\')) s = path.join(os.homedir(), s.slice(1));
|
|
119
|
+
const abs = path.resolve(s);
|
|
120
|
+
if (!path.isAbsolute(abs)) {
|
|
121
|
+
throw new Error('需要绝对路径 —— 对话调用传当前工作空间下的绝对路径,画布传项目目录下的绝对路径(或用 base + 相对路径组合)');
|
|
105
122
|
}
|
|
106
|
-
return
|
|
123
|
+
return abs;
|
|
107
124
|
}
|
|
108
125
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
126
|
+
function resolveBaseRel(base, rel) {
|
|
127
|
+
if (base) {
|
|
128
|
+
const b = resolveCallerPath(base);
|
|
129
|
+
if (rel == null || String(rel) === '') return b;
|
|
130
|
+
return path.resolve(b, String(rel));
|
|
131
|
+
}
|
|
132
|
+
return resolveCallerPath(rel);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function ensureDir(p) {
|
|
136
|
+
const abs = resolveCallerPath(p);
|
|
137
|
+
await fs.mkdir(abs, { recursive: true });
|
|
138
|
+
return abs;
|
|
113
139
|
}
|
|
114
140
|
|
|
115
141
|
function cliNameFor(key) {
|
|
@@ -258,15 +284,27 @@ function controlCall(port, method, params, { timeoutMs = 30_000 } = {}) {
|
|
|
258
284
|
|
|
259
285
|
const jobs = new Map(); // id -> job
|
|
260
286
|
|
|
261
|
-
|
|
287
|
+
/**
|
|
288
|
+
* 桥接语义:产物路径由调用方给定 ——
|
|
289
|
+
* 绝对路径直接使用;也可传 cwd(绝对目录)让 outputs 以它为基准。
|
|
290
|
+
*/
|
|
291
|
+
async function statOutputs(outputs, cwd) {
|
|
262
292
|
const out = [];
|
|
293
|
+
const base = cwd ? resolveCallerPath(cwd) : null;
|
|
263
294
|
for (const item of outputs ?? []) {
|
|
264
|
-
const
|
|
295
|
+
const spec = String(item.rel ?? item.path ?? item);
|
|
296
|
+
let abs;
|
|
297
|
+
try {
|
|
298
|
+
abs = base ? path.resolve(base, spec) : resolveCallerPath(spec);
|
|
299
|
+
} catch {
|
|
300
|
+
out.push({ rel: spec, exists: false, dir: false, size: null, mtime: null });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
265
303
|
try {
|
|
266
|
-
const st = await fs.stat(
|
|
267
|
-
out.push({ rel, exists: true, dir: st.isDirectory(), size: st.size, mtime: st.mtimeMs });
|
|
304
|
+
const st = await fs.stat(abs);
|
|
305
|
+
out.push({ rel: spec, path: abs, exists: true, dir: st.isDirectory(), size: st.size, mtime: st.mtimeMs });
|
|
268
306
|
} catch {
|
|
269
|
-
out.push({ rel, exists: false, dir: false, size: null, mtime: null });
|
|
307
|
+
out.push({ rel: spec, path: abs, exists: false, dir: false, size: null, mtime: null });
|
|
270
308
|
}
|
|
271
309
|
}
|
|
272
310
|
return out;
|
|
@@ -301,18 +339,19 @@ function pruneJobs() {
|
|
|
301
339
|
}
|
|
302
340
|
}
|
|
303
341
|
|
|
304
|
-
async function startJob(cfg, { bin, args,
|
|
342
|
+
async function startJob(cfg, { bin, args, cwd, outputs, timeoutMs }) {
|
|
305
343
|
if (!BINS.includes(bin)) throw new Error(`bin 必须是 ${BINS.join(' | ')} 之一`);
|
|
306
344
|
if (args != null && !Array.isArray(args)) throw new Error('args 必须是字符串数组');
|
|
307
345
|
const exe = await resolveBin(cfg, bin);
|
|
308
|
-
|
|
346
|
+
// 桥接语义:cwd 由调用方给绝对路径(对话=工作空间下的目录,画布=项目目录);不传则继承 Host 进程
|
|
347
|
+
const cwdAbs = cwd ? await ensureDir(cwd) : null;
|
|
309
348
|
const id = crypto.randomUUID();
|
|
310
349
|
const job = {
|
|
311
350
|
id,
|
|
312
351
|
bin,
|
|
313
352
|
exe,
|
|
314
353
|
args: (args ?? []).map(String),
|
|
315
|
-
cwd,
|
|
354
|
+
cwd: cwdAbs,
|
|
316
355
|
status: 'running',
|
|
317
356
|
code: null,
|
|
318
357
|
signal: null,
|
|
@@ -320,11 +359,11 @@ async function startJob(cfg, { bin, args, cwdRel, outputs, timeoutMs }) {
|
|
|
320
359
|
endedAt: null,
|
|
321
360
|
stdout: [],
|
|
322
361
|
stderr: [],
|
|
323
|
-
outputs: (outputs ?? []).map((
|
|
362
|
+
outputs: (outputs ?? []).map((o) => ({ rel: String(o.rel ?? o.path ?? o) })),
|
|
324
363
|
child: null,
|
|
325
364
|
timer: null,
|
|
326
365
|
};
|
|
327
|
-
const child = spawn(job.exe, job.args, { cwd, windowsHide: true });
|
|
366
|
+
const child = spawn(job.exe, job.args, { cwd: cwdAbs ?? undefined, windowsHide: true });
|
|
328
367
|
job.child = child;
|
|
329
368
|
child.stdout?.setEncoding('utf8');
|
|
330
369
|
child.stderr?.setEncoding('utf8');
|
|
@@ -376,13 +415,13 @@ async function statusInfo(cfg, meta) {
|
|
|
376
415
|
}
|
|
377
416
|
bins[key] = { configured: cfg.bins[key], exe, found, note };
|
|
378
417
|
}
|
|
379
|
-
let
|
|
380
|
-
try {
|
|
418
|
+
let configPath = null;
|
|
419
|
+
try { configPath = cfg.configPath && (await fs.stat(cfg.configPath)).isFile() ? cfg.configPath : null; } catch { /* 不存在 */ }
|
|
381
420
|
return {
|
|
382
421
|
plugin: NAME,
|
|
383
422
|
version: VERSION,
|
|
384
423
|
platform: process.platform,
|
|
385
|
-
|
|
424
|
+
config: { path: configPath, found: !!configPath },
|
|
386
425
|
bins,
|
|
387
426
|
control: cfg.control,
|
|
388
427
|
jobs: {
|
|
@@ -501,7 +540,7 @@ async function fetchLatestVersion() {
|
|
|
501
540
|
if (res.status === 404) return { latest: null, note: 'npm 上还没有这个包' };
|
|
502
541
|
if (!res.ok) throw new Error('registry 返回 ' + res.status);
|
|
503
542
|
const j = await res.json();
|
|
504
|
-
return { latest: j.version ?? null };
|
|
543
|
+
return { latest: j.version ?? null, tarball: j.dist?.tarball ?? null, integrity: j.dist?.integrity ?? null };
|
|
505
544
|
} catch (e) {
|
|
506
545
|
return { latest: null, note: '查询 npm 失败: ' + String(e?.cause?.message || e?.message || e) };
|
|
507
546
|
}
|
|
@@ -523,29 +562,10 @@ async function updateCheck() {
|
|
|
523
562
|
};
|
|
524
563
|
}
|
|
525
564
|
|
|
526
|
-
async function findRuntimeTools() {
|
|
527
|
-
const isWin = process.platform === 'win32';
|
|
528
|
-
const pnCmd = isWin ? 'pnpm.cmd' : 'pnpm';
|
|
529
|
-
const probe = await runProcess(pnCmd, ['--version'], { timeoutMs: 20000 });
|
|
530
|
-
if (probe.ok) return { pnpmCmd: pnCmd };
|
|
531
|
-
try {
|
|
532
|
-
const rtRoot = path.join(os.homedir(), '.dsh', 'dsh-runtimes');
|
|
533
|
-
const entries = await fs.readdir(rtRoot, { withFileTypes: true });
|
|
534
|
-
for (const e of entries) {
|
|
535
|
-
if (!e.isDirectory()) continue;
|
|
536
|
-
const pnpmMjs = path.join(rtRoot, e.name, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs');
|
|
537
|
-
const nodeExe = path.join(rtRoot, e.name, 'dependencies', 'node', 'bin', isWin ? 'node.exe' : 'node');
|
|
538
|
-
if ((await fileExists(pnpmMjs)) && (await fileExists(nodeExe))) {
|
|
539
|
-
return { nodeExe, pnpmMjs };
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
} catch { /* ignore */ }
|
|
543
|
-
return {};
|
|
544
|
-
}
|
|
545
|
-
|
|
546
565
|
async function updateApply() {
|
|
547
566
|
const info = await detectInstall();
|
|
548
|
-
const
|
|
567
|
+
const meta = await fetchLatestVersion();
|
|
568
|
+
const latest = meta.latest;
|
|
549
569
|
if (!latest) throw new Error('npm 上没有可用的发布版本');
|
|
550
570
|
if (!semverGt(latest, VERSION)) return { ok: true, message: `已是最新(${VERSION})`, needRestart: false };
|
|
551
571
|
|
|
@@ -557,19 +577,56 @@ async function updateApply() {
|
|
|
557
577
|
return { ok: r.ok, output: (r.stdout + '\n' + r.stderr).trim(), needRestart: r.ok, latest };
|
|
558
578
|
}
|
|
559
579
|
|
|
560
|
-
// npm
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
throw new Error('
|
|
580
|
+
// npm 安装模式:直下官方 tarball → sha512 校验 → 原子换目录。
|
|
581
|
+
// 不经 pnpm 解析器 —— 规避供应链年龄门槛 / link 依赖联动 / 镜像同步延迟。
|
|
582
|
+
if (!meta.tarball || !meta.integrity) throw new Error('registry 元数据缺少 tarball/integrity,无法安全更新');
|
|
583
|
+
const here = info.here;
|
|
584
|
+
const tmp = path.join(os.tmpdir(), 'artcraft-up-' + crypto.randomUUID());
|
|
585
|
+
await fs.mkdir(tmp, { recursive: true });
|
|
586
|
+
try {
|
|
587
|
+
const tgzPath = path.join(tmp, 'pkg.tgz');
|
|
588
|
+
const res = await fetch(meta.tarball, { signal: AbortSignal.timeout(180000) });
|
|
589
|
+
if (!res.ok) throw new Error('下载 tarball 失败: HTTP ' + res.status);
|
|
590
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
591
|
+
if (buf.length > 64 * 1024 * 1024) throw new Error('tarball 超过 64MB 上限,已终止');
|
|
592
|
+
const digest = crypto.createHash('sha512').update(buf).digest('base64');
|
|
593
|
+
if (digest !== String(meta.integrity).replace(/^sha512-/, '')) {
|
|
594
|
+
throw new Error('sha512 校验失败 —— 下载内容与 registry 元数据不符,已终止');
|
|
595
|
+
}
|
|
596
|
+
await fs.writeFile(tgzPath, buf);
|
|
597
|
+
const extract = await runProcess('tar', ['-xzf', tgzPath, '-C', tmp], { timeoutMs: 60000 });
|
|
598
|
+
if (!extract.ok) throw new Error('解压失败: ' + (extract.stderr || '系统 tar 不可用'));
|
|
599
|
+
const extractedPkg = JSON.parse(await fs.readFile(path.join(tmp, 'package', 'package.json'), 'utf8'));
|
|
600
|
+
if (extractedPkg.name !== PKG_NAME || extractedPkg.version !== latest) {
|
|
601
|
+
throw new Error(`tarball 内容与预期不符(${extractedPkg.name}@${extractedPkg.version})`);
|
|
602
|
+
}
|
|
603
|
+
// 原子换目录:rename 失败(跨卷等)时回退为整目录复制
|
|
604
|
+
const backup = here + '.old-' + Date.now();
|
|
605
|
+
try {
|
|
606
|
+
await fs.rename(here, backup).catch(() => { throw new Error('rename-busy'); });
|
|
607
|
+
await fs.rename(path.join(tmp, 'package'), here).catch(async () => {
|
|
608
|
+
await fs.cp(path.join(tmp, 'package'), here, { recursive: true, force: true });
|
|
609
|
+
});
|
|
610
|
+
await fs.rm(backup, { recursive: true, force: true }).catch(() => {});
|
|
611
|
+
} catch {
|
|
612
|
+
// 兜底:直接覆盖复制(不影响运行中的进程 —— 旧模块已在内存)
|
|
613
|
+
await fs.cp(path.join(tmp, 'package'), here, { recursive: true, force: true });
|
|
614
|
+
}
|
|
615
|
+
// 同步 profile package.json 的版本规格,防止未来 pnpm install 回滚
|
|
616
|
+
if (info.profileDir) {
|
|
617
|
+
try {
|
|
618
|
+
const pjPath = path.join(info.profileDir, 'package.json');
|
|
619
|
+
const pj = JSON.parse(await fs.readFile(pjPath, 'utf8'));
|
|
620
|
+
if (pj.dependencies && pj.dependencies[PKG_NAME] !== undefined) {
|
|
621
|
+
pj.dependencies[PKG_NAME] = latest;
|
|
622
|
+
await fs.writeFile(pjPath, JSON.stringify(pj, null, 2) + '\n');
|
|
623
|
+
}
|
|
624
|
+
} catch { /* 非关键 */ }
|
|
625
|
+
}
|
|
626
|
+
return { ok: true, output: `已更新到 v${latest}(sha512 校验通过,tarball 直装)`, needRestart: true, latest };
|
|
627
|
+
} finally {
|
|
628
|
+
await fs.rm(tmp, { recursive: true, force: true }).catch(() => {});
|
|
570
629
|
}
|
|
571
|
-
const r = await runProcess(cmd, args, { cwd: info.profileDir, timeoutMs: 300000, shell: !!tools.pnpmCmd });
|
|
572
|
-
return { ok: r.ok, output: (r.stdout + '\n' + r.stderr).trim(), needRestart: r.ok, latest };
|
|
573
630
|
}
|
|
574
631
|
|
|
575
632
|
/* ============================ 插件入口 ============================ */
|
|
@@ -606,7 +663,7 @@ export function apply(ctx) {
|
|
|
606
663
|
|
|
607
664
|
attempt({
|
|
608
665
|
name: 'artcraft_status',
|
|
609
|
-
description: '检查 ArtCraft
|
|
666
|
+
description: '检查 ArtCraft 家族 7 个 CLI 是否可用,报告配置目录、控制通道端口与任务状态。本插件只是桥接,产物路径由调用方决定。',
|
|
610
667
|
schema: { type: 'object', properties: {}, additionalProperties: false },
|
|
611
668
|
async execute() {
|
|
612
669
|
const cfg = await loadConfig();
|
|
@@ -622,7 +679,7 @@ export function apply(ctx) {
|
|
|
622
679
|
properties: {
|
|
623
680
|
bin: { type: 'string', enum: [...BINS], description: '工具键' },
|
|
624
681
|
args: { type: 'array', items: { type: 'string' }, description: '命令行参数(不含程序名)' },
|
|
625
|
-
|
|
682
|
+
cwd: { type: 'string', description: '工作目录(绝对路径,可选):对话=当前工作空间下的目录,画布=项目目录;不传则继承 Host 进程' },
|
|
626
683
|
timeoutMs: { type: 'number', description: '超时毫秒,默认 900000' },
|
|
627
684
|
},
|
|
628
685
|
required: ['bin', 'args'],
|
|
@@ -632,7 +689,7 @@ export function apply(ctx) {
|
|
|
632
689
|
const cfg = await loadConfig();
|
|
633
690
|
if (args.args != null && !Array.isArray(args.args)) throw new Error('args 必须是字符串数组');
|
|
634
691
|
const exe = await resolveBin(cfg, String(args.bin));
|
|
635
|
-
const cwd = await
|
|
692
|
+
const cwd = args.cwd ? await ensureDir(args.cwd) : null;
|
|
636
693
|
const r = await runProcess(exe, args.args ?? [], { cwd, timeoutMs: args.timeoutMs });
|
|
637
694
|
return { bin: args.bin, exe, cwd, ...r };
|
|
638
695
|
},
|
|
@@ -663,56 +720,57 @@ export function apply(ctx) {
|
|
|
663
720
|
|
|
664
721
|
attempt({
|
|
665
722
|
name: 'artcraft_fs',
|
|
666
|
-
description: '
|
|
723
|
+
description: '桥接文件操作:list / stat / readtext / write / mkdir。路径由调用方决定 —— 对话调用传当前工作空间下的绝对路径,画布调用传项目目录下的绝对路径(或 base + rel 组合)。',
|
|
667
724
|
schema: {
|
|
668
725
|
type: 'object',
|
|
669
726
|
properties: {
|
|
670
727
|
op: { type: 'string', enum: ['list', 'stat', 'readtext', 'write', 'mkdir'] },
|
|
671
|
-
|
|
728
|
+
path: { type: 'string', description: '绝对路径(或 ~/ 展开)' },
|
|
729
|
+
base: { type: 'string', description: '可选:绝对基准目录,rel 相对它解析' },
|
|
730
|
+
rel: { type: 'string', description: '可选:相对 base 的路径(需同时给 base)' },
|
|
672
731
|
text: { type: 'string', description: 'op=write 时的文本内容' },
|
|
673
732
|
base64: { type: 'string', description: 'op=write 时的二进制内容(base64)' },
|
|
674
733
|
},
|
|
675
|
-
required: ['op'
|
|
734
|
+
required: ['op'],
|
|
676
735
|
additionalProperties: false,
|
|
677
736
|
},
|
|
678
737
|
async execute(args) {
|
|
679
|
-
const cfg = await loadConfig();
|
|
680
|
-
const rel = String(args.rel ?? '');
|
|
681
738
|
const op = String(args.op);
|
|
739
|
+
const P = () => resolveBaseRel(args.base, args.path ?? args.rel);
|
|
682
740
|
if (op === 'list') {
|
|
683
|
-
const dir =
|
|
741
|
+
const dir = P();
|
|
684
742
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
685
743
|
const out = [];
|
|
686
744
|
for (const ent of entries) {
|
|
687
745
|
const st = await fs.stat(path.join(dir, ent.name)).catch(() => null);
|
|
688
746
|
out.push({ name: ent.name, dir: ent.isDirectory(), size: st?.size ?? null, mtime: st?.mtimeMs ?? null });
|
|
689
747
|
}
|
|
690
|
-
return {
|
|
748
|
+
return { path: dir, entries: out };
|
|
691
749
|
}
|
|
692
750
|
if (op === 'stat') {
|
|
693
|
-
const st = await fs.stat(
|
|
694
|
-
return {
|
|
751
|
+
const st = await fs.stat(P());
|
|
752
|
+
return { path: P(), size: st.size, mtime: st.mtimeMs, dir: st.isDirectory() };
|
|
695
753
|
}
|
|
696
754
|
if (op === 'readtext') {
|
|
697
|
-
const p =
|
|
755
|
+
const p = P();
|
|
698
756
|
const st = await fs.stat(p);
|
|
699
757
|
if (st.size > TEXT_CAP) throw new Error('文件超过 16MB,请用 GET /artcraft/api/file 取回');
|
|
700
758
|
const buf = await fs.readFile(p);
|
|
701
|
-
return {
|
|
759
|
+
return { path: p, text: buf.toString('utf8') };
|
|
702
760
|
}
|
|
703
761
|
if (op === 'mkdir') {
|
|
704
|
-
const p = await
|
|
705
|
-
return {
|
|
762
|
+
const p = await ensureDir(P());
|
|
763
|
+
return { path: p };
|
|
706
764
|
}
|
|
707
765
|
if (op === 'write') {
|
|
708
|
-
const p =
|
|
766
|
+
const p = P();
|
|
709
767
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
710
768
|
let data;
|
|
711
769
|
if (typeof args.base64 === 'string') data = Buffer.from(args.base64, 'base64');
|
|
712
770
|
else if (typeof args.text === 'string') data = Buffer.from(args.text, 'utf8');
|
|
713
771
|
else throw new Error('write 需要 text 或 base64');
|
|
714
772
|
await fs.writeFile(p, data);
|
|
715
|
-
return {
|
|
773
|
+
return { path: p, size: data.length };
|
|
716
774
|
}
|
|
717
775
|
throw new Error(`未知 op: ${op}`);
|
|
718
776
|
},
|
|
@@ -720,7 +778,7 @@ export function apply(ctx) {
|
|
|
720
778
|
|
|
721
779
|
attempt({
|
|
722
780
|
name: 'artcraft_update',
|
|
723
|
-
description: '检查/应用 @ninkch/dsh-artcraft 插件自身的更新:action=check 对比本地与 npm latest;action=apply 执行更新(git pull
|
|
781
|
+
description: '检查/应用 @ninkch/dsh-artcraft 插件自身的更新:action=check 对比本地与 npm latest;action=apply 执行更新(git pull 或官方 tarball 直装 + sha512 校验,按安装形态自动选择),完成后需重启 Harness 生效。',
|
|
724
782
|
schema: {
|
|
725
783
|
type: 'object',
|
|
726
784
|
properties: {
|
|
@@ -748,48 +806,34 @@ export function apply(ctx) {
|
|
|
748
806
|
sendJson(res, 200, await statusInfo(cfg, { registered: [...toolNames], errors: [...toolErrors] }));
|
|
749
807
|
}),
|
|
750
808
|
|
|
751
|
-
makeRoute(`/${NAME}/api/projects`, async (req, res) => {
|
|
752
|
-
const cfg = await loadConfig();
|
|
753
|
-
await fs.mkdir(cfg.dataRoot, { recursive: true });
|
|
754
|
-
const entries = await fs.readdir(cfg.dataRoot, { withFileTypes: true });
|
|
755
|
-
const projects = [];
|
|
756
|
-
for (const ent of entries) {
|
|
757
|
-
if (!ent.isDirectory()) continue;
|
|
758
|
-
const st = await fs.stat(path.join(cfg.dataRoot, ent.name)).catch(() => null);
|
|
759
|
-
projects.push({ name: ent.name, mtime: st?.mtimeMs ?? null });
|
|
760
|
-
}
|
|
761
|
-
projects.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
|
|
762
|
-
sendJson(res, 200, { dataRoot: cfg.dataRoot, projects });
|
|
763
|
-
}),
|
|
764
|
-
|
|
765
809
|
makeRoute(`/${NAME}/api/fs`, async (req, res) => {
|
|
766
|
-
const cfg = await loadConfig();
|
|
767
810
|
const url = new URL(req.url, 'http://x');
|
|
811
|
+
const base = url.searchParams.get('base'); // 可选:绝对基准目录(画布=项目目录,对话=工作空间下目录)
|
|
812
|
+
const P = () => resolveBaseRel(base, url.searchParams.get('path') ?? url.searchParams.get('rel'));
|
|
768
813
|
if (req.method === 'GET') {
|
|
769
814
|
const op = url.searchParams.get('op') ?? 'list';
|
|
770
|
-
const rel = url.searchParams.get('rel') ?? '.';
|
|
771
815
|
if (op === 'list') {
|
|
772
|
-
const dir =
|
|
816
|
+
const dir = P();
|
|
773
817
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
774
818
|
const out = [];
|
|
775
819
|
for (const ent of entries) {
|
|
776
820
|
const st = await fs.stat(path.join(dir, ent.name)).catch(() => null);
|
|
777
821
|
out.push({ name: ent.name, dir: ent.isDirectory(), size: st?.size ?? null, mtime: st?.mtimeMs ?? null });
|
|
778
822
|
}
|
|
779
|
-
sendJson(res, 200, {
|
|
823
|
+
sendJson(res, 200, { path: dir, entries: out });
|
|
780
824
|
return;
|
|
781
825
|
}
|
|
782
826
|
if (op === 'stat') {
|
|
783
|
-
const st = await fs.stat(
|
|
784
|
-
sendJson(res, 200, {
|
|
827
|
+
const st = await fs.stat(P());
|
|
828
|
+
sendJson(res, 200, { path: P(), size: st.size, mtime: st.mtimeMs, dir: st.isDirectory() });
|
|
785
829
|
return;
|
|
786
830
|
}
|
|
787
831
|
if (op === 'readtext') {
|
|
788
|
-
const p =
|
|
832
|
+
const p = P();
|
|
789
833
|
const st = await fs.stat(p);
|
|
790
834
|
if (st.size > TEXT_CAP) throw new Error('文件超过 16MB,请改用 GET /artcraft/api/file');
|
|
791
835
|
const buf = await fs.readFile(p);
|
|
792
|
-
sendJson(res, 200, {
|
|
836
|
+
sendJson(res, 200, { path: p, text: buf.toString('utf8') });
|
|
793
837
|
return;
|
|
794
838
|
}
|
|
795
839
|
throw new Error(`未知 op: ${op}`);
|
|
@@ -798,47 +842,43 @@ export function apply(ctx) {
|
|
|
798
842
|
const action = url.searchParams.get('action') ?? 'write';
|
|
799
843
|
if (action === 'mkdir') {
|
|
800
844
|
const body = await readJson(req);
|
|
801
|
-
const p = await
|
|
845
|
+
const p = await ensureDir(resolveBaseRel(body.base ?? url.searchParams.get('base'), body.path ?? body.rel ?? url.searchParams.get('rel')));
|
|
802
846
|
sendJson(res, 200, { ok: true, path: p });
|
|
803
847
|
return;
|
|
804
848
|
}
|
|
805
|
-
// write:JSON {rel, base64|text}
|
|
849
|
+
// write:JSON {path 或 base+rel, base64|text} 或原始字节流(路径走查询串)
|
|
806
850
|
const isJson = String(req.headers['content-type'] ?? '').includes('json');
|
|
807
|
-
let
|
|
851
|
+
let target;
|
|
808
852
|
let data;
|
|
809
853
|
if (isJson) {
|
|
810
854
|
const body = await readJson(req, WRITE_CAP);
|
|
811
|
-
rel = String(body.rel ?? url.searchParams.get('rel') ?? '');
|
|
812
855
|
if (typeof body.base64 === 'string') data = Buffer.from(body.base64.replace(/\s+/g, ''), 'base64');
|
|
813
856
|
else if (typeof body.text === 'string') data = Buffer.from(body.text, 'utf8');
|
|
814
857
|
else throw new Error('write 需要 base64 或 text 字段');
|
|
858
|
+
target = resolveBaseRel(body.base ?? url.searchParams.get('base'), body.path ?? body.rel ?? url.searchParams.get('rel'));
|
|
815
859
|
} else {
|
|
816
|
-
rel = String(url.searchParams.get('rel') ?? '');
|
|
817
860
|
data = await readBody(req, WRITE_CAP);
|
|
861
|
+
target = resolveBaseRel(url.searchParams.get('base'), url.searchParams.get('path') ?? url.searchParams.get('rel'));
|
|
818
862
|
}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
await fs.writeFile(p, data);
|
|
823
|
-
sendJson(res, 200, { ok: true, rel, size: data.length });
|
|
863
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
864
|
+
await fs.writeFile(target, data);
|
|
865
|
+
sendJson(res, 200, { ok: true, path: target, size: data.length });
|
|
824
866
|
return;
|
|
825
867
|
}
|
|
826
868
|
throw new Error('仅支持 GET/POST');
|
|
827
869
|
}),
|
|
828
870
|
|
|
829
871
|
makeRoute(`/${NAME}/api/file`, async (req, res) => {
|
|
830
|
-
const cfg = await loadConfig();
|
|
831
872
|
if (req.method !== 'GET') throw new Error('仅支持 GET');
|
|
832
873
|
const url = new URL(req.url, 'http://x');
|
|
833
|
-
const
|
|
834
|
-
const p = resolveSafeRel(cfg.dataRoot, rel);
|
|
874
|
+
const p = resolveBaseRel(url.searchParams.get('base'), url.searchParams.get('path') ?? url.searchParams.get('rel'));
|
|
835
875
|
const st = await fs.stat(p);
|
|
836
876
|
if (!st.isFile()) throw new Error('不是常规文件');
|
|
837
877
|
if (st.size > READ_CAP) throw new Error(`文件超过读取上限(${Math.round(READ_CAP / 1048576)}MB)`);
|
|
838
878
|
if (url.searchParams.get('as') === 'text') {
|
|
839
879
|
if (st.size > TEXT_CAP) throw new Error('文本模式上限 16MB,请直接下载原始文件');
|
|
840
880
|
const buf = await fs.readFile(p);
|
|
841
|
-
sendJson(res, 200, {
|
|
881
|
+
sendJson(res, 200, { path: p, text: buf.toString('utf8') });
|
|
842
882
|
return;
|
|
843
883
|
}
|
|
844
884
|
// 流式传输:产物可能是几百 MB 的视频,不整读进内存
|
|
@@ -856,13 +896,13 @@ export function apply(ctx) {
|
|
|
856
896
|
if (id) {
|
|
857
897
|
const job = jobs.get(id);
|
|
858
898
|
if (!job) throw new Error('任务不存在或已被回收');
|
|
859
|
-
job.outputs = await statOutputs(
|
|
899
|
+
job.outputs = await statOutputs(job.outputs, job.cwd);
|
|
860
900
|
sendJson(res, 200, jobSnapshot(job, tail));
|
|
861
901
|
return;
|
|
862
902
|
}
|
|
863
903
|
const list = [];
|
|
864
904
|
for (const job of [...jobs.values()].reverse()) {
|
|
865
|
-
job.outputs = await statOutputs(
|
|
905
|
+
job.outputs = await statOutputs(job.outputs, job.cwd);
|
|
866
906
|
list.push(jobSnapshot(job, 5));
|
|
867
907
|
}
|
|
868
908
|
sendJson(res, 200, { jobs: list });
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ninkch/dsh-artcraft",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Drive the open-source ArtCraft studio apps (PhotoCraft / FilmCraft / EffectCraft) from DeepSeek Harness
|
|
5
|
+
"description": "Drive the open-source ArtCraft studio apps (PhotoCraft / FilmCraft / EffectCraft) from DeepSeek Harness ? agent tools, a same-origin HTTP API, and plugins-page status with self-update.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"deepseek-harness",
|
|
8
8
|
"dsh-plugin",
|