@lark-apaas/miaoda-cli 0.1.30 → 0.1.31-alpha.1f31c36

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.md CHANGED
@@ -55,7 +55,7 @@ miaoda file ls --output json
55
55
 
56
56
  `miaoda registry` 提供 shadcn 式的「组件 registry」:把 SDK 文件**拷进**当前项目(copy-in),而非作为依赖安装。registry 是独立 npm 包,按运行时分发(design-html / html → buildless runtime → `@lark-apaas/coding-registry-buildless`)。
57
57
 
58
- 前置:当前目录(或 `--dir`)已走过 `miaoda app init`,且 `.spark/meta.json` 的 `stack` 有对应 registry 包(当前支持 `design-html` / `html`);其余 stack 执行时报 `REGISTRY_STACK_UNSUPPORTED` 并提示支持列表。
58
+ 前置:当前工作目录已走过 `miaoda app init`,且 `.spark/meta.json` 的 `stack` 有对应 registry 包(当前支持 `design-html` / `html`);其余 stack 执行时报 `REGISTRY_STACK_UNSUPPORTED` 并提示支持列表。meta 一律从 cwd 读取,不随 `--dir` 走。
59
59
 
60
60
  ```bash
61
61
  # 列出可用 SDK 条目(输出各文件 USAGE 注释块原文,便于 Agent 直接读用法)
@@ -76,7 +76,7 @@ miaoda registry add deck-slide --overwrite # 目标已存在时覆盖(
76
76
  - **拍平落地**:所有文件**拍平**到项目根(`scripts/deck-slide.js` → 项目 `deck-slide.js`,不保留子目录);闭包内同名 basename 撞车时后者覆盖前者。
77
77
  - **skip-by-disk**:项目已有同名(拍平后的 basename)文件默认跳过,`--overwrite` 才覆盖;不写任何安装记账文件,项目文件树即状态。
78
78
  - **add 回执带 usage**:`add` 结果的 `entries` 只列**你请求的条目**(不含被 `Depends` 传递拉进来的依赖——依赖照常落地在 `added`/`skipped`,但那是"管道"),逐条给出 `usage`(USAGE 块原文)与 `dependsOn`,Agent `add` 完即可直接读用法,无需再 `registry list`。请求的条目即使已存在被跳过也回带 usage(`status` 标 `added`/`skipped`,据此判断磁盘上是 registry 版还是你改过的旧版)。`--json` 结构为 `{registryVersion, added, skipped, dryRun, entries}`;pretty(默认)输出 `registry@<ver>` + `added:`/`skipped:` 逗号列表 + 各请求条目的 USAGE 块,稳定 label、无 JSON 数组、无截断,对 Agent 与人同样可读。
79
- - `--version <ver>` 指定 registry 包版本或 dist-tag(缺省 `latest`),`--dir <path>` 指定项目目录(缺省当前目录)。
79
+ - `--version <ver>` 指定 registry 包版本或 dist-tag(缺省 `latest`);`add` 的 `--dir <path>` 指定 copy-in 落地目录(缺省当前目录),不影响 `.spark/meta.json` 读取。
80
80
 
81
81
  ## 全局参数
82
82
 
@@ -72,7 +72,7 @@ JSON 输出(stdout)
72
72
  }));
73
73
  const patchCmd = deployCmd
74
74
  .command('patch')
75
- .description('design-html 增量发布:按文件 create/update/delete,.html 增删时同步 routes.json')
75
+ .description('design-html 增量发布:按文件 create/update/delete,.html 变动时同步 routes.json')
76
76
  .option('--create <relpath>', '新增文件(可重复)', shared_1.collectRepeatedOption, [])
77
77
  .option('--update <relpath>', '覆盖已有文件(可重复)', shared_1.collectRepeatedOption, [])
78
78
  .option('--delete <relpath>', '删除文件(可重复)', shared_1.collectRepeatedOption, [])
@@ -80,10 +80,11 @@ JSON 输出(stdout)
80
80
  .addHelpText('after', `
81
81
  --dir 为项目目录(与 deploy 同名参数,默认当前目录)。
82
82
  仅支持 design-html(design_local_deploy)。路径为工程根相对路径(= 服务端 latest 下 key),
83
- 禁绝对路径与 ".."。--create/--update 的本地文件必须存在。.html 被新增/删除(路由集合变化)
84
- 时自动重算并随包更新 routes.json;纯 --update 现有 .html 只改内容,不动 routes.json。
85
- routes.json 每项为 {path, file}:path 为相对路径(不含 base 前缀,由消费侧拼接),file 为
86
- 对应源文件(如 / → index.html)。
83
+ 禁绝对路径与 ".."。--create/--update 的本地文件必须存在。改动里含任意 .html(增 / 改 / 删)
84
+ 时自动重算并随包更新 routes.json;纯非 .html 改动不动 routes.json。
85
+ routes.json 每项为 {path, file, name?}:path 为相对路径(不含 base 前缀,由消费侧拼接),
86
+ file 为对应源文件(如 / → index.html),name 为该 HTML 的 <title> 文本(方案名,可缺省,
87
+ 缺省时由消费侧 fallback 到 path)。
87
88
 
88
89
  JSON 输出
89
90
  {"data": {"upsertCount": <n>, "deleteCount": <n>, "actionsSent": <n>, "routesRegenerated": <bool>}}
