@lark-apaas/miaoda-cli 0.1.33-alpha.2ce4ff9 → 0.1.33-alpha.3cbfb24

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
@@ -57,7 +57,7 @@ miaoda file ls --output json
57
57
  `miaoda app pack`(默认 `--mode standalone`)把当前应用构建成一份自包含静态产物,可脱离妙搭平台本地打开。
58
58
 
59
59
  ```bash
60
- miaoda app pack # → dist/standalone/ + dist/standalone.zip
60
+ miaoda app pack # → dist/standalone.zip(中间目录打完即删)
61
61
  miaoda app pack --out dist/share
62
62
  miaoda app pack --json
63
63
  miaoda app pack --upload-url 'https://tos.example.com/x.zip?sign=...'
@@ -184,8 +184,7 @@ function registerAppPack(parent) {
184
184
  .description('打包应用产物。默认 standalone 模式:自包含静态产物(目录 + zip),可脱离妙搭平台本地访问')
185
185
  .option('--dir <path>', '项目目录,默认 cwd(需含 .spark/meta.json)')
186
186
  .option('--mode <mode>', `打包形态(${index_1.PACK_MODES.join(' / ')})`, index_1.DEFAULT_PACK_MODE)
187
- .option('--out <dir>', '输出目录(相对项目根)', index_1.DEFAULT_PACK_OUT)
188
- .option('--dev-server <url>', '图片下载的请求前缀,默认沙箱网关 http://localhost:8080')
187
+ .option('--out <dir>', '产物路径(相对项目根),实际产出 <out>.zip', index_1.DEFAULT_PACK_OUT)
189
188
  .option('--upload-url <url>', '打包后把 zip 以 PUT 上传到该预签名 URL(http/https);上传成功后清掉本地产物')
190
189
  .addHelpText('after', `
191
190
  与 'miaoda app export' 的区别(**两个命令语义无关,别选错**)
