dsh-plugin-guide 0.1.2 → 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.
Files changed (51) hide show
  1. package/README.es.md +89 -20
  2. package/README.hi.md +89 -20
  3. package/README.md +92 -21
  4. package/README.pt.md +89 -20
  5. package/README.zh.md +92 -21
  6. package/SKILL.md +11 -1
  7. package/bin/dsh-plugin-dev.js +6 -0
  8. package/cordis.patch.yml +5 -0
  9. package/dist/dsh-plugin-dev.js +1623 -0
  10. package/guide/plugin-dev-guide.md +1 -1
  11. package/guide/quick-reference.es.md +1 -1
  12. package/guide/quick-reference.hi.md +1 -1
  13. package/guide/quick-reference.md +1 -1
  14. package/guide/quick-reference.pt.md +1 -1
  15. package/guide/quick-reference.zh-CN.md +1 -1
  16. package/package.json +50 -5
  17. package/references/harness-repo.md +1 -1
  18. package/references/official-docs/AGENTS.md +1 -1
  19. package/references/official-docs/docs/persistence-catalog.md +1 -12
  20. package/references/official-docs/docs/persistence-catalog.zh.md +1 -12
  21. package/references/official-docs/docs/subsystems/persistence.md +1 -1
  22. package/references/official-docs/docs/subsystems/persistence.zh.md +1 -1
  23. package/references/official-docs/docs/subsystems/session.md +0 -11
  24. package/references/official-docs/docs/subsystems/session.zh.md +0 -11
  25. package/scripts/check-docs-drift.ps1 +55 -0
  26. package/scripts/check-readme-sync.mjs +91 -0
  27. package/scripts/verify-artifacts.mjs +70 -0
  28. package/templates/js/LICENSE +15 -0
  29. package/templates/js/README.es.md +42 -0
  30. package/templates/js/README.hi.md +42 -0
  31. package/templates/js/README.md +42 -0
  32. package/templates/js/README.pt.md +42 -0
  33. package/templates/js/README.zh.md +42 -0
  34. package/templates/js/cordis.patch.yml +10 -0
  35. package/templates/js/index.js +38 -0
  36. package/templates/js/package.json +35 -0
  37. package/templates/js/tests/index.test.js +12 -0
  38. package/templates/ts/LICENSE +15 -0
  39. package/templates/ts/README.es.md +44 -0
  40. package/templates/ts/README.hi.md +44 -0
  41. package/templates/ts/README.md +44 -0
  42. package/templates/ts/README.pt.md +44 -0
  43. package/templates/ts/README.zh.md +44 -0
  44. package/templates/ts/cordis.patch.yml +10 -0
  45. package/templates/ts/package.json +44 -0
  46. package/templates/ts/src/config.ts +12 -0
  47. package/templates/ts/src/index.ts +36 -0
  48. package/templates/ts/tests/index.test.ts +12 -0
  49. package/templates/ts/tsconfig.json +17 -0
  50. package/templates/ts/tsdown.config.mjs +15 -0
  51. package/templates/ts/vitest.config.ts +8 -0
