@ninkch/dsh-artcraft 0.0.0-stage → 0.2.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/index.js ADDED
@@ -0,0 +1,923 @@
1
+ /**
2
+ * @ninkch/dsh-artcraft — DeepSeek Harness 插件(Host 半侧)
3
+ *
4
+ * 把 ArtCraft 家族的自动化接口封装成两种形态:
5
+ * 1) Agent Tools(对话内直接驱动):
6
+ * artcraft_status —— 检查三个 CLI 是否可用、数据目录、控制通道配置
7
+ * artcraft_cli —— 运行 photocraft-cli / filmcraft-cli / effectcraft-cli 任意命令
8
+ * artcraft_control —— 向运行中的 FilmCraft/EffectCraft JSON 控制通道发一条命令
9
+ * artcraft_fs —— 在 dataRoot 内读写文件(准备 actions.json / 取回产物)
10
+ * 2) HTTP API(同源 /artcraft/api/*,供无限画布等前端调用,无 CORS 问题):
11
+ * GET /artcraft/api/status 环境自检
12
+ * GET /artcraft/api/projects 列出 dataRoot 下的项目目录
13
+ * GET /artcraft/api/fs list / stat / readtext
14
+ * POST /artcraft/api/fs write(JSON base64/text 或原始字节流)
15
+ * GET /artcraft/api/file 下载产物(流式,raw 或 text)
16
+ * GET /artcraft/api/job 查询任务(单个或列表)
17
+ * POST /artcraft/api/job 启动长任务(渲染/导出/批处理)
18
+ * POST /artcraft/api/job/cancel 取消任务
19
+ * POST /artcraft/api/control 控制通道同步调用(engine.execute / ui.*)
20
+ *
21
+ * 安全约定:
22
+ * - 所有文件操作限制在 dataRoot 内(拒绝 .. 越界与绝对路径外逃);
23
+ * - 子进程只允许三个白名单键(photocraft/filmcraft/effectcraft),
24
+ * 具体可执行文件由 artcraft.config.json 指定;
25
+ * - 控制通道只连 127.0.0.1,与 ArtCraft 官方协议一致(App 需以 --control 启动;
26
+ * PhotoCraft 的控制通道为鉴权模式,本插件不代理,故仅支持 filmcraft/effectcraft);
27
+ * - 本插件与 infinite-canvas 零代码耦合,仅通过 HTTP + 磁盘目录约定协作。
28
+ *
29
+ * 信任模型:HTTP API 无鉴权,随 DSH Web GUI 仅监听回环;不要把 GUI 端口暴露到局域网。
30
+ */
31
+ import { spawn } from 'node:child_process';
32
+ import net from 'node:net';
33
+ import fs from 'node:fs/promises';
34
+ import { createReadStream } from 'node:fs';
35
+ import { pipeline } from 'node:stream/promises';
36
+ import path from 'node:path';
37
+ import os from 'node:os';
38
+ import crypto from 'node:crypto';
39
+ import { fileURLToPath } from 'node:url';
40
+
41
+ const NAME = 'artcraft';
42
+ const VERSION = '0.2.0';
43
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
44
+
45
+ const BINS = ['photocraft', 'filmcraft', 'effectcraft'];
46
+ const PKG_NAME = '@ninkch/dsh-artcraft';
47
+ const DEFAULT_PORTS = { filmcraft: 9876, effectcraft: 9877 };
48
+
49
+ const BODY_CAP = 2 * 1024 * 1024; // 普通 JSON 请求体上限
50
+ const WRITE_CAP = 320 * 1024 * 1024; // 上传(写盘)上限
51
+ const READ_CAP = 2 * 1024 * 1024 * 1024; // 读取/下载上限(流式传输,不占大内存)
52
+ const TEXT_CAP = 16 * 1024 * 1024; // readtext / as=text 的上限
53
+ const TAIL_LINES = 400; // 任务输出环形缓冲(行)
54
+ const LINE_CAP = 4000; // 单行截断
55
+ const KEEP_JOBS = 64; // 已结束任务保留条数
56
+
57
+ const DEFAULT_CONFIG = {
58
+ dataRoot: path.join(os.homedir(), 'craft-workspace'),
59
+ bins: {
60
+ photocraft: 'photocraft-cli',
61
+ filmcraft: 'filmcraft-cli',
62
+ effectcraft: 'effectcraft-cli',
63
+ },
64
+ control: { ...DEFAULT_PORTS },
65
+ };
66
+
67
+ /* ============================ 配置 ============================ */
68
+
69
+ async function loadConfig() {
70
+ const cfg = {
71
+ dataRoot: DEFAULT_CONFIG.dataRoot,
72
+ bins: { ...DEFAULT_CONFIG.bins },
73
+ control: { ...DEFAULT_PORTS },
74
+ };
75
+ try {
76
+ const user = JSON.parse(await fs.readFile(path.join(HERE, 'artcraft.config.json'), 'utf8'));
77
+ if (user && typeof user.dataRoot === 'string' && user.dataRoot) cfg.dataRoot = user.dataRoot;
78
+ if (user && user.bins) {
79
+ for (const k of BINS) {
80
+ if (typeof user.bins[k] === 'string' && user.bins[k]) cfg.bins[k] = user.bins[k];
81
+ }
82
+ }
83
+ if (user && user.control) {
84
+ for (const k of Object.keys(DEFAULT_PORTS)) {
85
+ const p = Number(user.control[k]);
86
+ if (Number.isInteger(p) && p > 0 && p < 65536) cfg.control[k] = p;
87
+ }
88
+ }
89
+ } catch { /* 无配置文件时用默认值 */ }
90
+ return cfg;
91
+ }
92
+
93
+ /* ============================ 路径与进程 ============================ */
94
+
95
+ /** 把 rel 解析为 dataRoot 内的绝对路径,拒绝越界 */
96
+ function resolveSafeRel(dataRoot, rel) {
97
+ const root = path.resolve(String(dataRoot));
98
+ const p = path.resolve(root, String(rel ?? '.'));
99
+ if (p !== root && !p.startsWith(root + path.sep)) {
100
+ throw new Error(`路径越界(必须在数据目录内): ${rel}`);
101
+ }
102
+ return p;
103
+ }
104
+
105
+ async function ensureDirSafe(cfg, rel) {
106
+ const p = resolveSafeRel(cfg.dataRoot, rel ?? '.');
107
+ await fs.mkdir(p, { recursive: true });
108
+ return p;
109
+ }
110
+
111
+ function cliNameFor(key) {
112
+ return process.platform === 'win32' ? `${key}-cli.exe` : `${key}-cli`;
113
+ }
114
+
115
+ /** 配置值三种形态:完整 exe 路径 / 目录(自动拼 <key>-cli[.exe])/ 裸命令名(搜 PATH) */
116
+ async function resolveBin(cfg, key) {
117
+ if (!BINS.includes(key)) throw new Error(`bin 必须是 ${BINS.join(' | ')} 之一`);
118
+ const raw = String(cfg.bins[key] ?? '').trim();
119
+ if (!raw) throw new Error(`artcraft.config.json 未配置 bins.${key}`);
120
+ if (path.isAbsolute(raw)) {
121
+ let st = null;
122
+ try { st = await fs.stat(raw); } catch { /* fallthrough */ }
123
+ if (st && st.isDirectory()) return path.join(raw, cliNameFor(key));
124
+ return raw;
125
+ }
126
+ // 裸命令名 → PATH 查找
127
+ const finder = process.platform === 'win32' ? 'where' : 'which';
128
+ const found = await new Promise((resolve) => {
129
+ let out = '';
130
+ let child;
131
+ try {
132
+ child = spawn(finder, [raw], { windowsHide: true });
133
+ } catch { resolve(null); return; }
134
+ child.stdout?.setEncoding('utf8');
135
+ child.stdout?.on('data', (d) => { out += d; });
136
+ child.on('error', () => resolve(null));
137
+ child.on('close', (code) => {
138
+ const first = String(out).split(/\r?\n/).find((l) => l.trim());
139
+ resolve(code === 0 && first ? first.trim() : null);
140
+ });
141
+ });
142
+ return found ?? raw; // 找不到也让 spawn 自己报错,错误信息更真实
143
+ }
144
+
145
+ function pushTail(arr, chunk) {
146
+ // CLI 进度常用 \r 刷新同一行,按 \r\n|\r|\n 全部切开,环形缓冲自然保留最后几百行
147
+ const lines = String(chunk).split(/\r\n|\r|\n/);
148
+ for (const line of lines) {
149
+ if (!line && lines.length === 1) continue;
150
+ arr.push(line.length > LINE_CAP ? line.slice(0, LINE_CAP) + '…' : line);
151
+ }
152
+ if (arr.length > TAIL_LINES) arr.splice(0, arr.length - TAIL_LINES);
153
+ }
154
+
155
+ /** 运行子进程到结束;返回 {ok, code, signal, stdout, stderr, ms} */
156
+ function runProcess(exe, args, { cwd, timeoutMs = 15 * 60_000, shell = false } = {}) {
157
+ return new Promise((resolve) => {
158
+ const started = Date.now();
159
+ const stdout = [];
160
+ const stderr = [];
161
+ let child;
162
+ try {
163
+ child = spawn(String(exe), (args ?? []).map(String), { cwd, windowsHide: true, shell });
164
+ } catch (e) {
165
+ resolve({ ok: false, code: -1, signal: null, stdout: '', stderr: String(e?.message ?? e), ms: 0 });
166
+ return;
167
+ }
168
+ let timedOut = false;
169
+ const timer = setTimeout(() => {
170
+ timedOut = true;
171
+ try { child.kill(); } catch { /* ignore */ }
172
+ }, Math.max(1000, Number(timeoutMs) || 15 * 60_000));
173
+
174
+ child.stdout?.setEncoding('utf8');
175
+ child.stderr?.setEncoding('utf8');
176
+ child.stdout?.on('data', (d) => pushTail(stdout, d));
177
+ child.stderr?.on('data', (d) => pushTail(stderr, d));
178
+ child.on('error', (e) => {
179
+ clearTimeout(timer);
180
+ resolve({
181
+ ok: false, code: -1, signal: null,
182
+ stdout: stdout.join('\n'),
183
+ stderr: stderr.join('\n') + `\n[spawn] ${e?.message ?? e}`,
184
+ ms: Date.now() - started,
185
+ });
186
+ });
187
+ child.on('close', (code, signal) => {
188
+ clearTimeout(timer);
189
+ resolve({
190
+ ok: code === 0 && !timedOut,
191
+ code,
192
+ signal: timedOut ? 'TIMEOUT' : signal,
193
+ stdout: stdout.join('\n'),
194
+ stderr: stderr.join('\n') + (timedOut ? `\n[timeout] 超过 ${timeoutMs}ms 已终止` : ''),
195
+ ms: Date.now() - started,
196
+ });
197
+ });
198
+ });
199
+ }
200
+
201
+ /* ============================ 控制通道(JSON lines over TCP) ============================ */
202
+
203
+ /**
204
+ * FilmCraft/EffectCraft 官方协议:127.0.0.1:<port>,一行请求一行回复。
205
+ * 请求:{"id":1,"method":"engine.execute","params":{...}}
206
+ * 回复:{"id":1,"ok":true,"result":{...}} 或 {"ok":false,"error":"..."}
207
+ */
208
+ function controlCall(port, method, params, { timeoutMs = 30_000 } = {}) {
209
+ return new Promise((resolve, reject) => {
210
+ const id = 1;
211
+ const sock = net.connect({ host: '127.0.0.1', port: Number(port) });
212
+ let buf = '';
213
+ let settled = false;
214
+ const finish = (fn, v) => {
215
+ if (settled) return;
216
+ settled = true;
217
+ clearTimeout(timer);
218
+ try { sock.destroy(); } catch { /* ignore */ }
219
+ fn(v);
220
+ };
221
+ const timer = setTimeout(
222
+ () => finish(reject, new Error(`控制通道 ${port} 响应超时(${timeoutMs}ms)`)),
223
+ Math.max(1000, Number(timeoutMs) || 30_000),
224
+ );
225
+ sock.on('connect', () => {
226
+ sock.write(JSON.stringify({ id, method, params: params ?? {} }) + '\n');
227
+ });
228
+ sock.on('data', (d) => {
229
+ buf += d.toString('utf8');
230
+ const i = buf.indexOf('\n');
231
+ if (i < 0) {
232
+ if (buf.length > 4 * 1024 * 1024) finish(reject, new Error('控制通道单行回复超过 4MiB'));
233
+ return;
234
+ }
235
+ const line = buf.slice(0, i).trim();
236
+ if (!line) return;
237
+ let reply;
238
+ try { reply = JSON.parse(line); } catch {
239
+ finish(reject, new Error(`控制通道返回非 JSON: ${line.slice(0, 200)}`));
240
+ return;
241
+ }
242
+ if (reply && reply.ok === false) finish(reject, new Error(String(reply.error ?? '控制通道错误')));
243
+ else finish(resolve, reply?.result !== undefined ? reply.result : reply);
244
+ });
245
+ sock.on('error', (e) => {
246
+ finish(reject, new Error(
247
+ `控制通道 127.0.0.1:${port} 连接失败(${e?.message ?? e})——目标 App 是否已用 --control ${port} 启动?`,
248
+ ));
249
+ });
250
+ });
251
+ }
252
+
253
+ /* ============================ 长任务(渲染/批处理) ============================ */
254
+
255
+ const jobs = new Map(); // id -> job
256
+
257
+ async function statOutputs(cfg, outputs) {
258
+ const out = [];
259
+ for (const item of outputs ?? []) {
260
+ const rel = String(item.rel ?? item);
261
+ try {
262
+ const st = await fs.stat(resolveSafeRel(cfg.dataRoot, rel));
263
+ out.push({ rel, exists: true, dir: st.isDirectory(), size: st.size, mtime: st.mtimeMs });
264
+ } catch {
265
+ out.push({ rel, exists: false, dir: false, size: null, mtime: null });
266
+ }
267
+ }
268
+ return out;
269
+ }
270
+
271
+ function jobSnapshot(job, tailLines = 60) {
272
+ const tail = Math.max(1, Math.min(TAIL_LINES, Number(tailLines) || 60));
273
+ return {
274
+ id: job.id,
275
+ bin: job.bin,
276
+ args: job.args,
277
+ cwd: job.cwd,
278
+ status: job.status, // running | done | failed | timeout
279
+ code: job.code,
280
+ signal: job.signal,
281
+ startedAt: job.startedAt,
282
+ endedAt: job.endedAt,
283
+ durationMs: (job.endedAt ?? Date.now()) - job.startedAt,
284
+ stdout: job.stdout.slice(-tail).join('\n'),
285
+ stderr: job.stderr.slice(-tail).join('\n'),
286
+ outputs: job.outputs,
287
+ };
288
+ }
289
+
290
+ function pruneJobs() {
291
+ const finished = [...jobs.values()]
292
+ .filter((j) => j.status !== 'running')
293
+ .sort((a, b) => a.startedAt - b.startedAt);
294
+ while (jobs.size > KEEP_JOBS && finished.length) {
295
+ const oldest = finished.shift();
296
+ jobs.delete(oldest.id);
297
+ }
298
+ }
299
+
300
+ async function startJob(cfg, { bin, args, cwdRel, outputs, timeoutMs }) {
301
+ if (!BINS.includes(bin)) throw new Error(`bin 必须是 ${BINS.join(' | ')} 之一`);
302
+ if (args != null && !Array.isArray(args)) throw new Error('args 必须是字符串数组');
303
+ const exe = await resolveBin(cfg, bin);
304
+ const cwd = await ensureDirSafe(cfg, cwdRel ?? '.');
305
+ const id = crypto.randomUUID();
306
+ const job = {
307
+ id,
308
+ bin,
309
+ exe,
310
+ args: (args ?? []).map(String),
311
+ cwd,
312
+ status: 'running',
313
+ code: null,
314
+ signal: null,
315
+ startedAt: Date.now(),
316
+ endedAt: null,
317
+ stdout: [],
318
+ stderr: [],
319
+ outputs: (outputs ?? []).map((rel) => ({ rel: String(rel) })),
320
+ child: null,
321
+ timer: null,
322
+ };
323
+ const child = spawn(job.exe, job.args, { cwd, windowsHide: true });
324
+ job.child = child;
325
+ child.stdout?.setEncoding('utf8');
326
+ child.stderr?.setEncoding('utf8');
327
+ child.stdout?.on('data', (d) => pushTail(job.stdout, d));
328
+ child.stderr?.on('data', (d) => pushTail(job.stderr, d));
329
+ let timedOut = false;
330
+ job.timer = setTimeout(() => {
331
+ timedOut = true;
332
+ try { child.kill(); } catch { /* ignore */ }
333
+ }, Math.max(1000, Number(timeoutMs) || 60 * 60_000));
334
+ child.on('error', (e) => {
335
+ clearTimeout(job.timer);
336
+ job.status = 'failed';
337
+ job.code = -1;
338
+ job.endedAt = Date.now();
339
+ pushTail(job.stderr, `[spawn] ${e?.message ?? e}`);
340
+ pruneJobs();
341
+ });
342
+ child.on('close', (code, signal) => {
343
+ clearTimeout(job.timer);
344
+ job.code = code;
345
+ job.signal = timedOut ? 'TIMEOUT' : signal;
346
+ job.status = timedOut ? 'timeout' : (code === 0 ? 'done' : 'failed');
347
+ job.endedAt = Date.now();
348
+ pruneJobs();
349
+ });
350
+ jobs.set(id, job);
351
+ return { id, bin, exe, args: job.args, status: job.status };
352
+ }
353
+
354
+ /* ============================ 状态自检 ============================ */
355
+
356
+ async function fileExists(p) {
357
+ try { return (await fs.stat(p)).isFile(); } catch { return false; }
358
+ }
359
+
360
+ async function statusInfo(cfg, meta) {
361
+ const bins = {};
362
+ for (const key of BINS) {
363
+ let exe = null;
364
+ let found = false;
365
+ let note = '';
366
+ try {
367
+ exe = await resolveBin(cfg, key);
368
+ found = exe ? await fileExists(exe) : false;
369
+ if (!found) note = '未找到可执行文件——请检查 artcraft.config.json 的 bins 路径或 PATH';
370
+ } catch (e) {
371
+ note = String(e?.message ?? e);
372
+ }
373
+ bins[key] = { configured: cfg.bins[key], exe, found, note };
374
+ }
375
+ let dataRoot = { path: cfg.dataRoot, exists: false };
376
+ try { dataRoot.exists = (await fs.stat(cfg.dataRoot)).isDirectory(); } catch { /* 不存在 */ }
377
+ return {
378
+ plugin: NAME,
379
+ version: VERSION,
380
+ platform: process.platform,
381
+ dataRoot,
382
+ bins,
383
+ control: cfg.control,
384
+ jobs: {
385
+ total: jobs.size,
386
+ running: [...jobs.values()].filter((j) => j.status === 'running').length,
387
+ },
388
+ tools: meta,
389
+ };
390
+ }
391
+
392
+ /* ============================ HTTP 基础设施 ============================ */
393
+
394
+ function sendJson(res, code, obj) {
395
+ res.setHeader('content-type', 'application/json; charset=utf-8');
396
+ res.statusCode = code;
397
+ res.end(JSON.stringify(obj));
398
+ }
399
+
400
+ async function readBody(req, cap = BODY_CAP) {
401
+ const chunks = [];
402
+ let n = 0;
403
+ for await (const chunk of req) {
404
+ n += chunk.length;
405
+ if (n > cap) throw new Error(`请求体超过上限(${cap} 字节)`);
406
+ chunks.push(chunk);
407
+ }
408
+ return Buffer.concat(chunks);
409
+ }
410
+
411
+ async function readJson(req, cap = BODY_CAP) {
412
+ const raw = await readBody(req, cap);
413
+ if (!raw.length) return {};
414
+ return JSON.parse(raw.toString('utf8'));
415
+ }
416
+
417
+ const CONTENT_TYPES = {
418
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp',
419
+ '.gif': 'image/gif', '.bmp': 'image/bmp', '.tif': 'image/tiff', '.tiff': 'image/tiff',
420
+ '.psd': 'image/vnd.adobe.photoshop',
421
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm', '.mkv': 'video/x-matroska',
422
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg',
423
+ '.json': 'application/json', '.xml': 'application/xml', '.edl': 'text/plain',
424
+ '.srt': 'text/plain', '.vtt': 'text/vtt', '.txt': 'text/plain', '.md': 'text/plain',
425
+ '.cube': 'text/plain', '.ecproj': 'application/json', '.fcproj': 'application/json',
426
+ };
427
+
428
+ function makeRoute(path, handler) {
429
+ return {
430
+ kind: 'exact',
431
+ path,
432
+ async handler(req, res) {
433
+ try {
434
+ await handler(req, res);
435
+ } catch (e) {
436
+ // 流式响应中途出错时头部已发出,只能断开连接
437
+ try {
438
+ if (res.headersSent) { res.destroy(); return; }
439
+ sendJson(res, 400, { error: String((e && e.message) || e) });
440
+ } catch { try { res.destroy(); } catch { /* ignore */ } }
441
+ }
442
+ },
443
+ };
444
+ }
445
+
446
+ /* ============================ 自更新(检查 / 应用) ============================ */
447
+
448
+ function semverGt(a, b) {
449
+ const p = (s) => String(s ?? '').trim().replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
450
+ const [a1, a2, a3] = p(a), [b1, b2, b3] = p(b);
451
+ return a1 !== b1 ? a1 > b1 : a2 !== b2 ? a2 > b2 : a3 > b3;
452
+ }
453
+
454
+ /** 探测本插件的安装形态:git 仓库(link 开发)还是 npm 安装 */
455
+ async function detectInstall() {
456
+ const here = path.dirname(fileURLToPath(import.meta.url));
457
+ const real = await fs.realpath(here);
458
+ let dir = real;
459
+ let repoDir = null;
460
+ while (true) {
461
+ try {
462
+ const st = await fs.stat(path.join(dir, '.git'));
463
+ if (st.isDirectory() || st.isFile()) { repoDir = dir; break; }
464
+ } catch { /* 继续 */ }
465
+ const parent = path.dirname(dir);
466
+ if (parent === dir) break;
467
+ dir = parent;
468
+ }
469
+ let profileDir = null;
470
+ if (!repoDir) {
471
+ dir = path.dirname(here);
472
+ while (true) {
473
+ try {
474
+ const txt = await fs.readFile(path.join(dir, 'package.json'), 'utf8');
475
+ if (txt.includes('"dsh"')) { profileDir = dir; break; }
476
+ } catch { /* 继续 */ }
477
+ const parent = path.dirname(dir);
478
+ if (parent === dir) break;
479
+ dir = parent;
480
+ }
481
+ }
482
+ let gitClean = null;
483
+ let branch = null;
484
+ if (repoDir) {
485
+ const st = await runProcess('git', ['status', '--porcelain'], { cwd: repoDir, timeoutMs: 20000 });
486
+ gitClean = st.ok && st.stdout.trim() === '';
487
+ const b = await runProcess('git', ['branch', '--show-current'], { cwd: repoDir, timeoutMs: 20000 });
488
+ branch = (b.stdout || '').trim() || null;
489
+ }
490
+ return { mode: repoDir ? 'git' : 'npm', here, real, repoDir, profileDir, gitClean, branch };
491
+ }
492
+
493
+ async function fetchLatestVersion() {
494
+ const url = `https://registry.npmjs.org/${PKG_NAME.replace('/', '%2f')}/latest`;
495
+ try {
496
+ const res = await fetch(url, { signal: AbortSignal.timeout(12000) });
497
+ if (res.status === 404) return { latest: null, note: 'npm 上还没有这个包' };
498
+ if (!res.ok) throw new Error('registry 返回 ' + res.status);
499
+ const j = await res.json();
500
+ return { latest: j.version ?? null };
501
+ } catch (e) {
502
+ return { latest: null, note: '查询 npm 失败: ' + String(e?.cause?.message || e?.message || e) };
503
+ }
504
+ }
505
+
506
+ async function updateCheck() {
507
+ const current = VERSION;
508
+ const { latest, note } = await fetchLatestVersion();
509
+ const info = await detectInstall();
510
+ return {
511
+ current,
512
+ latest,
513
+ updateAvailable: !!(latest && semverGt(latest, current)),
514
+ mode: info.mode,
515
+ repoDir: info.repoDir,
516
+ gitClean: info.gitClean,
517
+ branch: info.branch,
518
+ note,
519
+ };
520
+ }
521
+
522
+ async function findRuntimeTools() {
523
+ const isWin = process.platform === 'win32';
524
+ const pnCmd = isWin ? 'pnpm.cmd' : 'pnpm';
525
+ const probe = await runProcess(pnCmd, ['--version'], { timeoutMs: 20000 });
526
+ if (probe.ok) return { pnpmCmd: pnCmd };
527
+ try {
528
+ const rtRoot = path.join(os.homedir(), '.dsh', 'dsh-runtimes');
529
+ const entries = await fs.readdir(rtRoot, { withFileTypes: true });
530
+ for (const e of entries) {
531
+ if (!e.isDirectory()) continue;
532
+ const pnpmMjs = path.join(rtRoot, e.name, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs');
533
+ const nodeExe = path.join(rtRoot, e.name, 'dependencies', 'node', 'bin', isWin ? 'node.exe' : 'node');
534
+ if ((await fileExists(pnpmMjs)) && (await fileExists(nodeExe))) {
535
+ return { nodeExe, pnpmMjs };
536
+ }
537
+ }
538
+ } catch { /* ignore */ }
539
+ return {};
540
+ }
541
+
542
+ async function updateApply() {
543
+ const info = await detectInstall();
544
+ const { latest } = await fetchLatestVersion();
545
+ if (!latest) throw new Error('npm 上没有可用的发布版本');
546
+ if (!semverGt(latest, VERSION)) return { ok: true, message: `已是最新(${VERSION})`, needRestart: false };
547
+
548
+ if (info.mode === 'git') {
549
+ if (info.gitClean === false) {
550
+ throw new Error('插件目录有未提交的本地改动,为避免覆盖请先 git commit / stash,再执行更新');
551
+ }
552
+ const r = await runProcess('git', ['pull', '--ff-only'], { cwd: info.repoDir, timeoutMs: 120000 });
553
+ return { ok: r.ok, output: (r.stdout + '\n' + r.stderr).trim(), needRestart: r.ok, latest };
554
+ }
555
+
556
+ // npm 安装模式
557
+ if (!info.profileDir) throw new Error('未定位到 Harness profile 目录,无法自动更新');
558
+ const tools = await findRuntimeTools();
559
+ let cmd, args;
560
+ if (tools.pnpmCmd) {
561
+ cmd = tools.pnpmCmd; args = ['add', `${PKG_NAME}@latest`, '--dir', info.profileDir];
562
+ } else if (tools.pnpmMjs) {
563
+ cmd = tools.nodeExe; args = [tools.pnpmMjs, 'add', `${PKG_NAME}@latest`, '--dir', info.profileDir];
564
+ } else {
565
+ throw new Error('未找到 pnpm(PATH 与 ~/.dsh/dsh-runtimes 均未命中),请手动在 profile 目录执行 pnpm add');
566
+ }
567
+ const r = await runProcess(cmd, args, { cwd: info.profileDir, timeoutMs: 300000, shell: !!tools.pnpmCmd });
568
+ return { ok: r.ok, output: (r.stdout + '\n' + r.stderr).trim(), needRestart: r.ok, latest };
569
+ }
570
+
571
+ /* ============================ 插件入口 ============================ */
572
+
573
+ const toolNames = [];
574
+ const toolErrors = [];
575
+
576
+ export const inject = ['tools', 'webServer'];
577
+
578
+ export function apply(ctx) {
579
+ /* ---- Agent Tools(防御式注册:形状不匹配也不影响 HTTP API) ---- */
580
+ ctx.effect(() => {
581
+ const disposers = [];
582
+ const attempt = (def) => {
583
+ for (const shape of [
584
+ { name: def.name, description: def.description, parameters: def.schema, execute: def.execute },
585
+ { name: def.name, description: def.description, inputSchema: def.schema, handler: def.execute },
586
+ ]) {
587
+ try {
588
+ const d = ctx.tools.register(shape);
589
+ if (typeof d === 'function') disposers.push(d);
590
+ toolNames.push(def.name);
591
+ return;
592
+ } catch (e) {
593
+ def.lastError = String((e && e.message) || e);
594
+ }
595
+ }
596
+ toolErrors.push(`${def.name}: ${def.lastError ?? '注册失败'}`);
597
+ };
598
+
599
+ attempt({
600
+ name: 'artcraft_status',
601
+ description: '检查 ArtCraft 工具链(photocraft/filmcraft/effectcraft CLI)是否可用,报告数据目录、控制通道端口与任务状态。',
602
+ schema: { type: 'object', properties: {}, additionalProperties: false },
603
+ async execute() {
604
+ const cfg = await loadConfig();
605
+ return statusInfo(cfg, { registered: [...toolNames], errors: [...toolErrors] });
606
+ },
607
+ });
608
+
609
+ attempt({
610
+ name: 'artcraft_cli',
611
+ description: '运行 ArtCraft 家族 CLI 的任意子命令(photocraft-cli / filmcraft-cli / effectcraft-cli)。长渲染请改用 HTTP POST /artcraft/api/job 后台执行。',
612
+ schema: {
613
+ type: 'object',
614
+ properties: {
615
+ bin: { type: 'string', enum: [...BINS], description: '工具键' },
616
+ args: { type: 'array', items: { type: 'string' }, description: '命令行参数(不含程序名)' },
617
+ cwdRel: { type: 'string', description: '工作目录,相对 dataRoot' },
618
+ timeoutMs: { type: 'number', description: '超时毫秒,默认 900000' },
619
+ },
620
+ required: ['bin', 'args'],
621
+ additionalProperties: false,
622
+ },
623
+ async execute(args) {
624
+ const cfg = await loadConfig();
625
+ if (args.args != null && !Array.isArray(args.args)) throw new Error('args 必须是字符串数组');
626
+ const exe = await resolveBin(cfg, String(args.bin));
627
+ const cwd = await ensureDirSafe(cfg, args.cwdRel ?? '.');
628
+ const r = await runProcess(exe, args.args ?? [], { cwd, timeoutMs: args.timeoutMs });
629
+ return { bin: args.bin, exe, cwd, ...r };
630
+ },
631
+ });
632
+
633
+ attempt({
634
+ name: 'artcraft_control',
635
+ description: '向运行中的 FilmCraft(--control 9876) / EffectCraft(--control 9877) JSON 控制通道发送一条命令,如 engine.execute / ui.screenshot。PhotoCraft 控制通道为鉴权模式,不在代理范围。',
636
+ schema: {
637
+ type: 'object',
638
+ properties: {
639
+ app: { type: 'string', enum: ['filmcraft', 'effectcraft'], description: '目标应用(决定端口)' },
640
+ method: { type: 'string', description: '如 engine.execute / ui.inspect / ui.screenshot' },
641
+ params: { type: 'object', description: '命令参数' },
642
+ timeoutMs: { type: 'number' },
643
+ },
644
+ required: ['app', 'method'],
645
+ additionalProperties: false,
646
+ },
647
+ async execute(args) {
648
+ const cfg = await loadConfig();
649
+ const port = cfg.control[String(args.app)];
650
+ if (!port) throw new Error(`未知 app: ${args.app}`);
651
+ const result = await controlCall(port, String(args.method), args.params ?? {}, { timeoutMs: args.timeoutMs });
652
+ return { app: args.app, port, method: args.method, result };
653
+ },
654
+ });
655
+
656
+ attempt({
657
+ name: 'artcraft_fs',
658
+ description: '在 craft 数据目录(dataRoot)内进行文件操作:list / stat / readtext / write / mkdir。所有路径相对 dataRoot,禁止越界。',
659
+ schema: {
660
+ type: 'object',
661
+ properties: {
662
+ op: { type: 'string', enum: ['list', 'stat', 'readtext', 'write', 'mkdir'] },
663
+ rel: { type: 'string', description: '相对 dataRoot 的路径' },
664
+ text: { type: 'string', description: 'op=write 时的文本内容' },
665
+ base64: { type: 'string', description: 'op=write 时的二进制内容(base64)' },
666
+ },
667
+ required: ['op', 'rel'],
668
+ additionalProperties: false,
669
+ },
670
+ async execute(args) {
671
+ const cfg = await loadConfig();
672
+ const rel = String(args.rel ?? '');
673
+ const op = String(args.op);
674
+ if (op === 'list') {
675
+ const dir = resolveSafeRel(cfg.dataRoot, rel || '.');
676
+ const entries = await fs.readdir(dir, { withFileTypes: true });
677
+ const out = [];
678
+ for (const ent of entries) {
679
+ const st = await fs.stat(path.join(dir, ent.name)).catch(() => null);
680
+ out.push({ name: ent.name, dir: ent.isDirectory(), size: st?.size ?? null, mtime: st?.mtimeMs ?? null });
681
+ }
682
+ return { rel, entries: out };
683
+ }
684
+ if (op === 'stat') {
685
+ const st = await fs.stat(resolveSafeRel(cfg.dataRoot, rel));
686
+ return { rel, size: st.size, mtime: st.mtimeMs, dir: st.isDirectory() };
687
+ }
688
+ if (op === 'readtext') {
689
+ const p = resolveSafeRel(cfg.dataRoot, rel);
690
+ const st = await fs.stat(p);
691
+ if (st.size > TEXT_CAP) throw new Error('文件超过 16MB,请用 GET /artcraft/api/file 取回');
692
+ const buf = await fs.readFile(p);
693
+ return { rel, text: buf.toString('utf8') };
694
+ }
695
+ if (op === 'mkdir') {
696
+ const p = await ensureDirSafe(cfg, rel);
697
+ return { rel, path: p };
698
+ }
699
+ if (op === 'write') {
700
+ const p = resolveSafeRel(cfg.dataRoot, rel);
701
+ await fs.mkdir(path.dirname(p), { recursive: true });
702
+ let data;
703
+ if (typeof args.base64 === 'string') data = Buffer.from(args.base64, 'base64');
704
+ else if (typeof args.text === 'string') data = Buffer.from(args.text, 'utf8');
705
+ else throw new Error('write 需要 text 或 base64');
706
+ await fs.writeFile(p, data);
707
+ return { rel, size: data.length };
708
+ }
709
+ throw new Error(`未知 op: ${op}`);
710
+ },
711
+ });
712
+
713
+ attempt({
714
+ name: 'artcraft_update',
715
+ description: '检查/应用 @ninkch/dsh-artcraft 插件自身的更新:action=check 对比本地与 npm latest;action=apply 执行更新(git pull 或 pnpm add,按安装形态自动选择),完成后需重启 Harness 生效。',
716
+ schema: {
717
+ type: 'object',
718
+ properties: {
719
+ action: { type: 'string', enum: ['check', 'apply'] },
720
+ },
721
+ required: ['action'],
722
+ additionalProperties: false,
723
+ },
724
+ async execute(args) {
725
+ if (String(args.action) === 'apply') return updateApply();
726
+ return updateCheck();
727
+ },
728
+ });
729
+
730
+ return () => {
731
+ for (const d of disposers) { try { d(); } catch { /* ignore */ } }
732
+ };
733
+ }, `${NAME}: agent tools`);
734
+
735
+ /* ---- HTTP API ---- */
736
+ ctx.effect(() => {
737
+ const routes = [
738
+ makeRoute(`/${NAME}/api/status`, async (req, res) => {
739
+ const cfg = await loadConfig();
740
+ sendJson(res, 200, await statusInfo(cfg, { registered: [...toolNames], errors: [...toolErrors] }));
741
+ }),
742
+
743
+ makeRoute(`/${NAME}/api/projects`, async (req, res) => {
744
+ const cfg = await loadConfig();
745
+ await fs.mkdir(cfg.dataRoot, { recursive: true });
746
+ const entries = await fs.readdir(cfg.dataRoot, { withFileTypes: true });
747
+ const projects = [];
748
+ for (const ent of entries) {
749
+ if (!ent.isDirectory()) continue;
750
+ const st = await fs.stat(path.join(cfg.dataRoot, ent.name)).catch(() => null);
751
+ projects.push({ name: ent.name, mtime: st?.mtimeMs ?? null });
752
+ }
753
+ projects.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
754
+ sendJson(res, 200, { dataRoot: cfg.dataRoot, projects });
755
+ }),
756
+
757
+ makeRoute(`/${NAME}/api/fs`, async (req, res) => {
758
+ const cfg = await loadConfig();
759
+ const url = new URL(req.url, 'http://x');
760
+ if (req.method === 'GET') {
761
+ const op = url.searchParams.get('op') ?? 'list';
762
+ const rel = url.searchParams.get('rel') ?? '.';
763
+ if (op === 'list') {
764
+ const dir = resolveSafeRel(cfg.dataRoot, rel);
765
+ const entries = await fs.readdir(dir, { withFileTypes: true });
766
+ const out = [];
767
+ for (const ent of entries) {
768
+ const st = await fs.stat(path.join(dir, ent.name)).catch(() => null);
769
+ out.push({ name: ent.name, dir: ent.isDirectory(), size: st?.size ?? null, mtime: st?.mtimeMs ?? null });
770
+ }
771
+ sendJson(res, 200, { rel, entries: out });
772
+ return;
773
+ }
774
+ if (op === 'stat') {
775
+ const st = await fs.stat(resolveSafeRel(cfg.dataRoot, rel));
776
+ sendJson(res, 200, { rel, size: st.size, mtime: st.mtimeMs, dir: st.isDirectory() });
777
+ return;
778
+ }
779
+ if (op === 'readtext') {
780
+ const p = resolveSafeRel(cfg.dataRoot, rel);
781
+ const st = await fs.stat(p);
782
+ if (st.size > TEXT_CAP) throw new Error('文件超过 16MB,请改用 GET /artcraft/api/file');
783
+ const buf = await fs.readFile(p);
784
+ sendJson(res, 200, { rel, text: buf.toString('utf8') });
785
+ return;
786
+ }
787
+ throw new Error(`未知 op: ${op}`);
788
+ }
789
+ if (req.method === 'POST') {
790
+ const action = url.searchParams.get('action') ?? 'write';
791
+ if (action === 'mkdir') {
792
+ const body = await readJson(req);
793
+ const p = await ensureDirSafe(cfg, body.rel ?? url.searchParams.get('rel'));
794
+ sendJson(res, 200, { ok: true, path: p });
795
+ return;
796
+ }
797
+ // write:JSON {rel, base64|text} 或原始字节流(rel 走查询串)
798
+ const isJson = String(req.headers['content-type'] ?? '').includes('json');
799
+ let rel;
800
+ let data;
801
+ if (isJson) {
802
+ const body = await readJson(req, WRITE_CAP);
803
+ rel = String(body.rel ?? url.searchParams.get('rel') ?? '');
804
+ if (typeof body.base64 === 'string') data = Buffer.from(body.base64.replace(/\s+/g, ''), 'base64');
805
+ else if (typeof body.text === 'string') data = Buffer.from(body.text, 'utf8');
806
+ else throw new Error('write 需要 base64 或 text 字段');
807
+ } else {
808
+ rel = String(url.searchParams.get('rel') ?? '');
809
+ data = await readBody(req, WRITE_CAP);
810
+ }
811
+ if (!rel) throw new Error('缺少 rel');
812
+ const p = resolveSafeRel(cfg.dataRoot, rel);
813
+ await fs.mkdir(path.dirname(p), { recursive: true });
814
+ await fs.writeFile(p, data);
815
+ sendJson(res, 200, { ok: true, rel, size: data.length });
816
+ return;
817
+ }
818
+ throw new Error('仅支持 GET/POST');
819
+ }),
820
+
821
+ makeRoute(`/${NAME}/api/file`, async (req, res) => {
822
+ const cfg = await loadConfig();
823
+ if (req.method !== 'GET') throw new Error('仅支持 GET');
824
+ const url = new URL(req.url, 'http://x');
825
+ const rel = String(url.searchParams.get('rel') ?? '');
826
+ const p = resolveSafeRel(cfg.dataRoot, rel);
827
+ const st = await fs.stat(p);
828
+ if (!st.isFile()) throw new Error('不是常规文件');
829
+ if (st.size > READ_CAP) throw new Error(`文件超过读取上限(${Math.round(READ_CAP / 1048576)}MB)`);
830
+ if (url.searchParams.get('as') === 'text') {
831
+ if (st.size > TEXT_CAP) throw new Error('文本模式上限 16MB,请直接下载原始文件');
832
+ const buf = await fs.readFile(p);
833
+ sendJson(res, 200, { rel, text: buf.toString('utf8') });
834
+ return;
835
+ }
836
+ // 流式传输:产物可能是几百 MB 的视频,不整读进内存
837
+ res.setHeader('content-type', CONTENT_TYPES[path.extname(p).toLowerCase()] ?? 'application/octet-stream');
838
+ res.setHeader('content-length', st.size);
839
+ await pipeline(createReadStream(p), res);
840
+ }),
841
+
842
+ makeRoute(`/${NAME}/api/job`, async (req, res) => {
843
+ const cfg = await loadConfig();
844
+ const url = new URL(req.url, 'http://x');
845
+ if (req.method === 'GET') {
846
+ const id = url.searchParams.get('id');
847
+ const tail = Number(url.searchParams.get('tail') ?? 60);
848
+ if (id) {
849
+ const job = jobs.get(id);
850
+ if (!job) throw new Error('任务不存在或已被回收');
851
+ job.outputs = await statOutputs(cfg, job.outputs);
852
+ sendJson(res, 200, jobSnapshot(job, tail));
853
+ return;
854
+ }
855
+ const list = [];
856
+ for (const job of [...jobs.values()].reverse()) {
857
+ job.outputs = await statOutputs(cfg, job.outputs);
858
+ list.push(jobSnapshot(job, 5));
859
+ }
860
+ sendJson(res, 200, { jobs: list });
861
+ return;
862
+ }
863
+ if (req.method === 'POST') {
864
+ const body = await readJson(req);
865
+ const started = await startJob(cfg, body);
866
+ sendJson(res, 200, started);
867
+ return;
868
+ }
869
+ throw new Error('仅支持 GET/POST');
870
+ }),
871
+
872
+ makeRoute(`/${NAME}/api/job/cancel`, async (req, res) => {
873
+ if (req.method !== 'POST') throw new Error('仅支持 POST');
874
+ const { id } = await readJson(req);
875
+ const job = jobs.get(String(id ?? ''));
876
+ if (!job) throw new Error('任务不存在或已被回收');
877
+ if (job.status === 'running') {
878
+ try { job.child?.kill(); } catch { /* ignore */ }
879
+ sendJson(res, 200, { ok: true, id: job.id, status: 'cancelling' });
880
+ } else {
881
+ sendJson(res, 200, { ok: false, id: job.id, status: job.status });
882
+ }
883
+ }),
884
+
885
+ makeRoute(`/${NAME}/api/update/check`, async (req, res) => {
886
+ sendJson(res, 200, await updateCheck());
887
+ }),
888
+
889
+ makeRoute(`/${NAME}/api/update/apply`, async (req, res) => {
890
+ if (req.method !== 'POST') throw new Error('仅支持 POST');
891
+ sendJson(res, 200, await updateApply());
892
+ }),
893
+
894
+ makeRoute(`/${NAME}/api/control`, async (req, res) => {
895
+ if (req.method !== 'POST') throw new Error('仅支持 POST');
896
+ const cfg = await loadConfig();
897
+ const body = await readJson(req);
898
+ const port = body.port ?? cfg.control[String(body.app ?? '')];
899
+ if (!(Number(port) > 0)) throw new Error('需要 app (filmcraft|effectcraft) 或 port');
900
+ const result = await controlCall(
901
+ Number(port),
902
+ String(body.method),
903
+ body.params ?? {},
904
+ { timeoutMs: body.timeoutMs },
905
+ );
906
+ sendJson(res, 200, { ok: true, port: Number(port), method: body.method, result });
907
+ }),
908
+ ];
909
+
910
+ const disposers = routes.map((r) => ctx.webServer.register(r));
911
+ return () => {
912
+ for (const d of disposers) { try { d(); } catch { /* ignore */ } }
913
+ };
914
+ }, `${NAME}: api routes`);
915
+
916
+ /* ---- 清理:插件重载/停用时终止所有子任务 ---- */
917
+ ctx.effect(() => () => {
918
+ for (const job of jobs.values()) {
919
+ try { job.child?.kill(); } catch { /* ignore */ }
920
+ try { clearTimeout(job.timer); } catch { /* ignore */ }
921
+ }
922
+ }, `${NAME}: job cleanup`);
923
+ }