@cannbot-plugin/cannbot-harness 0.2.9 → 0.3.1

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/lib/index.js CHANGED
@@ -7,8 +7,8 @@ import { isUserInvocable, renderSkillContent } from "@deepseek-ai/dsh-skill";
7
7
  import z from "@deepseek-ai/schemastery";
8
8
  import { spawn } from "node:child_process";
9
9
  import { createHash, randomUUID } from "node:crypto";
10
- import { readFileSync, realpathSync, statSync } from "node:fs";
11
- import { cp, mkdir, mkdtemp, open, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
10
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
11
+ import { cp, lstat, mkdir, mkdtemp, open, readFile, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
13
  //#region src/workflows.ts
14
14
  const WORKFLOWS = [
@@ -273,6 +273,145 @@ async function withContentLock(path, action) {
273
273
  if (await readFile(path, "utf8").catch(() => "") === owner) await unlink(path);
274
274
  }
275
275
  }
276
+ //#endregion
277
+ //#region src/initializer-transaction.ts
278
+ const transient = new Set([
279
+ "EIO",
280
+ "EBUSY",
281
+ "EPERM",
282
+ "EACCES",
283
+ "ENOENT"
284
+ ]);
285
+ async function retry(action) {
286
+ for (let attempt = 0;; attempt++) try {
287
+ return await action();
288
+ } catch (error) {
289
+ if (!transient.has(error.code ?? "") || attempt === 6) throw error;
290
+ await new Promise((done) => setTimeout(done, Math.min(100 * 2 ** attempt, 800)));
291
+ }
292
+ }
293
+ function inside$1(root, path) {
294
+ const target = resolve(root, path), diff = relative(root, target);
295
+ if (!diff || diff === ".." || diff.startsWith(".." + sep) || isAbsolute(diff)) throw new Error("Invalid initialization recovery path");
296
+ return target;
297
+ }
298
+ async function present(path) {
299
+ try {
300
+ await lstat(path);
301
+ return true;
302
+ } catch (error) {
303
+ if (error.code === "ENOENT") return false;
304
+ throw error;
305
+ }
306
+ }
307
+ async function checkParent(workspace, path) {
308
+ let parent = resolve(path, "..");
309
+ while (parent !== workspace && !await present(parent)) parent = resolve(parent, "..");
310
+ if (parent !== workspace) inside$1(workspace, await realpath(parent));
311
+ }
312
+ /** Snapshot only installer-owned destinations; dependency repositories are never removed. */
313
+ async function initializeTransaction(workspace, recoveryRoot, descriptor, resources, initialize) {
314
+ await mkdir(recoveryRoot, { recursive: true });
315
+ const journalPath = join(recoveryRoot, "recovery.json");
316
+ async function restore(journal) {
317
+ if (journal.workspace !== workspace || !relative(recoveryRoot, journal.directory).startsWith("snapshot-") || relative(recoveryRoot, journal.directory).includes(sep)) throw new Error("Invalid initialization recovery journal");
318
+ for (const entry of journal.entries) {
319
+ const destination = inside$1(workspace, entry.path), saved = inside$1(journal.directory, entry.slot);
320
+ const parent = resolve(destination, "..");
321
+ await checkParent(workspace, destination);
322
+ await retry(() => rm(destination, {
323
+ recursive: true,
324
+ force: true,
325
+ maxRetries: 3,
326
+ retryDelay: 100
327
+ }));
328
+ if (entry.present) {
329
+ await mkdir(parent, { recursive: true });
330
+ await retry(() => cp(saved, destination, {
331
+ recursive: true,
332
+ dereference: false,
333
+ verbatimSymlinks: true
334
+ }));
335
+ }
336
+ }
337
+ }
338
+ const previous = await readFile(journalPath, "utf8").catch((error) => {
339
+ if (error.code === "ENOENT") return "";
340
+ throw error;
341
+ });
342
+ if (previous) {
343
+ const journal = JSON.parse(previous);
344
+ await restore(journal);
345
+ await rm(journalPath);
346
+ await rm(journal.directory, {
347
+ recursive: true,
348
+ force: true,
349
+ maxRetries: 3,
350
+ retryDelay: 100
351
+ }).catch(() => {});
352
+ }
353
+ const paths = new Set(descriptor.skills.map((skill) => `.dsh/skills/${skill.name}`));
354
+ paths.add(`.cannbot/plugins/${descriptor.id}`);
355
+ for (const file of [
356
+ "AGENTS.md",
357
+ "CLAUDE.md",
358
+ ".dsh/cannbot-plugin.json",
359
+ ".cannbot/settings.json",
360
+ ".cannbot/permissions"
361
+ ]) paths.add(file);
362
+ const manifestPath = join(resources, ".claude-plugin/plugin.json");
363
+ if (await present(manifestPath)) {
364
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
365
+ for (const path of manifest.agents ?? []) {
366
+ const text = await readFile(inside$1(resources, path), "utf8");
367
+ const name = /^name:\s*([^\r\n]+)$/m.exec(text)?.[1]?.trim();
368
+ if (!name || !/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(name)) throw new Error("Invalid initializer agent name");
369
+ paths.add(`.dsh/agents/${name}.md`);
370
+ }
371
+ }
372
+ const directory = await mkdtemp(join(recoveryRoot, "snapshot-"));
373
+ const journal = {
374
+ workspace,
375
+ directory,
376
+ entries: []
377
+ };
378
+ for (const path of paths) {
379
+ const source = inside$1(workspace, path);
380
+ await checkParent(workspace, source);
381
+ const exists = await present(source), slot = String(journal.entries.length);
382
+ if (exists) await retry(() => cp(source, join(directory, slot), {
383
+ recursive: true,
384
+ dereference: false,
385
+ verbatimSymlinks: true
386
+ }));
387
+ journal.entries.push({
388
+ path,
389
+ present: exists,
390
+ slot
391
+ });
392
+ }
393
+ const temporary = journalPath + ".tmp";
394
+ await writeFile(temporary, JSON.stringify(journal), { mode: 384 });
395
+ await rename(temporary, journalPath);
396
+ try {
397
+ await initialize();
398
+ } catch (error) {
399
+ try {
400
+ await restore(journal);
401
+ await rm(journalPath);
402
+ } catch (recovery) {
403
+ throw new Error(`${error.message}\n自动恢复未完成,备份保留在 ${directory}:${recovery.message}`);
404
+ }
405
+ throw new Error(`${error.message}\n已恢复本次初始化前的 Skills 和工作区配置。`);
406
+ }
407
+ await rm(journalPath);
408
+ await rm(directory, {
409
+ recursive: true,
410
+ force: true,
411
+ maxRetries: 3,
412
+ retryDelay: 100
413
+ }).catch(() => {});
414
+ }
276
415
  const CATALOG_PACKAGE = "@cannbot-plugin/workflow-catalog";
277
416
  const REGISTRY = "https://registry.npmjs.org/";
278
417
  const MIRROR = "https://registry.npmmirror.com/";
@@ -733,7 +872,13 @@ var ContentManager = class {
733
872
  async runInitializer(entry, args, cwd) {
734
873
  await new Promise((accept, reject) => {
735
874
  let output = "", stopped = false;
736
- const child = spawn(process.execPath, [entry, ...args], {
875
+ const compatibility = join(import.meta.dirname, "../runtime/initializer-compat.cjs");
876
+ const child = spawn(process.execPath, [
877
+ "--require",
878
+ compatibility,
879
+ entry,
880
+ ...args
881
+ ], {
737
882
  cwd,
738
883
  windowsHide: true,
739
884
  detached: process.platform !== "win32",
@@ -744,6 +889,8 @@ var ContentManager = class {
744
889
  ],
745
890
  env: {
746
891
  ...process.env,
892
+ ...process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {},
893
+ CANNBOT_INITIALIZER_WORKSPACE: cwd,
747
894
  GIT_TERMINAL_PROMPT: "0",
748
895
  GCM_INTERACTIVE: "never"
749
896
  }
@@ -800,7 +947,9 @@ var ContentManager = class {
800
947
  let job = this.jobs.get(jobKey);
801
948
  if (!job) {
802
949
  job = withContentLock(join(this.root, "initialized", key, "workspace.lock"), async () => {
803
- if (await readFile(marker, "utf8").catch(() => "") === receipt) return;
950
+ const recoveryRoot = join(this.root, "initialized", key, "recovery");
951
+ const skillsPresent = descriptor.skills.every((skill) => existsSync(join(workspace, ".dsh/skills", skill.name, "SKILL.md")));
952
+ if (await readFile(marker, "utf8").catch(() => "") === receipt && !existsSync(join(recoveryRoot, "recovery.json")) && (descriptor.initialization.kind !== "cannbot-cli" || skillsPresent)) return;
804
953
  this.publish({
805
954
  phase: "initializing",
806
955
  message: `Preparing ${id}@${record.version} for this workspace…`,
@@ -808,7 +957,8 @@ var ContentManager = class {
808
957
  });
809
958
  if (descriptor.initialization.kind === "cannbot-cli") {
810
959
  const root = inside(this.root, record.directory);
811
- await this.runInitializer(inside(root, descriptor.initialization.entry), [
960
+ const entry = inside(root, descriptor.initialization.entry);
961
+ await initializeTransaction(workspace, recoveryRoot, descriptor, join(root, "resources"), () => this.runInitializer(entry, [
812
962
  "install",
813
963
  id,
814
964
  "--tool",
@@ -817,7 +967,7 @@ var ContentManager = class {
817
967
  workspace,
818
968
  "--plugin-dir",
819
969
  join(root, "resources")
820
- ], workspace);
970
+ ], workspace));
821
971
  }
822
972
  await writeFile(marker, receipt, { mode: 384 });
823
973
  this.publish({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cannbot-plugin/cannbot-harness",
3
- "version": "0.2.9",
4
- "description": "CANNBot Harness: DSH interface with independently versioned npm workflow packages",
3
+ "version": "0.3.1",
4
+ "description": "CANNBot Harness: Ascend development workbench with independently versioned workflow packages",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN THIRD_PARTY_NOTICES.md",
7
7
  "main": "lib/index.js",
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "files": [
16
16
  "lib",
17
+ "runtime/initializer-compat.cjs",
17
18
  "workflow-defaults.json",
18
19
  "locale",
19
20
  "cordis.patch.yml",
@@ -0,0 +1,25 @@
1
+ // Used only by the official initializer subprocess. Never patches the DSH host.
2
+ const fs = require('node:fs')
3
+ const path = require('node:path')
4
+ const root = process.env.CANNBOT_INITIALIZER_WORKSPACE
5
+ if (process.platform === 'win32' && root) {
6
+ const cp = fs.cpSync
7
+ const wait = new Int32Array(new SharedArrayBuffer(4))
8
+ const managed = value => {
9
+ if (typeof value !== 'string') return false
10
+ const relative = path.relative(root, path.resolve(value))
11
+ return !path.isAbsolute(relative) && ['.dsh', '.cannbot'].some(name => relative.startsWith(name + path.sep))
12
+ }
13
+ fs.cpSync = function (source, destination, options) {
14
+ for (let attempt = 0; ; attempt++) {
15
+ try { return cp(source, destination, options) }
16
+ catch (error) {
17
+ // Windows may report EIO while a just-deleted directory is still closing.
18
+ // Do not rerun the whole installer, or retry missing source files.
19
+ if (!managed(destination) || !fs.existsSync(source) || !['EIO', 'EBUSY', 'EPERM', 'EACCES', 'ENOENT'].includes(error.code) || attempt === 6) throw error
20
+ Atomics.wait(wait, 0, 0, Math.min(100 * 2 ** attempt, 800))
21
+ }
22
+ }
23
+ }
24
+ require('node:module').syncBuiltinESMExports()
25
+ }