@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.
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.extractLaneFromTgzKey = extractLaneFromTgzKey;
6
+ exports.extractLaneFromTgzKey = void 0;
7
7
  exports.syncSandboxSkills = syncSandboxSkills;
8
8
  exports.validateSandboxSyncOpts = validateSandboxSyncOpts;
9
9
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -68,34 +68,11 @@ const BUSINESS_LINE_BY_TYPE = {
68
68
  openclaw: null, // openclaw 独立包,直接展平
69
69
  };
70
70
  /**
71
- * TCC skillsUpdateTgzKey 反推 lane 名。
72
- * skills_versions/all_feat-xxx.tgz → feat-xxx
73
- * skills_versions/all_feat_xxx.tgz → feat-xxx (_ sanitize → -)
74
- * skills_versions/all_latest.tgz → null(走 @latest)
75
- * 任何 all_<X>.tgz → <X> 里 _ 全部转 -
76
- *
77
- * **宽进严出**:正则允许 `_` 匹配(兼容老 miaoda-skills 时代 TOS key 含 `_` 的存量 TCC
78
- * 命名),但返回时 sanitize 成 `-` —— 对齐 publish-sandbox-skills.sh 侧只发 `-` 版
79
- * lane 的现实(npm SemVer pre-release 只允许 `[A-Za-z0-9-]`,不允许 `_`)。
80
- * 存量 TCC 里如果有 `all_feat_xxx.tgz` 这种命名,反推出 `feat-xxx` 后能正确命中
81
- * npm 上实际发出的 `@lane-feat-xxx` 包,而不是静默 fallback 到 @latest。
82
- *
83
- * 用 nullable 而不是空字符串 —— 让调用方能判断"没 lane 就走 latest"。
71
+ * lane 解析已下沉到 `utils/npm-pack`(与 `pickLaneVersion` 同处一地,供
72
+ * sandbox-skills 与 coding-steering 共用)。这里保留再导出,维持既有引用面。
84
73
  */