@@ -200,13 +199,14 @@ function registerAppPack(parent) {
200
199
  design-html / html / vite-react / nestjs-react-fullstack
201
200
 
202
201
  产物
203
- <out>/ index.html + assets/ + images/(+ static/,vite 系有 shared/static 时)
204
- <out>.zip 同内容的压缩包
202
+ <out>.zip **唯一交付物**。内含 index.html + assets/ + images/
203
+ (+ static/,vite 系有 shared/static 时)
204
+ <out>/ 打包用的中间目录,**打完包即删除**——它与 zip 内容重复,
205
+ 留着占双份磁盘。要预览请先解压。
205
206
  传了 --upload-url 时,zip 会以 PUT 裸 body(Content-Type: application/zip)
206
207
  上传到该地址——预签名 URL 的标准形态,鉴权由 URL 自带签名承担,不加额外 header。
207
- **上传成功后本地产物(目录 + zip)会被清掉**,交付物以远端为准,JSON 里
208
- localRemoved=true;此时不要再去读 out / zip 路径。
209
- 上传失败则保留本地产物,便于修正后重跑或手动上传。
208
+ **上传成功后本地 zip 也会被清掉**(目录本就已删),交付物以远端为准,
209
+ JSON 里 localRemoved=true。上传失败则保留 zip,便于修正后重跑或手动上传。
210
210
  默认 <out> 是 dist/standalone —— 落在 dist/ 下是刻意的:dist 已在部署扫描的
211
211
  EXCLUDES 与各模板 .gitignore 中,产物不会污染部署包、不会被误提交。
212
212
  代价:下一次构建会 rm -rf dist,导出产物是易失的。
@@ -249,7 +249,6 @@ JSON 输出
249
249
  await (0, index_1.handleAppPack)({
250
250
  dir: rawOpts.dir,
251
251
  out: rawOpts.out,
252
- devServer: rawOpts.devServer,
253
252
  uploadUrl: rawOpts.uploadUrl,
254
253
  mode: rawOpts.mode,
255
254
  });
@@ -7,6 +7,7 @@ exports.handleAppInit = handleAppInit;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const index_1 = require("../../../services/app/init/index");
10
+ const dir_lock_1 = require("../../../utils/dir-lock");
10
11
  const coding_steering_1 = require("../../../utils/coding-steering");
11
12
  const logger_1 = require("../../../utils/logger");
12
13
  const githooks_1 = require("../../../utils/githooks");
@@ -86,16 +87,23 @@ async function handleAppInit(opts) {
86
87
  version: 'unknown',
87
88
  syncedSkills: [],
88
89
  techSynced: false,
90
+ prunedStacks: [],
89
91
  };
90
92
  let steeringError;
91
93
  if (scaffoldSteering) {
92
94
  try {
93
- steeringResult = (0, coding_steering_1.syncCodingSteering)({
95
+ // 与会话期的 `skills sync` 互斥:两者都写 `.agent/skills`,而写入策略是按目录
96
+ // 整清再拷。线上实测过这条竞态 —— init 的 steering 同步撞上 sync 收尾的
97
+ // `chmod -R a=rX`,报 `EACCES: mkdir '.agent/skills/steering/<stack>/skills'`。
98
+ steeringResult = (0, dir_lock_1.withDirLock)(targetDir, () => (0, coding_steering_1.syncCodingSteering)({
94
99
  stack,
95
100
  targetDir,
96
101
  logPrefix: 'init',
97
102
  outputLayout,
98
- });
103
+ // match-template-version 门禁的比对基准:用刚渲染的 template 实际版本,
104
+ // 与随后写入 .spark/meta.json 的 version 同源(见下方 writeSparkMeta)
105
+ templateVersion: tplResult.version,
106
+ }));
99
107
  }
100
108
  catch (err) {
101
109
  steeringError = err instanceof Error ? err.message : String(err);
@@ -144,12 +144,10 @@ async function handleAppMigrate(opts) {
144
144
  // ignoreOutdatedRequests fix 注释)。直接 rm -rf 让新 vite 从干净状态 cold
145
145
  // optimize, 一次写到位 lockfile 一致的 metadata, 不再触发中途 re-optimize。
146
146
  //
147
- // - 只在 SANDBOX_ID 非空时做(本地一般不会撞这条链路)
147
+ // - 只在沙箱内做(isSandboxEnv;本地一般不会撞这条链路)
148
148
  // - 只在 install 成功时做(install 挂了 node_modules 状态不对, 清 cache 也救不回来)
149
149
  // - 软失败:.vite 不存在 / 删失败都不阻断
150
- if (installError === undefined &&
151
- process.env.SANDBOX_ID !== undefined &&
152
- process.env.SANDBOX_ID !== '') {
150
+ if (installError === undefined && (0, env_1.isSandboxEnv)()) {
153
151
  const viteCacheDir = node_path_1.default.join(targetDir, 'node_modules', '.vite');
154
152
  if (node_fs_1.default.existsSync(viteCacheDir)) {
155
153
  (0, logger_1.log)('migrate', '清理 node_modules/.vite (vite cache 跟新 lockfile 错位会 504)...');
@@ -37,19 +37,6 @@ function normalizeOutDir(rel) {
37
37
  }
38
38
  /** 沙箱网关地址:它才是代理 `/spark/app` 的那个服务(响应头 `Server: openresty`)。 */
39
39
  const SANDBOX_GATEWAY_BASE = 'http://localhost:8080';
40
- /**
41
- * 图片下载的请求前缀。
42
- *
43
- * **刻意写死 8080,不读 `CLIENT_DEV_PORT`。** 那个 env 表示的是「应用自己的 dev server
44
- * 监听端口」,与「谁在代理 `/spark/app`」是两回事:design-html / html 用
45
- * `coding-static-server`(默认 8001,纯静态、完全不代理),读了它会把请求打到静态
46
- * server 上,稳定 404。真机踩过。
47
- */
48
- function resolveDevServerBase(explicit) {
49
- if (explicit)
50
- return explicit.replace(/\/+$/, '');
51
- return SANDBOX_GATEWAY_BASE;
52
- }
53
40
  /** 遍历产物树里的文本文件,返回 posix 相对路径。 */
54
41
  function* walkText(dir, rel = '') {
55
42
  for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true })) {
@@ -174,7 +161,7 @@ async function handleAppPack(opts) {
174
161
  }
175
162
  }
176
163
  // 3. 下载
177
- const devServerBase = resolveDevServerBase(opts.devServer);
164
+ const devServerBase = SANDBOX_GATEWAY_BASE;
178
165
  (0, logger_1.log)('pack', `Matched ${String(urls.length)} image url(s), fetching via ${devServerBase}`);
179
166
  const { mapping, failed } = await (0, index_1.downloadImages)(urls, {
180
167
  imagesDir: node_path_1.default.join(outDir, 'images'),
@@ -189,17 +176,19 @@ async function handleAppPack(opts) {
189
176
  ...(failed.length > 1
190
177
  ? [`其余 ${String(failed.length - 1)} 条见 --verbose(每条请求地址都会打到 stderr)`]
191
178
  : []),
192
- `实际请求前缀:${devServerBase}${opts.devServer ? '(--dev-server 指定)' : '(沙箱网关默认)'}`,
193
- '若端口不对,可用 --dev-server http://localhost:<port> 覆盖',
179
+ `实际请求前缀:${devServerBase}(沙箱网关)`,
194
180
  ],
195
181
  });
196
182
  }
197
183
  // 4. 改写
198
184
  const rewrittenFiles = (0, index_1.rewriteTree)(outDir, mapping);
199
- // 5. 打包
200
- (0, index_1.zipDir)(outDir, zipPath);
201
- // 6. 统计。**必须在清理之前**——上传成功后本地产物会被删掉,那时就量不到了。
185
+ // 5. 统计。**必须在打包前做**——目录随后就被删掉,那时量不到了。
202
186
  const { fileCount, bytes } = measure(outDir);
187
+ // 6. 打包,随后删掉目录。目录是**中间态**,zip 才是交付物;两者内容重复,
188
+ // 留着等于占双份磁盘。预览需先解压。
189
+ (0, index_1.zipDir)(outDir, zipPath);
190
+ node_fs_1.default.rmSync(outDir, { recursive: true, force: true });
191
+ (0, logger_1.log)('pack', `Packed ${zipRel}, staging dir removed`);
203
192
  // 7. 上传(可选)。放在 emit 之前——传了 --upload-url 就说明上传是目的,
204
193
  // 失败要让整个命令失败(退出码 1),不能先 emit 成功再抛。
205
194
  // JSON 输出里**不回显 URL**(凭证类信息),只给状态码与服务端 log_id。
@@ -215,10 +204,10 @@ async function handleAppPack(opts) {
215
204
  let localRemoved = false;
216
205
  if (upload) {
217
206
  try {
218
- node_fs_1.default.rmSync(outDir, { recursive: true, force: true });
207
+ // 目录在打包后已删,这里只需清 zip
219
208
  node_fs_1.default.rmSync(zipPath, { force: true });
220
209
  localRemoved = true;
221
- (0, logger_1.log)('pack', `Uploaded, local artifacts removed (${outRel}, ${zipRel})`);
210
+ (0, logger_1.log)('pack', `Uploaded, local zip removed (${zipRel})`);
222
211
  }
223
212
  catch (err) {
224
213
  warnings.push({
@@ -248,8 +237,8 @@ async function handleAppPack(opts) {
248
237
  mode,
249
238
  out: outRel,
250
239
  zip: zipRel,
251
- // 上传成功后本地产物已被清掉——调用方**不要**再去读 out / zip 这两个路径,
252
- // 它们只描述"产出过什么",不代表现在还在。
240
+ // ⚠️ out 指向的**目录在打包后即被删除**(它只是中间态),永远不要去读它。
241
+ // zip 是本地交付物;localRemoved=true 时连 zip 也被清了(已上传到远端)。
253
242
  localRemoved,
254
243
  fileCount,
255
244
  bytes,
@@ -9,6 +9,7 @@ const coding_steering_1 = require("../../../utils/coding-steering");
9
9
  const sandbox_skills_1 = require("../../../utils/sandbox-skills");
10
10
  const file_ops_1 = require("../../../utils/file-ops");
11
11
  const spark_meta_1 = require("../../../utils/spark-meta");
12
+ const dir_lock_1 = require("../../../utils/dir-lock");
12
13
  const error_1 = require("../../../utils/error");
13
14
  const output_1 = require("../../../utils/output");
14
15
  /**
@@ -24,24 +25,35 @@ const output_1 = require("../../../utils/output");
24
25
  */
25
26
  function syncSteeringForSandbox(params) {
26
27
  let stack;
28
+ // 提到 try 外:下面 syncCodingSteering 要用它做 match-template-version 门禁
29
+ let templateVersion;
27
30
  try {
28
31
  const meta = (0, spark_meta_1.readSparkMeta)(params.targetDir);
29
32
  if (meta.stack === undefined || meta.stack === '' || meta.stack === 'null') {
30
33
  return { status: 'skipped', reason: '.spark/meta.json missing stack' };
31
34
  }
32
35
  stack = meta.stack;
36
+ templateVersion = meta.version;
33
37
  }
34
38
  catch (err) {
35
39
  return { status: 'skipped', reason: `read .spark/meta.json failed: ${err.message}` };
36
40
  }
37
- // 版本口径与下方本地分支保持一致:--version > BOE 的 dist-tag dev > latest
38
- const isBoe = process.env.FORCE_FRAMEWORK_ENVIRONMENT === 'boe';
39
- const effectiveVersion = params.version ?? (isBoe ? 'dev' : undefined);
41
+ // 版本口径:--version > lane(TCC tgzKey)> BOE 的 dist-tag dev > latest。
42
+ //
43
+ // **这里不预先把 BOE 解成 'dev'**(下方本地分支为了回报 versionSource 才那么做):
44
+ // 一旦提前填进 version,就会盖住 lane —— version 在优先级里高于 lane,BOE 沙箱上
45
+ // 配了 tgzKey 也永远命中不了。把 version 原样透传(未指定就是 undefined),让
46
+ // syncCodingSteering 一处算全链:version > lane > dev > latest。
40
47
  try {
41
48
  const r = (0, coding_steering_1.syncCodingSteering)({
42
49
  stack,
43
50
  targetDir: params.targetDir,
44
- version: effectiveVersion,
51
+ version: params.version,
52
+ // TCC 的 X 与平铺 skill 共用:pickLaneVersion 只匹配 `-alpha.<X>` 后缀,
53
+ // 不看 base 版本,所以一个 key 同时定向 coding-steering 与 sandbox-skills。
54
+ tgzKey: params.tgzKey,
55
+ // match-template-version 门禁的比对基准(老 update-skills.sh 读的同一个字段)
56
+ templateVersion,
45
57
  // 必须显式传 mode,不能落到 inferMode():它靠 env MIAODA_DEP_CACHE_DIR 嗅探,
46
58
  // 而 mode 决定读包内哪个 layer(sandbox→`skills/`,local→`skills_local/`)。
47
59
  // `--type` 分支本身就是沙箱语义,嗅探失手会去读不存在的 skills_local/、
@@ -56,6 +68,7 @@ function syncSteeringForSandbox(params) {
56
68
  version: r.version,
57
69
  syncedSkills: r.syncedSkills,
58
70
  techSynced: r.techSynced,
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
  */
@@ -95,41 +115,57 @@ async function handleSkillsSync(opts) {
95
115
  // 冻结只冻结了一半。
96
116
  if (opts.type !== undefined && opts.type !== '') {
97
117
  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
- },
118
+ // 并发锁覆盖「平铺同步 + steering 同步 + 收尾 chmod」整段:写入策略是按目录整清再拷,
119
+ // 两个实例并发会互相看到半清空状态,而 feida-ai 的 scanAllSkills 是会话期按需扫盘。
120
+ // 对齐老 update-skills.sh:157-159 的 flock。
121
+ (0, dir_lock_1.withDirLock)(targetDir, () => {
122
+ syncSandboxAndSteering({ opts, targetDir, type, tgzKey });
129
123
  });
130
124
  return;
131
125
  }
132
126
  // 原有 coding-steering 同步逻辑(本地 dev / miaoda app init 用)
127
+ (0, dir_lock_1.withDirLock)(targetDir, () => {
128
+ syncSteeringOnly({ opts, targetDir });
129
+ });
130
+ }
131
+ function syncSandboxAndSteering(params) {
132
+ const { opts, targetDir, type, tgzKey } = params;
133
+ const result = (0, sandbox_skills_1.syncSandboxSkills)({ type, tgzKey, targetDir });
134
+ const steering = result.skippedByKeepFile
135
+ ? { status: 'skipped', reason: '.keep file found, sync frozen' }
136
+ : syncSteeringForSandbox({ targetDir, version: opts.version, tgzKey });
137
+ // ③ 收尾锁只读(update-skills.sh:241 的 chmod -R a=rX)。必须在 ①② 全部写完之后,
138
+ // 锁上就写不进去了。`.keep` 冻结时不锁 —— 老链路那条路径是 exit 0 直接走、
139
+ // 末尾的 chmod 压根不执行,保持一致。
140
+ //
141
+ // **只锁沙箱侧**:这里的 result.targetDir 只会是 `.agent/skills` 或 `skills`
142
+ // (见 sandbox-skills 的 skillsRoot),都是平台下发的资产根。本地 `--local` 走
143
+ // flat layout 输出到 `.agents/`,压根不经过本分支,权限不会被碰。
144
+ //
145
+ // 回退风险由解锁侧兜:权限是持久化在磁盘的,代码能回退、磁盘状态不会。所以
146
+ // file-ops 的 unlockDirForWrite 在**两处写入前**都调用了(syncSandboxSkills 的
147
+ // 写入前 + syncCodingSteering 的 nested 分支写入前),任一入口都不会因为
148
+ // 上一轮的锁而 EACCES。改动这里之前先确认那两处解锁还在。
149
+ const lockedReadonly = result.skippedByKeepFile ? false : (0, file_ops_1.lockDirReadonly)(result.targetDir);
150
+ (0, output_1.emit)({
151
+ data: {
152
+ mode: 'sandbox',
153
+ type,
154
+ packageName: result.packageName,
155
+ version: result.version,
156
+ lane: result.lane,
157
+ syncedTop: result.syncedTop,
158
+ deleted: result.deleted,
159
+ skippedByKeepFile: result.skippedByKeepFile,
160
+ lockedReadonly,
161
+ targetDir: result.targetDir,
162
+ steering,
163
+ },
164
+ });
165
+ }
166
+ /** 不带 --type 的本地 / init 形态:只同步 coding-steering。 */
167
+ function syncSteeringOnly(params) {
168
+ const { opts, targetDir } = params;
133
169
  const meta = (0, spark_meta_1.readSparkMeta)(targetDir);
134
170
  if (meta.stack === undefined || meta.stack === '') {
135
171
  throw new error_1.AppError('SKILLS_META_INCOMPLETE', '.spark/meta.json missing stack — run `miaoda app init` first');
@@ -141,6 +177,7 @@ async function handleSkillsSync(opts) {
141
177
  stack: meta.stack,
142
178
  targetDir,
143
179
  version: effectiveVersion,
180
+ templateVersion: meta.version,
144
181
  outputLayout: opts.local === true ? 'flat' : 'nested',
145
182
  });
146
183
  (0, output_1.emit)({
@@ -150,6 +187,7 @@ async function handleSkillsSync(opts) {
150
187
  versionSource,
151
188
  syncedSkills: result.syncedSkills,
152
189
  techSynced: result.techSynced,
190
+ prunedStacks: result.prunedStacks,
153
191
  ...(result.claudeSkillsLink !== undefined
154
192
  ? { claudeSkillsLink: result.claudeSkillsLink }
155
193
  : {}),
@@ -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',
@@ -23,13 +23,15 @@ const node_fs_1 = __importDefault(require("node:fs"));
23
23
  const node_path_1 = __importDefault(require("node:path"));
24
24
  const nestjs_react_fullstack_1 = __importDefault(require("./nestjs-react-fullstack"));
25
25
  const design_stack_1 = __importDefault(require("./design-stack"));
26
+ const vite_react_1 = __importDefault(require("./vite-react"));
26
27
  /**
27
- * 已纳入新 sync 机制的 stack 注册表。未在表里的 stack(如老 vite-react / html)走旧的
28
+ * 已纳入新 sync 机制的 stack 注册表。未在表里的 stack(如 html / design-html)走旧的
28
29
  * `upgrade/templates/<stack>/{files,patches}` 机制,由 sync handler 兜底。
29
30
  */
30
31
  const STACK_REGISTRY = {
31
32
  'nestjs-react-fullstack': nestjs_react_fullstack_1.default,
32
33
  'design-stack': design_stack_1.default,
34
+ 'vite-react': vite_react_1.default,
33
35
  };
34
36
  /** 返回该 stack 的 SyncConfig;表里没有时返回 null。 */
35
37
  function getSyncConfig(stack) {
@@ -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',
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ /**
3
+ * vite-react sync 规则(镜像 fullstack-cli 的 viteReactProfile):只同步 build.sh 一支,
4
+ * 回溯 public/* → dist/output/ 修复。
5
+ * ⚠️ build.sh 源须与 miaoda-coding 模板、fullstack-cli `templates/vite-react/build.sh` 逐字一致。
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.SYNC_CONFIG = void 0;
9
+ exports.SYNC_CONFIG = {
10
+ sync: [
11
+ // 覆盖 scripts/build.sh:回溯 public/* → dist/output/(同源根 /app/<appId>/*)修复
12
+ {
13
+ type: 'file',
14
+ from: 'scripts/build.sh',
15
+ to: 'scripts/build.sh',
16
+ overwrite: true,
17
+ },
18
+ ],
19
+ };
20
+ exports.default = exports.SYNC_CONFIG;
@@ -53,6 +53,25 @@ function isSameOriginRelative(text, index) {
53
53
  return true;
54
54
  return BOUNDARY_CHARS.has(text[index - 1]);
55
55
  }
56
+ /**
57
+ * aPaaS 存储路径特征段。命中其一才认为是「应用存储里的资源」。
58
+ *
59
+ * **为什么必须限定**:只按 `/spark/app/` 前缀匹配会把**接口地址**一并扫走 ——
60
+ * 真机踩过,SDK 里的 `/spark/app/<id>/runtime/api/v1/observability/logs/collect`、
61
+ * `.../permissions/roles` 四条接口进了下载队列,全部 404 后被报成「4 张图片下载失败」。
62
+ *
63
+ * 这三段与 `miaoda-coding` 的 `cd-elements/src/core/tos.ts` 的 `SRC_ALLOWLIST` 同一份。
64
+ */
65
+ const STORAGE_PATH_SEGMENTS = [
66
+ '/runtime/api/v1/storage/object/',
67
+ '/aily/api/v1/feisuda/attachments/',
68
+ '/aily/api/v1/files/static/',
69
+ ];
70
+ /**
71
+ * 模板字符串插值标记:`/spark/app/${e}/...`。压缩后的 SDK 里大量存在,它们**不是具体 URL**
72
+ * ——变量在运行时才求值,照原样请求必然 404。
73
+ */
74
+ const TEMPLATE_INTERPOLATION = '${';
56
75
  /**
57
76
  * 扫出去重后的 URL 列表,保持首次出现的顺序(便于测试断言与日志可读)。
58
77
  */
@@ -63,6 +82,10 @@ function scanSparkAppUrls(text) {
63
82
  if (!isSameOriginRelative(text, m.index))
64
83
  continue;
65
84
  const url = m[0];
85
+ if (url.includes(TEMPLATE_INTERPOLATION))
86
+ continue;
87
+ if (!STORAGE_PATH_SEGMENTS.some((seg) => url.includes(seg)))
88
+ continue;
66
89
  if (seen.has(url))
67
90
  continue;
68
91
  seen.add(url);
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.PACK_SUPPORTED_STACKS = void 0;
7
7
  exports.buildStandaloneEnv = buildStandaloneEnv;
8
8
  exports.liftNestedEntry = liftNestedEntry;
9
+ exports.copySharedStatic = copySharedStatic;
10
+ exports.pruneStaticNoise = pruneStaticNoise;
9
11
  exports.resolveStrategy = resolveStrategy;
10
12
  const node_child_process_1 = require("node:child_process");
11
13
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -45,6 +47,12 @@ function buildStandaloneEnv(base) {
45
47
  // `/assets/x.js` 绝对路径,file:// 下指向文件系统根,页面白屏(真机踩过)。
46
48
  // vite-react 那边 build.sh 会用同样的值覆盖它,两条路结果一致。
47
49
  env.ASSETS_CDN_PATH = './';
50
+ // shared/static 的 URL 前缀。同样要**两个名字都给**:
51
+ // vite-react 走 build.sh,里面 `export STATIC_ASSETS_BASE_URL="${MIAODA_STATIC_CDN_PREFIX}"`
52
+ // 会无条件覆盖直接注入的值 —— 只给 STATIC_ASSETS_BASE_URL 会被冲成空,
53
+ // 插件退化成绝对路径 `/avatar.jpg`,file:// 下必挂(真机踩过)。
54
+ // nestjs 走 build:client 直连 vite,没有那层映射,只认 STATIC_ASSETS_BASE_URL。
55
+ env.MIAODA_STATIC_CDN_PREFIX = './static';
48
56
  env.STATIC_ASSETS_BASE_URL = './static';
49
57
  return env;
50
58
  }
@@ -109,6 +117,86 @@ function dropSourceMaps(outDir) {
109
117
  };
110
118
  walk(outDir);
111
119
  }
120
+ /**
121
+ * 把 `shared/static/` 拷进产物的 `static/`。
122
+ *
123
+ * `shared/static/*` 不是 vite 的产物 —— 插件只把它们解析成 URL 字符串,真实文件靠
124
+ * 构建脚本搬。vite-react 的 `scripts/build.sh` 会 rsync 到 `dist/output_static`,
125
+ * 但 **nestjs 的 build.sh 完全没有这段**,而我们又绕过它直接跑 `build:client`,
126
+ * 所以没人搬 —— 产物里引用了 `./static/avatar.jpg` 却没有这个文件(真机踩过)。
127
+ *
128
+ * 排除规则对齐 vite-react build.sh:跳过 `.ts/.tsx/.js/.jsx`(那些是代码不是资源,
129
+ * 由 bundler 处理)。JSON 无需排除:它走 `window.__STATIC_JSON__` 内联,多拷一份无害。
130
+ *
131
+ * 只负责「搬」,模板 README 的剔除交给 `pruneStaticNoise` —— vite-react 那条路径
132
+ * 的 static/ 是 build.sh 搬的、根本不经过这里,规则放这儿会漏掉它(真机漏过)。
133
+ */
134
+ const SHARED_STATIC_SKIP_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']);
135
+ function copySharedStatic(projectDir, outDir) {
136
+ const src = node_path_1.default.join(projectDir, 'shared', 'static');
137
+ if (!node_fs_1.default.existsSync(src))
138
+ return;
139
+ const dest = node_path_1.default.join(outDir, 'static');
140
+ const walk = (dir, rel) => {
141
+ for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true })) {
142
+ const abs = node_path_1.default.join(dir, entry.name);
143
+ const childRel = rel ? node_path_1.default.join(rel, entry.name) : entry.name;
144
+ if (entry.isDirectory()) {
145
+ walk(abs, childRel);
146
+ continue;
147
+ }
148
+ if (SHARED_STATIC_SKIP_EXTENSIONS.has(node_path_1.default.extname(entry.name).toLowerCase())) {
149
+ continue;
150
+ }
151
+ const to = node_path_1.default.join(dest, childRel);
152
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(to), { recursive: true });
153
+ node_fs_1.default.copyFileSync(abs, to);
154
+ }
155
+ };
156
+ walk(src, '');
157
+ }
158
+ const STATIC_NOISE_FILENAME = 'readme.md';
159
+ /**
160
+ * 从产物的 `static/` 里剔掉模板自带的 README.md,并清掉因此空掉的目录。
161
+ *
162
+ * 模板给 `shared/static/` 配了一份 README.md 讲这个目录怎么用(跟 `src/assets`、
163
+ * `public` 的区别、为什么要走 SDK 拿带 token 的 URL),是给开发者看的文档不是应用
164
+ * 资源。多数应用没往这个目录放东西,产物里就只剩这一个 README 孤零零躺着。
165
+ *
166
+ * **两条路径都要过这里**:nestjs 侧 static/ 由 `copySharedStatic` 搬,vite-react 侧
167
+ * 由 `scripts/build.sh` rsync 到 `dist/output_static` 再 merge 进来——build.sh 的
168
+ * `--exclude` 只排了四个代码扩展名,README 照样进产物。规则只写在搬运侧会漏掉另一条。
169
+ *
170
+ * 只排 `README.md` 这一个名字(大小写不敏感)。其余 `.md` 照留——应用拿 markdown
171
+ * 当资源(说明书、协议文本)是合理用法,不该替它做主。
172
+ */
173
+ function pruneStaticNoise(outDir) {
174
+ const root = node_path_1.default.join(outDir, 'static');
175
+ if (!node_fs_1.default.existsSync(root))
176
+ return;
177
+ // 返回该目录清理后是否已空,空则连目录一起删(空目录进 zip 同样是噪音)
178
+ const prune = (dir) => {
179
+ let kept = 0;
180
+ for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true })) {
181
+ const abs = node_path_1.default.join(dir, entry.name);
182
+ if (entry.isDirectory()) {
183
+ if (!prune(abs))
184
+ kept += 1;
185
+ continue;
186
+ }
187
+ if (entry.name.toLowerCase() === STATIC_NOISE_FILENAME) {
188
+ node_fs_1.default.rmSync(abs, { force: true });
189
+ continue;
190
+ }
191
+ kept += 1;
192
+ }
193
+ if (kept > 0)
194
+ return false;
195
+ node_fs_1.default.rmSync(dir, { recursive: true, force: true });
196
+ return true;
197
+ };
198
+ prune(root);
199
+ }
112
200
  /** 从产物树里删掉 routes.json —— 平台/server 路由用,standalone 无人读取。 */
113
201
  function dropRoutesJson(outDir) {
114
202
  node_fs_1.default.rmSync(node_path_1.default.join(outDir, 'routes.json'), { force: true });
@@ -162,6 +250,7 @@ const viteReactStrategy = ({ projectDir, outDir }) => {
162
250
  mergeInto(node_path_1.default.join(dist, 'output'), outDir);
163
251
  mergeInto(node_path_1.default.join(dist, 'output_resource', 'assets'), node_path_1.default.join(outDir, 'assets'));
164
252
  mergeInto(node_path_1.default.join(dist, 'output_static'), node_path_1.default.join(outDir, 'static'));
253
+ pruneStaticNoise(outDir);
165
254
  dropSourceMaps(outDir);
166
255
  dropRoutesJson(outDir);
167
256
  };
@@ -196,6 +285,8 @@ const nestjsStrategy = ({ projectDir, outDir }) => {
196
285
  node_fs_1.default.rmSync(outDir, { recursive: true, force: true });
197
286
  node_fs_1.default.mkdirSync(outDir, { recursive: true });
198
287
  mergeInto(tmpDist, outDir);
288
+ copySharedStatic(projectDir, outDir);
289
+ pruneStaticNoise(outDir);
199
290
  }
200
291
  finally {
201
292
  // 临时构建目录不留在用户项目里