@lark-apaas/miaoda-cli 0.1.35 → 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';
@@ -40,10 +67,31 @@ function inferMode() {
40
67
  */
41
68
  function syncCodingSteering(opts) {
42
69
  const logPrefix = opts.logPrefix ?? 'skills';
43
- const effectiveVersion = opts.version ?? 'latest';
70
+ // 版本优先级:--version(显式 pin / 止血)> lane(TCC 定向灰度)> BOE 默认 @dev > @latest
71
+ //
72
+ // 与 sandbox-skills 的 syncSandboxSkills 完全同一套口径,只是包名不同。BOE 的 @dev
73
+ // 由 miaoda-coding develop 分支的自动发布维护;lane 必须压过 dev —— TCC 配了 tgzKey
74
+ // 就是定向到某个 feat 分支的快照,不该被"BOE 跟 develop"这条默认规则盖掉。
75
+ //
76
+ // BOE→dev 这一档在本函数里算,不由调用方预填:`skills sync --type` 分支会带 tgzKey
77
+ // 进来,调用方若先把 'dev' 塞进 version,version 档位更高,lane 就永远命不中。
78
+ // 本地分支 / app init 为了回报 versionSource 仍会自己算 dev 再传 version —— 那两条路
79
+ // 没有 tgzKey,lane 档天然为空,两处口径一致不会打架。
80
+ //
81
+ // lane 解析与 sandbox-skills 同一套:`extractLaneFromTgzKey` 反推 X,`pickLaneVersion`
82
+ // 过滤后缀 `-alpha.<X>`。**匹配只看后缀、不看 base 版本**,所以 coding-steering
83
+ // (`0.1.3x-alpha.<X>`)和 sandbox-skills(`0.1.x-alpha.<X>`)能共用 TCC 里的同一个
84
+ // key —— 前提是 lane 发布脚本用同一个 X 把两边一起发出去。这个包没发 `-alpha.<X>`
85
+ // 时 pickLaneVersion 返回 null(它对报错 / 无匹配都返回 null,绝不 throw),静默
86
+ // 回落到 dev / latest,不影响现有行为。
87
+ assertSafeStack(opts.stack);
88
+ const lane = (0, npm_pack_1.extractLaneFromTgzKey)(opts.tgzKey);
89
+ const laneVersion = lane === null ? null : (0, npm_pack_1.pickLaneVersion)(STEERING_PACKAGE, lane);
90
+ const isBoe = process.env.FORCE_FRAMEWORK_ENVIRONMENT === 'boe';
91
+ const effectiveVersion = opts.version ?? laneVersion ?? (isBoe ? 'dev' : 'latest');
44
92
  const mode = opts.mode ?? inferMode();
45
93
  const layout = opts.outputLayout ?? 'nested';
46
- (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})...`);
47
95
  const fetched = (0, npm_pack_1.fetchNpmPackage)({
48
96
  packageName: STEERING_PACKAGE,
49
97
  version: effectiveVersion,
@@ -73,6 +121,20 @@ function syncCodingSteering(opts) {
73
121
  // (老脚本 :179/:208 写明 `steering/ 子树由 miaoda CLI 独占,update.sh 不写不删`),
74
122
  // 锁它没收益;而权限逻辑散在写入方会跟收尾的 lockDirReadonly 形成隐式耦合,
75
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
+ }
76
138
  node_fs_1.default.mkdirSync(dstSkillsDir, { recursive: true });
77
139
  let techSynced = false;
78
140
  const techSrc = node_path_1.default.join(stackDir, 'tech.md');
@@ -81,7 +143,17 @@ function syncCodingSteering(opts) {
81
143
  node_fs_1.default.copyFileSync(techSrc, dstTechPath);
82
144
  techSynced = true;
83
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
+ }
84
155
  const synced = [];
156
+ const filteredOut = [];
85
157
  // 顺序拷贝,后者覆盖前者:skills_common 铺底,专有层(skills 或 skills_local)覆盖同名
86
158
  const exclusiveLayer = mode === 'sandbox' ? 'skills' : 'skills_local';
87
159
  const layers = [
@@ -95,22 +167,58 @@ function syncCodingSteering(opts) {
95
167
  const src = node_path_1.default.join(layer.dir, name);
96
168
  if (!node_fs_1.default.statSync(src).isDirectory())
97
169
  continue;
170
+ if (!matchesTemplate(src, opts.stack, opts.templateVersion, logPrefix)) {
171
+ filteredOut.push(`${layer.label}/${name}`);
172
+ continue;
173
+ }
98
174
  const dst = node_path_1.default.join(dstSkillsDir, name);
175
+ // flat 下目录没被整清,同名仍需先删再拷(skills_common 铺底、专有层覆盖同名)
99
176
  if (node_fs_1.default.existsSync(dst))
100
177
  node_fs_1.default.rmSync(dst, { recursive: true, force: true });
101
178
  copyDir(src, dst);
102
179
  synced.push(`${layer.label}/${name}`);
103
180
  }
104
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
+ }
105
207
  let claudeSkillsLink;
106
208
  if (layout === 'flat') {
107
209
  claudeSkillsLink = ensureClaudeSkillsSymlink(opts.targetDir, logPrefix);
108
210
  }
109
- (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('; ')}]` : ''}`);
110
217
  return {
111
218
  version: fetched.version,
112
219
  syncedSkills: synced,
113
220
  techSynced,
221
+ prunedStacks,
114
222
  ...(claudeSkillsLink !== undefined ? { claudeSkillsLink } : {}),
115
223
  };
116
224
  }
@@ -144,7 +252,7 @@ function ensureClaudeSkillsSymlink(targetDir, logPrefix) {
144
252
  }
145
253
  if (existing === null) {
146
254
  node_fs_1.default.symlinkSync(linkTarget, linkPath, 'dir');
147
- (0, logger_1.log)(logPrefix, ` ✓ .claude/skills → ${linkTarget} (symlink created)`);
255
+ (0, logger_2.log)(logPrefix, ` ✓ .claude/skills → ${linkTarget} (symlink created)`);
148
256
  return 'created';
149
257
  }
150
258
  if (existing.isSymbolicLink()) {
@@ -154,12 +262,98 @@ function ensureClaudeSkillsSymlink(targetDir, logPrefix) {
154
262
  }
155
263
  node_fs_1.default.unlinkSync(linkPath);
156
264
  node_fs_1.default.symlinkSync(linkTarget, linkPath, 'dir');
157
- (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})`);
158
266
  return 'updated';