85
- function extractLaneFromTgzKey(tgzKey) {
86
- if (tgzKey === undefined || tgzKey === '') {
87
- return null;
88
- }
89
- const match = /(?:^|\/)all_([A-Za-z0-9][A-Za-z0-9_-]*)\.tgz$/.exec(tgzKey);
90
- if (match === null) {
91
- return null;
92
- }
93
- const lane = match[1].replace(/_/g, '-');
94
- if (lane === 'latest') {
95
- return null;
96
- }
97
- return lane;
98
- }
74
+ var npm_pack_2 = require("./npm-pack");
75
+ Object.defineProperty(exports, "extractLaneFromTgzKey", { enumerable: true, get: function () { return npm_pack_2.extractLaneFromTgzKey; } });
99
76
  /**
100
77
  * 主流程:按 type 拉包 + sync 到目标目录。
101
78
  *
@@ -113,7 +90,7 @@ function syncSandboxSkills(opts) {
113
90
  const targetDir = node_path_1.default.resolve(opts.targetDir ?? process.cwd());
114
91
  const packageName = PACKAGE_BY_TYPE[opts.type];
115
92
  const businessLine = BUSINESS_LINE_BY_TYPE[opts.type];
116
- const lane = extractLaneFromTgzKey(opts.tgzKey);
93
+ const lane = (0, npm_pack_1.extractLaneFromTgzKey)(opts.tgzKey);
117
94
  // ---- lane 拉包 ----
118
95
  // 新姿势(2026-08 迁离 lane-<X> dist-tag 之后):
119
96
  // TCC 里的 X(比如 `feat-migrate-skills-from-miaoda-skills-20260805143512`)
@@ -121,7 +98,9 @@ function syncSandboxSkills(opts) {
121
98
  // 过滤 `endsWith('-alpha.' + X)`(因 X 含 timestamp 全局唯一,唯一命中或 0 命中)。
122
99
  // 0 命中 → fallback @latest(对齐旧 TCC 值找不到、灰度切换期间的兜底语义)。
123
100
  // `pickLaneVersion` 内部对 `npm view` 报错 / 无匹配都返回 null,绝不 throw ——
124
- // 目标就是"不挂掉"
101
+ // 目标就是"不挂掉"。**同一个 X 也被 coding-steering 侧用同样方式解析**(见
102
+ // `syncCodingSteering`):匹配只看 `-alpha.<X>` 后缀、不看 base 版本,所以两个包
103
+ // base 版本不同也能共用一个 TCC key。
125
104
  let fetched;
126
105
  const laneVersion = lane === null ? null : (0, npm_pack_1.pickLaneVersion)(packageName, lane, opts.registry);
127
106
  if (lane !== null && laneVersion === null) {
@@ -167,6 +146,10 @@ function syncSandboxSkills(opts) {
167
146
  const skillsRoot = opts.type === 'openclaw'
168
147
  ? node_path_1.default.join(targetDir, 'skills')
169
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']);
170
153
  // `.keep` 短路:对齐 update-skills.sh:149-152,沙箱侧放了这个文件即表示
171
154
  // 「我手改过 skills,别覆盖我」,整体跳过同步(含 steering —— 调用方看到
172
155
  // skipped 就不该再往下写)。
@@ -189,60 +172,73 @@ function syncSandboxSkills(opts) {
189
172
  (0, file_ops_1.unlockDirForWrite)(skillsRoot);
190
173
  node_fs_1.default.mkdirSync(skillsRoot, { recursive: true });
191
174
  const syncedTop = [];
192
- // 源包提供的全部顶层条目 —— prune 的保留集。与 syncedTop 分开维护:syncedTop 是
193
- // 「本轮实际写入」,用于对外汇报;prune 只能依据「源包提供了什么」来判断 stale,
194
- // 两者一旦哪天再次分叉(比如某分支又加了跳过写入的条件),拿 syncedTop 当保留集
195
- // 就会把没重写的条目误判成 stale 删掉、下一轮再写回来,反复抖动。
175
+ // ---- 第一步:只收集「哪个顶层条目由哪些源目录贡献」,先不落盘 ----
176
+ //
177
+ // 为什么要分两步:写入策略是**按顶层条目整清再拷**(见第二步),而合并包里业务线与
178
+ // shared 可能提供同名条目(运行时 union、同名 shared 赢,逐字对齐老 build.sh:33/47)。
179
+ // 如果边遍历边「清+拷」,shared 那一轮会把业务线刚写进去的独有文件一起清掉。
180
+ // 先把 union 收集齐、每个条目只清一次,才能既做到镜像又保住 union 语义。
181
+ //
182
+ // sourceTop 同时是 pruneStaleEntries 的保留集:它是「源包提供了什么」,
183
+ // 不能用 syncedTop(本轮实际写入)—— 否则 shared 条目会被反复删了又写、来回抖动。
196
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
+ };
197
194
  if (businessLine !== null) {
198
- // 合并包 miaoda/miaoda-modern/miaoda-design:cp <extractDir>/<business-line>/* 到 target
199
195
  const sourceDir = node_path_1.default.join(fetched.extractDir, businessLine);
200
196
  if (node_fs_1.default.existsSync(sourceDir)) {
201
197
  for (const entry of node_fs_1.default.readdirSync(sourceDir)) {
202
- const from = node_path_1.default.join(sourceDir, entry);
203
- const to = node_path_1.default.join(skillsRoot, entry);
204
- sourceTop.add(entry);
205
- node_fs_1.default.cpSync(from, to, { recursive: true });
206
- syncedTop.push(entry);
198
+ addSource(entry, node_path_1.default.join(sourceDir, entry));
207
199
  }
208
200
  }
209
201
  else {
210
202
  (0, logger_1.debug)(`sandbox-skills: source dir ${sourceDir} not found in package (skipped)`);
211
203
  }
212
- // shared:合并包内 shared/* target。**无条件覆盖**,逐字对齐老 build.sh:42-52 ——
213
- // 那里业务线先 `cp -r`(:33-34)、shared 后 `cp -r`(:47),后写覆盖前写,
214
- // 即同名时 **shared 赢**。当前包里两边无同名交集,但语义得跟老链路一致。
215
- //
216
- // 早期这里写成「`fs.existsSync(to)` 就 continue」,有两个问题:
217
- // 1. 同名优先级反了(变成业务线赢);
218
- // 2. 更严重 —— existsSync 分不清「本轮业务线刚写的」和「上一轮 shared 自己写的」,
219
- // 于是 shared skill 首次落盘后就永久冻结,包里再更新也同步不到已有沙箱
220
- // (沙箱磁盘跨会话保留,每次 update-skills 都会命中 existsSync)。
204
+ // shared 排在业务线之后 → 同名时 shared 赢(老 build.sh:47 后写覆盖前写)
221
205
  const sharedDir = node_path_1.default.join(fetched.extractDir, 'shared');
222
206
  if (node_fs_1.default.existsSync(sharedDir)) {
223
207
  for (const entry of node_fs_1.default.readdirSync(sharedDir)) {
224
- const from = node_path_1.default.join(sharedDir, entry);
225
- const to = node_path_1.default.join(skillsRoot, entry);
226
- sourceTop.add(entry);
227
- node_fs_1.default.cpSync(from, to, { recursive: true });
228
- syncedTop.push(entry);
208
+ addSource(entry, node_path_1.default.join(sharedDir, entry));
229
209
  }
230
210
  }
231
211
  }
232
212
  else {
233
- // openclaw 独立包:直接把包内所有 skill 目录(除 package.json 等元文件外)平铺到 skillsRoot
213
+ // openclaw 独立包:包内 skill 目录直接平铺(跳过 package.json 等元文件)
234
214
  for (const entry of node_fs_1.default.readdirSync(fetched.extractDir)) {
235
215
  if (entry === 'package.json' || entry === 'README.md' || entry === 'LICENSE')
236
216
  continue;
237
217
  const from = node_path_1.default.join(fetched.extractDir, entry);
238
- const stat = node_fs_1.default.statSync(from);
239
- if (!stat.isDirectory())
218
+ if (!node_fs_1.default.statSync(from).isDirectory())
240
219
  continue;
241
- const to = node_path_1.default.join(skillsRoot, entry);
242
- 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) {
243
239
  node_fs_1.default.cpSync(from, to, { recursive: true });
244
- syncedTop.push(entry);
245
240
  }
241
+ syncedTop.push(entry);
246
242
  }
247
243
  // rsync 式收尾:删掉源包已不提供的顶层条目(对齐 update-skills.sh 的 deleted)。
248
244
  // 保留集用 sourceTop(源包提供的全部)而非 syncedTop(本轮实际写入的)——原因见
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/miaoda-cli",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "description": "Miaoda 平台命令行工具,面向 Agent 调用",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -72,20 +72,35 @@ const STYLELINT_CONFIG_FILES = [
72
72
  ];
73
73
 
74
74
  /**
75
- * 老模板(fullstack-nestjs-template 1.x 时代)的应用没有 stylelint 配置,跑 stylelint 会以
76
- * "ConfigurationError: No configuration provided" 挂掉、把 pre-commit 卡死。没配置就跳过。
75
+ * 老模板(fullstack-nestjs-template 1.x 时代)的应用跑不了 stylelint,跑了就挂、把 pre-commit
76
+ * 卡死。两种形态都实测过:
77
+ *
78
+ * - 缺 scripts.stylelint → `npm error Missing script: "stylelint"`
79
+ * - 缺 .stylelintrc.* → `ConfigurationError: No configuration provided`
80
+ *
81
+ * 注意光判断 stylelint 装没装拦不住:@lark-apaas/fullstack-presets 的 dependencies 里有
82
+ * stylelint,只要 dep 了 presets 二进制就在。缺任一件就跳过这一步。
77
83
  */
