@lark-apaas/fullstack-cli 1.1.62 → 1.1.63-alpha.20260825175418
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 +1 -9
- package/package.json +1 -1
- package/templates/.gitignore.append +0 -1
- package/templates/scripts/build.sh +12 -5
- package/templates/scripts/dev.js +14 -9
- package/templates/scripts/lib/__test__/preserve-dev-cache.test.ts +463 -0
- package/templates/scripts/lib/preserve-dev-cache.cjs +262 -0
- package/templates/vite-react/build.sh +0 -61
package/dist/index.js
CHANGED
|
@@ -2617,15 +2617,7 @@ function emptyProfile() {
|
|
|
2617
2617
|
}
|
|
2618
2618
|
function viteReactProfile() {
|
|
2619
2619
|
return {
|
|
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
|
-
],
|
|
2620
|
+
sync: [],
|
|
2629
2621
|
permissions: {},
|
|
2630
2622
|
activateGitHooks: true
|
|
2631
2623
|
};
|
package/package.json
CHANGED
|
@@ -146,14 +146,21 @@ STEP_START=$(node -e "console.log(Date.now())")
|
|
|
146
146
|
# 使用 mv 而非 cp:HTML 不能上传到公网 CDN,移走后 dist/client 中不再包含 HTML
|
|
147
147
|
if [ -d "$DIST_DIR/client" ]; then
|
|
148
148
|
mkdir -p "$DIST_DIR/dist/client"
|
|
149
|
-
# public 先进(顶层 client 打包被排除;server setBaseViewsDir + 中间件同源 serve)
|
|
150
|
-
if [ -d "$ROOT_DIR/client/public" ]; then
|
|
151
|
-
cp -R "$ROOT_DIR/client/public/." "$DIST_DIR/dist/client/"
|
|
152
|
-
fi
|
|
153
|
-
# 构建产物 HTML 随后 move,覆盖 public 里的同名文件(保证入口页是构建版,不被 public 静默覆盖)
|
|
154
149
|
find "$DIST_DIR/client" -maxdepth 1 -name "*.html" -exec mv {} "$DIST_DIR/dist/client/" \;
|
|
155
150
|
fi
|
|
156
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/"
|
|
162
|
+
fi
|
|
163
|
+
|
|
157
164
|
# server 相关产物准备(only_frontend_change=true 时跳过)
|
|
158
165
|
if [[ "${only_frontend_change:-false}" == "true" ]]; then
|
|
159
166
|
echo " [skip] 跳过 run.sh/.env 复制 (only_frontend_change=true)"
|
package/templates/scripts/dev.js
CHANGED
|
@@ -252,20 +252,25 @@ process.on('SIGTERM', cleanup);
|
|
|
252
252
|
process.on('SIGINT', cleanup);
|
|
253
253
|
process.on('SIGHUP', cleanup);
|
|
254
254
|
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
255
|
+
// dist 缓存策略:
|
|
256
|
+
// 沙箱首次启动 + dist 对应当前 lockfile → 保留 dist/, 让 nest --watch 复用上次编译产物
|
|
257
|
+
// 沙箱首次启动 + dist 对应别的 lockfile → 清 dist/, 避免依赖换了却用旧产物 (stale binding)
|
|
258
|
+
// 后续启动 (agent 主动触发 npm run dev) → 全清 dist/, 让模型面对新鲜产物避免 stale
|
|
259
|
+
// 依赖升级 (lockfile hash 变) → 同后续启动 (清 dist), log 里点出 "deps changed"
|
|
260
|
+
// FORCE_CLEAN_DIST=true → escape hatch, 强清
|
|
261
|
+
// marker 放 os.tmpdir()/.miaoda-dev-boot (跟沙箱容器 lifecycle 同, 沙箱重启会清空,
|
|
262
|
+
// 语义对齐"沙箱首次启动"); "dist 对应哪份 lockfile" 另记在 dist/.miaoda-dist-lockfile,
|
|
263
|
+
// 跟 dist 一起备份/恢复 —— 不能用 mtime 判, TOS 恢复会把 mtime 全部重写(详见该模块头注释)。
|
|
264
|
+
// 具体逻辑抽到 lib/preserve-dev-cache.cjs 便于单测。
|
|
265
|
+
const { preserveDevCache } = require('./lib/preserve-dev-cache.cjs');
|
|
263
266
|
|
|
264
267
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
265
268
|
async function main() {
|
|
266
269
|
logEvent('INFO', 'main', '========== Dev session started ==========');
|
|
267
270
|
|
|
268
|
-
|
|
271
|
+
preserveDevCache(PROJECT_ROOT, {
|
|
272
|
+
log: (level, msg) => logEvent(level, 'main', msg),
|
|
273
|
+
});
|
|
269
274
|
|
|
270
275
|
// Initialize action plugins
|
|
271
276
|
writeOutput('\n🔌 Initializing action plugins...\n');
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
8
|
+
const { preserveDevCache } = require('../preserve-dev-cache.cjs');
|
|
9
|
+
|
|
10
|
+
interface LogEntry {
|
|
11
|
+
level: string;
|
|
12
|
+
msg: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeLogger(entries: LogEntry[]) {
|
|
16
|
+
return (level: string, msg: string) => {
|
|
17
|
+
entries.push({ level, msg });
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('preserveDevCache', () => {
|
|
22
|
+
let tmpDir: string;
|
|
23
|
+
let markerPath: string;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'preserve-dev-cache-'));
|
|
27
|
+
markerPath = path.join(tmpDir, '.miaoda-dev-boot-marker');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
try {
|
|
32
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
33
|
+
} catch {
|
|
34
|
+
// ignore
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('FORCE_CLEAN_DIST escape hatch', () => {
|
|
39
|
+
it('cleans dist and writes marker regardless of state', () => {
|
|
40
|
+
fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
|
|
41
|
+
fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-content');
|
|
42
|
+
const logs: LogEntry[] = [];
|
|
43
|
+
|
|
44
|
+
const r = preserveDevCache(tmpDir, {
|
|
45
|
+
log: makeLogger(logs),
|
|
46
|
+
forceClean: true,
|
|
47
|
+
markerPath,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(r.action).toBe('force_cleaned');
|
|
51
|
+
expect(fs.existsSync(path.join(tmpDir, 'dist'))).toBe(false);
|
|
52
|
+
expect(fs.existsSync(markerPath)).toBe(true);
|
|
53
|
+
expect(logs[0].msg).toContain('FORCE_CLEAN_DIST');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('first boot (no marker)', () => {
|
|
58
|
+
it('preserves existing dist and writes marker', () => {
|
|
59
|
+
fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
|
|
60
|
+
fs.writeFileSync(
|
|
61
|
+
path.join(tmpDir, 'dist', 'server', 'main.js'),
|
|
62
|
+
'compiled-content'
|
|
63
|
+
);
|
|
64
|
+
const logs: LogEntry[] = [];
|
|
65
|
+
|
|
66
|
+
const r = preserveDevCache(tmpDir, {
|
|
67
|
+
log: makeLogger(logs),
|
|
68
|
+
forceClean: false,
|
|
69
|
+
markerPath,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(r.action).toBe('first_boot_preserved');
|
|
73
|
+
expect(fs.existsSync(path.join(tmpDir, 'dist', 'server', 'main.js'))).toBe(true);
|
|
74
|
+
expect(fs.existsSync(markerPath)).toBe(true);
|
|
75
|
+
expect(logs[0].msg).toContain('first boot');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('handles no dist as first_boot_no_dist', () => {
|
|
79
|
+
const logs: LogEntry[] = [];
|
|
80
|
+
|
|
81
|
+
const r = preserveDevCache(tmpDir, {
|
|
82
|
+
log: makeLogger(logs),
|
|
83
|
+
forceClean: false,
|
|
84
|
+
markerPath,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(r.action).toBe('first_boot_no_dist');
|
|
88
|
+
expect(fs.existsSync(markerPath)).toBe(true);
|
|
89
|
+
expect(logs[0].msg).toContain('no dist/');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('marker stores current lockfile hash', () => {
|
|
93
|
+
fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'initial-lock');
|
|
94
|
+
|
|
95
|
+
preserveDevCache(tmpDir, {
|
|
96
|
+
log: makeLogger([]),
|
|
97
|
+
forceClean: false,
|
|
98
|
+
markerPath,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const raw = fs.readFileSync(markerPath, 'utf-8');
|
|
102
|
+
const data = JSON.parse(raw);
|
|
103
|
+
expect(typeof data.lockfileHash).toBe('string');
|
|
104
|
+
expect(data.lockfileHash.length).toBeGreaterThan(0);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('subsequent boot (marker exists)', () => {
|
|
109
|
+
it('cleans dist and rewrites marker when lockfile unchanged', () => {
|
|
110
|
+
fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-content');
|
|
111
|
+
preserveDevCache(tmpDir, {
|
|
112
|
+
log: makeLogger([]),
|
|
113
|
+
forceClean: false,
|
|
114
|
+
markerPath,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
|
|
118
|
+
fs.writeFileSync(path.join(tmpDir, 'dist', 'server', 'main.js'), 'stale');
|
|
119
|
+
|
|
120
|
+
const logs: LogEntry[] = [];
|
|
121
|
+
const r = preserveDevCache(tmpDir, {
|
|
122
|
+
log: makeLogger(logs),
|
|
123
|
+
forceClean: false,
|
|
124
|
+
markerPath,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(r.action).toBe('subsequent_boot_cleaned');
|
|
128
|
+
// 编译产物必须清空; dist/ 目录本身会留下 provenance(记录接下来针对哪份 lockfile 编译),
|
|
129
|
+
// 否则下次冷启动读不到 provenance,会退化成每次全清。
|
|
130
|
+
expect(fs.existsSync(path.join(tmpDir, 'dist', 'server'))).toBe(false);
|
|
131
|
+
expect(fs.existsSync(path.join(tmpDir, 'dist', '.miaoda-dist-lockfile'))).toBe(true);
|
|
132
|
+
expect(fs.existsSync(markerPath)).toBe(true);
|
|
133
|
+
expect(logs[0].msg).toContain('subsequent boot');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('reports deps_changed when lockfile hash mismatches marker', () => {
|
|
137
|
+
fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-v1');
|
|
138
|
+
preserveDevCache(tmpDir, {
|
|
139
|
+
log: makeLogger([]),
|
|
140
|
+
forceClean: false,
|
|
141
|
+
markerPath,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-v2');
|
|
145
|
+
fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
|
|
146
|
+
|
|
147
|
+
const logs: LogEntry[] = [];
|
|
148
|
+
const r = preserveDevCache(tmpDir, {
|
|
149
|
+
log: makeLogger(logs),
|
|
150
|
+
forceClean: false,
|
|
151
|
+
markerPath,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(r.action).toBe('deps_changed_cleaned');
|
|
155
|
+
expect(fs.existsSync(path.join(tmpDir, 'dist', 'server'))).toBe(false);
|
|
156
|
+
// provenance 换成新 lockfile 的 hash,下次冷启动才可能复用
|
|
157
|
+
expect(fs.existsSync(path.join(tmpDir, 'dist', '.miaoda-dist-lockfile'))).toBe(true);
|
|
158
|
+
expect(logs[0].msg).toContain('deps changed');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('reports subsequent_boot_no_dist when marker exists but no dist', () => {
|
|
162
|
+
preserveDevCache(tmpDir, {
|
|
163
|
+
log: makeLogger([]),
|
|
164
|
+
forceClean: false,
|
|
165
|
+
markerPath,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const logs: LogEntry[] = [];
|
|
169
|
+
const r = preserveDevCache(tmpDir, {
|
|
170
|
+
log: makeLogger(logs),
|
|
171
|
+
forceClean: false,
|
|
172
|
+
markerPath,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(r.action).toBe('subsequent_boot_no_dist');
|
|
176
|
+
expect(fs.existsSync(markerPath)).toBe(true);
|
|
177
|
+
expect(logs[0].msg).toContain('no dist/');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('lockfile detection', () => {
|
|
182
|
+
it('supports package-lock.json', () => {
|
|
183
|
+
fs.writeFileSync(path.join(tmpDir, 'package-lock.json'), '{}');
|
|
184
|
+
preserveDevCache(tmpDir, {
|
|
185
|
+
log: makeLogger([]),
|
|
186
|
+
forceClean: false,
|
|
187
|
+
markerPath,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const raw = fs.readFileSync(markerPath, 'utf-8');
|
|
191
|
+
expect(JSON.parse(raw).lockfileHash).not.toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('supports pnpm-lock.yaml', () => {
|
|
195
|
+
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9');
|
|
196
|
+
preserveDevCache(tmpDir, {
|
|
197
|
+
log: makeLogger([]),
|
|
198
|
+
forceClean: false,
|
|
199
|
+
markerPath,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const raw = fs.readFileSync(markerPath, 'utf-8');
|
|
203
|
+
expect(JSON.parse(raw).lockfileHash).not.toBeNull();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('stores null lockfileHash when no lockfile present', () => {
|
|
207
|
+
preserveDevCache(tmpDir, {
|
|
208
|
+
log: makeLogger([]),
|
|
209
|
+
forceClean: false,
|
|
210
|
+
markerPath,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const raw = fs.readFileSync(markerPath, 'utf-8');
|
|
214
|
+
expect(JSON.parse(raw).lockfileHash).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('first boot dist staleness (lockfile content hash)', () => {
|
|
219
|
+
const PROVENANCE = '.miaoda-dist-lockfile';
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 复刻生产侧 computeLockfileHash 的口径: 文件名 + 内容一起 hash。
|
|
223
|
+
* 这里的用例统一只用 yarn.lock,所以固定带该文件名。
|
|
224
|
+
*/
|
|
225
|
+
function lockHash(content: string, name = 'yarn.lock') {
|
|
226
|
+
return crypto.createHash('sha1').update(name).update(content).digest('hex');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 造一个"已编译过"的 dist,可选带上 provenance 记录它针对哪份 lockfile 内容 */
|
|
230
|
+
function makeDist(builtAgainstLockContent: string | null) {
|
|
231
|
+
fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
|
|
232
|
+
// main.js 是生产侧判定"有编译产物"的入口,不能换成别的文件名
|
|
233
|
+
fs.writeFileSync(path.join(tmpDir, 'dist', 'server', 'main.js'), 'compiled');
|
|
234
|
+
if (builtAgainstLockContent !== null) {
|
|
235
|
+
fs.writeFileSync(
|
|
236
|
+
path.join(tmpDir, 'dist', PROVENANCE),
|
|
237
|
+
JSON.stringify({ lockfileHash: lockHash(builtAgainstLockContent) })
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
it('preserves dist when provenance matches current lockfile', () => {
|
|
243
|
+
fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-v1');
|
|
244
|
+
makeDist('lock-v1');
|
|
245
|
+
|
|
246
|
+
const logs: LogEntry[] = [];
|
|
247
|
+
const r = preserveDevCache(tmpDir, {
|
|
248
|
+
log: makeLogger(logs),
|
|
249
|
+
forceClean: false,
|
|
250
|
+
markerPath,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(r.action).toBe('first_boot_preserved');
|
|
254
|
+
expect(fs.existsSync(path.join(tmpDir, 'dist', 'server', 'main.js'))).toBe(true);
|
|
255
|
+
expect(logs[0].msg).toContain('preserved');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('cleans dist when provenance records a different lockfile', () => {
|
|
259
|
+
fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-v2');
|
|
260
|
+
makeDist('lock-v1');
|
|
261
|
+
|
|
262
|
+
const logs: LogEntry[] = [];
|
|
263
|
+
const r = preserveDevCache(tmpDir, {
|
|
264
|
+
log: makeLogger(logs),
|
|
265
|
+
forceClean: false,
|
|
266
|
+
markerPath,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(r.action).toBe('first_boot_deps_changed_cleaned');
|
|
270
|
+
expect(fs.existsSync(path.join(tmpDir, 'dist', 'server', 'main.js'))).toBe(false);
|
|
271
|
+
expect(logs[0].msg).toContain('deps changed');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('cleans dist when provenance is missing (pre-existing dist, cannot verify)', () => {
|
|
275
|
+
fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-v1');
|
|
276
|
+
makeDist(null);
|
|
277
|
+
|
|
278
|
+
const logs: LogEntry[] = [];
|
|
279
|
+
const r = preserveDevCache(tmpDir, {
|
|
280
|
+
log: makeLogger(logs),
|
|
281
|
+
forceClean: false,
|
|
282
|
+
markerPath,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(r.action).toBe('first_boot_unverifiable_cleaned');
|
|
286
|
+
expect(fs.existsSync(path.join(tmpDir, 'dist', 'server', 'main.js'))).toBe(false);
|
|
287
|
+
expect(logs[0].msg).toContain('provenance missing');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('preserves dist when no lockfile present (nothing to verify against)', () => {
|
|
291
|
+
makeDist(null);
|
|
292
|
+
|
|
293
|
+
const r = preserveDevCache(tmpDir, {
|
|
294
|
+
log: makeLogger([]),
|
|
295
|
+
forceClean: false,
|
|
296
|
+
markerPath,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
expect(r.action).toBe('first_boot_preserved');
|
|
300
|
+
expect(fs.existsSync(path.join(tmpDir, 'dist', 'server', 'main.js'))).toBe(true);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// 回归:这是 mtime 方案在 BOE 上 100% 失效的场景(2026-08-25 实测 2/2 冷启动)。
|
|
304
|
+
// 沙箱冷启动从 TOS 备份恢复 workspace 时会把所有文件 mtime 重写成恢复时刻,
|
|
305
|
+
// lockfile 稳定落在 dist/ 之后 → 旧的 `lockfile mtime > buildinfo mtime` 恒成立 →
|
|
306
|
+
// 每次都清 dist。依赖内容没变时必须复用,无论 mtime 谁新谁旧。
|
|
307
|
+
it('preserves dist even when lockfile mtime is newer than everything in dist', () => {
|
|
308
|
+
makeDist('lock-v1');
|
|
309
|
+
// 显式把 lockfile 的 mtime 推到未来,模拟恢复后 lockfile 比 dist 内所有文件都"新"
|
|
310
|
+
fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-v1');
|
|
311
|
+
const future = new Date(Date.now() + 60_000);
|
|
312
|
+
fs.utimesSync(path.join(tmpDir, 'yarn.lock'), future, future);
|
|
313
|
+
|
|
314
|
+
const r = preserveDevCache(tmpDir, {
|
|
315
|
+
log: makeLogger([]),
|
|
316
|
+
forceClean: false,
|
|
317
|
+
markerPath,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(r.action).toBe('first_boot_preserved');
|
|
321
|
+
expect(fs.existsSync(path.join(tmpDir, 'dist', 'server', 'main.js'))).toBe(true);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('writes provenance after cleaning so the next cold boot can reuse', () => {
|
|
325
|
+
fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-v1');
|
|
326
|
+
makeDist(null);
|
|
327
|
+
|
|
328
|
+
// 第一次冷启动:无 provenance → 清一次(迁移成本)
|
|
329
|
+
const first = preserveDevCache(tmpDir, {
|
|
330
|
+
log: makeLogger([]),
|
|
331
|
+
forceClean: false,
|
|
332
|
+
markerPath,
|
|
333
|
+
});
|
|
334
|
+
expect(first.action).toBe('first_boot_unverifiable_cleaned');
|
|
335
|
+
|
|
336
|
+
// 模拟随后的编译产出,并模拟沙箱重启(marker 消失)
|
|
337
|
+
fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
|
|
338
|
+
fs.writeFileSync(path.join(tmpDir, 'dist', 'server', 'main.js'), 'compiled');
|
|
339
|
+
fs.unlinkSync(markerPath);
|
|
340
|
+
|
|
341
|
+
// 第二次冷启动:provenance 已在,依赖没变 → 复用
|
|
342
|
+
const second = preserveDevCache(tmpDir, {
|
|
343
|
+
log: makeLogger([]),
|
|
344
|
+
forceClean: false,
|
|
345
|
+
markerPath,
|
|
346
|
+
});
|
|
347
|
+
expect(second.action).toBe('first_boot_preserved');
|
|
348
|
+
expect(fs.existsSync(path.join(tmpDir, 'dist', 'server', 'main.js'))).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('treats a dist holding only provenance as no dist', () => {
|
|
352
|
+
fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-v1');
|
|
353
|
+
fs.mkdirSync(path.join(tmpDir, 'dist'), { recursive: true });
|
|
354
|
+
fs.writeFileSync(
|
|
355
|
+
path.join(tmpDir, 'dist', PROVENANCE),
|
|
356
|
+
JSON.stringify({ lockfileHash: lockHash('lock-v1') })
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const logs: LogEntry[] = [];
|
|
360
|
+
const r = preserveDevCache(tmpDir, {
|
|
361
|
+
log: makeLogger(logs),
|
|
362
|
+
forceClean: false,
|
|
363
|
+
markerPath,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// provenance 匹配但没有真实产物,不能谎报 preserved
|
|
367
|
+
expect(r.action).toBe('first_boot_no_dist');
|
|
368
|
+
expect(logs[0].msg).toContain('no dist/');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// 新建应用的标准生命周期:首次进 dev 时 marker 和 dist 都没有。
|
|
372
|
+
// 这一分支若不写 provenance,每个应用的**首次冷启动**都必然复用失败。
|
|
373
|
+
it('records provenance on a brand-new project so its first cold boot can reuse', () => {
|
|
374
|
+
fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-v1');
|
|
375
|
+
|
|
376
|
+
const first = preserveDevCache(tmpDir, {
|
|
377
|
+
log: makeLogger([]),
|
|
378
|
+
forceClean: false,
|
|
379
|
+
markerPath,
|
|
380
|
+
});
|
|
381
|
+
expect(first.action).toBe('first_boot_no_dist');
|
|
382
|
+
|
|
383
|
+
// 编译产出 + 沙箱重启
|
|
384
|
+
fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
|
|
385
|
+
fs.writeFileSync(path.join(tmpDir, 'dist', 'server', 'main.js'), 'compiled');
|
|
386
|
+
fs.unlinkSync(markerPath);
|
|
387
|
+
|
|
388
|
+
const second = preserveDevCache(tmpDir, {
|
|
389
|
+
log: makeLogger([]),
|
|
390
|
+
forceClean: false,
|
|
391
|
+
markerPath,
|
|
392
|
+
});
|
|
393
|
+
expect(second.action).toBe('first_boot_preserved');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// 多种 lockfile 共存时,只 hash 第一个会让另一个的依赖变更完全隐形 —— 原 mtime 方案
|
|
397
|
+
// 取的是所有 lockfile 的 max(mtime),换成 hash 不能反而把守护面变窄。
|
|
398
|
+
it('detects dependency changes in any lockfile when several coexist', () => {
|
|
399
|
+
fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'yarn-lock-stable');
|
|
400
|
+
fs.writeFileSync(path.join(tmpDir, 'package-lock.json'), '{"v":1}');
|
|
401
|
+
fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
|
|
402
|
+
fs.writeFileSync(path.join(tmpDir, 'dist', 'server', 'main.js'), 'compiled');
|
|
403
|
+
|
|
404
|
+
// 第一次:无 provenance → 清一次并写入当前(两个 lockfile 的)组合 hash
|
|
405
|
+
preserveDevCache(tmpDir, { log: makeLogger([]), forceClean: false, markerPath });
|
|
406
|
+
// 模拟随后的编译产出(上一步把 dist 清了)
|
|
407
|
+
fs.mkdirSync(path.join(tmpDir, 'dist', 'server'), { recursive: true });
|
|
408
|
+
fs.writeFileSync(path.join(tmpDir, 'dist', 'server', 'main.js'), 'compiled');
|
|
409
|
+
|
|
410
|
+
// 依赖没变 → 应当复用,确认基线正常
|
|
411
|
+
fs.unlinkSync(markerPath);
|
|
412
|
+
const baseline = preserveDevCache(tmpDir, {
|
|
413
|
+
log: makeLogger([]),
|
|
414
|
+
forceClean: false,
|
|
415
|
+
markerPath,
|
|
416
|
+
});
|
|
417
|
+
expect(baseline.action).toBe('first_boot_preserved');
|
|
418
|
+
|
|
419
|
+
// 只改 package-lock.json(yarn.lock 不动) → 必须被识别为依赖变更
|
|
420
|
+
fs.writeFileSync(path.join(tmpDir, 'package-lock.json'), '{"v":2}');
|
|
421
|
+
fs.unlinkSync(markerPath);
|
|
422
|
+
const afterNpmInstall = preserveDevCache(tmpDir, {
|
|
423
|
+
log: makeLogger([]),
|
|
424
|
+
forceClean: false,
|
|
425
|
+
markerPath,
|
|
426
|
+
});
|
|
427
|
+
expect(afterNpmInstall.action).toBe('first_boot_deps_changed_cleaned');
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('stores the current lockfile hash into provenance when cleaning', () => {
|
|
431
|
+
fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), 'lock-v2');
|
|
432
|
+
makeDist('lock-v1');
|
|
433
|
+
|
|
434
|
+
preserveDevCache(tmpDir, { log: makeLogger([]), forceClean: false, markerPath });
|
|
435
|
+
|
|
436
|
+
// 断言内容而非仅存在: 写成 prevLockfileHash 之类也能让"文件存在"通过
|
|
437
|
+
const written = JSON.parse(
|
|
438
|
+
fs.readFileSync(path.join(tmpDir, 'dist', PROVENANCE), 'utf-8')
|
|
439
|
+
);
|
|
440
|
+
expect(written.lockfileHash).toBe(lockHash('lock-v2'));
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
describe('marker path (sandbox lifecycle alignment)', () => {
|
|
445
|
+
it('defaults markerPath to os.tmpdir()/.miaoda-dev-boot when not passed', () => {
|
|
446
|
+
// 不传 markerPath, 走默认路径
|
|
447
|
+
const defaultMarker = path.join(os.tmpdir(), '.miaoda-dev-boot');
|
|
448
|
+
// 清理默认位置的残留 marker (可能来自其他测试),避免干扰本用例
|
|
449
|
+
try { fs.unlinkSync(defaultMarker); } catch { /* noop */ }
|
|
450
|
+
|
|
451
|
+
const r = preserveDevCache(tmpDir, {
|
|
452
|
+
log: makeLogger([]),
|
|
453
|
+
forceClean: false,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
expect(r.action).toBe('first_boot_no_dist');
|
|
457
|
+
expect(fs.existsSync(defaultMarker)).toBe(true);
|
|
458
|
+
|
|
459
|
+
// cleanup
|
|
460
|
+
try { fs.unlinkSync(defaultMarker); } catch { /* noop */ }
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// dev.js 启动时的 dist 缓存策略:
|
|
4
|
+
// - 沙箱首次启动 (marker 不存在) + dist 是针对当前依赖编译的: 保留 dist/, 让 nest --watch 复用上次编译产物
|
|
5
|
+
// - 沙箱首次启动 + dist 对不上当前依赖 (或无从判断): 清 dist/, 避免 stale binding
|
|
6
|
+
// - 后续启动 (agent 主动触发 npm run dev): 全清 dist/, 让模型面对新鲜产物避免 stale
|
|
7
|
+
// - FORCE_CLEAN_DIST=true: escape hatch, 强清且不留 provenance
|
|
8
|
+
//
|
|
9
|
+
// 两个状态文件, 各回答一个问题:
|
|
10
|
+
//
|
|
11
|
+
// 1. marker (os.tmpdir()/.miaoda-dev-boot) —— "这是不是沙箱首次启动"
|
|
12
|
+
// 放 /tmp 是因为它跟容器 lifecycle 同生命周期: 沙箱重启 /tmp 被清, marker 消失,
|
|
13
|
+
// 下次进 dev 即视为首次启动。应用工作目录是 persistent volume, 跨重启保留,
|
|
14
|
+
// 放那里的 marker 反映不了"沙箱首次启动"。
|
|
15
|
+
//
|
|
16
|
+
// 2. provenance (dist/.miaoda-dist-lockfile) —— "当前 dist 是针对哪份 lockfile 编译的"
|
|
17
|
+
// 内容是所有 lockfile 的内容 hash。放 dist/ 内部, 跟 dist 一起备份/恢复/被清,
|
|
18
|
+
// 不会出现"dist 没了 hash 还在"的悬挂状态; 模板 .gitignore 已忽略 dist/。
|
|
19
|
+
//
|
|
20
|
+
// 首次启动分支判 stale 必须用内容 hash, **不能用 mtime**: 沙箱冷启动是
|
|
21
|
+
// "销毁 → 从备份恢复 workspace", 恢复会把所有文件 mtime 重写成恢复时刻, 彼此只差
|
|
22
|
+
// 几毫秒且先后由解包顺序决定 —— lockfile 与 buildinfo 的新旧关系在恢复后已无意义,
|
|
23
|
+
// 用 mtime 判会稳定误判为 stale, 使 dist 复用在冷启动这个唯一目标场景里失效。
|
|
24
|
+
// 内容 hash 不受恢复影响, 也顺带避免 `git checkout` touch lockfile 但内容没变的误判。
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const os = require('os');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const crypto = require('crypto');
|
|
30
|
+
|
|
31
|
+
const MARKER_FILENAME = '.miaoda-dev-boot';
|
|
32
|
+
// 记录"当前 dist 是针对哪份 lockfile 编译的"
|
|
33
|
+
const DIST_PROVENANCE_FILENAME = '.miaoda-dist-lockfile';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} projectRoot 项目根目录
|
|
37
|
+
* @param {object} [options]
|
|
38
|
+
* @param {(level: string, msg: string) => void} [options.log]
|
|
39
|
+
* @param {boolean} [options.forceClean]
|
|
40
|
+
* @param {string} [options.markerPath] 覆盖 marker 文件路径 (单测用; 生产默认 /tmp/.miaoda-dev-boot)
|
|
41
|
+
* @returns {{ action: 'force_cleaned' | 'first_boot_preserved' | 'first_boot_deps_changed_cleaned' | 'first_boot_unverifiable_cleaned' | 'first_boot_no_dist' | 'subsequent_boot_cleaned' | 'deps_changed_cleaned' | 'subsequent_boot_no_dist' }}
|
|
42
|
+
*/
|
|
43
|
+
function preserveDevCache(projectRoot, options) {
|
|
44
|
+
const log = (options && options.log) || defaultLogger;
|
|
45
|
+
const forceClean =
|
|
46
|
+
options && typeof options.forceClean === 'boolean'
|
|
47
|
+
? options.forceClean
|
|
48
|
+
: process.env.FORCE_CLEAN_DIST === 'true';
|
|
49
|
+
const markerPath =
|
|
50
|
+
(options && options.markerPath) ||
|
|
51
|
+
path.join(os.tmpdir(), MARKER_FILENAME);
|
|
52
|
+
|
|
53
|
+
const distPath = path.join(projectRoot, 'dist');
|
|
54
|
+
const currentLockfileHash = computeLockfileHash(projectRoot);
|
|
55
|
+
|
|
56
|
+
if (forceClean) {
|
|
57
|
+
// escape hatch 的语义是"彻底擦干净", 所以连 provenance 也不留 —— 下次冷启动
|
|
58
|
+
// 因此会多清一次 dist, 这正是显式要求强清的人期望的保守方向。
|
|
59
|
+
cleanDist(distPath);
|
|
60
|
+
writeMarker(markerPath, currentLockfileHash);
|
|
61
|
+
log('INFO', 'preserveDevCache: FORCE_CLEAN_DIST=true, cleaned dist/');
|
|
62
|
+
return { action: 'force_cleaned' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const markerExists = fs.existsSync(markerPath);
|
|
66
|
+
const distExists = distHasBuildOutput(distPath);
|
|
67
|
+
|
|
68
|
+
if (!markerExists) {
|
|
69
|
+
writeMarker(markerPath, currentLockfileHash);
|
|
70
|
+
if (distExists) {
|
|
71
|
+
// 无 lockfile 时无从判断 dist 针对哪份依赖, 保守保留(与"依赖没变"同解),
|
|
72
|
+
// 免得没有依赖管理的项目每次冷启动都白白全量重编。
|
|
73
|
+
if (currentLockfileHash === null) {
|
|
74
|
+
log('INFO', 'preserveDevCache: first boot, no lockfile to verify against, dist/ preserved');
|
|
75
|
+
return { action: 'first_boot_preserved' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const distLockfileHash = readDistProvenance(distPath);
|
|
79
|
+
// 日志带上两个 hash: 判错时能一眼区分"依赖真变了"和"读不到 provenance",
|
|
80
|
+
// 否则这类误判在现场完全不可观测。
|
|
81
|
+
const hashes = `dist=${short(distLockfileHash)} cur=${short(currentLockfileHash)}`;
|
|
82
|
+
|
|
83
|
+
if (distLockfileHash === currentLockfileHash) {
|
|
84
|
+
log('INFO', `preserveDevCache: first boot, dist/ matches current lockfile, preserved (${hashes})`);
|
|
85
|
+
return { action: 'first_boot_preserved' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
cleanDist(distPath);
|
|
89
|
+
writeDistProvenance(distPath, currentLockfileHash);
|
|
90
|
+
|
|
91
|
+
if (distLockfileHash === null) {
|
|
92
|
+
// 没有 provenance: dist 来自不带 provenance 机制的旧产物, 或刚被 npm run build
|
|
93
|
+
// 换成发布产物(build.sh 会 rm -rf dist)。都无从确认它针对哪份依赖 → 保守清一次;
|
|
94
|
+
// 清完写入 provenance, 下一次冷启动即可复用。
|
|
95
|
+
log(
|
|
96
|
+
'INFO',
|
|
97
|
+
`preserveDevCache: first boot but dist provenance missing (cannot verify deps), cleaned dist/ (${hashes})`
|
|
98
|
+
);
|
|
99
|
+
return { action: 'first_boot_unverifiable_cleaned' };
|
|
100
|
+
}
|
|
101
|
+
log(
|
|
102
|
+
'INFO',
|
|
103
|
+
`preserveDevCache: first boot but deps changed (dist built against a different lockfile), cleaned dist/ (${hashes})`
|
|
104
|
+
);
|
|
105
|
+
return { action: 'first_boot_deps_changed_cleaned' };
|
|
106
|
+
}
|
|
107
|
+
// 同样要写 provenance: 接下来编译出的 dist 就是针对当前 lockfile 的。
|
|
108
|
+
// 漏掉这里会让每个应用的**首次冷启动**必然复用失败 —— 新建应用第一次进 dev 走的正是
|
|
109
|
+
// 本分支(marker 无、dist 无), 若不记 provenance, 下次冷启动读不到就得保守全清。
|
|
110
|
+
writeDistProvenance(distPath, currentLockfileHash);
|
|
111
|
+
log('INFO', 'preserveDevCache: first boot, no dist/ (will be created)');
|
|
112
|
+
return { action: 'first_boot_no_dist' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 后续启动: 无脑清 dist
|
|
116
|
+
const prevLockfileHash = readMarkerHash(markerPath);
|
|
117
|
+
const depsChanged =
|
|
118
|
+
prevLockfileHash !== null &&
|
|
119
|
+
currentLockfileHash !== null &&
|
|
120
|
+
prevLockfileHash !== currentLockfileHash;
|
|
121
|
+
|
|
122
|
+
cleanDist(distPath);
|
|
123
|
+
// 清完就把 provenance 写回: 接下来的编译是针对当前 lockfile 的, 下次冷启动
|
|
124
|
+
// 才有依据判断能不能复用。不写的话首次启动分支永远读不到 provenance, 退化成每次全清。
|
|
125
|
+
writeDistProvenance(distPath, currentLockfileHash);
|
|
126
|
+
writeMarker(markerPath, currentLockfileHash);
|
|
127
|
+
|
|
128
|
+
if (depsChanged) {
|
|
129
|
+
log('INFO', 'preserveDevCache: deps changed (lockfile hash), cleaned dist/');
|
|
130
|
+
return { action: 'deps_changed_cleaned' };
|
|
131
|
+
}
|
|
132
|
+
if (distExists) {
|
|
133
|
+
log('INFO', 'preserveDevCache: subsequent boot, cleaned dist/');
|
|
134
|
+
return { action: 'subsequent_boot_cleaned' };
|
|
135
|
+
}
|
|
136
|
+
log('INFO', 'preserveDevCache: subsequent boot, no dist/ to clean');
|
|
137
|
+
return { action: 'subsequent_boot_no_dist' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* dist/ 里是否有真实编译产物。
|
|
142
|
+
*
|
|
143
|
+
* 判据用 server 入口而不是"目录非空": 清完 dist 会留下 provenance, 发布流程还会留下
|
|
144
|
+
* dist/node_modules、dist/dist 之类,靠"非空"会把这些空壳当成有产物 → 日志谎报 preserved。
|
|
145
|
+
* server/main.js 是 run.sh 实际启动的入口, 它在才算真编译过 —— 顺带让"编译中途失败"
|
|
146
|
+
* 留下的半成品被识别为无产物, 而不是被当成可复用。
|
|
147
|
+
*
|
|
148
|
+
* 出错方向偏向"有产物": 只有 ENOENT 才算真没有。EACCES / ENOTDIR 等情况下返回 false
|
|
149
|
+
* 会直接跳过清理分支, 让一个该清的 stale dist 被静默留下 —— 宁可多清一次。
|
|
150
|
+
*/
|
|
151
|
+
function distHasBuildOutput(distPath) {
|
|
152
|
+
try {
|
|
153
|
+
fs.statSync(path.join(distPath, 'server', 'main.js'));
|
|
154
|
+
return true;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
return !(err && err.code === 'ENOENT');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function cleanDist(distPath) {
|
|
161
|
+
if (fs.existsSync(distPath)) {
|
|
162
|
+
fs.rmSync(distPath, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readDistProvenance(distPath) {
|
|
167
|
+
try {
|
|
168
|
+
const raw = fs.readFileSync(
|
|
169
|
+
path.join(distPath, DIST_PROVENANCE_FILENAME),
|
|
170
|
+
'utf-8'
|
|
171
|
+
);
|
|
172
|
+
const data = JSON.parse(raw);
|
|
173
|
+
return typeof data.lockfileHash === 'string' ? data.lockfileHash : null;
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 记下"接下来编译出的 dist 是针对这份 lockfile 的"。
|
|
181
|
+
*
|
|
182
|
+
* 在 preserveDevCache 决策时写, 而不是等编译完 —— 此处没有编译完成的 hook。
|
|
183
|
+
* 因此 provenance 表达的是"接下来要针对哪份 lockfile 编译", 而非"已经编译完了"。
|
|
184
|
+
*
|
|
185
|
+
* 编译中途失败/被打断时它仍声称匹配, 但不会放大成 stale binding, 有三层理由:
|
|
186
|
+
* 1. 清理发生在写 provenance 之前, 所以残留一定是"新依赖的半成品", 不可能是"旧依赖的产物" ——
|
|
187
|
+
* 本模块要防的类别没被打开;
|
|
188
|
+
* 2. server/main.js 缺失时 distHasBuildOutput 判为无产物, 半成品走 no_dist 分支而不是被复用;
|
|
189
|
+
* 3. tsbuildinfo 就在 dist 内, 与产物同清同留, 不会出现"buildinfo 说完整、产物却缺"的错配。
|
|
190
|
+
*/
|
|
191
|
+
function writeDistProvenance(distPath, lockfileHash) {
|
|
192
|
+
try {
|
|
193
|
+
fs.mkdirSync(distPath, { recursive: true });
|
|
194
|
+
fs.writeFileSync(
|
|
195
|
+
path.join(distPath, DIST_PROVENANCE_FILENAME),
|
|
196
|
+
JSON.stringify({ lockfileHash: lockfileHash ?? null }),
|
|
197
|
+
'utf-8'
|
|
198
|
+
);
|
|
199
|
+
} catch {
|
|
200
|
+
// provenance 写失败只会让下次冷启动多清一次 dist, 不阻塞 dev 启动
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 把项目里**所有**存在的 lockfile 一起 hash。
|
|
206
|
+
*
|
|
207
|
+
* 不能只取第一个命中的: 多种 lockfile 共存时(如历史残留 yarn.lock + 沙箱实际在用的
|
|
208
|
+
* package-lock.json), 只 hash 前者会让 `npm install pkg@2` 完全不改变 hash →
|
|
209
|
+
* 冷启动判"依赖没变"→ 保留旧依赖编译的 dist, 正是本模块要防的 stale binding。
|
|
210
|
+
* 原 mtime 方案取的是所有 lockfile 的 max(mtime), 对这种组合是免疫的, 换 hash 不能反而变窄。
|
|
211
|
+
* 文件名也一起 hash, 这样"删掉一个 lockfile"也算依赖变动。
|
|
212
|
+
*/
|
|
213
|
+
function computeLockfileHash(projectRoot) {
|
|
214
|
+
const candidates = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'];
|
|
215
|
+
const hash = crypto.createHash('sha1');
|
|
216
|
+
let found = false;
|
|
217
|
+
for (const name of candidates) {
|
|
218
|
+
try {
|
|
219
|
+
const content = fs.readFileSync(path.join(projectRoot, name));
|
|
220
|
+
hash.update(name);
|
|
221
|
+
hash.update(content);
|
|
222
|
+
found = true;
|
|
223
|
+
} catch {
|
|
224
|
+
// 该 lockfile 不存在 / 读不了, 跳过
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return found ? hash.digest('hex') : null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readMarkerHash(markerPath) {
|
|
231
|
+
try {
|
|
232
|
+
const raw = fs.readFileSync(markerPath, 'utf-8');
|
|
233
|
+
const data = JSON.parse(raw);
|
|
234
|
+
return typeof data.lockfileHash === 'string' ? data.lockfileHash : null;
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function writeMarker(markerPath, lockfileHash) {
|
|
241
|
+
try {
|
|
242
|
+
fs.writeFileSync(
|
|
243
|
+
markerPath,
|
|
244
|
+
JSON.stringify({ lockfileHash: lockfileHash ?? null }),
|
|
245
|
+
'utf-8'
|
|
246
|
+
);
|
|
247
|
+
} catch {
|
|
248
|
+
// marker 写失败下次会重试, 不阻塞 dev 启动
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** hash 只取前 8 位进日志, 够区分且不刷屏; null 显式打成 none 而不是空 */
|
|
253
|
+
function short(hash) {
|
|
254
|
+
return typeof hash === 'string' ? hash.slice(0, 8) : 'none';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function defaultLogger(level, msg) {
|
|
258
|
+
// eslint-disable-next-line no-console
|
|
259
|
+
console.log(`[${level}] [main] ${msg}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = { preserveDevCache };
|
|
@@ -1,61 +0,0 @@
|
|
|
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
|