@@ -0,0 +1,1623 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { fileURLToPath } from "node:url";
5
+ import { spawn } from "node:child_process";
6
+
7
+ //#region src/cli/lib/args.ts
8
+ const LONG_ALIASES = {
9
+ help: "help",
10
+ version: "version"
11
+ };
12
+ /**
13
+ * Parse an argv slice (without the node/script prefix) into positionals and flags.
14
+ * @param argv - process.argv.slice(2) style input.
15
+ * @returns the parsed structure.
16
+ */
17
+ function parseArgs(argv) {
18
+ const positionals = [];
19
+ const flags = {};
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const arg = argv[i];
22
+ if (arg === "--") {
23
+ positionals.push(...argv.slice(i + 1));
24
+ break;
25
+ }
26
+ if (arg.startsWith("--")) {
27
+ const body = arg.slice(2);
28
+ const eq = body.indexOf("=");
29
+ if (eq >= 0) {
30
+ setFlag(flags, body.slice(0, eq), body.slice(eq + 1));
31
+ continue;
32
+ }
33
+ const key = body;
34
+ const next = argv[i + 1];
35
+ if (next !== void 0 && !next.startsWith("-")) {
36
+ setFlag(flags, key, next);
37
+ i++;
38
+ } else setFlag(flags, key, true);
39
+ continue;
40
+ }
41
+ if (arg.startsWith("-") && arg.length > 1) {
42
+ for (const ch of arg.slice(1)) flags[ch] = true;
43
+ continue;
44
+ }
45
+ positionals.push(arg);
46
+ }
47
+ return {
48
+ positionals,
49
+ flags
50
+ };
51
+ }
52
+ function setFlag(flags, key, value) {
53
+ const canonical = LONG_ALIASES[key] ?? key;
54
+ flags[canonical] = value;
55
+ }
56
+ /** Read a flag as a string, returning the fallback when absent or boolean-true. */
57
+ function flagString(flags, key, fallback) {
58
+ const v = flags[key];
59
+ if (typeof v === "string") return v;
60
+ return fallback;
61
+ }
62
+ /** Read a flag as a boolean. */
63
+ function flagBool(flags, key) {
64
+ return flags[key] === true || flags[key] === "true";
65
+ }
66
+
67
+ //#endregion
68
+ //#region src/cli/lib/fs.ts
69
+ /** Fixed temp-dir marker prefix; a security invariant, not a tunable. */
70
+ const TEMP_PREFIX = "dsh-pd-";
71
+ const createdTempDirs = /* @__PURE__ */ new Set();
72
+ /**
73
+ * Create a fresh temporary directory under the OS temp root and track it so only
74
+ * this process can clean it up.
75
+ * @param label - short label embedded in the directory name for diagnostics.
76
+ * @returns the absolute path of the new directory.
77
+ */
78
+ function createTempDir(label) {
79
+ const dir = mkdtempSync(join(tmpdir(), `${TEMP_PREFIX}${label}-`));
80
+ createdTempDirs.add(resolve(dir));
81
+ return dir;
82
+ }
83
+ /** Remove every still-tracked temp directory (best-effort teardown). */
84
+ function cleanupAllTempDirs() {
85
+ for (const dir of [...createdTempDirs]) {
86
+ try {
87
+ rmSync(dir, {
88
+ recursive: true,
89
+ force: true
90
+ });
91
+ } catch {}
92
+ createdTempDirs.delete(dir);
93
+ }
94
+ }
95
+ /** Read a UTF-8 file, returning `undefined` when it does not exist. */
96
+ function readFileIfExists(path) {
97
+ try {
98
+ return readFileSync(path, "utf8");
99
+ } catch {
100
+ return;
101
+ }
102
+ }
103
+ /** Read a JSON file as a structured value, returning `undefined` when absent. */
104
+ function readJsonIfExists(path) {
105
+ const text = readFileIfExists(path);
106
+ if (text === void 0) return void 0;
107
+ try {
108
+ return JSON.parse(text);
109
+ } catch {
110
+ return;
111
+ }
112
+ }
113
+ /** Write a UTF-8 file, creating parent directories as needed. */
114
+ function writeFileDeep(path, content) {
115
+ mkdirSync(dirname(path), { recursive: true });
116
+ writeFileSync(path, content, "utf8");
117
+ }
118
+ /** True when the path exists and is a regular file. */
119
+ function isFile(path) {
120
+ try {
121
+ return statSync(path).isFile();
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+ /** True when the path exists and is a directory. */
127
+ function isDir(path) {
128
+ try {
129
+ return statSync(path).isDirectory();
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ //#endregion
136
+ //#region src/cli/templates.ts
137
+ /** Resolve the templates root: env override first, then candidate walks. */
138
+ function resolveTemplatesRoot() {
139
+ const env = process.env.DSH_PLUGIN_DEV_TEMPLATES;
140
+ if (env) return resolve(env);
141
+ const here = dirname(fileURLToPath(import.meta.url));
142
+ const candidates = [
143
+ join(here, "..", "..", "templates"),
144
+ join(here, "..", "templates"),
145
+ join(here, "templates")
146
+ ];
147
+ for (const candidate of candidates) if (existsSync(join(candidate, "ts")) && existsSync(join(candidate, "js"))) return resolve(candidate);
148
+ throw new Error("templates directory not found; set DSH_PLUGIN_DEV_TEMPLATES to its path");
149
+ }
150
+ /** Recursively list relative file paths under a directory. */
151
+ function walkFiles(dir, base = "") {
152
+ const out = [];
153
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
154
+ const rel = base ? `${base}/${entry.name}` : entry.name;
155
+ if (entry.isDirectory()) out.push(...walkFiles(join(dir, entry.name), rel));
156
+ else out.push(rel);
157
+ }
158
+ return out;
159
+ }
160
+ /** Substitute placeholders in template text. */
161
+ function renderTemplate(text, context) {
162
+ return text.replaceAll("{{pkgName}}", context.pkgName).replaceAll("{{name}}", context.name).replaceAll("{{version}}", context.version).replaceAll("{{year}}", context.year);
163
+ }
164
+ /** Render every file of a language template. */
165
+ function renderScaffold(lang, context) {
166
+ const root = join(resolveTemplatesRoot(), lang);
167
+ return walkFiles(root).sort().map((rel) => {
168
+ return {
169
+ relativePath: rel,
170
+ content: renderTemplate(readFileSync(join(root, rel), "utf8"), context)
171
+ };
172
+ });
173
+ }
174
+ /** Write rendered files under a target directory. */
175
+ function writeScaffold(targetDir, files) {
176
+ for (const file of files) writeFileDeep(join(targetDir, file.relativePath), file.content);
177
+ }
178
+
179
+ //#endregion
180
+ //#region src/cli/meta.ts
181
+ /** Resolve the installed package root (parent of the templates directory). */
182
+ function resolvePackageRoot() {
183
+ return dirname(resolveTemplatesRoot());
184
+ }
185
+ /** Read the installed package version, falling back to a placeholder. */
186
+ function readCliVersion() {
187
+ return readJsonIfExists(join(resolvePackageRoot(), "package.json"))?.version ?? "0.0.0";
188
+ }
189
+
190
+ //#endregion
191
+ //#region src/cli/lib/report.ts
192
+ /** Aggregate a list of checks into a report, deriving `ok` from errors. */
193
+ function buildReport(target, version, checks) {
194
+ const summary = {
195
+ passed: checks.filter((c) => c.status === "pass").length,
196
+ failed: checks.filter((c) => c.status === "fail").length,
197
+ warned: checks.filter((c) => c.status === "warn").length,
198
+ skipped: checks.filter((c) => c.status === "skip").length
199
+ };
200
+ return {
201
+ schemaVersion: 1,
202
+ cli: "dsh-plugin-dev",
203
+ version,
204
+ target,
205
+ ok: summary.failed === 0,
206
+ summary,
207
+ checks
208
+ };
209
+ }
210
+ /** Render a check list as a human-readable line-oriented report. */
211
+ function renderHuman(report) {
212
+ const lines = [];
213
+ lines.push(`target: ${report.target}`);
214
+ lines.push(`result: ${report.ok ? "OK" : "FAILED"} (${report.summary.passed} passed, ${report.summary.failed} failed, ${report.summary.warned} warned, ${report.summary.skipped} skipped)`);
215
+ lines.push("");
216
+ for (const check of report.checks) {
217
+ const icon = check.status === "pass" ? "✓" : check.status === "fail" ? "✗" : check.status === "warn" ? "!" : "-";
218
+ lines.push(`${icon} [${check.id}] (${check.severity}/${check.kind}) ${check.message}`);
219
+ lines.push(` skill: ${check.skillRef.file} ${check.skillRef.section} — ${check.skillRef.heading}`);
220
+ for (const detail of check.detail ?? []) lines.push(` ${detail}`);
221
+ }
222
+ return lines.join("\n");
223
+ }
224
+
225
+ //#endregion
226
+ //#region src/cli/skill-sections.ts
227
+ /** Canonical skill citation for one check id. */
228
+ const SKILL_SECTIONS = {
229
+ "patch-valid": {
230
+ file: "guide/plugin-dev-guide.md",
231
+ section: "§2.2",
232
+ heading: "两个概念、两种清单(bundle manifest)"
233
+ },
234
+ "patch-ids-unique": {
235
+ file: "guide/plugin-dev-guide.md",
236
+ section: "§2.3",
237
+ heading: "配置分层顺序(按 id 整行覆盖)"
238
+ },
239
+ "manifest-bundle-patch": {
240
+ file: "guide/plugin-dev-guide.md",
241
+ section: "§2.2",
242
+ heading: "bundle 最小结构(dsh.bundle.patch)"
243
+ },
244
+ "manifest-main": {
245
+ file: "guide/plugin-dev-guide.md",
246
+ section: "§7.3",
247
+ heading: "main/types 指向 lib 产物"
248
+ },
249
+ "manifest-peers": {
250
+ file: "guide/plugin-dev-guide.md",
251
+ section: "§7.3",
252
+ heading: "cordis 双副本与 peer 对齐宿主"
253
+ },
254
+ "manifest-engines": {
255
+ file: "guide/plugin-dev-guide.md",
256
+ section: "§8",
257
+ heading: "规范与质量门禁(Node 版本)"
258
+ },
259
+ "manifest-files": {
260
+ file: "guide/plugin-dev-guide.md",
261
+ section: "§7.1",
262
+ heading: "files 白名单与构建产物"
263
+ },
264
+ "manifest-package-manager": {
265
+ file: "guide/plugin-dev-guide.md",
266
+ section: "§8",
267
+ heading: "packageManager 固定 pnpm"
268
+ },
269
+ "readme-five-langs": {
270
+ file: "guide/plugin-dev-guide.md",
271
+ section: "§8",
272
+ heading: "文档双语/多语成对"
273
+ },
274
+ "readme-consistency": {
275
+ file: "guide/plugin-dev-guide.md",
276
+ section: "§8",
277
+ heading: "五语 README 同步"
278
+ },
279
+ "redline-persona-role": {
280
+ file: "SKILL.md",
281
+ section: "§边界",
282
+ heading: "注入提示词段落以角色句开头、保持短小"
283
+ },
284
+ "redline-waterfall-next": {
285
+ file: "guide/plugin-dev-guide.md",
286
+ section: "§3.5",
287
+ heading: "waterfall 铁律:必须调用 next()"
288
+ },
289
+ "redline-no-hardcoded-tunables": {
290
+ file: "guide/plugin-dev-guide.md",
291
+ section: "§3.6",
292
+ heading: "配置 Schema 化、不硬编码可调参数"
293
+ },
294
+ "redline-effect-registration": {
295
+ file: "guide/plugin-dev-guide.md",
296
+ section: "§3.3",
297
+ heading: "注册即 effect(disposer 可逆)"
298
+ }
299
+ };
300
+ /** Fallback citation for any check id not in the table. */
301
+ function skillRefFor(id) {
302
+ return SKILL_SECTIONS[id] ?? {
303
+ file: "guide/plugin-dev-guide.md",
304
+ section: "§10",
305
+ heading: "从零到发布的标准路径"
306
+ };
307
+ }
308
+
309
+ //#endregion
310
+ //#region src/cli/lib/yaml.ts
311
+ /**
312
+ * Parse a YAML subset document.
313
+ * @param text - raw YAML text.
314
+ * @returns the parsed value; an empty document is `null`.
315
+ */
316
+ function parseYaml(text) {
317
+ const lines = tokenize(text);
318
+ return new Parser(lines).parseValue();
319
+ }
320
+ /** True when the value is a mapping. */
321
+ function isObject(value) {
322
+ return typeof value === "object" && value !== null && !Array.isArray(value);
323
+ }
324
+ /** True when the value is a sequence. */
325
+ function isArray(value) {
326
+ return Array.isArray(value);
327
+ }
328
+ function tokenize(text) {
329
+ const out = [];
330
+ const lines = text.split(/\r?\n/);
331
+ for (let n = 0; n < lines.length; n++) {
332
+ const stripped = stripComment(lines[n]);
333
+ if (stripped.trim() === "") continue;
334
+ out.push({
335
+ indent: stripped.length - stripped.trimStart().length,
336
+ text: stripped.trim(),
337
+ num: n + 1
338
+ });
339
+ }
340
+ return out;
341
+ }
342
+ function stripComment(line) {
343
+ let single = false;
344
+ let double = false;
345
+ for (let i = 0; i < line.length; i++) {
346
+ const c = line[i];
347
+ if (single) {
348
+ if (c === "'") single = false;
349
+ continue;
350
+ }
351
+ if (double) {
352
+ if (c === "\\") {
353
+ i++;
354
+ continue;
355
+ }
356
+ if (c === "\"") double = false;
357
+ continue;
358
+ }
359
+ if (c === "'") single = true;
360
+ else if (c === "\"") double = true;
361
+ else if (c === "#") return line.slice(0, i);
362
+ }
363
+ return line;
364
+ }
365
+ var Parser = class {
366
+ lines;
367
+ pos = 0;
368
+ constructor(lines) {
369
+ this.lines = lines;
370
+ }
371
+ parseValue() {
372
+ if (this.pos >= this.lines.length) return null;
373
+ const line = this.lines[this.pos];
374
+ if (isSeqItem(line.text)) return this.parseSequence(line.indent);
375
+ if (isMappingStart(line.text)) return this.parseMapping(line.indent);
376
+ this.pos++;
377
+ return parseScalar(line.text);
378
+ }
379
+ parseSequence(indent) {
380
+ const arr = [];
381
+ while (this.pos < this.lines.length && this.lines[this.pos].indent === indent && isSeqItem(this.lines[this.pos].text)) {
382
+ const text = this.lines[this.pos].text;
383
+ if (text === "-") {
384
+ this.pos++;
385
+ arr.push(this.parseValue());
386
+ continue;
387
+ }
388
+ const rest = text.slice(2);
389
+ if (rest === "") {
390
+ this.pos++;
391
+ arr.push(this.parseValue());
392
+ } else if (isMappingStart(rest)) arr.push(this.parseInlineMapping(rest, indent));
393
+ else {
394
+ this.pos++;
395
+ arr.push(parseScalar(rest));
396
+ }
397
+ }
398
+ return arr;
399
+ }
400
+ parseInlineMapping(firstEntry, seqIndent) {
401
+ const map = {};
402
+ let entry = firstEntry;
403
+ const keyIndent = seqIndent + 2;
404
+ while (true) {
405
+ const split = splitKeyValue(entry);
406
+ if (split.value === "") {
407
+ this.pos++;
408
+ map[split.key] = this.parseValue();
409
+ } else {
410
+ this.pos++;
411
+ map[split.key] = parseScalar(split.value);
412
+ }
413
+ const next = this.lines[this.pos];
414
+ if (next && next.indent === keyIndent && !isSeqItem(next.text) && isMappingStart(next.text)) entry = next.text;
415
+ else break;
416
+ }
417
+ return map;
418
+ }
419
+ parseMapping(indent) {
420
+ const map = {};
421
+ while (this.pos < this.lines.length && this.lines[this.pos].indent === indent && isMappingStart(this.lines[this.pos].text) && !isSeqItem(this.lines[this.pos].text)) {
422
+ const entry = this.lines[this.pos].text;
423
+ const split = splitKeyValue(entry);
424
+ if (split.value === "") {
425
+ this.pos++;
426
+ map[split.key] = this.parseValue();
427
+ } else {
428
+ this.pos++;
429
+ map[split.key] = parseScalar(split.value);
430
+ }
431
+ }
432
+ return map;
433
+ }
434
+ };
435
+ function isSeqItem(text) {
436
+ return text === "-" || text.startsWith("- ");
437
+ }
438
+ function isMappingStart(text) {
439
+ if (text.endsWith(":")) return true;
440
+ return text.includes(": ");
441
+ }
442
+ function splitKeyValue(text) {
443
+ for (let i = 0; i < text.length; i++) if (text[i] === ":" && (i + 1 >= text.length || text[i + 1] === " ")) return {
444
+ key: text.slice(0, i).trim(),
445
+ value: text.slice(i + 1).trim()
446
+ };
447
+ return {
448
+ key: text,
449
+ value: ""
450
+ };
451
+ }
452
+ function parseScalar(text) {
453
+ const t = text.trim();
454
+ if (t === "" || t === "~" || t === "null" || t === "Null" || t === "NULL") return null;
455
+ if (t === "true" || t === "True" || t === "TRUE") return true;
456
+ if (t === "false" || t === "False" || t === "FALSE") return false;
457
+ if (t.startsWith("'") && t.endsWith("'") && t.length >= 2) return t.slice(1, -1).replace(/''/g, "'");
458
+ if (t.startsWith("\"") && t.endsWith("\"") && t.length >= 2) return unescapeDouble(t.slice(1, -1));
459
+ if (/^-?\d+$/.test(t)) return Number.parseInt(t, 10);
460
+ if (/^-?\d+\.\d+$/.test(t)) return Number.parseFloat(t);
461
+ return t;
462
+ }
463
+ function unescapeDouble(text) {
464
+ return text.replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\r/g, "\r").replace(/\\0/g, "\0").replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
465
+ }
466
+
467
+ //#endregion
468
+ //#region src/cli/commands/check.ts
469
+ const README_LANGS = [
470
+ "README.md",
471
+ "README.zh.md",
472
+ "README.es.md",
473
+ "README.pt.md",
474
+ "README.hi.md"
475
+ ];
476
+ const EXCLUDED_DIRS = /* @__PURE__ */ new Set([
477
+ "node_modules",
478
+ ".git",
479
+ "dist",
480
+ "coverage",
481
+ "downloads",
482
+ "_check"
483
+ ]);
484
+ /** Run every check and return the report plus a process exit code. */
485
+ function runCheck(options) {
486
+ const root = resolve(options.root);
487
+ const pkg = readJsonIfExists(join(root, "package.json"));
488
+ const checks = [
489
+ checkPatchValid(root, pkg),
490
+ checkPatchIdsUnique(root, pkg),
491
+ checkManifestBundlePatch(root, pkg),
492
+ checkManifestMain(root, pkg),
493
+ checkManifestPeers(root, pkg),
494
+ checkManifestEngines(root, pkg),
495
+ checkManifestFiles(root, pkg),
496
+ checkManifestPackageManager(root, pkg),
497
+ checkReadmeFiveLangs(root),
498
+ checkReadmeConsistency(root),
499
+ checkRedlinePersonaRole(root),
500
+ checkRedlineWaterfallNext(root),
501
+ checkRedlineNoHardcodedTunables(root),
502
+ checkRedlineEffectRegistration(root)
503
+ ];
504
+ const report = buildReport(root, readCliVersion(), checks);
505
+ return {
506
+ report,
507
+ exitCode: report.summary.failed + (options.strict ? report.summary.warned : 0) > 0 ? 1 : 0
508
+ };
509
+ }
510
+ /** Render the report to the console in the requested format. */
511
+ function printCheckReport(report, format) {
512
+ if (format === "json") process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
513
+ else process.stdout.write(`${renderHuman(report)}\n`);
514
+ }
515
+ function patchPath(root, pkg) {
516
+ const pointer = pkg?.dsh?.bundle?.patch;
517
+ if (!pointer) return void 0;
518
+ return resolve(root, pointer);
519
+ }
520
+ /**
521
+ * List real source files: the `src/` tree plus root-level JS/ESM entry files.
522
+ * Templates, tests, scripts, references, and generated directories are data or
523
+ * fixtures, never runtime source, so they are excluded from static scans.
524
+ */
525
+ function listSourceFiles(root) {
526
+ const out = [];
527
+ let rootEntries;
528
+ try {
529
+ rootEntries = readdirSync(root);
530
+ } catch {
531
+ rootEntries = [];
532
+ }
533
+ for (const entry of rootEntries) if (/\.(js|mjs|cjs)$/.test(entry)) out.push(join(root, entry));
534
+ const walk = (dir) => {
535
+ let entries;
536
+ try {
537
+ entries = readdirSync(dir);
538
+ } catch {
539
+ return;
540
+ }
541
+ for (const entry of entries) {
542
+ if (EXCLUDED_DIRS.has(entry)) continue;
543
+ const full = join(dir, entry);
544
+ let stat;
545
+ try {
546
+ stat = statSync(full);
547
+ } catch {
548
+ continue;
549
+ }
550
+ if (stat.isDirectory()) walk(full);
551
+ else if (stat.isFile() && /\.(ts|js|mjs|cjs)$/.test(entry)) out.push(full);
552
+ }
553
+ };
554
+ walk(join(root, "src"));
555
+ return out;
556
+ }
557
+ function readText(path) {
558
+ try {
559
+ return readFileSync(path, "utf8");
560
+ } catch {
561
+ return;
562
+ }
563
+ }
564
+ function collectedImports(sourceFiles) {
565
+ const imports = /* @__PURE__ */ new Set();
566
+ const re = /(?:from\s+|import\s+|import\s*\(\s*)['"](@deepseek-ai\/[^'"]+)['"]/g;
567
+ for (const file of sourceFiles) {
568
+ const text = readText(file);
569
+ if (!text) continue;
570
+ let match;
571
+ while ((match = re.exec(text)) !== null) imports.add(match[1]);
572
+ }
573
+ return imports;
574
+ }
575
+ function validatePatch(text) {
576
+ const errors = [];
577
+ const rows = [];
578
+ let value;
579
+ try {
580
+ value = parseYaml(text);
581
+ } catch (err) {
582
+ return {
583
+ errors: [`cannot parse YAML: ${err instanceof Error ? err.message : String(err)}`],
584
+ rows
585
+ };
586
+ }
587
+ if (value === null || value === void 0) return {
588
+ errors: ["empty document"],
589
+ rows
590
+ };
591
+ if (!isArray(value)) return {
592
+ errors: ["top level must be a YAML sequence of row verbs"],
593
+ rows
594
+ };
595
+ value.forEach((verbEntry, i) => {
596
+ if (!isObject(verbEntry)) {
597
+ errors.push(`entry ${i} must be a mapping like \`- insert:\``);
598
+ return;
599
+ }
600
+ const verbs = Object.keys(verbEntry);
601
+ if (verbs.length !== 1) {
602
+ errors.push(`entry ${i} must have exactly one verb key (got ${verbs.length})`);
603
+ return;
604
+ }
605
+ const verb = verbs[0];
606
+ const rowsValue = verbEntry[verb];
607
+ if (!isArray(rowsValue)) {
608
+ errors.push(`entry ${i} verb \`${verb}\` must map to a sequence of rows`);
609
+ return;
610
+ }
611
+ rowsValue.forEach((row, r) => {
612
+ if (!isObject(row)) {
613
+ errors.push(`row ${i}.${r} must be a mapping`);
614
+ return;
615
+ }
616
+ const id = row.id;
617
+ const name = row.name;
618
+ if (typeof id !== "string" || id === "") errors.push(`row ${i}.${r} is missing a string \`id\``);
619
+ if (verb === "insert" && (typeof name !== "string" || name === "")) errors.push(`row ${i}.${r} (insert) is missing a string \`name\``);
620
+ rows.push({
621
+ id: typeof id === "string" ? id : void 0,
622
+ name: typeof name === "string" ? name : void 0
623
+ });
624
+ });
625
+ });
626
+ return {
627
+ errors,
628
+ rows
629
+ };
630
+ }
631
+ function checkPatchValid(root, pkg) {
632
+ const ref = skillRefFor("patch-valid");
633
+ const path = patchPath(root, pkg);
634
+ if (!path) return {
635
+ id: "patch-valid",
636
+ severity: "warning",
637
+ kind: "deterministic",
638
+ status: "warn",
639
+ message: "no dsh.bundle.patch pointer; nothing to validate",
640
+ skillRef: ref
641
+ };
642
+ if (!isFile(path)) return {
643
+ id: "patch-valid",
644
+ severity: "error",
645
+ kind: "deterministic",
646
+ status: "fail",
647
+ message: `patch file not found: ${path}`,
648
+ skillRef: ref
649
+ };
650
+ const { errors } = validatePatch(readText(path) ?? "");
651
+ if (errors.length > 0) return {
652
+ id: "patch-valid",
653
+ severity: "error",
654
+ kind: "deterministic",
655
+ status: "fail",
656
+ message: "cordis.patch.yml is invalid",
657
+ skillRef: ref,
658
+ detail: errors
659
+ };
660
+ return {
661
+ id: "patch-valid",
662
+ severity: "error",
663
+ kind: "deterministic",
664
+ status: "pass",
665
+ message: "cordis.patch.yml parses and every row is well-formed",
666
+ skillRef: ref
667
+ };
668
+ }
669
+ function checkPatchIdsUnique(root, pkg) {
670
+ const ref = skillRefFor("patch-ids-unique");
671
+ const path = patchPath(root, pkg);
672
+ if (!path || !isFile(path)) return {
673
+ id: "patch-ids-unique",
674
+ severity: "error",
675
+ kind: "deterministic",
676
+ status: "skip",
677
+ message: "no patch file to check for duplicate ids",
678
+ skillRef: ref
679
+ };
680
+ const { rows } = validatePatch(readText(path) ?? "");
681
+ const seen = /* @__PURE__ */ new Map();
682
+ const dupes = [];
683
+ rows.forEach((row, i) => {
684
+ if (!row.id) return;
685
+ if (seen.has(row.id)) dupes.push(`id "${row.id}" repeated at row ${seen.get(row.id)} and row ${i}`);
686
+ else seen.set(row.id, i);
687
+ });
688
+ if (dupes.length > 0) return {
689
+ id: "patch-ids-unique",
690
+ severity: "error",
691
+ kind: "deterministic",
692
+ status: "fail",
693
+ message: "duplicate row ids in cordis.patch.yml",
694
+ skillRef: ref,
695
+ detail: dupes
696
+ };
697
+ return {
698
+ id: "patch-ids-unique",
699
+ severity: "error",
700
+ kind: "deterministic",
701
+ status: "pass",
702
+ message: `row ids are unique (${rows.length} rows)`,
703
+ skillRef: ref
704
+ };
705
+ }
706
+ function checkManifestBundlePatch(root, pkg) {
707
+ const ref = skillRefFor("manifest-bundle-patch");
708
+ const pointer = pkg?.dsh?.bundle?.patch;
709
+ if (!pointer) return {
710
+ id: "manifest-bundle-patch",
711
+ severity: "warning",
712
+ kind: "deterministic",
713
+ status: "warn",
714
+ message: "no dsh.bundle.patch declared (pure cordis plugin, no bundle layer)",
715
+ skillRef: ref
716
+ };
717
+ const path = resolve(root, pointer);
718
+ if (!isFile(path)) return {
719
+ id: "manifest-bundle-patch",
720
+ severity: "error",
721
+ kind: "deterministic",
722
+ status: "fail",
723
+ message: `dsh.bundle.patch points at a missing file: ${pointer}`,
724
+ skillRef: ref
725
+ };
726
+ return {
727
+ id: "manifest-bundle-patch",
728
+ severity: "error",
729
+ kind: "deterministic",
730
+ status: "pass",
731
+ message: `dsh.bundle.patch resolves to ${pointer}`,
732
+ skillRef: ref
733
+ };
734
+ }
735
+ function checkManifestMain(root, pkg) {
736
+ const ref = skillRefFor("manifest-main");
737
+ const main = pkg?.main;
738
+ if (!main) return {
739
+ id: "manifest-main",
740
+ severity: "error",
741
+ kind: "deterministic",
742
+ status: "fail",
743
+ message: "package.json has no `main` entry",
744
+ skillRef: ref
745
+ };
746
+ if (!isFile(join(root, main))) return {
747
+ id: "manifest-main",
748
+ severity: "warning",
749
+ kind: "deterministic",
750
+ status: "warn",
751
+ message: `main entry not built yet: ${main} (run the build first)`,
752
+ skillRef: ref
753
+ };
754
+ return {
755
+ id: "manifest-main",
756
+ severity: "error",
757
+ kind: "deterministic",
758
+ status: "pass",
759
+ message: `main entry present: ${main}`,
760
+ skillRef: ref
761
+ };
762
+ }
763
+ function checkManifestPeers(root, pkg) {
764
+ const ref = skillRefFor("manifest-peers");
765
+ const peers = pkg?.peerDependencies ?? {};
766
+ const harnessImports = [...collectedImports(listSourceFiles(root))].filter((i) => i.startsWith("@deepseek-ai/"));
767
+ if (harnessImports.length === 0) return {
768
+ id: "manifest-peers",
769
+ severity: "info",
770
+ kind: "deterministic",
771
+ status: "pass",
772
+ message: "no @deepseek-ai/* packages imported; peers are optional",
773
+ skillRef: ref
774
+ };
775
+ const expected = {};
776
+ for (const imp of harnessImports) if (imp === "@deepseek-ai/cordis") expected[imp] = "^4.0.1";
777
+ else if (imp === "@deepseek-ai/schemastery") expected[imp] = "^3.18.0";
778
+ else if (imp.startsWith("@deepseek-ai/dsh-")) expected[imp] = ">=0.1.0-rc.8 <0.2.0";
779
+ const problems = [];
780
+ for (const [pkgName, range] of Object.entries(expected)) {
781
+ const declared = peers[pkgName];
782
+ if (!declared) problems.push(`imports ${pkgName} but declares no peerDependency`);
783
+ else if (declared !== range) problems.push(`peerDependency ${pkgName} should be "${range}", found "${declared}"`);
784
+ }
785
+ if (problems.length > 0) return {
786
+ id: "manifest-peers",
787
+ severity: "error",
788
+ kind: "deterministic",
789
+ status: "fail",
790
+ message: "harness imports without a matching peerDependency",
791
+ skillRef: ref,
792
+ detail: problems
793
+ };
794
+ return {
795
+ id: "manifest-peers",
796
+ severity: "error",
797
+ kind: "deterministic",
798
+ status: "pass",
799
+ message: `peerDependencies align with harness imports (${Object.keys(expected).length})`,
800
+ skillRef: ref
801
+ };
802
+ }
803
+ function checkManifestEngines(_root, pkg) {
804
+ const ref = skillRefFor("manifest-engines");
805
+ const node = pkg?.engines?.node;
806
+ if (!node) return {
807
+ id: "manifest-engines",
808
+ severity: "error",
809
+ kind: "deterministic",
810
+ status: "fail",
811
+ message: "package.json has no engines.node",
812
+ skillRef: ref
813
+ };
814
+ const has22 = /\b22\b/.test(node);
815
+ const has24 = /\b24\b/.test(node);
816
+ if (!has22 || !has24) return {
817
+ id: "manifest-engines",
818
+ severity: "error",
819
+ kind: "deterministic",
820
+ status: "fail",
821
+ message: `engines.node "${node}" should allow Node 22 and 24 (^22.19.0 || >=24.0.0)`,
822
+ skillRef: ref
823
+ };
824
+ return {
825
+ id: "manifest-engines",
826
+ severity: "error",
827
+ kind: "deterministic",
828
+ status: "pass",
829
+ message: `engines.node "${node}" covers Node 22 and 24`,
830
+ skillRef: ref
831
+ };
832
+ }
833
+ function checkManifestFiles(_root, pkg) {
834
+ const ref = skillRefFor("manifest-files");
835
+ const files = pkg?.files;
836
+ if (!files || files.length === 0) return {
837
+ id: "manifest-files",
838
+ severity: "warning",
839
+ kind: "deterministic",
840
+ status: "warn",
841
+ message: "no `files` whitelist; npm will publish everything",
842
+ skillRef: ref
843
+ };
844
+ const patchFile = pkg?.dsh?.bundle?.patch?.replace(/^\.\//, "") ?? "cordis.patch.yml";
845
+ const main = pkg?.main;
846
+ const problems = [];
847
+ if (!files.includes(patchFile)) problems.push(`files whitelist is missing the patch file "${patchFile}"`);
848
+ if (main && !files.includes(main) && !files.some((f) => main.startsWith(`${f.replace(/\/$/, "")}/`))) problems.push(`files whitelist is missing the main entry "${main}"`);
849
+ if (!files.some((f) => f === "lib" || f === "dist")) problems.push("files whitelist has no built-artifact directory (`lib` or `dist`)");
850
+ if (problems.length > 0) return {
851
+ id: "manifest-files",
852
+ severity: "error",
853
+ kind: "deterministic",
854
+ status: "fail",
855
+ message: "files whitelist incomplete",
856
+ skillRef: ref,
857
+ detail: problems
858
+ };
859
+ return {
860
+ id: "manifest-files",
861
+ severity: "error",
862
+ kind: "deterministic",
863
+ status: "pass",
864
+ message: "files whitelist includes patch, entry, and built artifacts",
865
+ skillRef: ref
866
+ };
867
+ }
868
+ function checkManifestPackageManager(_root, pkg) {
869
+ const ref = skillRefFor("manifest-package-manager");
870
+ const pm = pkg?.packageManager;
871
+ if (pm === "pnpm@11.7.0") return {
872
+ id: "manifest-package-manager",
873
+ severity: "warning",
874
+ kind: "deterministic",
875
+ status: "pass",
876
+ message: "packageManager pinned to pnpm@11.7.0",
877
+ skillRef: ref
878
+ };
879
+ if (pm) return {
880
+ id: "manifest-package-manager",
881
+ severity: "warning",
882
+ kind: "deterministic",
883
+ status: "warn",
884
+ message: `packageManager is "${pm}"; the family standard is pnpm@11.7.0`,
885
+ skillRef: ref
886
+ };
887
+ return {
888
+ id: "manifest-package-manager",
889
+ severity: "warning",
890
+ kind: "deterministic",
891
+ status: "warn",
892
+ message: "packageManager not pinned; add \"pnpm@11.7.0\" for reproducible installs",
893
+ skillRef: ref
894
+ };
895
+ }
896
+ function checkReadmeFiveLangs(root) {
897
+ const ref = skillRefFor("readme-five-langs");
898
+ const missing = [];
899
+ for (const lang of README_LANGS) {
900
+ const path = join(root, lang);
901
+ if (!isFile(path)) missing.push(lang);
902
+ else if ((readText(path) ?? "").trim() === "") missing.push(`${lang} (empty)`);
903
+ }
904
+ const missingEn = missing.includes("README.md");
905
+ const missingOthers = missing.filter((m) => m !== "README.md");
906
+ if (missingEn) return {
907
+ id: "readme-five-langs",
908
+ severity: "error",
909
+ kind: "deterministic",
910
+ status: "fail",
911
+ message: "README.md (English source) is missing",
912
+ skillRef: ref,
913
+ detail: missing
914
+ };
915
+ if (missingOthers.length > 0) return {
916
+ id: "readme-five-langs",
917
+ severity: "warning",
918
+ kind: "deterministic",
919
+ status: "warn",
920
+ message: "five-language README incomplete",
921
+ skillRef: ref,
922
+ detail: missingOthers
923
+ };
924
+ return {
925
+ id: "readme-five-langs",
926
+ severity: "error",
927
+ kind: "deterministic",
928
+ status: "pass",
929
+ message: "all five README languages present",
930
+ skillRef: ref
931
+ };
932
+ }
933
+ function headingsOf(text) {
934
+ return text.split(/\r?\n/).filter((line) => line.startsWith("## ")).map((line) => line.slice(3).trim());
935
+ }
936
+ function checkReadmeConsistency(root) {
937
+ const ref = skillRefFor("readme-consistency");
938
+ const existing = README_LANGS.filter((lang) => isFile(join(root, lang)));
939
+ if (existing.length < 2) return {
940
+ id: "readme-consistency",
941
+ severity: "warning",
942
+ kind: "deterministic",
943
+ status: "skip",
944
+ message: "fewer than two READMEs; nothing to compare",
945
+ skillRef: ref
946
+ };
947
+ const base = headingsOf(readText(join(root, "README.md")) ?? "");
948
+ const mismatches = [];
949
+ for (const lang of existing.slice(1)) {
950
+ const headings = headingsOf(readText(join(root, lang)) ?? "");
951
+ const missing = base.filter((h) => !headings.includes(h));
952
+ if (missing.length > 0) mismatches.push(`${lang} is missing headings: ${missing.join(", ")}`);
953
+ }
954
+ if (mismatches.length > 0) return {
955
+ id: "readme-consistency",
956
+ severity: "warning",
957
+ kind: "deterministic",
958
+ status: "warn",
959
+ message: "README headings drifted across languages",
960
+ skillRef: ref,
961
+ detail: mismatches
962
+ };
963
+ return {
964
+ id: "readme-consistency",
965
+ severity: "warning",
966
+ kind: "deterministic",
967
+ status: "pass",
968
+ message: `README headings match across ${existing.length} languages`,
969
+ skillRef: ref
970
+ };
971
+ }
972
+ /** Body of SKILL.md after its YAML frontmatter, or undefined. */
973
+ function skillBody(root) {
974
+ const text = readText(join(root, "SKILL.md"));
975
+ if (!text) return void 0;
976
+ if (!text.startsWith("---\n")) return text;
977
+ const end = text.indexOf("\n---", 4);
978
+ if (end < 0) return text;
979
+ return text.slice(end + 4).replace(/^\n+/, "");
980
+ }
981
+ function checkRedlinePersonaRole(root) {
982
+ const ref = skillRefFor("redline-persona-role");
983
+ const body = skillBody(root);
984
+ const problems = [];
985
+ if (body !== void 0) {
986
+ const firstContentPara = body.split(/\r?\n\s*\r?\n/).find((p) => !/^\s*#/.test(p)) ?? "";
987
+ const firstLine = firstContentPara.split(/\r?\n/)[0] ?? "";
988
+ if (!/[.。?!]/.test(firstLine)) problems.push("SKILL.md instruction body does not open with a role sentence");
989
+ else if (firstLine.length > 200) problems.push("SKILL.md opening role sentence is longer than 200 characters");
990
+ if (firstContentPara.length > 600) problems.push("SKILL.md first paragraph is a wall of text; keep injected persona paragraphs short");
991
+ }
992
+ for (const file of listSourceFiles(root)) {
993
+ const text = readText(file) ?? "";
994
+ const re = /systemPrompt\.section\(\s*['"][^'"]*['"]\s*,\s*`([^`]+)`/g;
995
+ let match;
996
+ while ((match = re.exec(text)) !== null) if (match[1].length > 400) problems.push(`${relativePath(root, file)}: injected systemPrompt section is a wall of text`);
997
+ }
998
+ if (problems.length > 0) return {
999
+ id: "redline-persona-role",
1000
+ severity: "warning",
1001
+ kind: "heuristic",
1002
+ status: "warn",
1003
+ message: "injected persona/prompt paragraphs should open with a short role sentence",
1004
+ skillRef: ref,
1005
+ detail: problems
1006
+ };
1007
+ if (body === void 0) return {
1008
+ id: "redline-persona-role",
1009
+ severity: "warning",
1010
+ kind: "heuristic",
1011
+ status: "skip",
1012
+ message: "no SKILL.md or systemPrompt section found to inspect",
1013
+ skillRef: ref
1014
+ };
1015
+ return {
1016
+ id: "redline-persona-role",
1017
+ severity: "warning",
1018
+ kind: "heuristic",
1019
+ status: "pass",
1020
+ message: "persona paragraphs open with a short role sentence",
1021
+ skillRef: ref
1022
+ };
1023
+ }
1024
+ function checkRedlineWaterfallNext(root) {
1025
+ const ref = skillRefFor("redline-waterfall-next");
1026
+ const flagged = [];
1027
+ for (const file of listSourceFiles(root)) {
1028
+ const text = readText(file) ?? "";
1029
+ if (!text.includes("waterfall")) continue;
1030
+ if (!text.includes("next")) flagged.push(`${relativePath(root, file)}: mentions waterfall but never calls next()`);
1031
+ }
1032
+ if (flagged.length > 0) return {
1033
+ id: "redline-waterfall-next",
1034
+ severity: "warning",
1035
+ kind: "heuristic",
1036
+ status: "warn",
1037
+ message: "possible waterfall listener missing next()",
1038
+ skillRef: ref,
1039
+ detail: flagged
1040
+ };
1041
+ if (!listSourceFiles(root).some((f) => (readText(f) ?? "").includes("waterfall"))) return {
1042
+ id: "redline-waterfall-next",
1043
+ severity: "warning",
1044
+ kind: "heuristic",
1045
+ status: "skip",
1046
+ message: "no waterfall listeners found",
1047
+ skillRef: ref
1048
+ };
1049
+ return {
1050
+ id: "redline-waterfall-next",
1051
+ severity: "warning",
1052
+ kind: "heuristic",
1053
+ status: "pass",
1054
+ message: "waterfall listeners call next()",
1055
+ skillRef: ref
1056
+ };
1057
+ }
1058
+ function checkRedlineNoHardcodedTunables(root) {
1059
+ const ref = skillRefFor("redline-no-hardcoded-tunables");
1060
+ const files = listSourceFiles(root);
1061
+ const plainObjectConfigs = [];
1062
+ const schemaConfigs = [];
1063
+ for (const file of files) {
1064
+ const text = readText(file) ?? "";
1065
+ if (/export\s+const\s+Config\s*=\s*\{/.test(text)) plainObjectConfigs.push(relativePath(root, file));
1066
+ if (/export\s+const\s+Config\s*=\s*Schema\./.test(text)) schemaConfigs.push(relativePath(root, file));
1067
+ }
1068
+ if (plainObjectConfigs.length > 0) return {
1069
+ id: "redline-no-hardcoded-tunables",
1070
+ severity: "error",
1071
+ kind: "heuristic",
1072
+ status: "fail",
1073
+ message: "Config is a plain object; it must be a Schemastery Schema",
1074
+ skillRef: ref,
1075
+ detail: plainObjectConfigs
1076
+ };
1077
+ if (schemaConfigs.length === 0) return {
1078
+ id: "redline-no-hardcoded-tunables",
1079
+ severity: "warning",
1080
+ kind: "heuristic",
1081
+ status: "skip",
1082
+ message: "no Config schema found to inspect",
1083
+ skillRef: ref
1084
+ };
1085
+ return {
1086
+ id: "redline-no-hardcoded-tunables",
1087
+ severity: "warning",
1088
+ kind: "heuristic",
1089
+ status: "pass",
1090
+ message: `Config uses Schemastery Schema (${schemaConfigs.length} file(s))`,
1091
+ skillRef: ref
1092
+ };
1093
+ }
1094
+ function checkRedlineEffectRegistration(root) {
1095
+ const ref = skillRefFor("redline-effect-registration");
1096
+ const files = listSourceFiles(root);
1097
+ const manualTeardown = [];
1098
+ for (const file of files) {
1099
+ const text = readText(file) ?? "";
1100
+ if (/removeListener\(|removeAllListeners\(|\.off\(/.test(text)) manualTeardown.push(`${relativePath(root, file)}: manual event-listener teardown detected; registrations must be reversible via ctx.effect()/disposer`);
1101
+ }
1102
+ if (manualTeardown.length > 0) return {
1103
+ id: "redline-effect-registration",
1104
+ severity: "warning",
1105
+ kind: "heuristic",
1106
+ status: "warn",
1107
+ message: "possible non-effect manual teardown",
1108
+ skillRef: ref,
1109
+ detail: manualTeardown
1110
+ };
1111
+ return {
1112
+ id: "redline-effect-registration",
1113
+ severity: "warning",
1114
+ kind: "heuristic",
1115
+ status: "pass",
1116
+ message: "no manual teardown patterns detected",
1117
+ skillRef: ref
1118
+ };
1119
+ }
1120
+ function relativePath(root, file) {
1121
+ return file.replace(root.replace(/[\\/]$/, ""), "").replace(/^[\\/]/, "");
1122
+ }
1123
+
1124
+ //#endregion
1125
+ //#region src/cli/lib/subprocess.ts
1126
+ /**
1127
+ * Run a command and capture its output. Uses piped stdio so callers can read
1128
+ * logs; the process is terminated on timeout or external abort.
1129
+ * @param command - executable name or path.
1130
+ * @param args - argument vector.
1131
+ * @param options - cwd, env, timeout, and abort signal.
1132
+ * @returns the exit code, captured streams, and timeout flag.
1133
+ */
1134
+ function run(command, args, options = {}) {
1135
+ return new Promise((resolve) => {
1136
+ const controller = new AbortController();
1137
+ let timedOut = false;
1138
+ let settled = false;
1139
+ const finish = (result) => {
1140
+ if (settled) return;
1141
+ settled = true;
1142
+ clearTimeout(timer);
1143
+ options.signal?.removeEventListener("abort", onAbort);
1144
+ resolve(result);
1145
+ };
1146
+ const timer = options.timeoutMs ? setTimeout(() => {
1147
+ timedOut = true;
1148
+ controller.abort(/* @__PURE__ */ new Error(`command timed out after ${options.timeoutMs}ms`));
1149
+ }, options.timeoutMs) : void 0;
1150
+ const onAbort = () => controller.abort();
1151
+ options.signal?.addEventListener("abort", onAbort);
1152
+ const env = { ...process.env };
1153
+ for (const [key, value] of Object.entries(options.env ?? {})) if (value === void 0) delete env[key];
1154
+ else env[key] = value;
1155
+ const child = spawnCommand(command, args, {
1156
+ cwd: options.cwd,
1157
+ env,
1158
+ signal: controller.signal
1159
+ });
1160
+ let stdout = "";
1161
+ let stderr = "";
1162
+ child.stdout?.setEncoding("utf8");
1163
+ child.stderr?.setEncoding("utf8");
1164
+ child.stdout?.on("data", (chunk) => {
1165
+ stdout += chunk;
1166
+ });
1167
+ child.stderr?.on("data", (chunk) => {
1168
+ stderr += chunk;
1169
+ });
1170
+ child.on("error", (err) => {
1171
+ finish({
1172
+ code: null,
1173
+ stdout,
1174
+ stderr: `${stderr}\n${err.message}`,
1175
+ timedOut,
1176
+ signal: null
1177
+ });
1178
+ });
1179
+ child.on("close", (code, signal) => {
1180
+ finish({
1181
+ code,
1182
+ stdout,
1183
+ stderr,
1184
+ timedOut,
1185
+ signal
1186
+ });
1187
+ });
1188
+ });
1189
+ }
1190
+ function spawnCommand(command, args, options) {
1191
+ if (process.platform === "win32") return spawn(winCommandLine(command, args), {
1192
+ cwd: options.cwd,
1193
+ env: options.env,
1194
+ stdio: [
1195
+ "ignore",
1196
+ "pipe",
1197
+ "pipe"
1198
+ ],
1199
+ signal: options.signal,
1200
+ shell: true,
1201
+ windowsHide: true
1202
+ });
1203
+ return spawn(command, args, {
1204
+ cwd: options.cwd,
1205
+ env: options.env,
1206
+ stdio: [
1207
+ "ignore",
1208
+ "pipe",
1209
+ "pipe"
1210
+ ],
1211
+ signal: options.signal
1212
+ });
1213
+ }
1214
+ function winCommandLine(command, args) {
1215
+ return [command, ...args].map(quoteWin).join(" ");
1216
+ }
1217
+ function quoteWin(arg) {
1218
+ if (!/[\s"&|<>^()]/.test(arg)) return arg;
1219
+ return `"${arg.replace(/(["\\])/g, "\\$1")}"`;
1220
+ }
1221
+ /** Format a run result's streams into a short tail for failure reports. */
1222
+ function tailOf(result, lines = 40) {
1223
+ return `${result.stdout}\n${result.stderr}`.trim().split(/\r?\n/).slice(-lines).join("\n");
1224
+ }
1225
+
1226
+ //#endregion
1227
+ //#region src/cli/commands/new.ts
1228
+ const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
1229
+ /** Normalize and validate a user-supplied plugin name. */
1230
+ function normalizeName(raw) {
1231
+ let name = raw.trim();
1232
+ if (name.startsWith("@")) {
1233
+ const slash = name.indexOf("/");
1234
+ if (slash >= 0) name = name.slice(slash + 1);
1235
+ }
1236
+ if (name.startsWith("dsh-")) name = name.slice(4);
1237
+ if (!NAME_RE.test(name)) throw new Error(`invalid plugin name "${raw}": use lowercase letters, digits, and hyphens (e.g. hello-plugin)`);
1238
+ return {
1239
+ name,
1240
+ pkgName: `dsh-${name}`
1241
+ };
1242
+ }
1243
+ /** True when the directory exists and contains any entry. */
1244
+ function dirNonEmpty(dir) {
1245
+ if (!isDir(dir)) return false;
1246
+ return readdirSync(dir).length > 0;
1247
+ }
1248
+ /**
1249
+ * Run the scaffolder. Returns the target directory path.
1250
+ * @param cwd - current working directory.
1251
+ * @param options - name, language, target dir, force, and git flags.
1252
+ */
1253
+ async function runNew(cwd, options) {
1254
+ const { name, pkgName } = normalizeName(options.name);
1255
+ const targetDir = resolve(options.dir ?? join(cwd, name));
1256
+ if (dirNonEmpty(targetDir) && !options.force) throw new Error(`target directory already exists and is not empty: ${targetDir} (use --force to overwrite template files)`);
1257
+ const year = String((/* @__PURE__ */ new Date()).getFullYear());
1258
+ const files = renderScaffold(options.lang, {
1259
+ name,
1260
+ pkgName,
1261
+ version: "0.1.0",
1262
+ year
1263
+ });
1264
+ writeScaffold(targetDir, files);
1265
+ if (options.git) await initGit(targetDir);
1266
+ return {
1267
+ targetDir,
1268
+ files: files.length
1269
+ };
1270
+ }
1271
+ async function initGit(targetDir) {
1272
+ await run("git", ["init"], {
1273
+ cwd: targetDir,
1274
+ timeoutMs: 3e4
1275
+ });
1276
+ }
1277
+
1278
+ //#endregion
1279
+ //#region src/cli/commands/verify.ts
1280
+ const PROFILE_WORKSPACE = `# scratch profile allowlist (mirrors the repo's compat workflow)
1281
+ packages:
1282
+ - .
1283
+ nodeLinker: hoisted
1284
+ autoInstallPeers: false
1285
+ allowBuilds:
1286
+ '@deepseek-ai/dsh-subprocess-local': true
1287
+ koffi: true
1288
+ node-pty: true
1289
+ protobufjs: true
1290
+ '@google/genai': true
1291
+ `;
1292
+ /**
1293
+ * Run the full verify smoke. Never throws for a verification failure; instead it
1294
+ * returns a non-zero exit code with the failing step tail and suggestions. Temp
1295
+ * directories are always cleaned up in `finally`.
1296
+ */
1297
+ async function runVerify(options) {
1298
+ const root = resolve(options.root);
1299
+ const pkgName = readJsonIfExists(join(root, "package.json"))?.name ?? "plugin";
1300
+ const workDir = createTempDir("verify");
1301
+ const homeDir = createTempDir("home");
1302
+ const steps = [];
1303
+ let failure;
1304
+ const push = (step, result) => {
1305
+ const ok = result.code === 0 && !result.timedOut;
1306
+ steps.push({
1307
+ step,
1308
+ ok,
1309
+ detail: ok ? "ok" : tailOf(result)
1310
+ });
1311
+ };
1312
+ try {
1313
+ const pack = await run(options.pnpmBin, [
1314
+ "pack",
1315
+ "--pack-destination",
1316
+ workDir
1317
+ ], {
1318
+ cwd: root,
1319
+ timeoutMs: options.timeoutMs
1320
+ });
1321
+ push("pack", pack);
1322
+ if (pack.code !== 0) {
1323
+ failure = {
1324
+ error: "pnpm pack failed",
1325
+ suggestions: suggestionsFor("pack", pack)
1326
+ };
1327
+ return {
1328
+ steps,
1329
+ exitCode: 1,
1330
+ ...failure
1331
+ };
1332
+ }
1333
+ const tarball = findTarball(workDir);
1334
+ if (!tarball) {
1335
+ failure = {
1336
+ error: `no tarball produced in ${workDir}`,
1337
+ suggestions: ["run `pnpm pack --pack-destination <dir>` manually and inspect the output"]
1338
+ };
1339
+ return {
1340
+ steps,
1341
+ exitCode: 1,
1342
+ ...failure
1343
+ };
1344
+ }
1345
+ const profileDir = join(homeDir, "profiles", options.profile);
1346
+ writeFileDeep(join(profileDir, "pnpm-workspace.yaml"), PROFILE_WORKSPACE);
1347
+ const add = await run(options.dshBin, [
1348
+ "plugin",
1349
+ "--profile",
1350
+ options.profile,
1351
+ "add",
1352
+ options.base,
1353
+ options.headless,
1354
+ tarball
1355
+ ], {
1356
+ cwd: homeDir,
1357
+ env: { DSH_HOME: homeDir },
1358
+ timeoutMs: options.timeoutMs
1359
+ });
1360
+ push("install", add);
1361
+ if (add.code !== 0) {
1362
+ failure = {
1363
+ error: "profile install failed",
1364
+ suggestions: suggestionsFor("install", add)
1365
+ };
1366
+ return {
1367
+ steps,
1368
+ exitCode: 1,
1369
+ ...failure
1370
+ };
1371
+ }
1372
+ const dump = await run(options.dshBin, [
1373
+ "--profile",
1374
+ options.profile,
1375
+ "--dump-config"
1376
+ ], {
1377
+ cwd: homeDir,
1378
+ env: { DSH_HOME: homeDir },
1379
+ timeoutMs: options.timeoutMs
1380
+ });
1381
+ push("dump-config", dump);
1382
+ if (dump.code !== 0) {
1383
+ failure = {
1384
+ error: "dump-config failed",
1385
+ suggestions: suggestionsFor("dump-config", dump)
1386
+ };
1387
+ return {
1388
+ steps,
1389
+ exitCode: 1,
1390
+ ...failure
1391
+ };
1392
+ }
1393
+ if (!dump.stdout.includes(pkgName)) {
1394
+ failure = {
1395
+ error: `bundle row "${pkgName}" did not mount`,
1396
+ suggestions: [`dump-config output did not contain "${pkgName}"`]
1397
+ };
1398
+ return {
1399
+ steps,
1400
+ exitCode: 1,
1401
+ ...failure
1402
+ };
1403
+ }
1404
+ const smoke = await run(options.dshBin, [
1405
+ "--profile",
1406
+ options.profile,
1407
+ "Reply with exactly: ok"
1408
+ ], {
1409
+ cwd: homeDir,
1410
+ env: { DSH_HOME: homeDir },
1411
+ timeoutMs: options.smokeTimeoutMs
1412
+ });
1413
+ const smokeOk = isSmokeOk(smoke.stdout, smoke.stderr);
1414
+ steps.push({
1415
+ step: "headless-smoke",
1416
+ ok: smokeOk,
1417
+ detail: smokeOk ? `${smoke.stdout}\n${smoke.stderr}`.includes("MISSING_CREDENTIAL") ? "ok (keyless)" : "ok (with key)" : tailOf(smoke)
1418
+ });
1419
+ if (!smokeOk) {
1420
+ failure = {
1421
+ error: "headless smoke did not prove tree load",
1422
+ suggestions: suggestionsFor("headless", smoke)
1423
+ };
1424
+ return {
1425
+ steps,
1426
+ exitCode: 1,
1427
+ ...failure
1428
+ };
1429
+ }
1430
+ const remove = await run(options.dshBin, [
1431
+ "plugin",
1432
+ "--profile",
1433
+ options.profile,
1434
+ "remove",
1435
+ pkgName
1436
+ ], {
1437
+ cwd: homeDir,
1438
+ env: { DSH_HOME: homeDir },
1439
+ timeoutMs: options.timeoutMs
1440
+ });
1441
+ push("uninstall", remove);
1442
+ if (remove.code !== 0) {
1443
+ failure = {
1444
+ error: "uninstall failed",
1445
+ suggestions: suggestionsFor("uninstall", remove)
1446
+ };
1447
+ return {
1448
+ steps,
1449
+ exitCode: 1,
1450
+ ...failure
1451
+ };
1452
+ }
1453
+ return {
1454
+ steps,
1455
+ exitCode: 0,
1456
+ suggestions: []
1457
+ };
1458
+ } finally {
1459
+ cleanupAllTempDirs();
1460
+ }
1461
+ }
1462
+ function findTarball(dir) {
1463
+ for (const entry of readdirSync(dir)) if (entry.endsWith(".tgz")) return join(dir, entry);
1464
+ }
1465
+ function suggestionsFor(step, result) {
1466
+ const base = [`tail of "${step}":\n${tailOf(result)}`];
1467
+ if (result.timedOut) base.push(`command timed out; raise --timeout or --smoke-timeout`);
1468
+ if (step === "install") {
1469
+ base.push("ensure the dsh CLI is @deepseek-ai/dsh@0.1.1-rc.2 (older rc.6 PATH builds do not satisfy the compat pin)");
1470
+ base.push("confirm the profile allowlist matches the repo compat workflow (native builds allowlisted)");
1471
+ }
1472
+ if (step === "headless") base.push("a hang usually means an injected service stayed pending; the smoke timeout surfaces exactly that");
1473
+ return base;
1474
+ }
1475
+ /**
1476
+ * True when a headless smoke run proves the plugin tree loaded: keyless runs
1477
+ * print `MISSING_CREDENTIAL` (on stderr), keyed runs print `ok`.
1478
+ * @param stdout - captured stdout.
1479
+ * @param stderr - captured stderr.
1480
+ */
1481
+ function isSmokeOk(stdout, stderr) {
1482
+ const text = `${stdout}\n${stderr}`;
1483
+ return text.includes("MISSING_CREDENTIAL") || /(^|[^a-z])ok([^a-z]|$)/i.test(text);
1484
+ }
1485
+ /** Resolve the dsh binary: explicit flag/env first, then PATH `dsh`. */
1486
+ function resolveDsh(flag) {
1487
+ return flag ?? process.env.DSH_PLUGIN_DEV_DSH ?? "dsh";
1488
+ }
1489
+ /** Resolve the pnpm binary: explicit flag/env first, then PATH `pnpm`. */
1490
+ function resolvePnpm(flag) {
1491
+ return flag ?? process.env.DSH_PLUGIN_DEV_PNPM ?? "pnpm";
1492
+ }
1493
+ /** Format the verify steps for the console. */
1494
+ function renderVerifySteps(root, steps) {
1495
+ const lines = [`verify: ${root}`, ""];
1496
+ for (const step of steps) lines.push(`${step.ok ? "✓" : "✗"} ${step.step}${step.ok ? "" : `\n${step.detail}`}`);
1497
+ return lines.join("\n");
1498
+ }
1499
+
1500
+ //#endregion
1501
+ //#region src/cli/main.ts
1502
+ const HELP = `dsh-plugin-dev — the DeepSeek Harness plugin-development CLI
1503
+
1504
+ Usage:
1505
+ dsh-plugin-dev <command> [options]
1506
+
1507
+ Commands:
1508
+ new <name> Scaffold a TypeScript or JavaScript plugin repo skeleton
1509
+ (src/index.ts contract template, Schemastery Config, tests,
1510
+ tsdown/vitest, cordis.patch.yml, five-language READMEs).
1511
+ Flags: --lang <ts|js> --dir <path> --force --git
1512
+ check Run static plugin checks and emit a CI-consumable report.
1513
+ Flags: --cwd <dir> --json --strict
1514
+ verify pnpm pack, then install/start/uninstall the bundle in a clean
1515
+ mkdtemp DSH_HOME profile (aligned with verify:self-contained).
1516
+ Flags: --cwd <dir> --dsh <bin> --pnpm <bin> --profile <name>
1517
+ --base <spec> --headless <spec> --timeout <ms>
1518
+ --smoke-timeout <ms>
1519
+
1520
+ Global:
1521
+ -h, --help Show this help
1522
+ -V, --version Print the CLI version
1523
+
1524
+ Environment tunables:
1525
+ DSH_PLUGIN_DEV_TEMPLATES override the scaffold templates directory
1526
+ DSH_PLUGIN_DEV_DSH override the dsh CLI used by verify
1527
+ DSH_PLUGIN_DEV_PNPM override the pnpm binary used by verify
1528
+ DSH_PLUGIN_DEV_TIMEOUT install/pack timeout in ms (default 300000)
1529
+ DSH_PLUGIN_DEV_SMOKE_TIMEOUT headless smoke timeout in ms (default 120000)
1530
+ `;
1531
+ const DEFAULT_TIMEOUT_MS = 3e5;
1532
+ const DEFAULT_SMOKE_TIMEOUT_MS = 12e4;
1533
+ /** Parse a millisecond integer flag with an env-tunable fallback. */
1534
+ function timeoutMs(flags, key, envKey, fallback) {
1535
+ const raw = flagString(flags, key, process.env[envKey]);
1536
+ if (raw === void 0) return fallback;
1537
+ const parsed = Number.parseInt(raw, 10);
1538
+ if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`invalid ${key} value "${raw}": expected a positive millisecond count`);
1539
+ return parsed;
1540
+ }
1541
+ /**
1542
+ * Run the CLI and return a process exit code.
1543
+ * @param argv - process.argv.slice(2) style input.
1544
+ * @param cwd - working directory (injectable for tests).
1545
+ */
1546
+ async function main(argv, cwd = process.cwd()) {
1547
+ const { positionals, flags } = parseArgs(argv);
1548
+ if (flagBool(flags, "version") || flags.V === true) {
1549
+ process.stdout.write(`${readCliVersion()}\n`);
1550
+ return 0;
1551
+ }
1552
+ if (flagBool(flags, "help") || flags.h === true || positionals.length === 0) {
1553
+ process.stdout.write(HELP);
1554
+ return 0;
1555
+ }
1556
+ const command = positionals[0];
1557
+ try {
1558
+ switch (command) {
1559
+ case "new": {
1560
+ const name = positionals[1];
1561
+ if (!name) throw new Error("missing plugin name: dsh-plugin-dev new <name>");
1562
+ const lang = flagString(flags, "lang", "ts");
1563
+ if (lang !== "ts" && lang !== "js") throw new Error(`--lang must be "ts" or "js", got "${lang}"`);
1564
+ const result = await runNew(cwd, {
1565
+ name,
1566
+ lang,
1567
+ dir: flagString(flags, "dir"),
1568
+ force: flagBool(flags, "force"),
1569
+ git: flagBool(flags, "git")
1570
+ });
1571
+ process.stdout.write(`scaffolded ${result.files} files into ${result.targetDir}\n`);
1572
+ return 0;
1573
+ }
1574
+ case "check": {
1575
+ const root = flagString(flags, "cwd", cwd) ?? cwd;
1576
+ const { report, exitCode } = runCheck({
1577
+ root,
1578
+ strict: flagBool(flags, "strict")
1579
+ });
1580
+ printCheckReport(report, flagBool(flags, "json") ? "json" : "text");
1581
+ return exitCode;
1582
+ }
1583
+ case "verify": {
1584
+ const root = flagString(flags, "cwd", cwd) ?? cwd;
1585
+ const result = await runVerify({
1586
+ root,
1587
+ dshBin: resolveDsh(flagString(flags, "dsh")),
1588
+ pnpmBin: resolvePnpm(flagString(flags, "pnpm")),
1589
+ profile: flagString(flags, "profile", "compat") ?? "compat",
1590
+ base: flagString(flags, "base", "@deepseek-ai/dsh-base@0.1.1-rc.2") ?? "@deepseek-ai/dsh-base@0.1.1-rc.2",
1591
+ headless: flagString(flags, "headless", "@deepseek-ai/dsh-headless@0.1.1-rc.2") ?? "@deepseek-ai/dsh-headless@0.1.1-rc.2",
1592
+ timeoutMs: timeoutMs(flags, "timeout", "DSH_PLUGIN_DEV_TIMEOUT", DEFAULT_TIMEOUT_MS),
1593
+ smokeTimeoutMs: timeoutMs(flags, "smoke-timeout", "DSH_PLUGIN_DEV_SMOKE_TIMEOUT", DEFAULT_SMOKE_TIMEOUT_MS)
1594
+ });
1595
+ process.stdout.write(renderVerifySteps(root, result.steps));
1596
+ process.stdout.write("\n");
1597
+ if (result.error) {
1598
+ process.stdout.write(`FAIL: ${result.error}\n`);
1599
+ for (const suggestion of result.suggestions) process.stdout.write(` - ${suggestion}\n`);
1600
+ } else process.stdout.write("verify: OK\n");
1601
+ return result.exitCode;
1602
+ }
1603
+ default:
1604
+ process.stderr.write(`unknown command "${command}"\n\n${HELP}`);
1605
+ return 2;
1606
+ }
1607
+ } catch (err) {
1608
+ process.stderr.write(`dsh-plugin-dev: ${err instanceof Error ? err.message : String(err)}\n`);
1609
+ return 1;
1610
+ }
1611
+ }
1612
+
1613
+ //#endregion
1614
+ //#region src/cli/entry.ts
1615
+ main(process.argv.slice(2)).then((code) => {
1616
+ process.exitCode = code;
1617
+ }, (err) => {
1618
+ process.stderr.write(`dsh-plugin-dev: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
1619
+ process.exitCode = 1;
1620
+ });
1621
+
1622
+ //#endregion
1623
+ export { };