78
- function hasStylelintConfig() {
84
+ function canRunStylelint() {
85
+ let pkg;
86
+ try {
87
+ pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
88
+ } catch {
89
+ return false;
90
+ }
91
+
92
+ if (!pkg.scripts || !pkg.scripts.stylelint) return false;
93
+
79
94
  return STYLELINT_CONFIG_FILES.some(file => fs.existsSync(path.join(cwd, file)));
80
95
  }
81
96
 
82
97
  async function runDefaultLint() {
83
98
  const commands = ['npm run eslint', 'npm run type:check'];
84
99
 
85
- if (hasStylelintConfig()) {
100
+ if (canRunStylelint()) {
86
101
  commands.push('npm run stylelint');
87
102
  } else {
88
- console.warn('[lint] Skip stylelint: no stylelint config found');
103
+ console.warn('[lint] Skip stylelint: missing scripts.stylelint or stylelint config');
89
104
  }
90
105
 
91
106
  const code = await runCommand(getBinName('npx'), ['concurrently', ...commands]);
@@ -126,8 +141,8 @@ async function runSelectiveLint(inputFiles) {
126
141
  tasks.push(runCommand(getBinName('npx'), ['eslint', '--quiet', ...eslintFiles]));
127
142
  }
128
143
 
129
- if (stylelintFiles.length > 0 && !hasStylelintConfig()) {
130
- console.warn('[lint] Skip stylelint: no stylelint config found');
144
+ if (stylelintFiles.length > 0 && !canRunStylelint()) {
145
+ console.warn('[lint] Skip stylelint: missing scripts.stylelint or stylelint config');
131
146
  } else if (stylelintFiles.length > 0) {
132
147
  tasks.push(runCommand(getBinName('npx'), ['stylelint', '--quiet', ...stylelintFiles]));
133
148
  }