@@ -33,8 +33,9 @@ function registerRegistryCommands(program) {
33
33
  - add 落地:拍平到项目根(包根 scripts/x.js → 项目 x.js);同名 basename 撞车时后者覆盖前者
34
34
 
35
35
  前置
36
- 当前目录(或 --dir)已走过 'miaoda app init',且 .spark/meta.json 的 stack 有对应
36
+ 当前工作目录已走过 'miaoda app init',且 .spark/meta.json 的 stack 有对应
37
37
  registry 包(当前支持 design-html / html);其余 stack 执行时报 REGISTRY_STACK_UNSUPPORTED
38
+ meta 一律从 cwd 读取,add 的 --dir 只影响 copy-in 落地目录
38
39
  `);
39
40
  registerRegistryList(registryCmd);
40
41
  registerRegistryAdd(registryCmd);
@@ -43,7 +44,6 @@ function registerRegistryList(parent) {
43
44
  const cmd = parent
44
45
  .command('list')
45
46
  .description('列出 registry 可用 SDK 条目,输出各文件的 USAGE 块原文')
46
- .option('--dir <path>', '项目目录,默认当前目录', '.')
47
47
  .option('--version <ver>', 'registry 包版本或 dist-tag,缺省 latest')
48
48
  .addHelpText('after', `
49
49
  输出
@@ -59,7 +59,6 @@ JSON 输出
59
59
  `);
60
60
  cmd.action((0, shared_1.withHelp)(cmd, async (rawOpts) => {
61
61
  await (0, index_1.handleRegistryList)({
62
- dir: rawOpts.dir,
63
62
  version: rawOpts.version,
64
63
  });
65
64
  }));
@@ -69,7 +68,7 @@ function registerRegistryAdd(parent) {
69
68
  .command('add')
70
69
  .description('把一个或多个 SDK 文件 copy-in 到当前项目(拍平到项目根,含 Depends 闭包)')
71
70
  .argument('<token...>', 'name / 完整文件名 / relPath,可传多个;同名多匹配全拉,Depends 自动传递解析')
72
- .option('--dir <path>', '项目目录,默认当前目录', '.')
71
+ .option('--dir <path>', 'copy-in 落地目录,默认当前目录(stack 一律读 cwd 的 .spark/meta.json)', '.')
73
72
  .option('--version <ver>', 'registry 包版本或 dist-tag,缺省 latest')
74
73
  .option('--overwrite', '目标文件已存在时覆盖(默认跳过以保护用户改动)', false)
75
74
  .option('--dry-run', '只报告将写 / 将跳哪些文件,不落盘', false)
@@ -77,8 +76,9 @@ function registerRegistryAdd(parent) {
77
76
  行为
78
77
  - token 寻址:name(foo)/ 完整文件名(foo.css)/ relPath(stylesheets/foo.css)任一形式
79
78
  - 多匹配全拉:token 命中多个同名条目时全部 copy-in;想只要其一就写完整文件名 / relPath
80
- - 解析 USAGE 块 Depends 传递闭包,把 SDK 文件**拍平**拷到项目根
81
- (包根 scripts/deck-stage.js → 项目 deck-stage.js;同名 basename 撞车时后者覆盖前者)
79
+ - 解析 USAGE 块 Depends 传递闭包,把 SDK 文件**拍平**拷到落地目录(--dir,默认当前目录)
80
+ (包根 scripts/deck-stage.js → 落地 deck-stage.js;同名 basename 撞车时后者覆盖前者)
81
+ - stack 一律读当前工作目录的 .spark/meta.json,--dir 不影响 meta 读取
82
82
  - skip-by-disk:目标文件已存在默认跳过;--overwrite 才覆盖
83
83
  - 不写任何安装记账文件(项目文件树即状态)
84
84
 
@@ -21,11 +21,16 @@ function registerSkillsCommands(program) {
21
21
  function registerSkillsSync(parent) {
22
22
  const cmd = parent
23
23
  .command('sync')
24
- .description('同步 coding-steering(latest 或指定版本)到 user app')
24
+ .description('同步 skills 到 user app(默认 coding-steering;带 --type 时走 sandbox-skills)')
25
25
  .option('--dir <path>', '项目目录,默认当前目录', '.')
26
26
  .option('--version <ver>', 'coding-steering 包版本或 dist-tag,缺省 latest')
27
27
  .option('--local', '走本地 dev / agent 输出形态:拷到 .agents/skills 平铺 + 创建 .claude/skills 软链。' +
28
28
  '不传时退回老形态:拷到 .agent/skills/steering/<stack>/skills/(跟沙箱端 update-skills.sh 对齐)', false)
29
+ .option('--type <type>', '走 sandbox 分支:miaoda | miaoda-modern | miaoda-design | openclaw。有值时忽略 --local,' +
30
+ '从 @lark-apaas/coding-<pkg>-sandbox-skills 拉包平铺写入,并额外同步 steering ' +
31
+ '到 .agent/skills/steering/<stack>/(--version 对后者仍生效)')
32
+ .option('--tgz-key <key>', '配合 --type 使用;老 TCC skillsUpdateTgzKey 值透传,' +
33
+ '从 skills_versions/all_<X>.tgz 反推出 lane 名 X,再过滤出 -alpha.<X> 版本;缺省走 @latest')
29
34
  .addHelpText('after', `
30
35
  前置要求
31
36
  当前目录(或 --dir)已走过 'miaoda app init'(.spark/meta.json 含 stack)
@@ -41,21 +46,36 @@ function registerSkillsSync(parent) {
41
46
  .agents/tech.md
42
47
  .claude/skills → ../.agents/skills(软链,让 Claude Code 识别同一份)
43
48
 
49
+ 传 --type(沙箱链路,两部分都同步):
50
+ .agent/skills/<skill>/... ← 业务线 skill,平铺
51
+ .agent/skills/steering/<stack>/skills/<skill>/ ← steering,同上面默认形态
52
+ (openclaw 的业务线部分落 skills/ 而非 .agent/skills/)
53
+
44
54
  JSON 输出
45
55
  {"data": {"stack": "...", "version": "...", "syncedSkills": [...], "techSynced": true|false,
46
56
  "claudeSkillsLink": "created|updated|noop|conflict" // 仅 --local 时}}
47
57
 
58
+ 传 --type 时:
59
+ {"data": {"mode": "sandbox", "type": "...", "packageName": "...", "version": "...",
60
+ "lane": "...", "syncedTop": [...], "targetDir": "...",
61
+ "steering": {"status": "synced|skipped|failed", ...}}}
62
+ steering 失败或无 stack 不影响整体成功——业务线 skill 已同步,不该因增量部分阻断
63
+
48
64
  示例
49
65
  $ miaoda skills sync # 拉 @latest
50
66
  $ miaoda skills sync --local # 本地 dev / agent 用
51
67
  $ miaoda skills sync --version 0.2.0 # 显式指定版本
52
68
  $ miaoda skills sync --dir ./my-app
69
+ # 沙箱链路(由 feida-ai 下发):业务线 skill + steering 一次同步到位
70
+ $ miaoda skills sync --type miaoda-modern --tgz-key 'skills_versions/all_demo-lane.tgz'
53
71
  `);
54
72
  cmd.action((0, shared_1.withHelp)(cmd, async (rawOpts) => {
55
73
  await (0, index_1.handleSkillsSync)({
56
74
  dir: rawOpts.dir,
57
75
  version: rawOpts.version,
58
76
  local: rawOpts.local,
77
+ type: rawOpts.type,
78
+ tgzKey: rawOpts.tgzKey,
59
79
  });
60
80
  }));
61
81
  }
@@ -13,7 +13,8 @@ const output_1 = require("../../../utils/output");
13
13
  * miaoda registry add <name...> [--overwrite] [--dry-run] [--dir <path>] [--version <ver>]
14
14
  *
15
15
  * 解析 USAGE 块 Depends 传递闭包,把每个条目对应的 SDK 文件从 registry 包根的 <relPath>
16
- * **保留相对路径**拷到项目根(--dir 指定目录的根)。
16
+ * **保留相对路径**拷到落地目录(--dir,默认 cwd)。stack 一律从 <cwd>/.spark/meta.json 读,
17
+ * 不随 --dir 走。
17
18
  * skip-by-disk:目标已存在默认跳过,--overwrite 才覆盖。不写任何安装记账文件。
18
19
  */
19
20
  async function handleRegistryAdd(opts) {
@@ -22,7 +23,7 @@ async function handleRegistryAdd(opts) {
22
23
  throw new error_1.AppError('ARGS_INVALID', 'registry add 至少需要一个条目名');
23
24
  }
24
25
  const targetDir = node_path_1.default.resolve(opts.dir ?? process.cwd());
25
- const stack = (0, shared_1.resolveRegistryStack)(targetDir);
26
+ const stack = (0, shared_1.resolveRegistryStack)();
26
27
  const reg = (0, index_1.loadRegistry)({ stack, version: opts.version });
27
28
  try {
28
29
  const plan = (0, index_1.planAdd)({
@@ -1,17 +1,13 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.handleRegistryList = handleRegistryList;
7
- const node_path_1 = __importDefault(require("node:path"));
8
4
  const index_1 = require("../../../services/registry/index");
9
5
  const shared_1 = require("./shared");
10
6
  const output_1 = require("../../../utils/output");
11
7
  /**
12
- * miaoda registry list [--dir <path>] [--version <ver>]
8
+ * miaoda registry list [--version <ver>]
13
9
  *
14
- * 从 .spark/meta.json 读 stack → runtime → 包名,拉对应 registry 包,从包根递归扫含 USAGE 块的 SDK 文件。
10
+ * 从 <cwd>/.spark/meta.json 读 stack → runtime → 包名,拉对应 registry 包,从包根递归扫含 USAGE 块的 SDK 文件。
15
11
  *
16
12
  * - 默认(pretty):输出各条目 USAGE 块原文,带文件名分隔,给 Agent 直接读注释。
17
13
  * - --json:输出 [{name, file, usage(原文字符串), dependsOn[]}],usage 保持原文不解析成字段。
@@ -19,8 +15,7 @@ const output_1 = require("../../../utils/output");
19
15
  */
20
16
  async function handleRegistryList(opts) {
21
17
  await Promise.resolve();
22
- const targetDir = node_path_1.default.resolve(opts.dir ?? process.cwd());
23
- const stack = (0, shared_1.resolveRegistryStack)(targetDir);
18
+ const stack = (0, shared_1.resolveRegistryStack)();
24
19
  const reg = (0, index_1.loadRegistry)({ stack, version: opts.version });
25
20
  try {
26
21
  if ((0, output_1.isJsonMode)()) {
@@ -5,16 +5,20 @@ const spark_meta_1 = require("../../../utils/spark-meta");
5
5
  const index_1 = require("../../../services/registry/index");
6
6
  const error_1 = require("../../../utils/error");
7
7
  /**
8
- * 从 <targetDir>/.spark/meta.json 读 stack,校验该 stack 有组件 registry。
8
+ * 从 <cwd>/.spark/meta.json 读 stack,校验该 stack 有组件 registry。
9
+ * meta 只与当前工作目录相关(CLI 必须在 user app 根目录下运行),不随 --dir 走——
10
+ * --dir 只影响 add 的 copy-in 落地目录。
9
11
  * 缺 stack → 提示先 init;stack 无 registry(无对应 runtime / runtime 无 registry 包)→ 提示支持哪些 stack。
10
12
  *
11
13
  * 这里只做「stack 是否在支持列表」的早校验给用户友好提示;包名的两跳解析
12
14
  * (stack → runtime → 包)由 loadRegistry/resolveRegistryPackage 负责。
13
15
  */
14
- function resolveRegistryStack(targetDir) {
15
- const meta = (0, spark_meta_1.readSparkMeta)(targetDir);
16
+ function resolveRegistryStack() {
17
+ const meta = (0, spark_meta_1.readSparkMeta)(process.cwd());
16
18
  if (meta.stack === undefined || meta.stack === '') {
17
- throw new error_1.AppError('REGISTRY_META_INCOMPLETE', '.spark/meta.json missing stack — run `miaoda app init` first');
19
+ throw new error_1.AppError('REGISTRY_META_INCOMPLETE', '.spark/meta.json missing stack — run `miaoda app init` first', {
20
+ next_actions: ['meta 一律读当前工作目录(不随 --dir),请 cd 到 user app 根目录后重试'],
21
+ });
18
22
  }
19
23
  if (!index_1.REGISTRY_SUPPORTED_STACKS.includes(meta.stack)) {
20
24
  throw new error_1.AppError('REGISTRY_STACK_UNSUPPORTED', `stack "${meta.stack}" 没有组件 registry`, {
@@ -6,9 +6,62 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.handleSkillsSync = handleSkillsSync;
7
7
  const node_path_1 = __importDefault(require("node:path"));
8
8
  const coding_steering_1 = require("../../../utils/coding-steering");
9
+ const sandbox_skills_1 = require("../../../utils/sandbox-skills");
10
+ const file_ops_1 = require("../../../utils/file-ops");
9
11
  const spark_meta_1 = require("../../../utils/spark-meta");
10
12
  const error_1 = require("../../../utils/error");
11
13
  const output_1 = require("../../../utils/output");
14
+ /**
15
+ * sandbox 分支下补同步 steering(`.agent/skills/steering/<stack>/`)。
16
+ *
17
+ * **绝不抛错**,因为它在 sandbox 链路里是「附加项」而非门禁:
18
+ * - 无 `.spark/meta.json` / 无 stack → skipped。对齐老 cp-skills.sh 的
19
+ * `[ -n "$STACK" ] && [ "$STACK" != "null" ]` 判断(无 stack 就跳过 steering)。
20
+ * - 拉包 / 写盘失败 → failed,但仍让整条 sync 算成功。
21
+ *
22
+ * 为什么不能抛:feida-ai 侧 TCC 可配 `allowFailure: false`,一抛就阻断 agent 启动。
23
+ * 平铺业务线 skill 已经同步好了,不该因为 steering 这个增量部分让用户开不了会话。
24
+ */
25
+ function syncSteeringForSandbox(params) {
26
+ let stack;
27
+ try {
28
+ const meta = (0, spark_meta_1.readSparkMeta)(params.targetDir);
29
+ if (meta.stack === undefined || meta.stack === '' || meta.stack === 'null') {
30
+ return { status: 'skipped', reason: '.spark/meta.json missing stack' };
31
+ }
32
+ stack = meta.stack;
33
+ }
34
+ catch (err) {
35
+ return { status: 'skipped', reason: `read .spark/meta.json failed: ${err.message}` };
36
+ }
37
+ // 版本口径与下方本地分支保持一致:--version > BOE 的 dist-tag dev > latest
38
+ const isBoe = process.env.FORCE_FRAMEWORK_ENVIRONMENT === 'boe';
39
+ const effectiveVersion = params.version ?? (isBoe ? 'dev' : undefined);
40
+ try {
41
+ const r = (0, coding_steering_1.syncCodingSteering)({
42
+ stack,
43
+ targetDir: params.targetDir,
44
+ version: effectiveVersion,
45
+ // 必须显式传 mode,不能落到 inferMode():它靠 env MIAODA_DEP_CACHE_DIR 嗅探,
46
+ // 而 mode 决定读包内哪个 layer(sandbox→`skills/`,local→`skills_local/`)。
47
+ // `--type` 分支本身就是沙箱语义,嗅探失手会去读不存在的 skills_local/、
48
+ // 静默同步 0 个 skill。
49
+ mode: 'sandbox',
50
+ // 同理固定 nested:落 .agent/skills/steering/<stack>/,与老链路一致
51
+ outputLayout: 'nested',
52
+ });
53
+ return {
54
+ status: 'synced',
55
+ stack,
56
+ version: r.version,
57
+ syncedSkills: r.syncedSkills,
58
+ techSynced: r.techSynced,
59
+ };
60
+ }
61
+ catch (err) {
62
+ return { status: 'failed', reason: err.message };
63
+ }
64
+ }
12
65
  /**
13
66
  * miaoda skills sync [--dir <path>] [--version <ver>]
14
67
  *
@@ -26,6 +79,57 @@ const output_1 = require("../../../utils/output");
26
79
  async function handleSkillsSync(opts) {
27
80
  await Promise.resolve();
28
81
  const targetDir = node_path_1.default.resolve(opts.dir ?? process.cwd());
82
+ // --type 走 sandbox-skills 分支(迁仓后沙箱内由 feida-ai 直接下发调用)。
83
+ //
84
+ // 这条分支必须**同时**同步两部分,缺一不可 —— 线上老链路 update-skills.sh 也是两步
85
+ // (自己 rsync 平铺,再于第 230 行调不带 --type 的 `miaoda skills sync` 写 steering):
86
+ // ① syncSandboxSkills:平铺业务线 skill,rsync 式对齐(保 .keep/.config/steering)
87
+ // ② steering:`.agent/skills/steering/<stack>/`,按 .spark/meta.json 的 stack 过滤
88
+ // ③ lockDirReadonly:锁只读收尾(update-skills.sh:241),仅沙箱侧路径
89
+ //
90
+ // 历史 bug:早期这里 ① 完就 return,steering 既不同步、又被当时的整目录清空删掉,
91
+ // 沙箱少掉整批技术栈 skill(BOE 实测 scanAllSkills 从 59 掉到 34)。
92
+ //
93
+ // `.keep` 语义(update-skills.sh:149):目标目录挂了 .keep 表示「我手改过,别覆盖」,
94
+ // ① 会跳过并回报 skippedByKeepFile,此时 ② 也必须跳过 —— 否则 steering 仍被覆写,
95
+ // 冻结只冻结了一半。
96
+ if (opts.type !== undefined && opts.type !== '') {
97
+ const { type, tgzKey } = (0, sandbox_skills_1.validateSandboxSyncOpts)({ type: opts.type, tgzKey: opts.tgzKey });
98
+ const result = (0, sandbox_skills_1.syncSandboxSkills)({ type, tgzKey, targetDir });
99
+ const steering = result.skippedByKeepFile
100
+ ? { status: 'skipped', reason: '.keep file found, sync frozen' }
101
+ : syncSteeringForSandbox({ targetDir, version: opts.version });
102
+ // ③ 收尾锁只读(update-skills.sh:241 的 chmod -R a=rX)。必须在 ①② 全部写完之后,
103
+ // 锁上就写不进去了。`.keep` 冻结时不锁 —— 老链路那条路径是 exit 0 直接走、
104
+ // 末尾的 chmod 压根不执行,保持一致。
105
+ //
106
+ // **只锁沙箱侧**:这里的 result.targetDir 只会是 `.agent/skills` 或 `skills`
107
+ // (见 sandbox-skills 的 skillsRoot),都是平台下发的资产根。本地 `--local` 走
108
+ // flat layout 输出到 `.agents/`,压根不经过本分支,权限不会被碰。
109
+ //
110
+ // 回退风险由解锁侧兜:权限是持久化在磁盘的,代码能回退、磁盘状态不会。所以
111
+ // file-ops 的 unlockDirForWrite 在**两处写入前**都调用了(syncSandboxSkills 的
112
+ // 写入前 + syncCodingSteering 的 nested 分支写入前),任一入口都不会因为
113
+ // 上一轮的锁而 EACCES。改动这里之前先确认那两处解锁还在。
114
+ const lockedReadonly = result.skippedByKeepFile ? false : (0, file_ops_1.lockDirReadonly)(result.targetDir);
115
+ (0, output_1.emit)({
116
+ data: {
117
+ mode: 'sandbox',
118
+ type,
119
+ packageName: result.packageName,
120
+ version: result.version,
121
+ lane: result.lane,
122
+ syncedTop: result.syncedTop,
123
+ deleted: result.deleted,
124
+ skippedByKeepFile: result.skippedByKeepFile,
125
+ lockedReadonly,
126
+ targetDir: result.targetDir,
127
+ steering,
128
+ },
129
+ });
130
+ return;
131
+ }
132
+ // 原有 coding-steering 同步逻辑(本地 dev / miaoda app init 用)
29
133
  const meta = (0, spark_meta_1.readSparkMeta)(targetDir);
30
134
  if (meta.stack === undefined || meta.stack === '') {
31
135
  throw new error_1.AppError('SKILLS_META_INCOMPLETE', '.spark/meta.json missing stack — run `miaoda app init` first');
@@ -36,8 +36,10 @@ function isHtml(rel) {
36
36
  /**
37
37
  * 把 changeset 组成 TosFileAction[]:
38
38
  * - create/update 读本地文件、自动编码;delete 仅 FilePath。
39
- * - .html 被 create / delete(路由集合变化)时,追加重算 routes.json 的 UPDATE action
40
- * update 现有 .html 只改内容、不改路由集合,不重算 routes.json。
39
+ * - 任一 .html 被 create / update / delete 时,追加重算 routes.json 的 UPDATE action
40
+ * update 也算:routes.json name 取自 HTML 的 `<title>`,改方案名就是一次纯 update,
41
+ * 只按 create/delete 判定会让改名永远进不了 routes.json。
42
+ * - 纯非 .html 改动(js / css / 图片)不动 routes.json。
41
43
  */
42
44
  function buildTosActions(projectDir, cs) {
43
45
  const actions = [];
@@ -48,8 +50,8 @@ function buildTosActions(projectDir, cs) {
48
50
  for (const rel of cs.deletes) {
49
51
  actions.push({ actionType: index_1.TosActionType.DELETE, filePath: normalizeRel(rel) });
50
52
  }
51
- const routeSetChanged = [...cs.creates, ...cs.deletes].some(isHtml);
52
- if (routeSetChanged) {
53
+ const routesMayChange = [...cs.creates, ...cs.updates, ...cs.deletes].some(isHtml);
54
+ if (routesMayChange) {
53
55
  actions.push({
54
56
  actionType: index_1.TosActionType.UPDATE,
55
57
  filePath: 'routes.json',
@@ -0,0 +1,75 @@
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.extractTitle = extractTitle;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ /**
9
+ * 读 `<title>` 时的扫描窗口上限(字节)。title 规范上在 `<head>` 里、正常紧贴文档开头,
10
+ * 64KB 足以覆盖 head 里塞了内联 style / script 的情况,同时给大 HTML 兜住 IO 上限
11
+ * (generateRoutes 会遍历工程内所有 .html,不能整文件读)。
12
+ */
13
+ const HEAD_SCAN_BYTES = 64 * 1024;
14
+ const TITLE_RE = /<title[^>]*>([^<]*)<\/title>/i;
15
+ const HEAD_CLOSE_RE = /<\/head\s*>/i;
16
+ const COMMENT_RE = /<!--[\s\S]*?-->/g;
17
+ const ENTITY_RE = /&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi;
18
+ /** HTML 里可能出现在 title 文本中的命名实体。未列出的原样保留,不做全量实体表。 */
19
+ const NAMED_ENTITIES = {
20
+ amp: '&',
21
+ lt: '<',
22
+ gt: '>',
23
+ quot: '"',
24
+ apos: "'",
25
+ nbsp: ' ',
26
+ };
27
+ /** 把 title 文本里的实体还原成明文——name 是给前端直接当文本渲染的,不该带 `&amp;`。 */
28
+ function decodeEntities(s) {
29
+ return s.replace(ENTITY_RE, (matched, body) => {
30
+ if (!body.startsWith('#')) {
31
+ return NAMED_ENTITIES[body.toLowerCase()] ?? matched;
32
+ }
33
+ const code = /^#x/i.test(body) ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
34
+ if (!Number.isInteger(code) || code <= 0 || code > 0x10ffff)
35
+ return matched;
36
+ return String.fromCodePoint(code);
37
+ });
38
+ }
39
+ /** 只读文件头部 HEAD_SCAN_BYTES 字节。尾部可能截断多字节字符,对 title 提取无影响。 */
40
+ function readHead(absPath) {
41
+ const fd = node_fs_1.default.openSync(absPath, 'r');
42
+ try {
43
+ const buf = Buffer.alloc(HEAD_SCAN_BYTES);
44
+ const bytes = node_fs_1.default.readSync(fd, buf, 0, HEAD_SCAN_BYTES, 0);
45
+ return buf.subarray(0, bytes).toString('utf-8');
46
+ }
47
+ finally {
48
+ node_fs_1.default.closeSync(fd);
49
+ }
50
+ }
51
+ /**
52
+ * 取 HTML 的 `<title>` 文本作为该页面的方案名。
53
+ *
54
+ * - 扫描范围截到第一个 `</head>`:body 里的 `<title>` 不算——内联 SVG 图标的 a11y
55
+ * `<title>` 很常见,抓到就会把图标描述当成方案名。找不到 `</head>` 时退回整个窗口。
56
+ * - 先剔 HTML 注释,避免抓到被注释掉的 title。
57
+ * - 实体还原 + 空白折叠 + trim;空 title / 无 title / 读失败一律返回 undefined,
58
+ * 由消费侧 fallback 到 path(旧应用没有 title 时保持显示页面地址)。
59
+ */
60
+ function extractTitle(absPath) {
61
+ let head;
62
+ try {
63
+ head = readHead(absPath);
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ const headClose = HEAD_CLOSE_RE.exec(head)?.index ?? -1;
69
+ const scope = (headClose >= 0 ? head.slice(0, headClose) : head).replace(COMMENT_RE, '');
70
+ const raw = TITLE_RE.exec(scope)?.[1];
71
+ if (raw === undefined)
72
+ return undefined;
73
+ const text = decodeEntities(raw).replace(/\s+/g, ' ').trim();
74
+ return text === '' ? undefined : text;
75
+ }
@@ -11,7 +11,7 @@ const actions_1 = require("./actions");
11
11
  var actions_2 = require("./actions");
12
12
  Object.defineProperty(exports, "buildTosActions", { enumerable: true, get: function () { return actions_2.buildTosActions; } });
13
13
  /**
14
- * design-html 增量发布:读本地文件组 TosFileAction(create/delete .html 时带重算的
14
+ * design-html 增量发布:读本地文件组 TosFileAction(改动含 .html 时带重算的
15
15
  * routes.json),调后端 applyTosDiff 应用到 latest。scope 限 design_local_deploy。
16
16
  */
17
17
  async function patchDesignDeploy(opts) {
@@ -1,6 +1,11 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.generateRoutes = generateRoutes;
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ const html_title_1 = require("./html-title");
4
9
  const source_scan_1 = require("./source-scan");
5
10
  /**
6
11
  * .html → route path 推导(相对路径,不带 base 前缀)。rel 为 posix 相对路径。
@@ -16,8 +21,9 @@ function toRoute(rel) {
16
21
  }
17
22
  /**
18
23
  * 扫描 projectDir 下所有 .html(套 source-scan 的 EXCLUDES),生成 routes.json 文本(含尾换行)。
19
- * 每项为 { path, file }:path 为相对路径(不带 CLIENT_BASE_PATH 前缀,由消费侧拼接),
20
- * file 为该路由对应的原始 .html 源文件。按 path 排序、同 path 取字典序靠前的 file 去重。
24
+ * 每项为 { path, file, name? }:path 为相对路径(不带 CLIENT_BASE_PATH 前缀,由消费侧拼接),
25
+ * file 为该路由对应的原始 .html 源文件,name 为该文件 `<title>` 解析出的方案名(可缺省)。
26
+ * 按 path 排序、同 path 取字典序靠前的 file 去重。
21
27
  * 零 html 时输出空数组——不伪造解析不到文件的根路由。
22
28
  */
23
29
  function generateRoutes(projectDir) {
@@ -27,7 +33,11 @@ function generateRoutes(projectDir) {
27
33
  if (!byPath.has(entry.path))
28
34
  byPath.set(entry.path, entry);
29
35
  }
30
- const routes = [...byPath.values()];
36
+ // title 只读去重后胜出的 file:同 path 多源(foo.html / foo/index.html)时不做无用 IO。
37
+ const routes = [...byPath.values()].map((entry) => {
38
+ const name = (0, html_title_1.extractTitle)(node_path_1.default.join(projectDir, entry.file));
39
+ return name === undefined ? entry : { ...entry, name };
40
+ });
31
41
  return JSON.stringify(routes, null, 2) + '\n';
32
42
  }
33
43
  function byPathThenFile(a, b) {
@@ -63,6 +63,16 @@ function syncCodingSteering(opts) {
63
63
  dstSkillsDir: node_path_1.default.join(opts.targetDir, '.agent', 'skills', 'steering', opts.stack, 'skills'),
64
64
  dstTechPath: node_path_1.default.join(opts.targetDir, '.agent', 'skills', 'steering', opts.stack, 'tech.md'),
65
65
  };
66
+ // **本函数不碰任何权限位**——解锁是调用方的事:
67
+ // - 老链路:`update-skills.sh:162` 在跑一切之前统一 `chmod -R u+w "$TARGET_DIR"`
68
+ // - 新链路(`skills sync --type X`):同一次调用里 syncSandboxSkills 已经递归解锁过
69
+ // 整个 skills 根,本函数紧随其后执行,目录必然可写
70
+ // - 本地 dev / `app init`:目录从来不会被锁
71
+ //
72
+ // 曾在这里加过「解锁 + 写完还原」,两个理由下掉:steering 子树的所有权明确归 CLI
73
+ // (老脚本 :179/:208 写明 `steering/ 子树由 miaoda CLI 独占,update.sh 不写不删`),
74
+ // 锁它没收益;而权限逻辑散在写入方会跟收尾的 lockDirReadonly 形成隐式耦合,
75
+ // 范围一旦不对称就会出「跑一次 skills sync 把整个 skills 目录解封」这种事。
66
76
  node_fs_1.default.mkdirSync(dstSkillsDir, { recursive: true });
67
77
  let techSynced = false;
68
78
  const techSrc = node_path_1.default.join(stackDir, 'tech.md');
@@ -5,6 +5,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.removeLineFromFile = removeLineFromFile;
7
7
  exports.removeLineFromContent = removeLineFromContent;
8
+ exports.unlockDirForWrite = unlockDirForWrite;
9
+ exports.lockDirReadonly = lockDirReadonly;
10
+ exports.forceRemove = forceRemove;
11
+ const node_child_process_1 = require("node:child_process");
8
12
  const node_fs_1 = __importDefault(require("node:fs"));
9
13
  const node_path_1 = __importDefault(require("node:path"));
10
14
  const logger_1 = require("./logger");
@@ -43,3 +47,80 @@ function removeLineFromContent(content, pattern) {
43
47
  changed: filtered.length !== lines.length,
44
48
  };
45
49
  }
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+ // 目录权限:沙箱 skill 目录被刻意锁成只读,写入前须解锁、写完须锁回。
52
+ //
53
+ // 背景:老 update-skills.sh 同步末尾 `chmod -R a=rX "$TARGET_DIR"`(:241)把
54
+ // skills 锁成只读——skills 是平台下发的资产,不该被用户/agent 手改;下一轮同步
55
+ // 开头再 `chmod -R u+w`(:162)统一解锁。目录 owner 是沙箱当前用户,虽无写位但
56
+ // 有权改权限,所以不需要 sudo(沙箱容器带 no_new_privileges,sudo 本就被禁)。
57
+ //
58
+ // **解锁只做在「整棵树的接管者」那一层**:新链路里就是 syncSandboxSkills(它重写
59
+ // 整个平铺层),对齐老脚本 :162 那一次统一解锁。写 steering 的 syncCodingSteering
60
+ // 不碰权限 —— 它跑在同一次调用里、紧随 syncSandboxSkills 之后,目录已经可写;
61
+ // 各写入方各自 chmod 会与收尾的 lockDirReadonly 形成隐式耦合,范围一旦不对称就会
62
+ // 出「跑一次 skills sync 把整个 skills 目录解封」这种事。
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+ /**
65
+ * 解锁目录使其可写(`chmod -R u+w`)。
66
+ *
67
+ * 写入被只读锁保护的目录前必须调用,否则 `fs.cpSync` / `fs.rmSync` 报 EACCES。
68
+ * 只加 owner 写位、不动 g/o,与老链路一致。目录不存在直接返回 false(调用方
69
+ * 通常紧接着 mkdir)。失败只 debug 不抛:解锁是尽力而为,真写不进去让后续
70
+ * 写操作自己报错,错误信息更具体。
71
+ */
72
+ function unlockDirForWrite(dir) {
73
+ if (!node_fs_1.default.existsSync(dir))
74
+ return false;
75
+ try {
76
+ (0, node_child_process_1.execFileSync)('chmod', ['-R', 'u+w', dir], { stdio: ['ignore', 'ignore', 'pipe'] });
77
+ return true;
78
+ }
79
+ catch (err) {
80
+ (0, logger_1.debug)(`file-ops: chmod -R u+w ${dir} failed: ${err.message}`);
81
+ return false;
82
+ }
83
+ }
84
+ /**
85
+ * 把目录锁成只读(`chmod -R a=rX`),对齐 update-skills.sh:241。
86
+ *
87
+ * `a=rX`:所有人只读;`X` 是大写,只给目录和「原本已有执行位」的文件加 x,
88
+ * 不会把 SKILL.md 变成可执行。会连 owner 写位一并清掉,所以下一轮写入前必须
89
+ * {@link unlockDirForWrite}。
90
+ *
91
+ * **调用时机必须是全部写入完成之后**——锁上就写不进去了。
92
+ */
93
+ function lockDirReadonly(dir) {
94
+ if (!node_fs_1.default.existsSync(dir))
95
+ return false;
96
+ try {
97
+ (0, node_child_process_1.execFileSync)('chmod', ['-R', 'a=rX', dir], { stdio: ['ignore', 'ignore', 'pipe'] });
98
+ return true;
99
+ }
100
+ catch (err) {
101
+ (0, logger_1.debug)(`file-ops: chmod -R a=rX ${dir} failed: ${err.message}`);
102
+ return false;
103
+ }
104
+ }
105
+ /**
106
+ * 强制删除文件或目录,三层兜底:`chmod -R u+w` → `rm -rf` → `fs.rmSync`。
107
+ *
108
+ * 为什么不能直接 `fs.rmSync`:删条目需要**父目录**写权限,而 skill 目录常处于
109
+ * 只读锁下;另外老 update.sh 曾以 root 跑过,可能留下异主文件(如 root-owned 的
110
+ * `.last-update`),`rmSync{force:true}` 只忽略 ENOENT、遇 EACCES 仍抛。
111
+ * shell `rm -rf` 对异主文件 / 怪符号链接比 rmSync 宽容,故作主档。
112
+ */
113
+ function forceRemove(target) {
114
+ if (!node_fs_1.default.existsSync(target))
115
+ return;
116
+ unlockDirForWrite(target);
117
+ try {
118
+ (0, node_child_process_1.execFileSync)('rm', ['-rf', target], { stdio: ['ignore', 'ignore', 'pipe'] });
119
+ if (!node_fs_1.default.existsSync(target))
120
+ return;
121
+ }
122
+ catch (err) {
123
+ (0, logger_1.debug)(`file-ops: rm -rf ${target} failed: ${err.message}`);
124
+ }
125
+ node_fs_1.default.rmSync(target, { recursive: true, force: true });
126
+ }
@@ -3,6 +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.pickLaneVersion = pickLaneVersion;
6
7
  exports.fetchNpmPackage = fetchNpmPackage;
7
8
  const node_child_process_1 = require("node:child_process");
8
9
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -11,6 +12,38 @@ const node_path_1 = __importDefault(require("node:path"));
11
12
  const error_1 = require("./error");
12
13
  const logger_1 = require("./logger");
13
14
  const DEFAULT_REGISTRY = 'https://registry.npmmirror.com/';
15
+ /**
16
+ * 从 `npm view <pkg> versions --json` 里过滤出后缀 `-alpha.<X>` 的版本。
17
+ *
18
+ * 用于 sandbox-skills 新姿势(2026-08 迁离 lane-<X> dist-tag 之后):
19
+ * publish-sandbox-skills.sh 每次发的版本号 = `<base>-alpha.<LANE>-<YYYYMMDDHHMMSS>`,
20
+ * TCC 里的 X = `<LANE>-<TIMESTAMP>` 全局唯一。反推侧过滤 versions 数组即可精确命中。
21
+ *
22
+ * **不 throw 是硬要求**(消费方 fallback @latest 依赖返回 null 判定):
23
+ * - `npm view` 网络挂、包不存在、metadata 解析失败 → 返回 null
24
+ * - 无版本匹配 → 返回 null
25
+ * - 多版本匹配(理论上不该发生,因 timestamp 精度到秒)→ 字符串排序取最大(timestamp 最大)
26
+ */
27
+ function pickLaneVersion(packageName, X, registry) {
28
+ const reg = registry ?? process.env.MIAODA_NPM_REGISTRY ?? DEFAULT_REGISTRY;
29
+ const suffix = `-alpha.${X}`;
30
+ try {
31
+ const stdout = (0, node_child_process_1.execFileSync)('npm', ['view', packageName, 'versions', '--json', '--registry', reg], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
32
+ const versions = JSON.parse(stdout);
33
+ if (!Array.isArray(versions))
34
+ return null;
35
+ const matched = versions.filter((v) => typeof v === 'string' && v.endsWith(suffix));
36
+ if (matched.length === 0)
37
+ return null;
38
+ // 通常唯一命中(X 含 timestamp 到秒);多命中时字符串排序取最大(timestamp 更晚)
39
+ matched.sort();
40
+ return matched[matched.length - 1] ?? null;
41
+ }
42
+ catch (err) {
43
+ (0, logger_1.debug)(`pickLaneVersion(${packageName}, X=${X}) failed: ${err.message}`);
44
+ return null;
45
+ }
46
+ }
14
47
  /**
15
48
  * `npm pack <pkg>@<ver> --registry <r>` + `tar -xzf`,解到临时目录。
16
49
  * extractDir 是包内容根(含 package.json)。使用完必须 cleanup()。
@@ -0,0 +1,271 @@
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.extractLaneFromTgzKey = extractLaneFromTgzKey;
7
+ exports.syncSandboxSkills = syncSandboxSkills;
8
+ exports.validateSandboxSyncOpts = validateSandboxSyncOpts;
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const npm_pack_1 = require("../utils/npm-pack");
12
+ const error_1 = require("../utils/error");
13
+ const logger_1 = require("../utils/logger");
14
+ const file_ops_1 = require("../utils/file-ops");
15
+ const PACKAGE_BY_TYPE = {
16
+ miaoda: '@lark-apaas/coding-miaoda-sandbox-skills',
17
+ 'miaoda-modern': '@lark-apaas/coding-miaoda-sandbox-skills',
18
+ 'miaoda-design': '@lark-apaas/coding-miaoda-sandbox-skills',
19
+ openclaw: '@lark-apaas/coding-openclaw-sandbox-skills',
20
+ };
21
+ /**
22
+ * 目标根目录下**永不删除**的控制文件(对齐 `update-skills.sh` 的删除白名单):
23
+ * - `.keep` 沙箱级「冻结 skills」开关,存在即整体跳过同步(见 {@link syncSandboxSkills})
24
+ * - `.config` 沙箱级 tgzKey 覆盖,feida-ai 的 `enableConfigFileOverride` 读它
25
+ * (`getKeepFileTgzKey` → `.agent/skills/.config`),删了会让版本钉死失效
26
+ * - `.last-update` 同步时间戳,feida-ai freshness 检查读它;每轮末尾会重写,列进来只是语义完备
27
+ */
28
+ const PRESERVED_ENTRIES = new Set(['.keep', '.config', '.last-update']);
29
+ /** `.keep` 存在时整体跳过同步的标记文件名,语义同 `update-skills.sh:149`。 */
30
+ const KEEP_FILE = '.keep';
31
+ /**
32
+ * rsync 式对齐:删掉目标里「源包已不提供」的顶层条目,对齐 `update-skills.sh:196-218`。
33
+ *
34
+ * 与整目录清空的区别(后者是本函数取代的旧实现):
35
+ * - 保住 {@link PRESERVED_ENTRIES} 里的控制文件
36
+ * - 保住 `steering/` 子树 —— 它由 handler 层的 syncCodingSteering 独占管理
37
+ * (`update-skills.sh:207` 平铺型分支同样明确「归 miaoda CLI 管,update.sh 不删不覆盖」)
38
+ *
39
+ * 只处理顶层:源包内容本身就是「一个 skill 一个顶层目录」,逐文件比对没有额外收益。
40
+ */
41
+ function pruneStaleEntries(skillsRoot, sourceTopEntries) {
42
+ if (!node_fs_1.default.existsSync(skillsRoot))
43
+ return [];
44
+ const deleted = [];
45
+ for (const entry of node_fs_1.default.readdirSync(skillsRoot)) {
46
+ if (PRESERVED_ENTRIES.has(entry) || entry === 'steering')
47
+ continue;
48
+ if (sourceTopEntries.has(entry))
49
+ continue;
50
+ const target = node_path_1.default.join(skillsRoot, entry);
51
+ try {
52
+ // forceRemove 的 chmod→rm -rf→rmSync 三层兜底对文件同样适用:
53
+ // 老 update.sh 以 root 跑过、且会 chmod -R 锁只读,直接 rmSync 会 EACCES
54
+ (0, file_ops_1.forceRemove)(target);
55
+ deleted.push(entry);
56
+ }
57
+ catch (err) {
58
+ (0, logger_1.debug)(`sandbox-skills: prune ${entry} failed: ${err.message}`);
59
+ }
60
+ }
61
+ return deleted;
62
+ }
63
+ // 合并包内的业务线子目录(miaoda / miaoda-modern / miaoda-design);shared 单独处理。
64
+ const BUSINESS_LINE_BY_TYPE = {
65
+ miaoda: 'miaoda',
66
+ 'miaoda-modern': 'miaoda-modern',
67
+ 'miaoda-design': 'miaoda-design',
68
+ openclaw: null, // openclaw 独立包,直接展平
69
+ };
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"。
84
+ */
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
+ }
99
+ /**
100
+ * 主流程:按 type 拉包 + sync 到目标目录。
101
+ *
102
+ * sync 策略:**rsync 式对齐**,逐条对应线上真实跑的 `update-skills.sh`(不是
103
+ * `cp-skills.sh`——那个脚本线上不执行,日志里的 `copied/skipped/deleted` 出自前者):
104
+ * 1. 目标目录有 `.keep` → 整体跳过(`update-skills.sh:149`)
105
+ * 2. 覆盖式写入源包提供的顶层条目
106
+ * 3. 删掉源包已不提供的顶层条目({@link pruneStaleEntries}),但保住
107
+ * {@link PRESERVED_ENTRIES} 与 `steering/`
108
+ *
109
+ * 不用整目录清空:那会删掉 `.config`(沙箱级 tgzKey 覆盖)、`.keep`(冻结开关)
110
+ * 和 handler 层刚写的 `steering/`,而老链路对这三者都有保护。
111
+ */
112
+ function syncSandboxSkills(opts) {
113
+ const targetDir = node_path_1.default.resolve(opts.targetDir ?? process.cwd());
114
+ const packageName = PACKAGE_BY_TYPE[opts.type];
115
+ const businessLine = BUSINESS_LINE_BY_TYPE[opts.type];
116
+ const lane = extractLaneFromTgzKey(opts.tgzKey);
117
+ // ---- lane 拉包 ----
118
+ // 新姿势(2026-08 迁离 lane-<X> dist-tag 之后):
119
+ // TCC 里的 X(比如 `feat-migrate-skills-from-miaoda-skills-20260805143512`)
120
+ // 包含 branch + timestamp,`pickLaneVersion` 走 `npm view <pkg> versions --json`
121
+ // 过滤 `endsWith('-alpha.' + X)`(因 X 含 timestamp 全局唯一,唯一命中或 0 命中)。
122
+ // 0 命中 → fallback @latest(对齐旧 TCC 值找不到、灰度切换期间的兜底语义)。
123
+ // `pickLaneVersion` 内部对 `npm view` 报错 / 无匹配都返回 null,绝不 throw ——
124
+ // 目标就是"不挂掉"。
125
+ let fetched;
126
+ const laneVersion = lane === null ? null : (0, npm_pack_1.pickLaneVersion)(packageName, lane, opts.registry);
127
+ if (lane !== null && laneVersion === null) {
128
+ (0, logger_1.debug)(`sandbox-skills: no npm version matched -alpha.${lane}, fallback to @latest ` +
129
+ `(TCC 里的 X 可能是老 lane dist-tag 命名 / 已 unpublish / npm view 挂 / 网络断)`);
130
+ }
131
+ const primaryVersion = laneVersion ?? 'latest';
132
+ try {
133
+ (0, logger_1.debug)(`sandbox-skills: fetching ${packageName}@${primaryVersion}`);
134
+ fetched = (0, npm_pack_1.fetchNpmPackage)({
135
+ packageName,
136
+ version: primaryVersion,
137
+ registry: opts.registry,
138
+ });
139
+ }
140
+ catch (err) {
141
+ if (primaryVersion === 'latest') {
142
+ throw err;
143
+ }
144
+ (0, logger_1.debug)(`sandbox-skills: pack ${primaryVersion} failed, fallback to @latest (err: ${err.message})`);
145
+ fetched = (0, npm_pack_1.fetchNpmPackage)({
146
+ packageName,
147
+ version: 'latest',
148
+ registry: opts.registry,
149
+ });
150
+ }
151
+ try {
152
+ // 计算 skill 目标根目录:
153
+ // - openclaw → <targetDir>/skills/(跟老 cp-skills.sh 的 openclaw 分支对齐,落 $WORKSPACE_DIR/agent/skills)
154
+ // - 其它 → <targetDir>/.agent/skills/
155
+ const skillsRoot = opts.type === 'openclaw'
156
+ ? node_path_1.default.join(targetDir, 'skills')
157
+ : node_path_1.default.join(targetDir, '.agent', 'skills');
158
+ // `.keep` 短路:对齐 update-skills.sh:149-152,沙箱侧放了这个文件即表示
159
+ // 「我手改过 skills,别覆盖我」,整体跳过同步(含 steering —— 调用方看到
160
+ // skipped 就不该再往下写)。
161
+ if (node_fs_1.default.existsSync(node_path_1.default.join(skillsRoot, KEEP_FILE))) {
162
+ (0, logger_1.debug)(`sandbox-skills: ${KEEP_FILE} found in ${skillsRoot}, skip sync`);
163
+ return {
164
+ packageName,
165
+ version: fetched.version,
166
+ lane,
167
+ syncedTop: [],
168
+ deleted: [],
169
+ skippedByKeepFile: true,
170
+ targetDir: skillsRoot,
171
+ };
172
+ }
173
+ // 写入前解锁,对齐 update-skills.sh:160-165:
174
+ // 上一轮同步末尾会 chmod -R a=rX 锁只读(见 file-ops 的 lockDirReadonly),
175
+ // 不先解锁,下面 fs.cpSync 覆盖已存在的 skill 目录必 EACCES。
176
+ // 只加 u+w(不动 g/o),与老链路一致;owner 是沙箱当前用户,无需 sudo。
177
+ (0, file_ops_1.unlockDirForWrite)(skillsRoot);
178
+ node_fs_1.default.mkdirSync(skillsRoot, { recursive: true });
179
+ const syncedTop = [];
180
+ // 源包提供的全部顶层条目 —— prune 的保留集。与 syncedTop 分开维护:syncedTop 是
181
+ // 「本轮实际写入」,用于对外汇报;prune 只能依据「源包提供了什么」来判断 stale,
182
+ // 两者一旦哪天再次分叉(比如某分支又加了跳过写入的条件),拿 syncedTop 当保留集
183
+ // 就会把没重写的条目误判成 stale 删掉、下一轮再写回来,反复抖动。
184
+ const sourceTop = new Set();
185
+ if (businessLine !== null) {
186
+ // 合并包 miaoda/miaoda-modern/miaoda-design:cp <extractDir>/<business-line>/* 到 target
187
+ const sourceDir = node_path_1.default.join(fetched.extractDir, businessLine);
188
+ if (node_fs_1.default.existsSync(sourceDir)) {
189
+ for (const entry of node_fs_1.default.readdirSync(sourceDir)) {
190
+ const from = node_path_1.default.join(sourceDir, entry);
191
+ const to = node_path_1.default.join(skillsRoot, entry);
192
+ sourceTop.add(entry);
193
+ node_fs_1.default.cpSync(from, to, { recursive: true });
194
+ syncedTop.push(entry);
195
+ }
196
+ }
197
+ else {
198
+ (0, logger_1.debug)(`sandbox-skills: source dir ${sourceDir} not found in package (skipped)`);
199
+ }
200
+ // shared:合并包内 shared/* 到 target。**无条件覆盖**,逐字对齐老 build.sh:42-52 ——
201
+ // 那里业务线先 `cp -r`(:33-34)、shared 后 `cp -r`(:47),后写覆盖前写,
202
+ // 即同名时 **shared 赢**。当前包里两边无同名交集,但语义得跟老链路一致。
203
+ //
204
+ // 早期这里写成「`fs.existsSync(to)` 就 continue」,有两个问题:
205
+ // 1. 同名优先级反了(变成业务线赢);
206
+ // 2. 更严重 —— existsSync 分不清「本轮业务线刚写的」和「上一轮 shared 自己写的」,
207
+ // 于是 shared skill 首次落盘后就永久冻结,包里再更新也同步不到已有沙箱
208
+ // (沙箱磁盘跨会话保留,每次 update-skills 都会命中 existsSync)。
209
+ const sharedDir = node_path_1.default.join(fetched.extractDir, 'shared');
210
+ if (node_fs_1.default.existsSync(sharedDir)) {
211
+ for (const entry of node_fs_1.default.readdirSync(sharedDir)) {
212
+ const from = node_path_1.default.join(sharedDir, entry);
213
+ const to = node_path_1.default.join(skillsRoot, entry);
214
+ sourceTop.add(entry);
215
+ node_fs_1.default.cpSync(from, to, { recursive: true });
216
+ syncedTop.push(entry);
217
+ }
218
+ }
219
+ }
220
+ else {
221
+ // openclaw 独立包:直接把包内所有 skill 目录(除 package.json 等元文件外)平铺到 skillsRoot
222
+ for (const entry of node_fs_1.default.readdirSync(fetched.extractDir)) {
223
+ if (entry === 'package.json' || entry === 'README.md' || entry === 'LICENSE')
224
+ continue;
225
+ const from = node_path_1.default.join(fetched.extractDir, entry);
226
+ const stat = node_fs_1.default.statSync(from);
227
+ if (!stat.isDirectory())
228
+ continue;
229
+ const to = node_path_1.default.join(skillsRoot, entry);
230
+ sourceTop.add(entry);
231
+ node_fs_1.default.cpSync(from, to, { recursive: true });
232
+ syncedTop.push(entry);
233
+ }
234
+ }
235
+ // rsync 式收尾:删掉源包已不提供的顶层条目(对齐 update-skills.sh 的 deleted)。
236
+ // 保留集用 sourceTop(源包提供的全部)而非 syncedTop(本轮实际写入的)——原因见
237
+ // sourceTop 声明处的注释:否则 shared skill 会被反复删了又写。
238
+ const deleted = pruneStaleEntries(skillsRoot, sourceTop);
239
+ // 写 .last-update 时间戳(对齐老 update-skills.sh;供 feida-ai freshness 检查用)
240
+ const lastUpdatePath = node_path_1.default.join(skillsRoot, '.last-update');
241
+ node_fs_1.default.writeFileSync(lastUpdatePath, String(Date.now()));
242
+ return {
243
+ packageName,
244
+ version: fetched.version,
245
+ lane,
246
+ deleted,
247
+ skippedByKeepFile: false,
248
+ syncedTop,
249
+ targetDir: skillsRoot,
250
+ };
251
+ }
252
+ finally {
253
+ fetched.cleanup();
254
+ }
255
+ }
256
+ /**
257
+ * 参数校验:type 白名单 + tgzKey 格式(避免非法值透传到 npm 包名)。
258
+ */
259
+ function validateSandboxSyncOpts(opts) {
260
+ const validTypes = ['miaoda', 'miaoda-modern', 'miaoda-design', 'openclaw'];
261
+ if (opts.type === undefined || !validTypes.includes(opts.type)) {
262
+ throw new error_1.AppError('SKILLS_INVALID_TYPE', `--type must be one of: ${validTypes.join(', ')} (got: ${opts.type ?? 'undefined'})`);
263
+ }
264
+ if (opts.tgzKey !== undefined && opts.tgzKey !== '') {
265
+ // 校验 tgz key 大致格式(前缀 skills_versions/ + all_ + 允许字符 + .tgz);避免非法值影响 lane 反推
266
+ if (!/^[A-Za-z0-9/_\-.]+\.tgz$/.test(opts.tgzKey)) {
267
+ throw new error_1.AppError('SKILLS_INVALID_TGZ_KEY', `--tgz-key value looks invalid: ${opts.tgzKey}`);
268
+ }
269
+ }
270
+ return { type: opts.type, tgzKey: opts.tgzKey };
271
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/miaoda-cli",
3
- "version": "0.1.30",
3
+ "version": "0.1.31-alpha.1f31c36",
4
4
  "description": "Miaoda 平台命令行工具,面向 Agent 调用",
5
5
  "type": "commonjs",
6
6
  "bin": {