dsh-plugin-guide 0.1.2 → 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/README.es.md +54 -3
- package/README.hi.md +54 -3
- package/README.md +55 -4
- package/README.pt.md +54 -3
- package/README.zh.md +55 -4
- package/SKILL.md +11 -1
- package/bin/dsh-plugin-dev.js +6 -0
- package/cordis.patch.yml +5 -0
- package/dist/dsh-plugin-dev.js +1623 -0
- package/package.json +39 -3
- package/scripts/verify-artifacts.mjs +70 -0
- package/templates/js/LICENSE +15 -0
- package/templates/js/README.es.md +42 -0
- package/templates/js/README.hi.md +42 -0
- package/templates/js/README.md +42 -0
- package/templates/js/README.pt.md +42 -0
- package/templates/js/README.zh.md +42 -0
- package/templates/js/cordis.patch.yml +10 -0
- package/templates/js/index.js +38 -0
- package/templates/js/package.json +35 -0
- package/templates/js/tests/index.test.js +12 -0
- package/templates/ts/LICENSE +15 -0
- package/templates/ts/README.es.md +44 -0
- package/templates/ts/README.hi.md +44 -0
- package/templates/ts/README.md +44 -0
- package/templates/ts/README.pt.md +44 -0
- package/templates/ts/README.zh.md +44 -0
- package/templates/ts/cordis.patch.yml +10 -0
- package/templates/ts/package.json +44 -0
- package/templates/ts/src/config.ts +12 -0
- package/templates/ts/src/index.ts +36 -0
- package/templates/ts/tests/index.test.ts +12 -0
- package/templates/ts/tsconfig.json +17 -0
- package/templates/ts/tsdown.config.mjs +15 -0
- 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 { };
|