@lark-apaas/miaoda-cli 0.1.33-alpha.59a1bf2 → 0.1.33-alpha.bcb2fec

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.
@@ -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>}}
@@ -86,6 +86,8 @@ async function handleAppInit(opts) {
86
86
  version: 'unknown',
87
87
  syncedSkills: [],
88
88
  techSynced: false,
89
+ prunedSkills: [],
90
+ prunedStacks: [],
89
91
  };
90
92
  let steeringError;
91
93
  if (scaffoldSteering) {
@@ -95,6 +97,9 @@ async function handleAppInit(opts) {
95
97
  targetDir,
96
98
  logPrefix: 'init',
97
99
  outputLayout,
100
+ // match-template-version 门禁的比对基准:用刚渲染的 template 实际版本,
101
+ // 与随后写入 .spark/meta.json 的 version 同源(见下方 writeSparkMeta)
102
+ templateVersion: tplResult.version,
98
103
  });
99
104
  }
100
105
  catch (err) {
@@ -15,7 +15,6 @@ const spark_meta_1 = require("../../../utils/spark-meta");
15
15
  const error_1 = require("../../../utils/error");
16
16
  const output_1 = require("../../../utils/output");
17
17
  const env_1 = require("../../../utils/env");
18
- const preview_control_1 = require("../../../utils/preview-control");
19
18
  const logger_1 = require("../../../utils/logger");
20
19
  /**
21
20
  * miaoda app migrate --to <stack> [--from <stack>] [--dir <path>]
@@ -100,130 +99,76 @@ async function handleAppMigrate(opts) {
100
99
  // 的 miaodaTemplate.archType 拿到的动态值,rule 配置时不知道,由 handler 在拿到
101
100
  // renderTemplate 结果后单独写。version 也一并写回方便 sync 后续诊断。
102
101
  (0, spark_meta_1.writeSparkMeta)(targetDir, { archType: templateArchType, version: templateVersion });
103
- // 沙箱环境下先停掉 dev server(预览)—— 保活由 code-server 管,`pkill dev.js` 会被立刻
104
- // 重新拉起,拿不到"停住"的状态。dev server 活着时 vite 持续往 node_modules/.vite/deps
105
- // 文件,下面 rm node_modules 走到顶层 rmdir 时目录又非空,直接 ENOTEMPTY 失败(历史故障)。
106
- // - 只在沙箱内做(本地用户自己管 dev 进程,不该被 CLI 动)
107
- // - 软失败:接口不存在(老版本 code-server 返 404)/ 连不上都不阻断,降级回老路径
108
- // (带重试的 rm + 结尾 pkill 让保活拉起)
109
- // - ⚠️ stop 成功后必须恢复:下面整段包在 try/finally 里,任何异常路径都会走 startPreview
110
- let previewStopped = false;
111
- let previewStopError;
112
- if ((0, env_1.isSandboxEnv)()) {
113
- (0, logger_1.log)('migrate', '停止 dev server(预览)以便安全重装依赖...');
114
- const stopped = await (0, preview_control_1.stopPreview)({ logPrefix: 'migrate' });
115
- previewStopped = stopped.ok;
116
- if (!stopped.ok) {
117
- previewStopError = stopped.error;
118
- (0, logger_1.log)('migrate', `⚠ 停止预览失败 (continuing, 降级为不停 dev 直接重装): ${stopped.error ?? ''}`);
119
- }
102
+ // 清掉 user app node_modules —— migrate 改了 dependencies 集合(删 lite + 加 NestJS
103
+ // 全套)+ rule 已经把 package-lock.json 删了。如果 node_modules 留着,npm install 时旧
104
+ // 包还在 node_modules 顶层 hoist 树里,可能跟新 package.json spec 撞 peer 冲突
105
+ // (比如旧 coding-preset-vite-react@1.0.8 peer vite@^8 跟新 vite@^7 撞)。
106
+ // 软失败:node_modules 不存在不报错。
107
+ const nodeModulesPath = node_path_1.default.join(targetDir, 'node_modules');
108
+ if (node_fs_1.default.existsSync(nodeModulesPath)) {
109
+ (0, logger_1.log)('migrate', '清理 node_modules(dependencies 集合已变)...');
110
+ node_fs_1.default.rmSync(nodeModulesPath, { recursive: true, force: true });
120
111
  }
112
+ // 跑 npm install 物化新依赖集合 + 把 followLatestPackages 钉 latest
113
+ // --ignore-scripts 绕开 action-plugin postinstall 在缺平台 env 时的 ENOENT
114
+ // --registry 钉同一份 npmmirror(跟 init / sync 行为一致)
115
+ // 软失败:install 挂了不阻断 emit;用户拿到详细 error,自行 npm install 兜底
121
116
  const followLatest = config.followLatestPackages ?? [];
117
+ const installArgs = [
118
+ 'install',
119
+ '--no-audit',
120
+ '--no-fund',
121
+ '--ignore-scripts',
122
+ '--registry',
123
+ (0, index_2.resolveNpmInstallRegistry)(),
124
+ ...followLatest.map((pkg) => `${pkg}@latest`),
125
+ ];
126
+ (0, logger_1.log)('migrate', `Running npm ${installArgs.join(' ')}...`);
122
127
  let installError;
123
- let nodeModulesCleanError;
124
- let previewRestarted = false;
125
- let previewRestartError;
126
128
  try {
127
- // 清掉 user app 的 node_modules —— migrate 改了 dependencies 集合(删 lite + 加 NestJS
128
- // 全套)+ rule 已经把 package-lock.json 删了。如果 node_modules 留着,npm install 时旧
129
- // 包还在 node_modules 顶层 hoist 树里,可能跟新 package.json spec 撞 peer 冲突
130
- // (比如旧 coding-preset-vite-react@1.0.8 peer vite@^8 跟新 vite@^7 撞)。
131
- // - maxRetries:force 只吞 ENOENT,不管 ENOTEMPTY/EBUSY;这类错误 node 只在
132
- // maxRetries>0 时才重试。停预览失败降级时靠它兜住残余写入。
133
- // - 软失败:删不干净也继续跑 npm install npm 自己 reconcile —— 半迁移态
134
- // (package-lock 已删 + deps 没装)比"清理不彻底"糟得多。
135
- const nodeModulesPath = node_path_1.default.join(targetDir, 'node_modules');
136
- if (node_fs_1.default.existsSync(nodeModulesPath)) {
137
- (0, logger_1.log)('migrate', '清理 node_modules(dependencies 集合已变)...');
129
+ (0, node_child_process_1.execFileSync)('npm', installArgs, {
130
+ cwd: targetDir,
131
+ stdio: (0, output_1.isJsonMode)() ? ['ignore', 'ignore', 'inherit'] : 'inherit',
132
+ });
133
+ }
134
+ catch (err) {
135
+ installError = err instanceof Error ? err.message : String(err);
136
+ (0, logger_1.log)('migrate', `⚠ npm install failed (continuing): ${installError}`);
137
+ }
138
+ // 重启 dev process 前清 vite cache —— migrate 改了 package.json + lockfile,
139
+ // 老 vite 进程的 .vite/deps cache lockfileHash 跟新 lockfile 不一致。
140
+ // 不清的话 supervisor 拉起的新 vite cold start 会立即 "Re-optimizing because
141
+ // lockfile has changed", re-optimize 期间 ModuleGraph transform cache 跟
142
+ // deps middleware metadata 短暂不同步, 浏览器请求老 hash 触发
143
+ // ERR_OUTDATED_OPTIMIZED_DEP → 504 死锁(详见 fullstack-plugin#1143 的 504
144
+ // ignoreOutdatedRequests fix 注释)。直接 rm -rf 让新 vite 从干净状态 cold
145
+ // optimize, 一次写到位 lockfile 一致的 metadata, 不再触发中途 re-optimize。
146
+ //
147
+ // - 只在沙箱内做(isSandboxEnv;本地一般不会撞这条链路)
148
+ // - 只在 install 成功时做(install 挂了 node_modules 状态不对, 清 cache 也救不回来)
149
+ // - 软失败:.vite 不存在 / 删失败都不阻断
150
+ if (installError === undefined && (0, env_1.isSandboxEnv)()) {
151
+ const viteCacheDir = node_path_1.default.join(targetDir, 'node_modules', '.vite');
152
+ if (node_fs_1.default.existsSync(viteCacheDir)) {
153
+ (0, logger_1.log)('migrate', '清理 node_modules/.vite (vite cache 跟新 lockfile 错位会 504)...');
138
154
  try {
139
- node_fs_1.default.rmSync(nodeModulesPath, {
140
- recursive: true,
141
- force: true,
142
- maxRetries: 5,
143
- retryDelay: 300,
144
- });
155
+ node_fs_1.default.rmSync(viteCacheDir, { recursive: true, force: true });
145
156
  }
146
157
  catch (err) {
147
- nodeModulesCleanError = err instanceof Error ? err.message : String(err);
148
- (0, logger_1.log)('migrate', `⚠ 清理 node_modules 失败 (continuing): ${nodeModulesCleanError}`);
149
- }
150
- }
151
- // 跑 npm install 物化新依赖集合 + 把 followLatestPackages 钉 latest
152
- // --ignore-scripts 绕开 action-plugin postinstall 在缺平台 env 时的 ENOENT
153
- // --registry 钉同一份 npmmirror(跟 init / sync 行为一致)
154
- // 软失败:install 挂了不阻断 emit;用户拿到详细 error,自行 npm install 兜底
155
- const installArgs = [
156
- 'install',
157
- '--no-audit',
158
- '--no-fund',
159
- '--ignore-scripts',
160
- '--registry',
161
- (0, index_2.resolveNpmInstallRegistry)(),
162
- ...followLatest.map((pkg) => `${pkg}@latest`),
163
- ];
164
- (0, logger_1.log)('migrate', `Running npm ${installArgs.join(' ')}...`);
165
- try {
166
- (0, node_child_process_1.execFileSync)('npm', installArgs, {
167
- cwd: targetDir,
168
- stdio: (0, output_1.isJsonMode)() ? ['ignore', 'ignore', 'inherit'] : 'inherit',
169
- });
170
- }
171
- catch (err) {
172
- installError = err instanceof Error ? err.message : String(err);
173
- (0, logger_1.log)('migrate', `⚠ npm install failed (continuing): ${installError}`);
174
- }
175
- // 重启 dev process 前清 vite cache —— migrate 改了 package.json + lockfile,
176
- // 老 vite 进程的 .vite/deps cache 的 lockfileHash 跟新 lockfile 不一致。
177
- // 不清的话拉起的新 vite cold start 会立即 "Re-optimizing because
178
- // lockfile has changed", re-optimize 期间 ModuleGraph transform cache 跟
179
- // deps middleware metadata 短暂不同步, 浏览器请求老 hash 触发
180
- // ERR_OUTDATED_OPTIMIZED_DEP → 504 死锁(详见 fullstack-plugin#1143 的 504
181
- // ignoreOutdatedRequests fix 注释)。直接 rm -rf 让新 vite 从干净状态 cold
182
- // optimize, 一次写到位 lockfile 一致的 metadata, 不再触发中途 re-optimize。
183
- //
184
- // - 只在 SANDBOX_ID 非空时做(本地一般不会撞这条链路)
185
- // - 只在 install 成功时做(install 挂了 node_modules 状态不对, 清 cache 也救不回来)
186
- // - 软失败:.vite 不存在 / 删失败都不阻断
187
- if (installError === undefined &&
188
- process.env.SANDBOX_ID !== undefined &&
189
- process.env.SANDBOX_ID !== '') {
190
- const viteCacheDir = node_path_1.default.join(targetDir, 'node_modules', '.vite');
191
- if (node_fs_1.default.existsSync(viteCacheDir)) {
192
- (0, logger_1.log)('migrate', '清理 node_modules/.vite (vite cache 跟新 lockfile 错位会 504)...');
193
- try {
194
- node_fs_1.default.rmSync(viteCacheDir, { recursive: true, force: true });
195
- }
196
- catch (err) {
197
- (0, logger_1.log)('migrate', `⚠ 清 .vite 失败 (continuing): ${err instanceof Error ? err.message : String(err)}`);
198
- }
199
- }
200
- }
201
- }
202
- finally {
203
- // 恢复 dev server —— 只要上面 stop 成功过就必须调,且必须在 finally 里:
204
- // rule 应用 / install / 清 cache 任何一步抛异常都不能把保活留在关闭状态,否则用户沙箱
205
- // 的预览再也起不来(比迁移失败严重得多)。
206
- // 无条件恢复(不看 installError):dev 进程已经被停了,留着不起来只会更糟;新 scripts/dev.js
207
- // + 新依赖也正是靠这次 start 生效。
208
- if (previewStopped) {
209
- (0, logger_1.log)('migrate', '恢复 dev server(预览)...');
210
- const started = await (0, preview_control_1.startPreview)({ logPrefix: 'migrate' });
211
- previewRestarted = started.ok;
212
- if (!started.ok) {
213
- previewRestartError = started.error;
214
- (0, logger_1.log)('migrate', `✗ 恢复预览失败, 需人工处理: ${started.error ?? ''}`);
158
+ (0, logger_1.log)('migrate', `⚠ 清 .vite 失败 (continuing): ${err instanceof Error ? err.message : String(err)}`);
215
159
  }
216
160
  }
217
161
  }
218
- // 降级路径:preview stop 没成功(老版本 code-server / 接口连不上),dev 进程还是老的,
219
- // 按历史行为 pkill dev.js 主进程,靠平台 supervisor 自动重新 exec dev.sh → dev.js,
220
- // 让新 deps + scripts 生效。
162
+ // 沙箱环境下重启 dev process —— scripts/dev.js 被 fullstack 版本覆盖了,但当前正在
163
+ // 跑的 node 进程仍按老逻辑工作(只起 vite,没起 nest),整体没切到 fullstack 模式。
164
+ // 通过 pkill 杀掉 dev.js 主进程,依赖沙箱平台 supervisor 自动重新 exec dev.sh → dev.js,
165
+ // 新进程用新 deps + 新 scripts,正常进入 fullstack 模式。
221
166
  // - 只在沙箱内做(isSandboxEnv;本地环境用户进程混杂,主动 pkill 风险大)
222
167
  // - install 挂了不做 —— 新依赖不全, 杀旧 dev 后新 dev 起不来反而更糟,
223
168
  // 留着旧进程让用户先处理 installError
224
169
  // - 软失败:pkill 无匹配进程退出 1,catch 吞掉
225
- let devRestarted = previewRestarted;
226
- if (!previewStopped && installError === undefined && (0, env_1.isSandboxEnv)()) {
170
+ let devRestarted = false;
171
+ if (installError === undefined && (0, env_1.isSandboxEnv)()) {
227
172
  (0, logger_1.log)('migrate', '沙箱环境,重启 dev process(平台 supervisor 会自动拉起)...');
228
173
  try {
229
174
  (0, node_child_process_1.execFileSync)('pkill', ['-f', 'node.*scripts/dev\\.js'], { stdio: 'ignore' });
@@ -247,22 +192,11 @@ async function handleAppMigrate(opts) {
247
192
  ...summarizeResults(results),
248
193
  followLatestPackages: followLatest,
249
194
  installError,
250
- nodeModulesCleanError,
251
195
  devRestarted,
252
- previewStopped,
253
- previewStopError,
254
- previewRestarted,
255
- previewRestartError,
256
196
  nextActions: (0, env_1.isSandboxEnv)()
257
197
  ? [
258
198
  'git status / git diff 评估改动并 commit',
259
199
  'miaoda skills sync 同步到新 stack 的 agent skills',
260
- // 预览恢复失败时置顶一条人工兜底动作 —— 保活已被关掉且不会自愈
261
- ...(previewRestartError === undefined
262
- ? []
263
- : [
264
- `⚠ dev server(预览)未恢复成功,需手动 curl -X POST ${(0, preview_control_1.resolveCodeServerBaseUrl)()}/api/v1/preview/start 恢复保活`,
265
- ]),
266
200
  ]
267
201
  : [
268
202
  'git status / git diff 评估改动并 commit',
@@ -24,24 +24,35 @@ const output_1 = require("../../../utils/output");
24
24
  */
25
25
  function syncSteeringForSandbox(params) {
26
26
  let stack;
27
+ // 提到 try 外:下面 syncCodingSteering 要用它做 match-template-version 门禁
28
+ let templateVersion;
27
29
  try {
28
30
  const meta = (0, spark_meta_1.readSparkMeta)(params.targetDir);
29
31
  if (meta.stack === undefined || meta.stack === '' || meta.stack === 'null') {
30
32
  return { status: 'skipped', reason: '.spark/meta.json missing stack' };
31
33
  }
32
34
  stack = meta.stack;
35
+ templateVersion = meta.version;
33
36
  }
34
37
  catch (err) {
35
38
  return { status: 'skipped', reason: `read .spark/meta.json failed: ${err.message}` };
36
39
  }
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
+ // 版本口径:--version > lane(TCC tgzKey)> BOE 的 dist-tag dev > latest
41
+ //
42
+ // **这里不预先把 BOE 解成 'dev'**(下方本地分支为了回报 versionSource 才那么做):
43
+ // 一旦提前填进 version,就会盖住 lane —— version 在优先级里高于 lane,BOE 沙箱上
44
+ // 配了 tgzKey 也永远命中不了。把 version 原样透传(未指定就是 undefined),让
45
+ // syncCodingSteering 一处算全链:version > lane > dev > latest。
40
46
  try {
41
47
  const r = (0, coding_steering_1.syncCodingSteering)({
42
48
  stack,
43
49
  targetDir: params.targetDir,
44
- version: effectiveVersion,
50
+ version: params.version,
51
+ // TCC 的 X 与平铺 skill 共用:pickLaneVersion 只匹配 `-alpha.<X>` 后缀,
52
+ // 不看 base 版本,所以一个 key 同时定向 coding-steering 与 sandbox-skills。
53
+ tgzKey: params.tgzKey,
54
+ // match-template-version 门禁的比对基准(老 update-skills.sh 读的同一个字段)
55
+ templateVersion,
45
56
  // 必须显式传 mode,不能落到 inferMode():它靠 env MIAODA_DEP_CACHE_DIR 嗅探,
46
57
  // 而 mode 决定读包内哪个 layer(sandbox→`skills/`,local→`skills_local/`)。
47
58
  // `--type` 分支本身就是沙箱语义,嗅探失手会去读不存在的 skills_local/、
@@ -56,6 +67,8 @@ function syncSteeringForSandbox(params) {
56
67
  version: r.version,
57
68
  syncedSkills: r.syncedSkills,
58
69
  techSynced: r.techSynced,
70
+ prunedSkills: r.prunedSkills,
71
+ prunedStacks: r.prunedStacks,
59
72
  };
60
73
  }
61
74
  catch (err) {
@@ -70,9 +83,16 @@ function syncSteeringForSandbox(params) {
70
83
  *
71
84
  * 版本来源优先级(从高到低):
72
85
  * 1. CLI `--version` flag(显式指定)
73
- * 2. `FORCE_FRAMEWORK_ENVIRONMENT=boe` dist-tag `dev`(BOE 测试灰度,跟随
86
+ * 2. lane:`--type` 分支下由 TCC `skillsUpdateTgzKey` 透传 `--tgz-key`,反推 X
87
+ * 解析出 `-alpha.<X>` 版本。**与平铺 skill 共用同一个 X** —— 匹配只看后缀、
88
+ * 不看 base 版本,所以 TCC 配一个 key 就同时定向 coding-steering 与 sandbox-skills。
89
+ * 本地分支(不带 `--type`)没有 tgzKey,这一档天然不参与。
90
+ * 3. `FORCE_FRAMEWORK_ENVIRONMENT=boe` → dist-tag `dev`(BOE 测试灰度,跟随
74
91
  * publish-alpha.sh 在 develop 分支打的 dev tag,便于在 BOE 沙箱里验证预发 steering)
75
- * 3. 默认 `latest`
92
+ * 4. 默认 `latest`
93
+ *
94
+ * 2/3/4 档统一在 `syncCodingSteering` 里算(见那边注释),handler 只负责透传,避免
95
+ * 提前把 `dev` 填进 version 把 lane 挤掉。
76
96
  *
77
97
  * **不读 meta.json**——steering 版本不在 meta 里钉。
78
98
  */
@@ -98,7 +118,7 @@ async function handleSkillsSync(opts) {
98
118
  const result = (0, sandbox_skills_1.syncSandboxSkills)({ type, tgzKey, targetDir });
99
119
  const steering = result.skippedByKeepFile
100
120
  ? { status: 'skipped', reason: '.keep file found, sync frozen' }
101
- : syncSteeringForSandbox({ targetDir, version: opts.version });
121
+ : syncSteeringForSandbox({ targetDir, version: opts.version, tgzKey });
102
122
  // ③ 收尾锁只读(update-skills.sh:241 的 chmod -R a=rX)。必须在 ①② 全部写完之后,
103
123
  // 锁上就写不进去了。`.keep` 冻结时不锁 —— 老链路那条路径是 exit 0 直接走、
104
124
  // 末尾的 chmod 压根不执行,保持一致。
@@ -121,6 +141,7 @@ async function handleSkillsSync(opts) {
121
141
  lane: result.lane,
122
142
  syncedTop: result.syncedTop,
123
143
  deleted: result.deleted,
144
+ deletedFiles: result.deletedFiles,
124
145
  skippedByKeepFile: result.skippedByKeepFile,
125
146
  lockedReadonly,
126
147
  targetDir: result.targetDir,
@@ -141,6 +162,7 @@ async function handleSkillsSync(opts) {
141
162
  stack: meta.stack,
142
163
  targetDir,
143
164
  version: effectiveVersion,
165
+ templateVersion: meta.version,
144
166
  outputLayout: opts.local === true ? 'flat' : 'nested',
145
167
  });
146
168
  (0, output_1.emit)({
@@ -150,6 +172,8 @@ async function handleSkillsSync(opts) {
150
172
  versionSource,
151
173
  syncedSkills: result.syncedSkills,
152
174
  techSynced: result.techSynced,
175
+ prunedSkills: result.prunedSkills,
176
+ prunedStacks: result.prunedStacks,
153
177
  ...(result.claudeSkillsLink !== undefined
154
178
  ? { claudeSkillsLink: result.claudeSkillsLink }
155
179
  : {}),
@@ -91,7 +91,7 @@ exports.SYNC_CONFIG = {
91
91
  ifStartsWith: 'npm run upgrade && ',
92
92
  },
93
93
  // ===== miaoda-cli 本地开发新增规则 =====
94
- // M1. scripts.dev:local —— 本地用户绕过 SANDBOX_ID 分发直接跑本地链路(npm run dev:local)。
94
+ // M1. scripts.dev:local —— 本地用户绕过沙箱判定直接跑本地链路(npm run dev:local)。
95
95
  {
96
96
  type: 'add-script',
97
97
  name: 'dev:local',
@@ -135,8 +135,8 @@ exports.SYNC_CONFIG = {
135
135
  ifStartsWith: 'concurrently ',
136
136
  },
137
137
  // 10. 把老 `npm run upgrade && ./scripts/dev.sh` 形式迁移到 `./scripts/dev.sh`,
138
- // 让本地 `npm run dev` 不再前置跑 fullstack-cli sync。新版 dev.sh 按 SANDBOX_ID 是否非空
139
- // 判分支:沙箱直接 exec dev.js(脚本同步由平台 pod 启动时做过,dev 入口不再 upgrade);
138
+ // 让本地 `npm run dev` 不再前置跑 fullstack-cli sync。新版 dev.sh 按 MIAODA_DEP_CACHE_DIR
139
+ // 是否非空判分支:沙箱直接 exec dev.js(脚本同步由平台 pod 启动时做过,dev 入口不再 upgrade);
140
140
  // 本地走 miaoda app sync 兜底 + dev-local.js。
141
141
  {
142
142
  type: 'patch-script',
@@ -160,8 +160,8 @@ exports.SYNC_CONFIG = {
160
160
  ifStartsWith: 'npx -y @lark-apaas/fullstack-cli sync',
161
161
  },
162
162
  // ===== miaoda-cli 本地开发新增规则(fullstack-cli sync 不会执行) =====
163
- // M1. scripts.dev:local —— 本地用户绕过 SANDBOX_ID 分发直接跑本地链路(npm run dev:local)。
164
- // dev.sh 在 SANDBOX_ID 非空时跑 dev.js(沙箱保活)、否则 exec dev-local.js;显式 dev:local
163
+ // M1. scripts.dev:local —— 本地用户绕过沙箱判定直接跑本地链路(npm run dev:local)。
164
+ // dev.sh 在 MIAODA_DEP_CACHE_DIR 非空时跑 dev.js(沙箱保活)、否则 exec dev-local.js;显式 dev:local
165
165
  // 用于 agent 在本地稳定调试,不受任何 env 影响。
166
166
  {
167
167
  type: 'add-script',
@@ -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) {