@lark-apaas/fullstack-cli 1.1.62-alpha.20260824081932 → 1.1.62-alpha.20260824095126

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/dist/index.js CHANGED
@@ -2617,7 +2617,23 @@ function emptyProfile() {
2617
2617
  }
2618
2618
  function viteReactProfile() {
2619
2619
  return {
2620
- sync: [],
2620
+ sync: [
2621
+ // 覆盖 scripts/build.sh(回溯 public/* → dist/output/ 的修复)
2622
+ {
2623
+ from: "templates/vite-react/build.sh",
2624
+ to: "scripts/build.sh",
2625
+ type: "file",
2626
+ overwrite: true
2627
+ },
2628
+ // 补 upgrade 脚本:沙箱启动主动调 `fullstack-cli sync`(镜像必带,裸命令,与 postinstall 口径一致)。
2629
+ // 存量应用首次由沙箱直接 `fullstack-cli sync` 兜到,本 add-script 保证脚本存在 + 后续一致。
2630
+ {
2631
+ type: "add-script",
2632
+ name: "upgrade",
2633
+ command: "fullstack-cli sync",
2634
+ overwrite: false
2635
+ }
2636
+ ],
2621
2637
  permissions: {},
2622
2638
  activateGitHooks: true
2623
2639
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/fullstack-cli",
3
- "version": "1.1.62-alpha.20260824081932",
3
+ "version": "1.1.62-alpha.20260824095126",
4
4
  "description": "CLI tool for fullstack template management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -5,3 +5,4 @@
5
5
  # scripts/ directory is auto-generated
6
6
  # DO NOT modify manually
7
7
  scripts/
8
+
@@ -149,16 +149,12 @@ if [ -d "$DIST_DIR/client" ]; then
149
149
  find "$DIST_DIR/client" -maxdepth 1 -name "*.html" -exec mv {} "$DIST_DIR/dist/client/" \;
150
150
  fi
151
151
 
152
- # 拷贝 shared/static → dist/shared/static
153
- # Nest server 生产从 process.cwd() + shared/static 读 (nestjs-core static.controller.ts:100),
154
- # 生产 cwd = dist/, 所以目标是 dist/shared/static。coding-preset 的 static-assets plugin
155
- # 之前 closeBundle 拷贝, 现在删除后交给 build.sh 显式做 (跟 jsPage 的 output_static rsync 对齐)。
156
- # 排除 .ts/.tsx/.js/.jsx: 避免 shared/static/ 里若有开发脚本被误传到生产。
157
- if [ -d "$ROOT_DIR/shared/static" ]; then
158
- mkdir -p "$DIST_DIR/shared/static"
159
- rsync -a --exclude='*.ts' --exclude='*.tsx' --exclude='*.js' --exclude='*.jsx' \
160
- "$ROOT_DIR/shared/static/" "$DIST_DIR/shared/static/"
161
- echo " Static → dist/shared/static/"
152
+ # 拷贝 client/public → 产物根 public/:供 nestjs-core 的 public 中间件在应用根同源 serve
153
+ # (修 nestjs-fullstack 的 public 线上 404:同源 fetch('/x') / resolveAppUrl('/x'))。
154
+ # 运行时 cwd=DIST_DIR,中间件读 process.cwd()/public。
155
+ if [ -d "$ROOT_DIR/client/public" ]; then
156
+ mkdir -p "$DIST_DIR/public"
157
+ cp -R "$ROOT_DIR/client/public/." "$DIST_DIR/public/"
162
158
  fi
163
159
 
164
160
  # server 相关产物准备(only_frontend_change=true 时跳过)
@@ -252,22 +252,20 @@ process.on('SIGTERM', cleanup);
252
252
  process.on('SIGINT', cleanup);
253
253
  process.on('SIGHUP', cleanup);
254
254
 
255
- // dist 缓存策略:
256
- // 沙箱首次启动 → 保留 dist/, 让 nest --watch 复用上次编译产物
257
- // 后续启动 (agent 主动触发 npm run dev) → 全清 dist/, 让模型面对新鲜产物避免 stale
258
- // 依赖升级 (lockfile hash 变) → 同后续启动 (清 dist), log 里点出 "deps changed"
259
- // FORCE_CLEAN_DIST=true → escape hatch, 强清
260
- // marker 放 os.tmpdir()/.miaoda-dev-boot (跟沙箱容器 lifecycle 同, 沙箱重启会清空,
261
- // 语义对齐"沙箱首次启动"); 具体逻辑抽到 lib/preserve-dev-cache.cjs 便于单测。
262
- const { preserveDevCache } = require('./lib/preserve-dev-cache.cjs');
255
+ // Stale dist makes nest --watch skip missing files; watcher won't self-heal.
256
+ function cleanStaleDist() {
257
+ const distPath = path.join(PROJECT_ROOT, 'dist');
258
+ if (fs.existsSync(distPath)) {
259
+ fs.rmSync(distPath, { recursive: true, force: true });
260
+ logEvent('INFO', 'main', 'Cleaned dist/ to force full rebuild');
261
+ }
262
+ }
263
263
 
264
264
  // ── Main ──────────────────────────────────────────────────────────────────────
265
265
  async function main() {
266
266
  logEvent('INFO', 'main', '========== Dev session started ==========');
267
267
 
268
- preserveDevCache(PROJECT_ROOT, {
269
- log: (level, msg) => logEvent(level, 'main', msg),
270
- });
268
+ cleanStaleDist();
271
269
 
272
270
  // Initialize action plugins
273
271
  writeOutput('\n🔌 Initializing action plugins...\n');
@@ -0,0 +1,61 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
5
+ OUTPUT="$ROOT/dist/output"
6
+ OUTPUT_RESOURCE="$ROOT/dist/output_resource"
7
+ OUTPUT_STATIC="$ROOT/dist/output_static"
8
+
9
+ # 映射平台环境变量到 preset 期望的变量名
10
+ # MIAODA_APP_ID → /app/<appId> 作为客户端 base path
11
+ # MIAODA_RESOURCE_CDN_PREFIX → assets (JS/CSS) 的 CDN 前缀
12
+ # CLI 注入约定见 miaoda-cli src/services/deploy/modern/atoms/build.ts
13
+ export CLIENT_BASE_PATH="${MIAODA_APP_ID:+/app/$MIAODA_APP_ID}"
14
+ export ASSETS_CDN_PATH="${MIAODA_RESOURCE_CDN_PREFIX:-/}"
15
+ export STATIC_ASSETS_BASE_URL="${MIAODA_STATIC_CDN_PREFIX}"
16
+ export NODE_ENV="${NODE_ENV:-production}"
17
+
18
+ # 清理
19
+ rm -rf "$ROOT/dist"
20
+
21
+ # 1. Vite 构建 → dist/client/(相对于项目根目录输出)
22
+ npx vite build --outDir "$ROOT/dist/client" --emptyOutDir
23
+
24
+ # 2. public/ 静态资源 + HTML → dist/output/(模型 B:public = 应用根目录,同源 /app/<appId>/*)
25
+ mkdir -p "$OUTPUT"
26
+ # 2a. public/*:vite copyPublicDir 已把 public/* 平铺进 dist/client,但下面白名单只取
27
+ # *.html/routes.json,其余(favicon / 图片 / 运行时 fetch 的数据文件)会随 dist/client 被删。
28
+ # 从源 public/ 补拷到 output/(同源根),让 fetch('/x') / resolveAppUrl('/x') 线上可用。
29
+ # 先拷 public、再拷 HTML,保证构建产出的 index.html/routes.json 覆盖 public 里的同名文件(若有)。
30
+ if [ -d "$ROOT/public" ]; then
31
+ cp -R "$ROOT/public/." "$OUTPUT/"
32
+ fi
33
+ # 2b. HTML + routes.json(构建产物)
34
+ find "$ROOT/dist/client" -maxdepth 1 \( -name '*.html' -o -name 'routes.json' \) -exec cp {} "$OUTPUT/" \;
35
+
36
+ # 3. assets/ → dist/output_resource/(JS/CSS/字体,上传到 CDN)
37
+ if [ -d "$ROOT/dist/client/assets" ]; then
38
+ mkdir -p "$OUTPUT_RESOURCE"
39
+ cp -r "$ROOT/dist/client/assets" "$OUTPUT_RESOURCE/"
40
+ fi
41
+
42
+ # 4. 私有静态资源 → dist/output_static/(排除代码文件)
43
+ if [ -d "$ROOT/shared/static" ]; then
44
+ mkdir -p "$OUTPUT_STATIC"
45
+ rsync -a --exclude='*.ts' --exclude='*.tsx' --exclude='*.js' --exclude='*.jsx' "$ROOT/shared/static/" "$OUTPUT_STATIC/"
46
+ fi
47
+
48
+ # 5. capability 配置 → dist/output_capabilities/
49
+ if [ -d "$ROOT/shared/capabilities" ]; then
50
+ mkdir -p "$ROOT/dist/output_capabilities"
51
+ cp -r "$ROOT/shared/capabilities/." "$ROOT/dist/output_capabilities/"
52
+ fi
53
+
54
+ # 清理中间产物
55
+ rm -rf "$ROOT/dist/client"
56
+
57
+ echo "Build complete"
58
+ echo " HTML → dist/output/"
59
+ [ -d "$OUTPUT_RESOURCE" ] && echo " Resource → dist/output_resource/" || true
60
+ [ -d "$OUTPUT_STATIC" ] && echo " Static → dist/output_static/" || true
61
+ [ -d "$ROOT/dist/output_capabilities" ] && echo " Capabilities → dist/output_capabilities/" || true
@@ -1,308 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import fs from 'fs';
3
- import os from 'os';
4
- import path from 'path';
5
-
6
- // eslint-disable-next-line @typescript-eslint/no-require-imports
7
- const { preserveDevCache } = require('../preserve-dev-cache.cjs');
8
-
9
- interface LogEntry {
10
- level: string;
11
- msg: string;
12
- }
13
-
14
- function makeLogger(entries: LogEntry[]) {
15
- return (level: string, msg: string) => {
16
- entries.push({ level, msg });
17
- };
18
- }
19
-
20
- describe('preserveDevCache', () => {
21
- let tmpDir: string;
22
- let markerPath: string;
23
-
24
- beforeEach(() => {
25
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'preserve-dev-cache-'));
26
- markerPath = path.join(tmpDir, '.miaoda-dev-boot-marker');
27
- });
28
-
29
- afterEach(() => {
30
- try {
31
- fs.rmSync(tmpDir, { recursive: true, force: true });
32
- } catch {
33
- // ignore
34
- }
35
- });
36
-
37
- describe('FORCE_CLEAN_DIST escape hatch', () => {
38
- it('cleans dist and writes marker regardless of state', () => {
39
- fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
40
- fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-content');
41
- const logs: LogEntry[] = [];
42
-
43
- const r = preserveDevCache(tmpDir, {
44
- log: makeLogger(logs),
45
- forceClean: true,
46
- markerPath,
47
- });
48
-
49
- expect(r.action).toBe('force_cleaned');
50
- expect(fs.existsSync(path.join(tmpDir, 'dist'))).toBe(false);
51
- expect(fs.existsSync(markerPath)).toBe(true);
52
- expect(logs[0].msg).toContain('FORCE_CLEAN_DIST');
53
- });
54
- });
55
-
56
- describe('first boot (no marker)', () => {
57
- it('preserves existing dist and writes marker', () => {
58
- fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
59
- fs.writeFileSync(
60
- path.join(tmpDir, 'dist', 'server', 'main.js'),
61
- 'compiled-content'
62
- );
63
- const logs: LogEntry[] = [];
64
-
65
- const r = preserveDevCache(tmpDir, {
66
- log: makeLogger(logs),
67
- forceClean: false,
68
- markerPath,
69
- });
70
-
71
- expect(r.action).toBe('first_boot_preserved');
72
- expect(fs.existsSync(path.join(tmpDir, 'dist', 'server', 'main.js'))).toBe(true);
73
- expect(fs.existsSync(markerPath)).toBe(true);
74
- expect(logs[0].msg).toContain('first boot');
75
- });
76
-
77
- it('handles no dist as first_boot_no_dist', () => {
78
- const logs: LogEntry[] = [];
79
-
80
- const r = preserveDevCache(tmpDir, {
81
- log: makeLogger(logs),
82
- forceClean: false,
83
- markerPath,
84
- });
85
-
86
- expect(r.action).toBe('first_boot_no_dist');
87
- expect(fs.existsSync(markerPath)).toBe(true);
88
- expect(logs[0].msg).toContain('no dist/');
89
- });
90
-
91
- it('marker stores current lockfile hash', () => {
92
- fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'initial-lock');
93
-
94
- preserveDevCache(tmpDir, {
95
- log: makeLogger([]),
96
- forceClean: false,
97
- markerPath,
98
- });
99
-
100
- const raw = fs.readFileSync(markerPath, 'utf-8');
101
- const data = JSON.parse(raw);
102
- expect(typeof data.lockfileHash).toBe('string');
103
- expect(data.lockfileHash.length).toBeGreaterThan(0);
104
- });
105
- });
106
-
107
- describe('subsequent boot (marker exists)', () => {
108
- it('cleans dist and rewrites marker when lockfile unchanged', () => {
109
- fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-content');
110
- preserveDevCache(tmpDir, {
111
- log: makeLogger([]),
112
- forceClean: false,
113
- markerPath,
114
- });
115
-
116
- fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
117
- fs.writeFileSync(path.join(tmpDir, 'dist', 'server', 'stale.js'), 'stale');
118
-
119
- const logs: LogEntry[] = [];
120
- const r = preserveDevCache(tmpDir, {
121
- log: makeLogger(logs),
122
- forceClean: false,
123
- markerPath,
124
- });
125
-
126
- expect(r.action).toBe('subsequent_boot_cleaned');
127
- expect(fs.existsSync(path.join(tmpDir, 'dist'))).toBe(false);
128
- expect(fs.existsSync(markerPath)).toBe(true);
129
- expect(logs[0].msg).toContain('subsequent boot');
130
- });
131
-
132
- it('reports deps_changed when lockfile hash mismatches marker', () => {
133
- fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-v1');
134
- preserveDevCache(tmpDir, {
135
- log: makeLogger([]),
136
- forceClean: false,
137
- markerPath,
138
- });
139
-
140
- fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-v2');
141
- fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
142
-
143
- const logs: LogEntry[] = [];
144
- const r = preserveDevCache(tmpDir, {
145
- log: makeLogger(logs),
146
- forceClean: false,
147
- markerPath,
148
- });
149
-
150
- expect(r.action).toBe('deps_changed_cleaned');
151
- expect(fs.existsSync(path.join(tmpDir, 'dist'))).toBe(false);
152
- expect(logs[0].msg).toContain('deps changed');
153
- });
154
-
155
- it('reports subsequent_boot_no_dist when marker exists but no dist', () => {
156
- preserveDevCache(tmpDir, {
157
- log: makeLogger([]),
158
- forceClean: false,
159
- markerPath,
160
- });
161
-
162
- const logs: LogEntry[] = [];
163
- const r = preserveDevCache(tmpDir, {
164
- log: makeLogger(logs),
165
- forceClean: false,
166
- markerPath,
167
- });
168
-
169
- expect(r.action).toBe('subsequent_boot_no_dist');
170
- expect(fs.existsSync(markerPath)).toBe(true);
171
- expect(logs[0].msg).toContain('no dist/');
172
- });
173
- });
174
-
175
- describe('lockfile detection', () => {
176
- it('supports package-lock.json', () => {
177
- fs.writeFileSync(path.join(tmpDir, 'package-lock.json'), '{}');
178
- preserveDevCache(tmpDir, {
179
- log: makeLogger([]),
180
- forceClean: false,
181
- markerPath,
182
- });
183
-
184
- const raw = fs.readFileSync(markerPath, 'utf-8');
185
- expect(JSON.parse(raw).lockfileHash).not.toBeNull();
186
- });
187
-
188
- it('supports pnpm-lock.yaml', () => {
189
- fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9');
190
- preserveDevCache(tmpDir, {
191
- log: makeLogger([]),
192
- forceClean: false,
193
- markerPath,
194
- });
195
-
196
- const raw = fs.readFileSync(markerPath, 'utf-8');
197
- expect(JSON.parse(raw).lockfileHash).not.toBeNull();
198
- });
199
-
200
- it('stores null lockfileHash when no lockfile present', () => {
201
- preserveDevCache(tmpDir, {
202
- log: makeLogger([]),
203
- forceClean: false,
204
- markerPath,
205
- });
206
-
207
- const raw = fs.readFileSync(markerPath, 'utf-8');
208
- expect(JSON.parse(raw).lockfileHash).toBeNull();
209
- });
210
- });
211
-
212
- describe('first boot + deps changed (lockfile newer than buildinfo)', () => {
213
- // 制造"lockfile mtime > buildinfo mtime": 先建 dist + buildinfo,sleep 20ms,再 touch lockfile
214
- // 20ms 是保守值,避免 macOS/APFS mtime 精度问题让两个时间戳相等
215
- function makeStaleDist() {
216
- fs.mkdirSync(path.join(tmpDir, 'dist'), { recursive: true });
217
- fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
218
- fs.writeFileSync(path.join(tmpDir, 'dist', 'server', 'main.js'), 'compiled');
219
- fs.writeFileSync(path.join(tmpDir, 'dist', 'tsconfig.node.tsbuildinfo'), '{}');
220
- }
221
-
222
- it('cleans dist when lockfile mtime > buildinfo mtime', async () => {
223
- makeStaleDist();
224
- await new Promise((r) => setTimeout(r, 20));
225
- fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'newer-lock');
226
-
227
- const logs: LogEntry[] = [];
228
- const r = preserveDevCache(tmpDir, {
229
- log: makeLogger(logs),
230
- forceClean: false,
231
- markerPath,
232
- });
233
-
234
- expect(r.action).toBe('first_boot_deps_changed_cleaned');
235
- expect(fs.existsSync(path.join(tmpDir, 'dist'))).toBe(false);
236
- expect(logs[0].msg).toContain('deps changed');
237
- });
238
-
239
- it('preserves dist when lockfile mtime <= buildinfo mtime', async () => {
240
- fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'older-lock');
241
- await new Promise((r) => setTimeout(r, 20));
242
- makeStaleDist();
243
-
244
- const logs: LogEntry[] = [];
245
- const r = preserveDevCache(tmpDir, {
246
- log: makeLogger(logs),
247
- forceClean: false,
248
- markerPath,
249
- });
250
-
251
- expect(r.action).toBe('first_boot_preserved');
252
- expect(fs.existsSync(path.join(tmpDir, 'dist', 'server', 'main.js'))).toBe(true);
253
- });
254
-
255
- it('cleans dist when buildinfo is missing (dist exists but never compiled)', () => {
256
- fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock');
257
- fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
258
- fs.writeFileSync(path.join(tmpDir, 'dist', 'server', 'main.js'), 'compiled');
259
- // 故意不建 tsconfig.node.tsbuildinfo
260
-
261
- const r = preserveDevCache(tmpDir, {
262
- log: makeLogger([]),
263
- forceClean: false,
264
- markerPath,
265
- });
266
-
267
- expect(r.action).toBe('first_boot_deps_changed_cleaned');
268
- expect(fs.existsSync(path.join(tmpDir, 'dist'))).toBe(false);
269
- });
270
-
271
- it('preserves dist when no lockfile present (cannot judge staleness)', () => {
272
- // dist 有 buildinfo 但没 lockfile
273
- fs.mkdirSync(path.join(tmpDir, 'dist'), { recursive: true });
274
- fs.writeFileSync(path.join(tmpDir, 'dist', 'tsconfig.node.tsbuildinfo'), '{}');
275
- fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
276
- fs.writeFileSync(path.join(tmpDir, 'dist', 'server', 'main.js'), 'compiled');
277
-
278
- const r = preserveDevCache(tmpDir, {
279
- log: makeLogger([]),
280
- forceClean: false,
281
- markerPath,
282
- });
283
-
284
- expect(r.action).toBe('first_boot_preserved');
285
- expect(fs.existsSync(path.join(tmpDir, 'dist', 'server', 'main.js'))).toBe(true);
286
- });
287
- });
288
-
289
- describe('marker path (sandbox lifecycle alignment)', () => {
290
- it('defaults markerPath to os.tmpdir()/.miaoda-dev-boot when not passed', () => {
291
- // 不传 markerPath, 走默认路径
292
- const defaultMarker = path.join(os.tmpdir(), '.miaoda-dev-boot');
293
- // 清理默认位置的残留 marker (可能来自其他测试),避免干扰本用例
294
- try { fs.unlinkSync(defaultMarker); } catch { /* noop */ }
295
-
296
- const r = preserveDevCache(tmpDir, {
297
- log: makeLogger([]),
298
- forceClean: false,
299
- });
300
-
301
- expect(r.action).toBe('first_boot_no_dist');
302
- expect(fs.existsSync(defaultMarker)).toBe(true);
303
-
304
- // cleanup
305
- try { fs.unlinkSync(defaultMarker); } catch { /* noop */ }
306
- });
307
- });
308
- });
@@ -1,178 +0,0 @@
1
- 'use strict';
2
-
3
- // dev.js 启动时的 dist 缓存策略:
4
- // - 沙箱首次启动 (marker 不存在) + dist 未过期: 保留 dist/, 让 nest --watch 复用上次编译产物
5
- // - 沙箱首次启动 + dist 过期 (lockfile mtime > buildinfo mtime): 清 dist/, 避免 stale binding
6
- // - 后续启动 (agent 主动触发 npm run dev): 全清 dist/, 让模型面对新鲜产物避免 stale
7
- // - 依赖升级 (marker 里 lockfile hash 变): 后续启动分支自然覆盖 (清 dist), log 里点出原因
8
- // - FORCE_CLEAN_DIST=true: escape hatch, 强清
9
- //
10
- // marker 位置放 os.tmpdir()(通常是 /tmp), 跟沙箱容器 lifecycle 同 —— 沙箱重启时
11
- // /tmp 会清空, marker 消失, 下次进 dev 视为"首次启动"复用 dist; 应用工作目录是
12
- // persistent volume 会跨沙箱重启保留, 放这里的 marker 无法反映"沙箱首次启动"语义。
13
- //
14
- // dist 过期判定 (仅首次启动分支需要): lockfile mtime > dist/tsconfig.node.tsbuildinfo mtime
15
- // 意味着依赖变动之后 dist 未重新编译, 保留会 stale binding。后续启动分支反正全清不需要判。
16
-
17
- const fs = require('fs');
18
- const os = require('os');
19
- const path = require('path');
20
- const crypto = require('crypto');
21
-
22
- const MARKER_FILENAME = '.miaoda-dev-boot';
23
-
24
- /**
25
- * @param {string} projectRoot 项目根目录
26
- * @param {object} [options]
27
- * @param {(level: string, msg: string) => void} [options.log]
28
- * @param {boolean} [options.forceClean]
29
- * @param {string} [options.markerPath] 覆盖 marker 文件路径 (单测用; 生产默认 /tmp/.miaoda-dev-boot)
30
- * @returns {{ action: 'force_cleaned' | 'first_boot_preserved' | 'first_boot_deps_changed_cleaned' | 'first_boot_no_dist' | 'subsequent_boot_cleaned' | 'deps_changed_cleaned' | 'subsequent_boot_no_dist' }}
31
- */
32
- function preserveDevCache(projectRoot, options) {
33
- const log = (options && options.log) || defaultLogger;
34
- const forceClean =
35
- options && typeof options.forceClean === 'boolean'
36
- ? options.forceClean
37
- : process.env.FORCE_CLEAN_DIST === 'true';
38
- const markerPath =
39
- (options && options.markerPath) ||
40
- path.join(os.tmpdir(), MARKER_FILENAME);
41
-
42
- const distPath = path.join(projectRoot, 'dist');
43
- const currentLockfileHash = computeLockfileHash(projectRoot);
44
-
45
- if (forceClean) {
46
- if (fs.existsSync(distPath)) {
47
- fs.rmSync(distPath, { recursive: true, force: true });
48
- }
49
- writeMarker(markerPath, currentLockfileHash);
50
- log('INFO', 'preserveDevCache: FORCE_CLEAN_DIST=true, cleaned dist/');
51
- return { action: 'force_cleaned' };
52
- }
53
-
54
- const markerExists = fs.existsSync(markerPath);
55
- const distExists = fs.existsSync(distPath);
56
-
57
- if (!markerExists) {
58
- writeMarker(markerPath, currentLockfileHash);
59
- if (distExists) {
60
- if (distIsStaleVsLockfile(projectRoot)) {
61
- fs.rmSync(distPath, { recursive: true, force: true });
62
- log(
63
- 'INFO',
64
- 'preserveDevCache: first boot but deps changed (lockfile newer than buildinfo), cleaned dist/'
65
- );
66
- return { action: 'first_boot_deps_changed_cleaned' };
67
- }
68
- log('INFO', 'preserveDevCache: first boot, dist/ preserved');
69
- return { action: 'first_boot_preserved' };
70
- }
71
- log('INFO', 'preserveDevCache: first boot, no dist/ (will be created)');
72
- return { action: 'first_boot_no_dist' };
73
- }
74
-
75
- // 后续启动: 无脑清 dist
76
- const prevLockfileHash = readMarkerHash(markerPath);
77
- const depsChanged =
78
- prevLockfileHash !== null &&
79
- currentLockfileHash !== null &&
80
- prevLockfileHash !== currentLockfileHash;
81
-
82
- if (distExists) {
83
- fs.rmSync(distPath, { recursive: true, force: true });
84
- }
85
- writeMarker(markerPath, currentLockfileHash);
86
-
87
- if (depsChanged) {
88
- log('INFO', 'preserveDevCache: deps changed (lockfile hash), cleaned dist/');
89
- return { action: 'deps_changed_cleaned' };
90
- }
91
- if (distExists) {
92
- log('INFO', 'preserveDevCache: subsequent boot, cleaned dist/');
93
- return { action: 'subsequent_boot_cleaned' };
94
- }
95
- log('INFO', 'preserveDevCache: subsequent boot, no dist/ to clean');
96
- return { action: 'subsequent_boot_no_dist' };
97
- }
98
-
99
- /**
100
- * 判定当前 dist 是否 stale (相对当前 lockfile)。
101
- *
102
- * 依据: 依赖变动会更新 lockfile mtime; tsc --watch 编译完更新 buildinfo mtime。
103
- * lockfile mtime > buildinfo mtime → dist 是"依赖变动前"编译的 → stale。
104
- *
105
- * 边界:
106
- * - lockfile 不存在 → 返回 false (无法判断, 保守认为不 stale, 保留 dist)
107
- * - buildinfo 不存在 → 返回 true (dist 里没编译过 → 保守清)
108
- * - `git checkout` 之类 touch lockfile 但内容没改 → 误判 stale 多清一次 (可接受, 安全方向)
109
- */
110
- function distIsStaleVsLockfile(projectRoot) {
111
- const lockfiles = ['yarn.lock', 'package-lock.json', 'pnpm-lock.yaml'];
112
- let lockMtime = 0;
113
- for (const name of lockfiles) {
114
- try {
115
- lockMtime = Math.max(
116
- lockMtime,
117
- fs.statSync(path.join(projectRoot, name)).mtimeMs
118
- );
119
- } catch {
120
- // lockfile 不存在,跳过
121
- }
122
- }
123
- if (lockMtime === 0) return false;
124
-
125
- try {
126
- const buildinfoMtime = fs.statSync(
127
- path.join(projectRoot, 'dist', 'tsconfig.node.tsbuildinfo')
128
- ).mtimeMs;
129
- return lockMtime > buildinfoMtime;
130
- } catch {
131
- return true;
132
- }
133
- }
134
-
135
- function computeLockfileHash(projectRoot) {
136
- const candidates = ['yarn.lock', 'package-lock.json', 'pnpm-lock.yaml'];
137
- for (const name of candidates) {
138
- const p = path.join(projectRoot, name);
139
- if (fs.existsSync(p)) {
140
- try {
141
- const content = fs.readFileSync(p);
142
- return crypto.createHash('sha1').update(content).digest('hex');
143
- } catch {
144
- return null;
145
- }
146
- }
147
- }
148
- return null;
149
- }
150
-
151
- function readMarkerHash(markerPath) {
152
- try {
153
- const raw = fs.readFileSync(markerPath, 'utf-8');
154
- const data = JSON.parse(raw);
155
- return typeof data.lockfileHash === 'string' ? data.lockfileHash : null;
156
- } catch {
157
- return null;
158
- }
159
- }
160
-
161
- function writeMarker(markerPath, lockfileHash) {
162
- try {
163
- fs.writeFileSync(
164
- markerPath,
165
- JSON.stringify({ lockfileHash: lockfileHash || null }),
166
- 'utf-8'
167
- );
168
- } catch {
169
- // marker 写失败下次会重试, 不阻塞 dev 启动
170
- }
171
- }
172
-
173
- function defaultLogger(level, msg) {
174
- // eslint-disable-next-line no-console
175
- console.log(`[${level}] [main] ${msg}`);
176
- }
177
-
178
- module.exports = { preserveDevCache };