159
267
  }
160
- (0, logger_1.log)(logPrefix, ` ⚠ .claude/skills 已是普通目录/文件,跳过软链创建`);
268
+ (0, logger_2.log)(logPrefix, ` ⚠ .claude/skills 已是普通目录/文件,跳过软链创建`);
161
269
  return 'conflict';
162
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
+ }
163
357
  function copyDir(src, dest) {
164
358
  node_fs_1.default.mkdirSync(dest, { recursive: true });
165
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
+ }
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.pickLaneVersion = pickLaneVersion;
7
+ exports.extractLaneFromTgzKey = extractLaneFromTgzKey;
7
8
  exports.fetchNpmPackage = fetchNpmPackage;
8
9
  const node_child_process_1 = require("node:child_process");
9
10
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -44,6 +45,35 @@ function pickLaneVersion(packageName, X, registry) {
44
45
  return null;
45
46
  }
46
47
  }
48
+ /**
49
+ * 从 TCC skillsUpdateTgzKey 反推 lane 名。
50
+ * skills_versions/all_feat-xxx.tgz → feat-xxx
51
+ * skills_versions/all_feat_xxx.tgz → feat-xxx (_ sanitize → -)
52
+ * skills_versions/all_latest.tgz → null(走各自的默认版本口径)
53
+ * 任何 all_<X>.tgz → <X> 里 _ 全部转 -
54
+ *
55
+ * **宽进严出**:正则允许 `_` 匹配(兼容老 miaoda-skills 时代 TOS key 含 `_` 的存量 TCC
56
+ * 命名),但返回时 sanitize 成 `-` —— 对齐 publish-sandbox-skills.sh 侧只发 `-` 版
57
+ * lane 的现实(npm SemVer pre-release 只允许 `[A-Za-z0-9-]`,不允许 `_`)。
58
+ * 存量 TCC 里如果有 `all_feat_xxx.tgz` 这种命名,反推出 `feat-xxx` 后能正确命中
59
+ * npm 上实际发出的 `-alpha.feat-xxx-<ts>` 包,而不是静默 fallback。
60
+ *
61
+ * 用 nullable 而不是空字符串 —— 让调用方能判断"没 lane 就走默认版本"。
62
+ */
63
+ function extractLaneFromTgzKey(tgzKey) {
64
+ if (tgzKey === undefined || tgzKey === '') {
65
+ return null;
66
+ }
67
+ const match = /(?:^|\/)all_([A-Za-z0-9][A-Za-z0-9_-]*)\.tgz$/.exec(tgzKey);
68
+ if (match === null) {
69
+ return null;
70
+ }
71
+ const lane = match[1].replace(/_/g, '-');
72
+ if (lane === 'latest') {
73
+ return null;
74
+ }
75
+ return lane;
76
+ }
47
77
  /**
48
78
  * `npm pack <pkg>@<ver> --registry <r>` + `tar -xzf`,解到临时目录。
49
79
  * extractDir 是包内容根(含 package.json)。使用完必须 cleanup()。