@lark-apaas/miaoda-cli 0.1.36 → 0.1.37

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.
@@ -7,9 +7,36 @@ exports.syncCodingSteering = syncCodingSteering;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const npm_pack_1 = require("../utils/npm-pack");
10
+ const error_1 = require("../utils/error");
11
+ const file_ops_1 = require("../utils/file-ops");
10
12
  const logger_1 = require("../utils/logger");
13
+ const logger_2 = require("../utils/logger");
11
14
  const env_1 = require("../utils/env");
12
15
  const STEERING_PACKAGE = '@lark-apaas/coding-steering';
16
+ /** nested 目标的祖先链,破坏性操作前逐段验真(见 `assertNoSymlinkedAncestor`)。 */
17
+ const NESTED_ANCESTORS = ['.agent', 'skills', 'steering'];
18
+ /**
19
+ * 合法 stack 名:单个路径段,首字符必须是字母数字。
20
+ *
21
+ * 为什么必须校验:`stack` 直接来自用户仓库里的 `.spark/meta.json`,而 `readSparkMeta`
22
+ * (spark-meta.ts:22)只做 `as SparkMeta` 类型断言、没有任何运行时校验。它会被 join 进
23
+ * 删除路径,而本文件的清理是**整目录 forceRemove**,所以 traversal 的后果是删掉整棵树:
24
+ *
25
+ * ```
26
+ * stack = "" → forceRemove <targetDir>/.agent/skills/steering (整个 steering 根)
27
+ * stack = "../.." → forceRemove <targetDir>/.agent
28
+ * stack = "../../.." → forceRemove <targetDir> (整个用户仓库)
29
+ * ```
30
+ *
31
+ * 首字符限定字母数字,顺带排掉了 `.` / `..` 与隐藏目录名。
32
+ */
33
+ const STACK_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
34
+ function assertSafeStack(stack) {
35
+ if (!STACK_NAME_PATTERN.test(stack)) {
36
+ throw new error_1.AppError('SKILLS_INVALID_STACK', `stack 名非法:${JSON.stringify(stack)}。只允许单个路径段(首字符为字母数字,其余可含 . _ -);` +
37
+ `请检查 .spark/meta.json 的 stack 字段`);
38
+ }
39
+ }
13
40
  function inferMode() {
14
41
  // 沙箱(MIAODA_DEP_CACHE_DIR 非空)→ sandbox;本地 → local
15
42
  return (0, env_1.isSandboxEnv)() ? 'sandbox' : 'local';
@@ -57,13 +84,14 @@ function syncCodingSteering(opts) {
57
84
  // key —— 前提是 lane 发布脚本用同一个 X 把两边一起发出去。这个包没发 `-alpha.<X>`
58
85
  // 时 pickLaneVersion 返回 null(它对报错 / 无匹配都返回 null,绝不 throw),静默
59
86
  // 回落到 dev / latest,不影响现有行为。
87
+ assertSafeStack(opts.stack);
60
88
  const lane = (0, npm_pack_1.extractLaneFromTgzKey)(opts.tgzKey);
61
89
  const laneVersion = lane === null ? null : (0, npm_pack_1.pickLaneVersion)(STEERING_PACKAGE, lane);
62
90
  const isBoe = process.env.FORCE_FRAMEWORK_ENVIRONMENT === 'boe';
63
91
  const effectiveVersion = opts.version ?? laneVersion ?? (isBoe ? 'dev' : 'latest');
64
92
  const mode = opts.mode ?? inferMode();
65
93
  const layout = opts.outputLayout ?? 'nested';
66
- (0, logger_1.log)(logPrefix, `Fetching ${STEERING_PACKAGE}@${effectiveVersion} (mode=${mode}, layout=${layout})...`);
94
+ (0, logger_2.log)(logPrefix, `Fetching ${STEERING_PACKAGE}@${effectiveVersion} (mode=${mode}, layout=${layout})...`);
67
95
  const fetched = (0, npm_pack_1.fetchNpmPackage)({
68
96
  packageName: STEERING_PACKAGE,
69
97
  version: effectiveVersion,
@@ -93,6 +121,20 @@ function syncCodingSteering(opts) {
93
121
  // (老脚本 :179/:208 写明 `steering/ 子树由 miaoda CLI 独占,update.sh 不写不删`),
94
122
  // 锁它没收益;而权限逻辑散在写入方会跟收尾的 lockDirReadonly 形成隐式耦合,
95
123
  // 范围一旦不对称就会出「跑一次 skills sync 把整个 skills 目录解封」这种事。
124
+ // nested:把 `steering/<stack>/` 整棵清掉再重建。
125
+ //
126
+ // 这一刀同时覆盖了三件事,省掉了此前一堆对账逻辑:源包删掉的 skill、被门禁判否的
127
+ // skill、源包这版不再提供的 tech.md —— 全都因为「先清后拷」自然消失。
128
+ //
129
+ // flat(`.agents/skills`)**刻意不清**:那是本地 dev 目录,开发者会往里放自己手写的
130
+ // skill,「不在本轮同步清单里就删」等于替用户删东西。老脚本压根没有 flat 概念。
131
+ if (layout === 'nested') {
132
+ (0, file_ops_1.assertNoSymlinkedAncestor)(opts.targetDir, NESTED_ANCESTORS);
133
+ const steeringRootDst = node_path_1.default.join(opts.targetDir, '.agent', 'skills', 'steering');
134
+ const stackDst = node_path_1.default.join(steeringRootDst, opts.stack);
135
+ (0, file_ops_1.assertStrictlyInside)(steeringRootDst, stackDst);
136
+ (0, file_ops_1.forceRemove)(stackDst);
137
+ }
96
138
  node_fs_1.default.mkdirSync(dstSkillsDir, { recursive: true });
97
139
  let techSynced = false;
98
140
  const techSrc = node_path_1.default.join(stackDir, 'tech.md');
@@ -101,7 +143,17 @@ function syncCodingSteering(opts) {
101
143
  node_fs_1.default.copyFileSync(techSrc, dstTechPath);
102
144
  techSynced = true;
103
145
  }
146
+ else if (layout === 'flat' && node_fs_1.default.existsSync(dstTechPath)) {
147
+ // flat 没有整清,源包不再提供 tech.md 时需显式删掉残留;nested 已被整清带走
148
+ try {
149
+ (0, file_ops_1.forceRemove)(dstTechPath);
150
+ }
151
+ catch (err) {
152
+ (0, logger_1.debug)(`coding-steering: remove stale tech.md failed: ${err.message}`);
153
+ }
154
+ }
104
155
  const synced = [];
156
+ const filteredOut = [];
105
157
  // 顺序拷贝,后者覆盖前者:skills_common 铺底,专有层(skills 或 skills_local)覆盖同名
106
158
  const exclusiveLayer = mode === 'sandbox' ? 'skills' : 'skills_local';
107
159
  const layers = [
@@ -115,22 +167,58 @@ function syncCodingSteering(opts) {
115
167
  const src = node_path_1.default.join(layer.dir, name);
116
168
  if (!node_fs_1.default.statSync(src).isDirectory())
117
169
  continue;
170
+ if (!matchesTemplate(src, opts.stack, opts.templateVersion, logPrefix)) {
171
+ filteredOut.push(`${layer.label}/${name}`);
172
+ continue;
173
+ }
118
174
  const dst = node_path_1.default.join(dstSkillsDir, name);
175
+ // flat 下目录没被整清,同名仍需先删再拷(skills_common 铺底、专有层覆盖同名)
119
176
  if (node_fs_1.default.existsSync(dst))
120
177
  node_fs_1.default.rmSync(dst, { recursive: true, force: true });
121
178
  copyDir(src, dst);
122
179
  synced.push(`${layer.label}/${name}`);
123
180
  }
124
181
  }
182
+ // 收尾(仅 nested):删掉同级其它 stack 的整棵 steering 目录。
183
+ // meta.json 的 stack 唯一,切栈后旧 stack 的 steering 不该再留 —— 修的正是
184
+ // 「vite-react 升级到 nestjs-react-fullstack 后 steering/vite-react/ 永久残留」。
185
+ // 老脚本靠 should_sync 里 `path_stack != STACK → return 1` + 逐文件删除达到同样效果。
186
+ // flat layout 没有 stack 命名空间,天然不涉及。
187
+ const prunedStacks = [];
188
+ if (layout === 'nested') {
189
+ (0, file_ops_1.assertNoSymlinkedAncestor)(opts.targetDir, NESTED_ANCESTORS);
190
+ const steeringDst = node_path_1.default.join(opts.targetDir, '.agent', 'skills', 'steering');
191
+ if (node_fs_1.default.existsSync(steeringDst)) {
192
+ for (const entry of node_fs_1.default.readdirSync(steeringDst)) {
193
+ if (entry === opts.stack)
194
+ continue;
195
+ try {
196
+ const entryDst = node_path_1.default.join(steeringDst, entry);
197
+ (0, file_ops_1.assertStrictlyInside)(steeringDst, entryDst);
198
+ (0, file_ops_1.forceRemove)(entryDst);
199
+ prunedStacks.push(entry);
200
+ }
201
+ catch (err) {
202
+ (0, logger_1.debug)(`coding-steering: prune stale stack ${entry} failed: ${err.message}`);
203
+ }
204
+ }
205
+ }
206
+ }
125
207
  let claudeSkillsLink;
126
208
  if (layout === 'flat') {
127
209
  claudeSkillsLink = ensureClaudeSkillsSymlink(opts.targetDir, logPrefix);
128
210
  }
129
- (0, logger_1.log)(logPrefix, `Synced ${String(synced.length)} skill(s), tech.md ${techSynced ? 'yes' : 'no'} (mode=${mode}, layout=${layout})`);
211
+ const extras = [
212
+ prunedStacks.length > 0 ? `pruned stack(s) ${prunedStacks.join(',')}` : '',
213
+ filteredOut.length > 0 ? `filtered ${String(filteredOut.length)} by version` : '',
214
+ ].filter((x) => x !== '');
215
+ (0, logger_2.log)(logPrefix, `Synced ${String(synced.length)} skill(s), tech.md ${techSynced ? 'yes' : 'no'} ` +
216
+ `(mode=${mode}, layout=${layout})${extras.length > 0 ? ` [${extras.join('; ')}]` : ''}`);
130
217
  return {
131
218
  version: fetched.version,
132
219
  syncedSkills: synced,
133
220
  techSynced,
221
+ prunedStacks,
134
222
  ...(claudeSkillsLink !== undefined ? { claudeSkillsLink } : {}),
135
223
  };
136
224
  }
@@ -164,7 +252,7 @@ function ensureClaudeSkillsSymlink(targetDir, logPrefix) {
164
252
  }
165
253
  if (existing === null) {
166
254
  node_fs_1.default.symlinkSync(linkTarget, linkPath, 'dir');
167
- (0, logger_1.log)(logPrefix, ` ✓ .claude/skills → ${linkTarget} (symlink created)`);
255
+ (0, logger_2.log)(logPrefix, ` ✓ .claude/skills → ${linkTarget} (symlink created)`);
168
256
  return 'created';
169
257
  }
170
258
  if (existing.isSymbolicLink()) {
@@ -174,12 +262,98 @@ function ensureClaudeSkillsSymlink(targetDir, logPrefix) {
174
262
  }
175
263
  node_fs_1.default.unlinkSync(linkPath);
176
264
  node_fs_1.default.symlinkSync(linkTarget, linkPath, 'dir');
177
- (0, logger_1.log)(logPrefix, ` ✓ .claude/skills → ${linkTarget} (symlink updated, was → ${current})`);
265
+ (0, logger_2.log)(logPrefix, ` ✓ .claude/skills → ${linkTarget} (symlink updated, was → ${current})`);
178
266
  return 'updated';
179
267
  }
180
- (0, logger_1.log)(logPrefix, ` ⚠ .claude/skills 已是普通目录/文件,跳过软链创建`);
268
+ (0, logger_2.log)(logPrefix, ` ⚠ .claude/skills 已是普通目录/文件,跳过软链创建`);
181
269
  return 'conflict';
182
270
  }
271
+ /**
272
+ * 读 SKILL.md frontmatter 里的 `match-template-version`。逐字对齐老脚本的取值方式
273
+ * (`update-skills.sh:80-90`):只看第一段 `---` 围起来的 frontmatter,去掉引号与全部空白。
274
+ * 没有该字段(或没有 SKILL.md)返回 undefined = 不设版本门禁。
275
+ */
276
+ function readMatchTemplateVersion(skillDir) {
277
+ const skillMd = node_path_1.default.join(skillDir, 'SKILL.md');
278
+ if (!node_fs_1.default.existsSync(skillMd))
279
+ return undefined;
280
+ let content;
281
+ try {
282
+ content = node_fs_1.default.readFileSync(skillMd, 'utf-8');
283
+ }
284
+ catch {
285
+ return undefined;
286
+ }
287
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
288
+ if (fm === null)
289
+ return undefined;
290
+ const line = /^match-template-version:(.*)$/m.exec(fm[1]);
291
+ if (line === null)
292
+ return undefined;
293
+ const value = line[1].replace(/["']/g, '').replace(/\s/g, '');
294
+ return value === '' ? undefined : value;
295
+ }
296
+ /** 读 frontmatter 里的 `match-template-name`(同上,取不到返回 undefined)。 */
297
+ function readMatchTemplateName(skillDir) {
298
+ const skillMd = node_path_1.default.join(skillDir, 'SKILL.md');
299
+ if (!node_fs_1.default.existsSync(skillMd))
300
+ return undefined;
301
+ let content;
302
+ try {
303
+ content = node_fs_1.default.readFileSync(skillMd, 'utf-8');
304
+ }
305
+ catch {
306
+ return undefined;
307
+ }
308
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
309
+ if (fm === null)
310
+ return undefined;
311
+ const line = /^match-template-name:(.*)$/m.exec(fm[1]);
312
+ if (line === null)
313
+ return undefined;
314
+ const value = line[1].replace(/["']/g, '').trim();
315
+ return value === '' ? undefined : value;
316
+ }
317
+ /**
318
+ * `match-template-version` 门禁,语义逐字对齐老 `check_version_match`
319
+ * (`update-skills.sh:76-108`):
320
+ * - skill 没声明该字段 → 放行
321
+ * - 声明值是纯整数 → 与用户版本的 **major** 比(`3` 匹配 `3.2.1`)
322
+ * - 其它形态(如 `2.2.6`) → 与用户版本**全等**
323
+ * - 用户版本未知(meta 里没 version)→ 放行(见 opts.templateVersion 注释)
324
+ */
325
+ function matchesTemplateVersion(skillDir, templateVersion) {
326
+ const required = readMatchTemplateVersion(skillDir);
327
+ if (required === undefined)
328
+ return true;
329
+ if (templateVersion === undefined || templateVersion === '') {
330
+ (0, logger_1.debug)(`coding-steering: ${node_path_1.default.basename(skillDir)} 声明 match-template-version=${required},` +
331
+ `但 .spark/meta.json 无 version,放行不过滤`);
332
+ return true;
333
+ }
334
+ if (/^\d+$/.test(required)) {
335
+ return templateVersion.split('.')[0] === required;
336
+ }
337
+ return templateVersion === required;
338
+ }
339
+ /**
340
+ * 组合门禁:`match-template-name` + `match-template-version`。
341
+ *
342
+ * `match-template-name` 在这里是**防御性校验**而非过滤主力:包内 skill 本来就住在
343
+ * `steering/<stack>/` 下、目录结构已经按 stack 隔离,声明值理应等于所在 stack
344
+ * (实测包里 23 处全是 `nestjs-react-fullstack`)。真正跨 stack 的池化过滤在 feida-ai
345
+ * 加载时做(`skills.ts:1209` 的 `isTemplateMatched`)。所以这里对不上就是**包结构出问题**,
346
+ * 用 log(常显)而不是 debug 报出来,别静默下发到错误的 stack。
347
+ */
348
+ function matchesTemplate(skillDir, stack, templateVersion, logPrefix) {
349
+ const declaredName = readMatchTemplateName(skillDir);
350
+ if (declaredName !== undefined && declaredName !== stack) {
351
+ (0, logger_2.log)(logPrefix, `⚠ ${node_path_1.default.basename(skillDir)} 声明 match-template-name=${declaredName},` +
352
+ `但它位于 steering/${stack}/ 下 —— 包结构异常,跳过该 skill`);
353
+ return false;
354
+ }
355
+ return matchesTemplateVersion(skillDir, templateVersion);
356
+ }
183
357
  function copyDir(src, dest) {
184
358
  node_fs_1.default.mkdirSync(dest, { recursive: true });
185
359
  for (const entry of node_fs_1.default.readdirSync(src, { withFileTypes: true })) {
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.withDirLock = withDirLock;
7
+ const node_crypto_1 = __importDefault(require("node:crypto"));
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_os_1 = __importDefault(require("node:os"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const error_1 = require("../utils/error");
12
+ const logger_1 = require("../utils/logger");
13
+ /**
14
+ * 跨进程互斥锁,对齐老 `update-skills.sh:157-159` 的 `flock`:
15
+ *
16
+ * ```bash
17
+ * LOCK_FILE="/tmp/update-skills-$(echo "$TARGET_DIR" | md5sum | cut -d' ' -f1).lock"
18
+ * exec 9>"$LOCK_FILE"; flock 9
19
+ * ```
20
+ *
21
+ * 为什么必须有:skills 同步现在是「按目录整清再拷」,两个实例并发时会互相看到半清空
22
+ * 状态;而 feida-ai 的 `scanAllSkills`(skills.ts:533)是会话期按需扫盘,正好能撞上这个
23
+ * 窗口 —— 历史上出过 skill 集合从 59 掉到 34 的事故。触发并发的现实路径:
24
+ * sandbox_updater 自带重试 + `app init` 与会话期 sync 同时跑。
25
+ *
26
+ * 用 `mkdir` 而不是写文件:`mkdirSync` 在 POSIX 与 Windows 上都是原子的「存在即失败」,
27
+ * 不需要 O_EXCL 之类的平台差异处理,也不依赖第三方库。
28
+ */
29
+ /** 轮询间隔 */
30
+ const POLL_INTERVAL_MS = 200;
31
+ /** 默认最长等待:另一个实例正常跑完是秒级,给足余量 */
32
+ const DEFAULT_TIMEOUT_MS = 120_000;
33
+ /**
34
+ * 陈旧锁接管阈值。进程被 SIGKILL(沙箱重建 / OOM)时 finally 不会执行、锁目录会留下,
35
+ * 没有接管机制就会把后续所有同步永久堵死。10 分钟远大于一次正常同步的耗时。
36
+ */
37
+ const STALE_TAKEOVER_MS = 600_000;
38
+ function lockDirFor(key) {
39
+ const hash = node_crypto_1.default.createHash('md5').update(node_path_1.default.resolve(key)).digest('hex');
40
+ return node_path_1.default.join(node_os_1.default.tmpdir(), `miaoda-skills-sync-${hash}.lock`);
41
+ }
42
+ /** 锁目录存在且已超过陈旧阈值 → 接管(删掉重建)。返回是否接管过。 */
43
+ function takeoverIfStale(lockDir) {
44
+ try {
45
+ const ageMs = Date.now() - node_fs_1.default.statSync(lockDir).mtimeMs;
46
+ if (ageMs < STALE_TAKEOVER_MS)
47
+ return false;
48
+ node_fs_1.default.rmSync(lockDir, { recursive: true, force: true });
49
+ (0, logger_1.log)('skills', `接管陈旧锁(持有 ${String(Math.round(ageMs / 1000))}s,超过阈值):${lockDir}`);
50
+ return true;
51
+ }
52
+ catch {
53
+ // stat 失败通常是刚好被别人释放了,交回上层重试
54
+ return false;
55
+ }
56
+ }
57
+ /**
58
+ * 以 `key`(一般传目标目录)为粒度加锁执行 `fn`。
59
+ *
60
+ * 拿不到锁时**抛 AppError 而不是无锁硬跑**:并发方此刻正在把同一棵树刷成最新,硬跑只会
61
+ * 互相覆盖。抛错让上层(sandbox_updater 有重试)稍后再来,磁盘状态始终是完整的一份。
62
+ *
63
+ * 注意不可重入:同进程内嵌套调用同一 key 会等到超时。调用点都在各自的「一次完整同步」
64
+ * 外层,不存在嵌套。
65
+ */
66
+ function withDirLock(key, fn, timeoutMs = DEFAULT_TIMEOUT_MS) {
67
+ const lockDir = lockDirFor(key);
68
+ const deadline = Date.now() + timeoutMs;
69
+ let acquired = false;
70
+ while (!acquired) {
71
+ try {
72
+ node_fs_1.default.mkdirSync(lockDir, { recursive: false });
73
+ acquired = true;
74
+ }
75
+ catch (err) {
76
+ if (err.code !== 'EEXIST')
77
+ throw err;
78
+ if (takeoverIfStale(lockDir))
79
+ continue;
80
+ if (Date.now() >= deadline) {
81
+ throw new error_1.AppError('SKILLS_SYNC_LOCKED', `另一个 skills 同步正在进行(锁:${lockDir}),等待 ${String(Math.round(timeoutMs / 1000))}s 后仍未释放`);
82
+ }
83
+ // 忙等:同步本身是重 IO 的短任务,简单轮询比引入 async 更贴合现有同步调用链
84
+ const until = Date.now() + POLL_INTERVAL_MS;
85
+ while (Date.now() < until) {
86
+ /* spin */
87
+ }
88
+ }
89
+ }
90
+ (0, logger_1.debug)(`dir-lock: acquired ${lockDir}`);
91
+ try {
92
+ return fn();
93
+ }
94
+ finally {
95
+ try {
96
+ node_fs_1.default.rmSync(lockDir, { recursive: true, force: true });
97
+ (0, logger_1.debug)(`dir-lock: released ${lockDir}`);
98
+ }
99
+ catch (err) {
100
+ (0, logger_1.debug)(`dir-lock: release failed ${lockDir}: ${err.message}`);
101
+ }
102
+ }
103
+ }
package/dist/utils/env.js CHANGED
@@ -7,9 +7,9 @@ exports.isSandboxEnv = isSandboxEnv;
7
7
  * 口径:`process.env.MIAODA_DEP_CACHE_DIR` 非空(既非 undefined 也非空串)即为沙箱。
8
8
  * 沙箱平台运行时会给应用进程注入依赖缓存目录;本地 dev / agent 环境拿不到该变量。
9
9
  *
10
- * 历史上用 `SANDBOX_ID` 判断,但该变量在部分沙箱场景下并不会注入,判断不可靠,已换成
11
- * `MIAODA_DEP_CACHE_DIR`。注意:下发给 user app 的 `scripts/dev.sh` 仍按 `SANDBOX_ID`
12
- * 判断(独立 bash 链路,不复用本函数)。
10
+ * 历史上用 `SANDBOX_ID` 判断,但它由 sandbox_console 在绑定沙箱时才写入
11
+ * `.force/environment/env`,预热池实例在绑定前拿不到,故换成 `MIAODA_DEP_CACHE_DIR`。
12
+ * 下发给 user app 的 `scripts/dev.sh` 是独立 bash 链路(不复用本函数),现已同口径。
13
13
  *
14
14
  * 这是 CLI 运行时沙箱判定的 SSOT —— init outputLayout 分流、migrate 重启 dev process、
15
15
  * coding-steering 同步 mode 推断、async-install 写 marker 都走这里,别再各处内联读环境变量。
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EXCLUDES = void 0;
4
+ /**
5
+ * 与模板 `scripts/build.sh` 的 rsync EXCLUDES 对齐:这些名字(任意层级 basename)不进产物/不计入路由。
6
+ *
7
+ * 放在 `utils/` 而非某个 domain 的 service 下,是因为它被 `deploy`(`source-scan.ts` 的
8
+ * `listSourceFiles`)与 `app`(`pack` 的 `copyTree`)两个 domain 共用 ——
9
+ * service 层跨域互调被 `boundaries/dependencies` 禁止(见 docs/dependency-rules.md F4),
10
+ * 共享的纯数据/纯逻辑必须下沉到 `utils/`。
11
+ */
12
+ exports.EXCLUDES = new Set([
13
+ '.git',
14
+ 'node_modules',
15
+ 'dist',
16
+ 'scripts',
17
+ 'package.json',
18
+ 'package-lock.json',
19
+ 'pnpm-lock.yaml',
20
+ 'yarn.lock',
21
+ '.gitignore',
22
+ '.npmrc',
23
+ // agent / 编辑器配置目录与指令文件:只服务本地开发,不属于应用资产。
24
+ // 注意 .claude/skills 在 flat layout 下是指向 ../.agents/skills 的软链,而 listSourceFiles
25
+ // 会 follow 软链,所以必须按 basename 把 .claude 整个排掉,否则 skills 会从软链侧被带进产物。
26
+ '.agent',
27
+ '.agents',
28
+ '.claude',
29
+ '.codex',
30
+ '.cursor',
31
+ '.gemini',
32
+ '.trae',
33
+ '.windsurf',
34
+ '.vscode',
35
+ '.idea',
36
+ 'AGENTS.md',
37
+ 'CLAUDE.md',
38
+ 'GEMINI.md',
39
+ 'skills',
40
+ '.env',
41
+ '.env.local',
42
+ 'README.md',
43
+ '.DS_Store',
44
+ '.spark',
45
+ // 本地开发的临时/运行时目录:tmp 里的 .html 是草稿或中间产物,logs 是本地日志,
46
+ // 两者既不进产物也不该出现在 routes.json。
47
+ 'tmp',
48
+ 'logs',
49
+ ]);
@@ -8,10 +8,13 @@ exports.removeLineFromContent = removeLineFromContent;
8
8
  exports.unlockDirForWrite = unlockDirForWrite;
9
9
  exports.lockDirReadonly = lockDirReadonly;
10
10
  exports.forceRemove = forceRemove;
11
+ exports.assertNoSymlinkedAncestor = assertNoSymlinkedAncestor;
12
+ exports.assertStrictlyInside = assertStrictlyInside;
11
13
  const node_child_process_1 = require("node:child_process");
12
14
  const node_fs_1 = __importDefault(require("node:fs"));
13
15
  const node_path_1 = __importDefault(require("node:path"));
14
16
  const logger_1 = require("./logger");
17
+ const error_1 = require("../utils/error");
15
18
  /**
16
19
  * 从文件中移除匹配的行。
17
20
  * 搬自 @lark-apaas/fullstack-cli/src/utils/file-ops.ts,行为完全对齐:精确匹配(trim 后)。
@@ -124,3 +127,43 @@ function forceRemove(target) {
124
127
  }
125
128
  node_fs_1.default.rmSync(target, { recursive: true, force: true });
126
129
  }
130
+ /**
131
+ * 破坏性操作前,确认 `baseDir` 往下经过 `segments` 的这条祖先链上**没有一段是软链**(也不是
132
+ * 普通文件)。任一段不合法就抛 `SKILLS_UNSAFE_TARGET`。
133
+ *
134
+ * `existsSync` / `readdirSync` / `rm -rf` 都会穿透祖先软链。把 `.agent/skills` 换成指向
135
+ * 外部目录的软链,「删掉目标里多余的条目」就会删到软链指向处的真实文件,随后的
136
+ * `mkdirSync` + 拷贝也会写到项目外。软链祖先不是合法状态,直接拒绝而不是带着它继续跑。
137
+ *
138
+ * 某一段还不存在 → 放行:调用方随后的 `mkdirSync(recursive)` 会创建真实目录,且其下没有
139
+ * 任何东西可删。
140
+ */
141
+ function assertNoSymlinkedAncestor(baseDir, segments) {
142
+ let cur = node_path_1.default.resolve(baseDir);
143
+ for (const seg of segments) {
144
+ cur = node_path_1.default.join(cur, seg);
145
+ let st;
146
+ try {
147
+ st = node_fs_1.default.lstatSync(cur);
148
+ }
149
+ catch {
150
+ return;
151
+ }
152
+ if (st.isSymbolicLink() || !st.isDirectory()) {
153
+ throw new error_1.AppError('SKILLS_UNSAFE_TARGET', `${cur} 不是真实目录(软链或普通文件),拒绝在其下做清理与写入`);
154
+ }
155
+ }
156
+ }
157
+ /**
158
+ * 兜底 containment 断言:`target` 必须严格位于 `root` **之内**(不含 root 自身)。
159
+ *
160
+ * 各调用方对拼进路径的用户输入都有自己的校验(如 coding-steering 的 `assertSafeStack`),
161
+ * 这里防的是后续重构换一种方式拼路径时把破坏性操作指到了树外 —— 破坏性操作的边界值得
162
+ * 留一道断言。
163
+ */
164
+ function assertStrictlyInside(root, target) {
165
+ const rel = node_path_1.default.relative(node_path_1.default.resolve(root), node_path_1.default.resolve(target));
166
+ if (rel === '' || rel.startsWith('..') || node_path_1.default.isAbsolute(rel)) {
167
+ throw new error_1.AppError('SKILLS_UNSAFE_TARGET', `拒绝删除 ${target}:不在 ${root} 之内`);
168
+ }
169
+ }
@@ -146,6 +146,10 @@ function syncSandboxSkills(opts) {
146
146
  const skillsRoot = opts.type === 'openclaw'
147
147
  ? node_path_1.default.join(targetDir, 'skills')
148
148
  : node_path_1.default.join(targetDir, '.agent', 'skills');
149
+ // skillsRoot 全部由字面量拼成(没有用户可控段),所以不存在 traversal;但 `rm -rf` 与
150
+ // `readdirSync` 会穿透**祖先软链** —— 把 `.agent/skills` 换成指向外部目录的软链,下面
151
+ // 的按条目整清就会删到软链指向处的真实文件。破坏性操作前先验真这条链。
152
+ (0, file_ops_1.assertNoSymlinkedAncestor)(targetDir, opts.type === 'openclaw' ? ['skills'] : ['.agent', 'skills']);
149
153
  // `.keep` 短路:对齐 update-skills.sh:149-152,沙箱侧放了这个文件即表示
150
154
  // 「我手改过 skills,别覆盖我」,整体跳过同步(含 steering —— 调用方看到
151
155
  // skipped 就不该再往下写)。
@@ -168,60 +172,73 @@ function syncSandboxSkills(opts) {
168
172
  (0, file_ops_1.unlockDirForWrite)(skillsRoot);
169
173
  node_fs_1.default.mkdirSync(skillsRoot, { recursive: true });
170
174
  const syncedTop = [];
171
- // 源包提供的全部顶层条目 —— prune 的保留集。与 syncedTop 分开维护:syncedTop 是
172
- // 「本轮实际写入」,用于对外汇报;prune 只能依据「源包提供了什么」来判断 stale,
173
- // 两者一旦哪天再次分叉(比如某分支又加了跳过写入的条件),拿 syncedTop 当保留集
174
- // 就会把没重写的条目误判成 stale 删掉、下一轮再写回来,反复抖动。
175
+ // ---- 第一步:只收集「哪个顶层条目由哪些源目录贡献」,先不落盘 ----
176
+ //
177
+ // 为什么要分两步:写入策略是**按顶层条目整清再拷**(见第二步),而合并包里业务线与
178
+ // shared 可能提供同名条目(运行时 union、同名 shared 赢,逐字对齐老 build.sh:33/47)。
179
+ // 如果边遍历边「清+拷」,shared 那一轮会把业务线刚写进去的独有文件一起清掉。
180
+ // 先把 union 收集齐、每个条目只清一次,才能既做到镜像又保住 union 语义。
181
+ //
182
+ // sourceTop 同时是 pruneStaleEntries 的保留集:它是「源包提供了什么」,
183
+ // 不能用 syncedTop(本轮实际写入)—— 否则 shared 条目会被反复删了又写、来回抖动。
175
184
  const sourceTop = new Set();
185
+ const plan = new Map();
186
+ const addSource = (entry, dir) => {
187
+ sourceTop.add(entry);
188
+ const list = plan.get(entry);
189
+ if (list === undefined)
190
+ plan.set(entry, [dir]);
191
+ else
192
+ list.push(dir);
193
+ };
176
194
  if (businessLine !== null) {
177
- // 合并包 miaoda/miaoda-modern/miaoda-design:cp <extractDir>/<business-line>/* 到 target
178
195
  const sourceDir = node_path_1.default.join(fetched.extractDir, businessLine);
179
196
  if (node_fs_1.default.existsSync(sourceDir)) {
180
197
  for (const entry of node_fs_1.default.readdirSync(sourceDir)) {
181
- const from = node_path_1.default.join(sourceDir, entry);
182
- const to = node_path_1.default.join(skillsRoot, entry);
183
- sourceTop.add(entry);
184
- node_fs_1.default.cpSync(from, to, { recursive: true });
185
- syncedTop.push(entry);
198
+ addSource(entry, node_path_1.default.join(sourceDir, entry));
186
199
  }
187
200
  }
188
201
  else {
189
202
  (0, logger_1.debug)(`sandbox-skills: source dir ${sourceDir} not found in package (skipped)`);
190
203
  }
191
- // shared:合并包内 shared/* target。**无条件覆盖**,逐字对齐老 build.sh:42-52 ——
192
- // 那里业务线先 `cp -r`(:33-34)、shared 后 `cp -r`(:47),后写覆盖前写,
193
- // 即同名时 **shared 赢**。当前包里两边无同名交集,但语义得跟老链路一致。
194
- //
195
- // 早期这里写成「`fs.existsSync(to)` 就 continue」,有两个问题:
196
- // 1. 同名优先级反了(变成业务线赢);
197
- // 2. 更严重 —— existsSync 分不清「本轮业务线刚写的」和「上一轮 shared 自己写的」,
198
- // 于是 shared skill 首次落盘后就永久冻结,包里再更新也同步不到已有沙箱
199
- // (沙箱磁盘跨会话保留,每次 update-skills 都会命中 existsSync)。
204
+ // shared 排在业务线之后 → 同名时 shared 赢(老 build.sh:47 后写覆盖前写)
200
205
  const sharedDir = node_path_1.default.join(fetched.extractDir, 'shared');
201
206
  if (node_fs_1.default.existsSync(sharedDir)) {
202
207
  for (const entry of node_fs_1.default.readdirSync(sharedDir)) {
203
- const from = node_path_1.default.join(sharedDir, entry);
204
- const to = node_path_1.default.join(skillsRoot, entry);
205
- sourceTop.add(entry);
206
- node_fs_1.default.cpSync(from, to, { recursive: true });
207
- syncedTop.push(entry);
208
+ addSource(entry, node_path_1.default.join(sharedDir, entry));
208
209
  }
209
210
  }
210
211
  }
211
212
  else {
212
- // openclaw 独立包:直接把包内所有 skill 目录(除 package.json 等元文件外)平铺到 skillsRoot
213
+ // openclaw 独立包:包内 skill 目录直接平铺(跳过 package.json 等元文件)
213
214
  for (const entry of node_fs_1.default.readdirSync(fetched.extractDir)) {
214
215
  if (entry === 'package.json' || entry === 'README.md' || entry === 'LICENSE')
215
216
  continue;
216
217
  const from = node_path_1.default.join(fetched.extractDir, entry);
217
- const stat = node_fs_1.default.statSync(from);
218
- if (!stat.isDirectory())
218
+ if (!node_fs_1.default.statSync(from).isDirectory())
219
219
  continue;
220
- const to = node_path_1.default.join(skillsRoot, entry);
221
- sourceTop.add(entry);
220
+ addSource(entry, from);
221
+ }
222
+ }
223
+ // ---- 第二步:逐条目「整清再拷」----
224
+ //
225
+ // 整清而非 `cpSync` 覆盖:cpSync 是覆盖不是镜像,源包删掉某个 skill *内部* 的文件
226
+ // (如 `references/old.md`)时目标那份会永久残留。老脚本靠逐文件比对 + `rm` 达到镜像
227
+ // (update-skills.sh:196-215),这里用「目录重建」达到同样效果,实现上更简单。
228
+ //
229
+ // 清理粒度刻意收在**单个条目**而不是整个 skillsRoot:
230
+ // - `.keep` / `.config` / `.last-update` 是控制文件,整清会连它们一起删
231
+ // (`.config` 是沙箱级 tgzKey 覆盖,feida-ai `sandbox-updater.ts:30` 读它,删了版本钉死失效)
232
+ // - `steering/` 归 syncCodingSteering 管,本函数不该碰
233
+ // - 单条目重建是毫秒级、一次只影响一个 skill;整清则会出现「整棵 skills 为空」的窗口,
234
+ // 而 feida-ai 的 scanAllSkills 会话期按需扫盘,正好能撞上
235
+ for (const [entry, srcDirs] of plan) {
236
+ const to = node_path_1.default.join(skillsRoot, entry);
237
+ (0, file_ops_1.forceRemove)(to);
238
+ for (const from of srcDirs) {
222
239
  node_fs_1.default.cpSync(from, to, { recursive: true });
223
- syncedTop.push(entry);
224
240
  }
241
+ syncedTop.push(entry);
225
242
  }
226
243
  // rsync 式收尾:删掉源包已不提供的顶层条目(对齐 update-skills.sh 的 deleted)。
227
244
  // 保留集用 sourceTop(源包提供的全部)而非 syncedTop(本轮实际写入的)——原因见
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/miaoda-cli",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "description": "Miaoda 平台命令行工具,面向 Agent 调用",
5
5
  "type": "commonjs",
6
6
  "bin": {