@cyning/harness 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cli.js CHANGED
@@ -1,6 +1,9 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import path from 'node:path';
1
3
  import { readManifest, isNewerVersion } from './manifest.js';
2
4
  import {
3
5
  resolveHarnessRoot,
6
+ resolveHarnessRootForTarget,
4
7
  resolvePackageVersion,
5
8
  resolveTarget,
6
9
  wizardScript,
@@ -8,15 +11,21 @@ import {
8
11
  import { runBash } from './run.js';
9
12
  import { checkTaskFile } from './task.js';
10
13
  import { auditTarget } from './audit.js';
14
+ import { mergePackageScripts } from './package-scripts.js';
11
15
 
12
16
  function usage() {
13
- console.log(`@cyning/harness · cyning-harness CLI (v0.3+)
17
+ console.log(`@cyning/harness · cyning-harness CLI (v1.0.2)
14
18
 
15
19
  用法:
16
- npx @cyning/harness init [--preset NAME] [--ide LIST] [--target PATH] [--yes]
17
- npx @cyning/harness upgrade [--target PATH] [--ide LIST] [--yes] [--force] [--gate-check]
20
+ npx @cyning/harness --version | -V
21
+ npx @cyning/harness --help | -h
22
+ npx @cyning/harness init [--preset NAME] [--ide LIST] [--target PATH] [--yes] [--with-scripts] [--pm pnpm|npm|yarn|auto]
23
+ npx @cyning/harness upgrade [--target PATH] [--ide LIST] [--yes] [--force] [--gate-check] [--with-scripts] [--pm pnpm|npm|yarn|auto]
18
24
  npx @cyning/harness check [--target PATH]
19
25
  npx @cyning/harness audit [--target PATH] [--task FILE]
26
+ npx @cyning/harness gate-check [--target PATH] [--task FILE] [--graph] [--json]
27
+ npx @cyning/harness verify [--target PATH] [--task FILE] [--graph]
28
+ npx @cyning/harness sync index [--target PATH]
20
29
  npx @cyning/harness task check --file PATH [--no-circular] [--registry DIR]...
21
30
 
22
31
  说明:
@@ -24,6 +33,9 @@ function usage() {
24
33
  upgrade 升级已接入仓 · 等价 wizard/upgrade.sh --yes
25
34
  check 读 manifest.json · 对比当前包版本是否有可升级版本
26
35
  audit ICVO 机械审计:复用 gate-check · D5 测试声明检查 · S5 git-clean 提醒
36
+ gate-check 调用 wizard/gate-check.sh(业务仓无需 clone 即可用)
37
+ verify 30 前聚合:gate-check + audit D5 + S5 warn + 可选 --graph
38
+ sync index 生成 .cyning-harness/invoke_index.json
27
39
  task check 校验 task.harness.v1.json sidecar · --no-circular 检测 depends_on 环
28
40
 
29
41
  环境:
@@ -51,6 +63,11 @@ export async function runCli(argv) {
51
63
  return;
52
64
  }
53
65
 
66
+ if (argv.includes('--version') || argv.includes('-V')) {
67
+ console.log(pkgVersion);
68
+ return;
69
+ }
70
+
54
71
  const [cmd, ...rest] = argv;
55
72
 
56
73
  if (cmd === 'init') {
@@ -69,6 +86,18 @@ export async function runCli(argv) {
69
86
  await cmdAudit(rest);
70
87
  return;
71
88
  }
89
+ if (cmd === 'gate-check') {
90
+ await cmdGateCheck(rest);
91
+ return;
92
+ }
93
+ if (cmd === 'verify') {
94
+ await cmdVerify(rest);
95
+ return;
96
+ }
97
+ if (cmd === 'sync') {
98
+ await cmdSync(rest);
99
+ return;
100
+ }
72
101
  if (cmd === 'task') {
73
102
  await cmdTask(rest);
74
103
  return;
@@ -81,13 +110,16 @@ export async function runCli(argv) {
81
110
 
82
111
  async function cmdInit(args, harnessRoot, pkgVersion) {
83
112
  const yes = args.includes('--yes');
84
- let rest = args.filter((a) => a !== '--yes');
113
+ const withScripts = args.includes('--with-scripts');
114
+ let rest = args.filter((a) => a !== '--yes' && a !== '--with-scripts');
85
115
  const { value: preset, rest: r1 } = takeOption(rest, '--preset');
86
116
  rest = r1;
87
117
  const { value: ide, rest: r2 } = takeOption(rest, '--ide');
88
118
  rest = r2;
89
119
  const { value: targetArg, rest: r3 } = takeOption(rest, '--target');
90
120
  rest = r3;
121
+ const { value: pm, rest: r4 } = takeOption(rest, '--pm');
122
+ rest = r4;
91
123
 
92
124
  if (rest.length > 0) {
93
125
  const err = new Error(`init 未知参数: ${rest.join(' ')}`);
@@ -109,6 +141,18 @@ async function cmdInit(args, harnessRoot, pkgVersion) {
109
141
  inherit: true,
110
142
  });
111
143
 
144
+ if (withScripts) {
145
+ const ps = mergePackageScripts(target, pkgVersion, pm || 'auto');
146
+ if (ps.skipped) {
147
+ console.log(`warn: ${ps.reason}`);
148
+ } else if (ps.changed) {
149
+ console.log(`scripts: ${ps.reason}(pm: ${ps.pm})`);
150
+ console.log('提示: 运行 `pnpm install` 或对应 install 使 devDep 生效');
151
+ } else {
152
+ console.log(`scripts: ${ps.reason}`);
153
+ }
154
+ }
155
+
112
156
  console.log(`manifest: ${target}/.cyning-harness/manifest.json`);
113
157
  if (!yes) {
114
158
  console.log('init 完成。');
@@ -120,13 +164,23 @@ async function cmdUpgrade(args, harnessRoot, pkgVersion) {
120
164
  const force = args.includes('--force');
121
165
  const gateCheck = args.includes('--gate-check');
122
166
  const yes = args.includes('--yes') || !checkOnly;
167
+ const withScripts = args.includes('--with-scripts');
123
168
  let rest = args.filter(
124
- (a) => !['--check', '--force', '--gate-check', '--yes'].includes(a),
169
+ (a) =>
170
+ ![
171
+ '--check',
172
+ '--force',
173
+ '--gate-check',
174
+ '--yes',
175
+ '--with-scripts',
176
+ ].includes(a),
125
177
  );
126
178
  const { value: ide, rest: r1 } = takeOption(rest, '--ide');
127
179
  rest = r1;
128
180
  const { value: targetArg, rest: r2 } = takeOption(rest, '--target');
129
181
  rest = r2;
182
+ const { value: pm, rest: r3 } = takeOption(rest, '--pm');
183
+ rest = r3;
130
184
 
131
185
  if (rest.length > 0) {
132
186
  const err = new Error(`upgrade 未知参数: ${rest.join(' ')}`);
@@ -156,6 +210,18 @@ async function cmdUpgrade(args, harnessRoot, pkgVersion) {
156
210
  },
157
211
  inherit: true,
158
212
  });
213
+
214
+ if (withScripts) {
215
+ const ps = mergePackageScripts(target, pkgVersion, pm || 'auto');
216
+ if (ps.skipped) {
217
+ console.log(`warn: ${ps.reason}`);
218
+ } else if (ps.changed) {
219
+ console.log(`scripts: ${ps.reason}(pm: ${ps.pm})`);
220
+ console.log('提示: 运行 `pnpm install` 或对应 install 使 devDep 生效');
221
+ } else {
222
+ console.log(`scripts: ${ps.reason}`);
223
+ }
224
+ }
159
225
  }
160
226
 
161
227
  async function cmdCheck(args, harnessRoot, pkgVersion) {
@@ -236,6 +302,133 @@ async function cmdAudit(args) {
236
302
  }
237
303
  }
238
304
 
305
+ async function cmdGateCheck(args) {
306
+ if (args.includes('--help') || args.includes('-h')) {
307
+ console.log(`用法: npx @cyning/harness gate-check [--target PATH] [--task FILE] [--graph] [--json]`);
308
+ return;
309
+ }
310
+
311
+ let rest = args;
312
+ const { value: targetArg, rest: r1 } = takeOption(rest, '--target');
313
+ rest = r1;
314
+ const { value: taskFile, rest: r2 } = takeOption(rest, '--task');
315
+ rest = r2;
316
+ const graph = rest.includes('--graph');
317
+ const json = rest.includes('--json');
318
+ rest = rest.filter((a) => a !== '--graph' && a !== '--json');
319
+
320
+ if (rest.length > 0) {
321
+ const err = new Error(`gate-check 未知参数: ${rest.join(' ')}`);
322
+ err.exitCode = 1;
323
+ throw err;
324
+ }
325
+
326
+ const target = resolveTarget(process.cwd(), targetArg);
327
+ const harnessRoot = resolveHarnessRootForTarget(target);
328
+ const script = wizardScript(harnessRoot, 'gate-check.sh');
329
+ const childArgs = ['--target', target];
330
+ if (taskFile) childArgs.push('--task', taskFile);
331
+ if (graph) childArgs.push('--graph');
332
+ if (json) childArgs.push('--json');
333
+
334
+ const result = spawnSync('bash', [script, ...childArgs], {
335
+ stdio: 'inherit',
336
+ env: { ...process.env, CYNING_HARNESS: harnessRoot },
337
+ });
338
+
339
+ const exitCode = result.status ?? 1;
340
+ if (exitCode !== 0) {
341
+ const err = new Error('');
342
+ err.exitCode = exitCode;
343
+ throw err;
344
+ }
345
+ }
346
+
347
+ async function cmdVerify(args) {
348
+ // D3 · implemented in lib/verify.js
349
+ const { verifyTarget } = await import('./verify.js');
350
+ if (args.includes('--help') || args.includes('-h')) {
351
+ console.log(`用法: npx @cyning/harness verify [--target PATH] [--task FILE] [--graph]
352
+
353
+ 无 --task 时:扫描 docs/tasks/active/task_*.md 并逐项列出闸表(同 gate-check)。
354
+ 任一 task 阻塞则 VERIFY: BLOCKED · exit 2;全部可 30 则 VERIFY: PASS。
355
+ `);
356
+ return;
357
+ }
358
+
359
+ let rest = args;
360
+ const { value: targetArg, rest: r1 } = takeOption(rest, '--target');
361
+ rest = r1;
362
+ const { value: taskFile, rest: r2 } = takeOption(rest, '--task');
363
+ rest = r2;
364
+ const graph = rest.includes('--graph');
365
+ rest = rest.filter((a) => a !== '--graph');
366
+
367
+ if (rest.length > 0) {
368
+ const err = new Error(`verify 未知参数: ${rest.join(' ')}`);
369
+ err.exitCode = 1;
370
+ throw err;
371
+ }
372
+
373
+ const target = resolveTarget(process.cwd(), targetArg);
374
+ const result = verifyTarget(target, { taskFile, graph });
375
+
376
+ // forward gate-check/audit/graph stdout if any
377
+ if (result.stdout) {
378
+ process.stdout.write(result.stdout);
379
+ }
380
+
381
+ const summary = taskFile
382
+ ? `VERIFY: ${result.ok ? 'PASS' : 'BLOCKED'}${result.reason ? ` · ${result.reason}` : ''} · ${path.basename(taskFile)}`
383
+ : `VERIFY: ${result.ok ? 'PASS' : 'BLOCKED'}${result.reason ? ` · ${result.reason}` : ''}`;
384
+ console.log(summary);
385
+
386
+ if (!result.ok) {
387
+ process.exitCode = result.exitCode ?? 2;
388
+ return;
389
+ }
390
+ }
391
+
392
+ async function cmdSync(args) {
393
+ if (args.includes('--help') || args.includes('-h')) {
394
+ console.log(`用法: npx @cyning/harness sync index [--target PATH]`);
395
+ return;
396
+ }
397
+
398
+ const [sub, ...rest] = args;
399
+ if (sub !== 'index') {
400
+ const err = new Error(`sync 子命令未知: ${sub ?? '(空)'}\n用法: sync index [--target PATH]`);
401
+ err.exitCode = 1;
402
+ throw err;
403
+ }
404
+
405
+ let remaining = rest;
406
+ const { value: targetArg, rest: r1 } = takeOption(remaining, '--target');
407
+ remaining = r1;
408
+
409
+ if (remaining.length > 0) {
410
+ const err = new Error(`sync index 未知参数: ${remaining.join(' ')}`);
411
+ err.exitCode = 1;
412
+ throw err;
413
+ }
414
+
415
+ const target = resolveTarget(process.cwd(), targetArg);
416
+ const harnessRoot = resolveHarnessRootForTarget(target);
417
+ const script = wizardScript(harnessRoot, 'harness-sync.sh');
418
+
419
+ const result = spawnSync('bash', [script, '--index', '--target', target], {
420
+ stdio: 'inherit',
421
+ env: { ...process.env, CYNING_HARNESS: harnessRoot },
422
+ });
423
+
424
+ const exitCode = result.status ?? 1;
425
+ if (exitCode !== 0) {
426
+ const err = new Error('');
427
+ err.exitCode = exitCode;
428
+ throw err;
429
+ }
430
+ }
431
+
239
432
  async function cmdTask(args) {
240
433
  const [sub, ...rest] = args;
241
434
  if (sub !== 'check') {
@@ -0,0 +1,65 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const DEFAULT_SCRIPTS = {
5
+ 'harness:verify': 'harness verify',
6
+ 'harness:gate': 'harness gate-check',
7
+ 'harness:audit': 'harness audit',
8
+ };
9
+
10
+ function detectPackageManager(target) {
11
+ if (fs.existsSync(path.join(target, 'pnpm-lock.yaml'))) return 'pnpm';
12
+ if (fs.existsSync(path.join(target, 'yarn.lock'))) return 'yarn';
13
+ if (fs.existsSync(path.join(target, 'package-lock.json'))) return 'npm';
14
+ return 'npm';
15
+ }
16
+
17
+ /**
18
+ * 在 target 仓的 package.json 中 merge devDep + scripts(不覆盖已有 key)。
19
+ * @returns {{ok: boolean, skipped?: boolean, reason?: string, changed: boolean}}
20
+ */
21
+ export function mergePackageScripts(target, version, pm = 'auto') {
22
+ const pkgPath = path.join(target, 'package.json');
23
+ if (!fs.existsSync(pkgPath)) {
24
+ return {
25
+ ok: true,
26
+ skipped: true,
27
+ reason: '未找到 package.json,跳过 --with-scripts',
28
+ changed: false,
29
+ };
30
+ }
31
+
32
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
33
+ const original = JSON.stringify(pkg);
34
+
35
+ if (!pkg.devDependencies) pkg.devDependencies = {};
36
+ if (pkg.devDependencies['@cyning/harness'] !== version) {
37
+ pkg.devDependencies['@cyning/harness'] = version;
38
+ }
39
+
40
+ if (!pkg.scripts) pkg.scripts = {};
41
+ for (const [key, value] of Object.entries(DEFAULT_SCRIPTS)) {
42
+ if (!Object.prototype.hasOwnProperty.call(pkg.scripts, key)) {
43
+ pkg.scripts[key] = value;
44
+ }
45
+ }
46
+
47
+ if (JSON.stringify(pkg) !== original) {
48
+ fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
49
+ return {
50
+ ok: true,
51
+ skipped: false,
52
+ changed: true,
53
+ pm: pm === 'auto' ? detectPackageManager(target) : pm,
54
+ reason: `已写入 @cyning/harness@${version} 与 scripts`,
55
+ };
56
+ }
57
+
58
+ return {
59
+ ok: true,
60
+ skipped: false,
61
+ changed: false,
62
+ pm: pm === 'auto' ? detectPackageManager(target) : pm,
63
+ reason: 'package.json 已包含所需 devDep/scripts',
64
+ };
65
+ }
package/lib/paths.js CHANGED
@@ -12,6 +12,33 @@ export function resolveHarnessRoot() {
12
12
  return path.resolve(__dirname, '..');
13
13
  }
14
14
 
15
+ /**
16
+ * 对指定业务仓 target 解析可用产品包根。
17
+ * 顺序:env CYNING_HARNESS → target/.cyning-harness/local.json → npm 包根
18
+ */
19
+ export function resolveHarnessRootForTarget(target) {
20
+ if (process.env.CYNING_HARNESS) {
21
+ return path.resolve(process.env.CYNING_HARNESS);
22
+ }
23
+
24
+ const localJson = path.join(target, '.cyning-harness', 'local.json');
25
+ if (fs.existsSync(localJson)) {
26
+ try {
27
+ const data = JSON.parse(fs.readFileSync(localJson, 'utf8'));
28
+ if (data.cyning_harness_root) {
29
+ const candidate = path.resolve(target, data.cyning_harness_root);
30
+ if (fs.existsSync(path.join(candidate, 'wizard', 'gate-check.sh'))) {
31
+ return candidate;
32
+ }
33
+ }
34
+ } catch {
35
+ // fall through
36
+ }
37
+ }
38
+
39
+ return path.resolve(__dirname, '..');
40
+ }
41
+
15
42
  export function resolvePackageVersion(harnessRoot) {
16
43
  if (process.env.HARNESS_VERSION) {
17
44
  return process.env.HARNESS_VERSION;
package/lib/verify.js ADDED
@@ -0,0 +1,152 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { resolveHarnessRootForTarget, wizardScript } from './paths.js';
5
+ import { auditTarget } from './audit.js';
6
+
7
+ /**
8
+ * 30 前聚合验证:gate-check + audit D5(仅 --task)+ S5 git-clean warn + 可选 --graph
9
+ */
10
+ export function verifyTarget(target, options = {}) {
11
+ const { taskFile, graph } = options;
12
+ const harnessRoot = resolveHarnessRootForTarget(target);
13
+
14
+ // 1. gate-check(含 --graph)
15
+ const gateResult = runGateCheck(target, taskFile, graph, harnessRoot);
16
+ if (!gateResult.ok) {
17
+ return {
18
+ ok: false,
19
+ exitCode: 2,
20
+ reason: gateResult.reason,
21
+ stdout: gateResult.stdout,
22
+ };
23
+ }
24
+
25
+ // 2. audit D5(仅当指定 task)
26
+ if (taskFile) {
27
+ const audit = auditTarget(target, { taskFile });
28
+ if (!audit.ok) {
29
+ return {
30
+ ok: false,
31
+ exitCode: 2,
32
+ reason: audit.test.ok ? 'gate-check 或 audit 未通过' : audit.test.reason,
33
+ stdout: gateResult.stdout,
34
+ };
35
+ }
36
+ }
37
+
38
+ // 3. S5 git-clean:warn 不挡
39
+ const gitResult = runGitCleanCheck(target);
40
+ if (gitResult.dirty) {
41
+ // 把 warn 拼到 stdout 里让 CLI 透传
42
+ const warn = `WARN: 工作区未 clean(S5):建议 commit 后再执行 apply\n`;
43
+ gateResult.stdout += warn;
44
+ }
45
+
46
+ return {
47
+ ok: true,
48
+ exitCode: 0,
49
+ stdout: gateResult.stdout,
50
+ };
51
+ }
52
+
53
+ function runGateCheck(target, taskFile, graph, harnessRoot) {
54
+ const script = wizardScript(harnessRoot, 'gate-check.sh');
55
+ const args = [script, '--target', target];
56
+ if (taskFile) args.push('--task', taskFile);
57
+ if (graph) args.push('--graph');
58
+
59
+ const result = spawnSync('bash', args, {
60
+ encoding: 'utf8',
61
+ env: { ...process.env, CYNING_HARNESS: harnessRoot },
62
+ });
63
+
64
+ if (result.status !== 0) {
65
+ return {
66
+ ok: false,
67
+ status: result.status,
68
+ stdout: result.stdout,
69
+ reason: extractBlockReason(result.stdout, taskFile),
70
+ };
71
+ }
72
+
73
+ return {
74
+ ok: true,
75
+ status: 0,
76
+ stdout: result.stdout,
77
+ };
78
+ }
79
+
80
+ function extractBlockReason(stdout, taskFile) {
81
+ const taskBasename = taskFile ? path.basename(taskFile) : undefined;
82
+ const sections = splitGateCheckTaskSections(stdout);
83
+
84
+ if (taskBasename) {
85
+ const section = sections.get(taskBasename) ?? stdout;
86
+ const arrows = extractArrowBlockLines(section);
87
+ if (arrows.length > 0) return arrows[0];
88
+ return 'gate-check blocked';
89
+ }
90
+
91
+ const blocked = [];
92
+ for (const [name, section] of sections) {
93
+ const arrows = extractArrowBlockLines(section);
94
+ if (arrows.length > 0) {
95
+ blocked.push({ name, reason: arrows[0] });
96
+ }
97
+ }
98
+
99
+ if (blocked.length === 0) return 'gate-check blocked';
100
+
101
+ const total = sections.size;
102
+ if (blocked.length === 1) {
103
+ const { name, reason } = blocked[0];
104
+ return total === 1 ? reason : `${name} · ${reason}`;
105
+ }
106
+
107
+ const names = blocked.map((b) => b.name).join(', ');
108
+ return `${blocked.length}/${total} tasks blocked · ${names}`;
109
+ }
110
+
111
+ /** 按 gate-check 输出的 `task: xxx.md` 切段 */
112
+ function splitGateCheckTaskSections(stdout) {
113
+ const map = new Map();
114
+ let current = null;
115
+ const lines = [];
116
+
117
+ const flush = () => {
118
+ if (current) map.set(current, lines.join('\n'));
119
+ };
120
+
121
+ for (const line of stdout.split('\n')) {
122
+ const match = line.match(/^task: (.+\.md)\s*$/);
123
+ if (match) {
124
+ flush();
125
+ current = match[1];
126
+ lines.length = 0;
127
+ lines.push(line);
128
+ } else if (current) {
129
+ lines.push(line);
130
+ }
131
+ }
132
+ flush();
133
+ return map;
134
+ }
135
+
136
+ /** 只认 gate-check 的 `→ 30 不可开工` 行,避免误匹配表格里的 `❌ 拒 30` */
137
+ function extractArrowBlockLines(section) {
138
+ return section
139
+ .split('\n')
140
+ .filter((line) => line.trimStart().startsWith('→ 30 不可开工'))
141
+ .map((line) => line.trim().replace(/^→\s*/, ''));
142
+ }
143
+
144
+ function runGitCleanCheck(target) {
145
+ const result = spawnSync('git', ['status', '--porcelain'], {
146
+ encoding: 'utf8',
147
+ cwd: target,
148
+ });
149
+
150
+ const dirty = result.status === 0 && result.stdout.trim().length > 0;
151
+ return { dirty };
152
+ }
package/ontology.yaml CHANGED
@@ -3,7 +3,7 @@
3
3
  # 冲突时以 Markdown 为准 · 供未来 harness ontology-check 使用
4
4
 
5
5
  version: "1.2"
6
- product_semver: "1.0.0"
6
+ product_semver: "1.0.2"
7
7
  license: MIT
8
8
 
9
9
  classes:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyning/harness",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "cyning-harness discipline package · init / upgrade / check CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/wizard/README.md CHANGED
@@ -15,6 +15,9 @@ npx @cyning/harness@latest init --preset harness-only --ide cursor,agents
15
15
  npx @cyning/harness upgrade
16
16
  npx @cyning/harness upgrade --target /path/to/project --yes --force
17
17
 
18
+ # 30 前验证
19
+ npx @cyning/harness verify --target /path/to/project
20
+
18
21
  # 仅检查版本
19
22
  npx @cyning/harness check
20
23
  ```
@@ -22,6 +25,7 @@ npx @cyning/harness check
22
25
  - 内部单源:`wizard/install.sh` → `harness-sync.sh apply`(S2 写路径不变)
23
26
  - manifest schema:[`schema/manifest.v1.schema.json`](../schema/manifest.v1.schema.json)
24
27
  - 本地开发:`node bin/harness.js init --target /path/to/project --preset harness-only --ide cursor,agents --yes`
28
+ - Node 仓可选:`npx @cyning/harness init --with-scripts --pm pnpm`
25
29
 
26
30
  ---
27
31