@lark-apaas/fullstack-cli 1.1.61 → 1.1.62-alpha.20260823195003
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 -1
- package/package.json +1 -1
- package/templates/.gitignore.append +0 -1
- package/templates/scripts/build.sh +12 -0
- package/templates/scripts/dev.js +11 -9
- package/templates/scripts/dev.sh +5 -9
- package/templates/scripts/lib/__test__/preserve-dev-cache.test.ts +308 -0
- package/templates/scripts/lib/preserve-dev-cache.cjs +178 -0
- package/templates/scripts/lint.js +2 -40
package/dist/index.js
CHANGED
|
@@ -7698,7 +7698,7 @@ function sanitizeStructuredLog(value) {
|
|
|
7698
7698
|
delete sanitized.pid;
|
|
7699
7699
|
return sanitized;
|
|
7700
7700
|
}
|
|
7701
|
-
var TRANSIENT_CONNECTION_ERROR_PATTERN = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|
|
|
7701
|
+
var TRANSIENT_CONNECTION_ERROR_PATTERN = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|proxy error|\[Proxy\] (?:Error:\s*$|Error during|Connection error|Non-connection error|Headers already sent|Service (?:recovered|did not recover))/i;
|
|
7702
7702
|
function hasErrorInStdLines(lines) {
|
|
7703
7703
|
const filtered = lines.filter((line) => !TRANSIENT_CONNECTION_ERROR_PATTERN.test(line));
|
|
7704
7704
|
const combined = filtered.join("\n");
|
package/package.json
CHANGED
|
@@ -149,6 +149,18 @@ 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/"
|
|
162
|
+
fi
|
|
163
|
+
|
|
152
164
|
# server 相关产物准备(only_frontend_change=true 时跳过)
|
|
153
165
|
if [[ "${only_frontend_change:-false}" == "true" ]]; then
|
|
154
166
|
echo " [skip] 跳过 run.sh/.env 复制 (only_frontend_change=true)"
|
package/templates/scripts/dev.js
CHANGED
|
@@ -252,20 +252,22 @@ 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/, 让 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');
|
|
263
263
|
|
|
264
264
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
265
265
|
async function main() {
|
|
266
266
|
logEvent('INFO', 'main', '========== Dev session started ==========');
|
|
267
267
|
|
|
268
|
-
|
|
268
|
+
preserveDevCache(PROJECT_ROOT, {
|
|
269
|
+
log: (level, msg) => logEvent(level, 'main', msg),
|
|
270
|
+
});
|
|
269
271
|
|
|
270
272
|
// Initialize action plugins
|
|
271
273
|
writeOutput('\n🔌 Initializing action plugins...\n');
|
package/templates/scripts/dev.sh
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# `npm run dev` 入口;按
|
|
3
|
-
#
|
|
4
|
-
# - 非空(沙箱平台注入的依赖缓存目录)→ 直接跑 dev.js
|
|
2
|
+
# `npm run dev` 入口;按 SANDBOX_ID 是否非空判断运行环境:
|
|
3
|
+
# - SANDBOX_ID 非空(沙箱平台注入应用所属沙箱 ID)→ 直接跑 dev.js
|
|
5
4
|
# (保活 / restart loop / 文件日志 —— 沙箱生产形态)。脚本同步由平台 pod 启动阶段做过,
|
|
6
5
|
# dev 入口不再额外 `npm run upgrade`。
|
|
7
6
|
# - 否则(本地)→ 走 miaoda app sync 兜底 + 跑 dev-local.js:纯 stdout、崩了就崩、Agent 友好。
|
|
8
|
-
# 显式想跑本地路径可用 `npm run dev:local
|
|
9
|
-
#
|
|
10
|
-
# 不用 SANDBOX_ID:它由 sandbox_console 在绑定沙箱时才写入 .force/environment/env,预热池实例
|
|
11
|
-
# 在绑定前拿不到,会被误判成本地。保留为附加条件只为兼容,不影响判定结果。
|
|
7
|
+
# 显式想跑本地路径可用 `npm run dev:local`(绕过 SANDBOX_ID 判断)。
|
|
12
8
|
set -euo pipefail
|
|
13
9
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
14
10
|
|
|
15
|
-
if [ -n "${
|
|
11
|
+
if [ -n "${SANDBOX_ID:-}" ]; then
|
|
16
12
|
exec node "$SCRIPT_DIR/dev.js" "$@"
|
|
17
13
|
fi
|
|
18
14
|
|
|
@@ -22,7 +18,7 @@ if [ ! -f "$SCRIPT_DIR/dev-local.js" ]; then
|
|
|
22
18
|
fi
|
|
23
19
|
|
|
24
20
|
# 本地启动前先跑一次 miaoda app sync:同步 platform-controlled 内容 + 升 @lark-apaas/* 到
|
|
25
|
-
# latest + 迁移老 npm scripts
|
|
21
|
+
# latest + 迁移老 npm scripts。沙箱不走这里(SANDBOX_ID 分支已经 exec return)。
|
|
26
22
|
npx -y @lark-apaas/miaoda-cli@latest app sync || echo "[dev] miaoda app sync 失败,按现状继续" >&2
|
|
27
23
|
|
|
28
24
|
exec node "$SCRIPT_DIR/dev-local.js" "$@"
|
|
@@ -0,0 +1,308 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
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 };
|
|
@@ -76,49 +76,13 @@ function isStylelintTarget(filePath) {
|
|
|
76
76
|
return filePath.endsWith('.css');
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
const STYLELINT_CONFIG_FILES = [
|
|
80
|
-
'.stylelintrc',
|
|
81
|
-
'.stylelintrc.js',
|
|
82
|
-
'.stylelintrc.cjs',
|
|
83
|
-
'.stylelintrc.json',
|
|
84
|
-
'stylelint.config.js',
|
|
85
|
-
];
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* 老模板(fullstack-nestjs-template 1.x 时代)的应用跑不了 stylelint,跑了就挂、把 pre-commit
|
|
89
|
-
* 卡死。两种形态都实测过:
|
|
90
|
-
*
|
|
91
|
-
* - 缺 scripts.stylelint → `npm error Missing script: "stylelint"`
|
|
92
|
-
* - 缺 .stylelintrc.* → `ConfigurationError: No configuration provided`
|
|
93
|
-
*
|
|
94
|
-
* 注意光判断 stylelint 装没装拦不住:@lark-apaas/fullstack-presets 的 dependencies 里有
|
|
95
|
-
* stylelint,只要 dep 了 presets 二进制就在。缺任一件就跳过这一步。
|
|
96
|
-
*/
|
|
97
|
-
function canRunStylelint() {
|
|
98
|
-
let pkg;
|
|
99
|
-
try {
|
|
100
|
-
pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
101
|
-
} catch {
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (!pkg.scripts || !pkg.scripts.stylelint) return false;
|
|
106
|
-
|
|
107
|
-
return STYLELINT_CONFIG_FILES.some(file => fs.existsSync(path.join(cwd, file)));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
79
|
async function runDefaultLint() {
|
|
111
80
|
const taskSpecs = [
|
|
112
81
|
[getBinName('npm'), ['run', 'eslint']],
|
|
113
82
|
[getBinName('npm'), ['run', 'type:check']],
|
|
83
|
+
[getBinName('npm'), ['run', 'stylelint']],
|
|
114
84
|
];
|
|
115
85
|
|
|
116
|
-
if (canRunStylelint()) {
|
|
117
|
-
taskSpecs.push([getBinName('npm'), ['run', 'stylelint']]);
|
|
118
|
-
} else {
|
|
119
|
-
console.warn('[lint] Skip stylelint: missing scripts.stylelint or stylelint config');
|
|
120
|
-
}
|
|
121
|
-
|
|
122
86
|
process.exit(await runTasksSerially(taskSpecs));
|
|
123
87
|
}
|
|
124
88
|
|
|
@@ -156,9 +120,7 @@ async function runSelectiveLint(inputFiles) {
|
|
|
156
120
|
taskSpecs.push([getBinName('npx'), ['eslint', '--quiet', ...eslintFiles]]);
|
|
157
121
|
}
|
|
158
122
|
|
|
159
|
-
if (stylelintFiles.length > 0
|
|
160
|
-
console.warn('[lint] Skip stylelint: missing scripts.stylelint or stylelint config');
|
|
161
|
-
} else if (stylelintFiles.length > 0) {
|
|
123
|
+
if (stylelintFiles.length > 0) {
|
|
162
124
|
taskSpecs.push([getBinName('npx'), ['stylelint', '--quiet', ...stylelintFiles]]);
|
|
163
125
|
}
|
|
164
126
|
|