@coze-arch/cli 0.1.8-alpha.a5ed27 → 0.1.8-alpha.a87968

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.
@@ -98,6 +98,7 @@ printf '%s\n' "$$" > "$LOCK_DIR/pid"
98
98
  trap release_lock EXIT
99
99
 
100
100
  mkdir -p "$WORK_DIR"
101
+ INSTALL_STATE_FILE="$WORK_DIR/.install-state"
101
102
 
102
103
  # Previous versions mirrored the whole project into WORK_DIR. That gives pnpm
103
104
  # and framework dev servers two paths for the same source tree, which breaks
@@ -118,12 +119,15 @@ cleanup_legacy_workspace() {
118
119
  done < <(find "$workspace_dir" -mindepth 1 -maxdepth 1 -print0)
119
120
  }
120
121
 
122
+ # caches is reserved for the sibling prepare scripts of this project. They keep
123
+ # regenerable framework output there (the Next helper points .next/dev into it), and that
124
+ # output has to outlive an install, so this cleanup must not read it as leftover.
121
125
  cleanup_legacy_shadow_project() {
122
126
  local entry name
123
127
  while IFS= read -r -d '' entry; do
124
128
  name="$(basename "$entry")"
125
129
  case "$name" in
126
- node_modules|client|server) continue ;;
130
+ node_modules|client|server|caches|.install-state) continue ;;
127
131
  esac
128
132
  rm -rf "$entry"
129
133
  done < <(find "$WORK_DIR" -mindepth 1 -maxdepth 1 -print0)
@@ -141,6 +145,162 @@ link_install_entry() {
141
145
  fi
142
146
  }
143
147
 
148
+ hash_install_input() {
149
+ if command -v sha256sum >/dev/null 2>&1; then
150
+ sha256sum | awk '{print $1}'
151
+ elif command -v shasum >/dev/null 2>&1; then
152
+ shasum -a 256 | awk '{print $1}'
153
+ else
154
+ cksum | awk '{print $1 "-" $2}'
155
+ fi
156
+ }
157
+
158
+ append_file_to_install_fingerprint() {
159
+ local relative_path="$1"
160
+ local source_path="$PROJECT_ROOT/$relative_path"
161
+ printf 'path=%s\n' "$relative_path"
162
+ if [ -f "$source_path" ]; then
163
+ cat "$source_path"
164
+ else
165
+ printf '<missing>\n'
166
+ fi
167
+ }
168
+
169
+ append_directory_to_install_fingerprint() {
170
+ local relative_path="$1"
171
+ local source_path="$PROJECT_ROOT/$relative_path"
172
+ local file
173
+ printf 'path=%s\n' "$relative_path"
174
+ if [ ! -d "$source_path" ]; then
175
+ printf '<missing>\n'
176
+ return
177
+ fi
178
+
179
+ while IFS= read -r file; do
180
+ printf 'file=%s\n' "${file#"${PROJECT_ROOT}/"}"
181
+ cat "$file"
182
+ done < <(find -P "$source_path" -type f -print | LC_ALL=C sort)
183
+ }
184
+
185
+ # Everything that can change the resolved dependency tree. The leading version marker
186
+ # invalidates every stored state when this scheme itself changes.
187
+ calculate_install_fingerprint() {
188
+ local filename workspace argument
189
+ {
190
+ printf 'prepare-node-modules-install-state=1\n'
191
+ printf 'node-version=%s\n' "$(node --version)"
192
+ printf 'pnpm-version=%s\n' "$(pnpm --version)"
193
+ printf 'node-env=%s\n' "${NODE_ENV:-}"
194
+ printf 'npm-config-production=%s\n' "${npm_config_production:-}"
195
+ printf 'npm-config-production-upper=%s\n' "${NPM_CONFIG_PRODUCTION:-}"
196
+ printf 'pnpm-config-production=%s\n' "${PNPM_CONFIG_PRODUCTION:-}"
197
+ for argument in "$@"; do
198
+ printf 'install-argument=%s\n' "$argument"
199
+ done
200
+ for filename in package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc; do
201
+ append_file_to_install_fingerprint "$filename"
202
+ done
203
+ for workspace in client server; do
204
+ append_file_to_install_fingerprint "$workspace/package.json"
205
+ done
206
+ append_directory_to_install_fingerprint patches
207
+ } | hash_install_input
208
+ }
209
+
210
+ install_requires_refresh() {
211
+ local argument
212
+ for argument in "$@"; do
213
+ case "$argument" in
214
+ --force|-f|--lockfile-only|--fix-lockfile) return 0 ;;
215
+ esac
216
+ done
217
+ return 1
218
+ }
219
+
220
+ # Reuse skips pnpm install entirely, so it must not skip side effects that live outside
221
+ # node_modules. preinstall is exempt: it only guards which package manager may run, and
222
+ # there is no install to guard when the tree is already in place.
223
+ package_has_install_lifecycle() {
224
+ local package_path="$1"
225
+ node - "$package_path" <<'NODE'
226
+ const fs = require('node:fs');
227
+ const [packagePath] = process.argv.slice(2);
228
+
229
+ try {
230
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
231
+ const scripts = packageJson && typeof packageJson.scripts === 'object' && packageJson.scripts
232
+ ? packageJson.scripts
233
+ : {};
234
+ const hasLifecycle = ['install', 'postinstall', 'prepare'].some(
235
+ name => typeof scripts[name] === 'string' && scripts[name].trim() !== '',
236
+ );
237
+
238
+ process.exit(hasLifecycle ? 0 : 1);
239
+ } catch {
240
+ process.exit(0);
241
+ }
242
+ NODE
243
+ }
244
+
245
+ install_reuse_is_safe() {
246
+ local workspace package_path
247
+ for package_path in "$PROJECT_ROOT/.pnpmfile.cjs" "$PROJECT_ROOT/.pnpmfile.js"; do
248
+ if [ -e "$package_path" ]; then
249
+ echo "[node_modules] Reinstalling because pnpm hooks are present: $package_path"
250
+ return 1
251
+ fi
252
+ done
253
+
254
+ if [ -f "$PROJECT_ROOT/.npmrc" ] && grep -Eq '^[[:space:]]*pnpmfile[[:space:]]*=' "$PROJECT_ROOT/.npmrc"; then
255
+ echo "[node_modules] Reinstalling because .npmrc configures a pnpm hook."
256
+ return 1
257
+ fi
258
+
259
+ for workspace in . client server; do
260
+ package_path="$PROJECT_ROOT/$workspace/package.json"
261
+ [ -f "$package_path" ] || continue
262
+ if package_has_install_lifecycle "$package_path"; then
263
+ echo "[node_modules] Reinstalling because $package_path has an install lifecycle script."
264
+ return 1
265
+ fi
266
+ done
267
+ return 0
268
+ }
269
+
270
+ dependencies_are_ready() {
271
+ local workspace
272
+ [ -f "$WORK_DIR/node_modules/.modules.yaml" ] || return 1
273
+ for workspace in client server; do
274
+ if [ -f "$PROJECT_ROOT/$workspace/package.json" ] &&
275
+ [ ! -f "$WORK_DIR/$workspace/node_modules/.modules.yaml" ]; then
276
+ return 1
277
+ fi
278
+ done
279
+ return 0
280
+ }
281
+
282
+ # 判断顺序按代价从小到大排。指纹要读云盘上的 lockfile 和整个 patches/,所以放最后:只有其余
283
+ # 条件都满足、确实可能复用时才付这份开销。build.sh 和 prepare.sh 不设开关,第一行就返回了。
284
+ install_can_be_reused() {
285
+ local installed_fingerprint=""
286
+
287
+ [ "${COZE_REUSE_NODE_MODULES:-}" = "1" ] || return 1
288
+ install_requires_refresh "$@" && return 1
289
+ [ -f "$INSTALL_STATE_FILE" ] || return 1
290
+ IFS= read -r installed_fingerprint < "$INSTALL_STATE_FILE" || return 1
291
+ dependencies_are_ready || return 1
292
+ install_reuse_is_safe || return 1
293
+ [ "$installed_fingerprint" = "$(calculate_install_fingerprint "$@")" ] || return 1
294
+ return 0
295
+ }
296
+
297
+ write_install_state() {
298
+ local fingerprint="$1" temporary_state
299
+ temporary_state="$(mktemp "$WORK_DIR/.install-state.XXXXXX")"
300
+ printf '%s\n' "$fingerprint" > "$temporary_state"
301
+ mv -f "$temporary_state" "$INSTALL_STATE_FILE"
302
+ }
303
+
144
304
  copy_install_metadata() {
145
305
  local filename source_file target_file workspace
146
306
  for filename in pnpm-lock.yaml pnpm-workspace.yaml .npmrc; do
@@ -190,16 +350,26 @@ link_node_modules() {
190
350
  cleanup_legacy_shadow_project
191
351
  copy_install_metadata
192
352
 
193
- echo "[node_modules] Installing dependencies in $WORK_DIR"
194
- if ! (cd "$WORK_DIR" && pnpm install "$@"); then
195
- exit 1
196
- fi
353
+ if install_can_be_reused "$@"; then
354
+ echo "[node_modules] Reusing unchanged dependencies in $WORK_DIR"
355
+ else
356
+ # A stale state file must not survive a failed install.
357
+ rm -f "$INSTALL_STATE_FILE"
358
+ echo "[node_modules] Installing dependencies in $WORK_DIR"
359
+ if ! (cd "$WORK_DIR" && pnpm install "$@"); then
360
+ exit 1
361
+ fi
362
+
363
+ if [ -f "$WORK_DIR/pnpm-lock.yaml" ]; then
364
+ temporary_lockfile="$(mktemp "$PROJECT_ROOT/.pnpm-lock.yaml.XXXXXX")"
365
+ cp "$WORK_DIR/pnpm-lock.yaml" "$temporary_lockfile"
366
+ chmod 0644 "$temporary_lockfile"
367
+ mv -f "$temporary_lockfile" "$PROJECT_ROOT/pnpm-lock.yaml"
368
+ fi
197
369
 
198
- if [ -f "$WORK_DIR/pnpm-lock.yaml" ]; then
199
- temporary_lockfile="$(mktemp "$PROJECT_ROOT/.pnpm-lock.yaml.XXXXXX")"
200
- cp "$WORK_DIR/pnpm-lock.yaml" "$temporary_lockfile"
201
- chmod 0644 "$temporary_lockfile"
202
- mv -f "$temporary_lockfile" "$PROJECT_ROOT/pnpm-lock.yaml"
370
+ # pnpm may have rewritten the lockfile, so record the fingerprint of the tree as it
371
+ # now stands. Otherwise the next run would see a changed input and reinstall.
372
+ write_install_state "$(calculate_install_fingerprint "$@")"
203
373
  fi
204
374
 
205
375
  link_node_modules "$PROJECT_ROOT" "$WORK_DIR/node_modules"
@@ -14,7 +14,10 @@ else
14
14
  echo "⚠️ Warning: init_env.sh not found, skipping environment init."
15
15
  fi
16
16
  echo "Installing dependencies..."
17
+ # 安装所有依赖(包含 Taro 核心和 React)
18
+ bash "$ROOT_DIR/.cozeproj/scripts/prepare-node-modules.sh" --prefer-frozen-lockfile --prefer-offline
19
+
17
20
  echo "Building the Taro project..."
18
- bash "$ROOT_DIR/.cozeproj/scripts/build-on-local-disk.sh" pnpm build
21
+ pnpm build
19
22
 
20
23
  echo "Build completed successfully! Assets are in /dist"
@@ -119,15 +119,8 @@ process_belongs_to_workspace() {
119
119
 
120
120
  case "${cwd}" in
121
121
  "${COZE_WORKSPACE_PATH}"|"${COZE_WORKSPACE_PATH}"/*) return 0 ;;
122
+ *) return 1 ;;
122
123
  esac
123
-
124
- if [[ -n "${COZE_LOCAL_WORKSPACE_PATH:-}" ]]; then
125
- case "${cwd}" in
126
- "${COZE_LOCAL_WORKSPACE_PATH}"|"${COZE_LOCAL_WORKSPACE_PATH}"/*) return 0 ;;
127
- esac
128
- fi
129
-
130
- return 1
131
124
  }
132
125
 
133
126
  process_group_belongs_to_workspace() {
@@ -224,51 +217,6 @@ PNPM_BIN="$(command -v pnpm)"
224
217
  bash "$ROOT_DIR/.cozeproj/scripts/prepare-node-modules.sh" --prefer-frozen-lockfile --prefer-offline
225
218
  echo "✅ Dependencies installed successfully!"
226
219
 
227
- is_under_root() {
228
- local path="$1"
229
- local root="$2"
230
- [ -n "$root" ] || return 1
231
- case "$path" in
232
- "$root"|"$root"/*) return 0 ;;
233
- *) return 1 ;;
234
- esac
235
- }
236
-
237
- is_coze_drive_workspace() {
238
- local path="$1"
239
- local root
240
- local candidates=()
241
-
242
- if [ -n "${COZE_DRIVE_ROOT:-}" ]; then
243
- candidates+=("$COZE_DRIVE_ROOT")
244
- fi
245
- candidates+=("/Coze/Drive")
246
-
247
- for root in "${candidates[@]}"; do
248
- if [ -d "$root" ]; then
249
- root="$(cd "$root" && pwd -P)"
250
- else
251
- root="${root%/}"
252
- fi
253
- if is_under_root "$path" "$root"; then
254
- return 0
255
- fi
256
- done
257
-
258
- return 1
259
- }
260
-
261
- if is_coze_drive_workspace "${COZE_WORKSPACE_PATH}"; then
262
- COZE_LOCAL_BUILD_ROOT="${COZE_LOCAL_BUILD_ROOT:-/tmp/coze-taro-build}"
263
- COZE_LOCAL_WORKSPACE_PATH="${COZE_LOCAL_BUILD_ROOT}/$(basename "$COZE_WORKSPACE_PATH")-$(printf '%s' "$COZE_WORKSPACE_PATH" | cksum | awk '{print $1}')/workspace"
264
- export COZE_LOCAL_BUILD_ROOT
265
- export COZE_LOCAL_WORKSPACE_PATH
266
- DEV_COMMAND=(env COZE_SKIP_PREPARE_NODE_MODULES=1 COZE_SYNC_SOURCE_TO_LOCAL=1 bash "$ROOT_DIR/.cozeproj/scripts/build-on-local-disk.sh" "${PNPM_BIN}" dev)
267
- echo "📁 Taro dev will run from a local disk workspace."
268
- else
269
- DEV_COMMAND=("${PNPM_BIN}" dev)
270
- fi
271
-
272
220
  # ---------------------------------------------------------
273
221
  # 3. 清理旧进程 + 端口
274
222
  # ---------------------------------------------------------
@@ -300,6 +248,7 @@ start_service() {
300
248
  echo "⚠️ 警告: COZE_PROJECT_DOMAIN_DEFAULT 未设置,使用 .env.local 中的配置"
301
249
  fi
302
250
 
251
+ # 启动 Taro H5 和 NestJS Server
303
252
  echo "Starting Taro H5 Dev Server and NestJS Server..."
304
253
 
305
254
  : > "${LOG_FILE}"
@@ -307,7 +256,7 @@ start_service() {
307
256
  DEV_PID="$(spawn_detached \
308
257
  "${COZE_WORKSPACE_PATH}" \
309
258
  "${LOG_FILE}" \
310
- "${DEV_COMMAND[@]}")"
259
+ "${PNPM_BIN}" dev)"
311
260
  if [[ -z "${DEV_PID}" ]]; then
312
261
  echo "❌ 无法获取 dev 后台进程 PID"
313
262
  return 1
@@ -1,5 +1,4 @@
1
1
  #!/bin/bash
2
- set -Eeuo pipefail
3
2
 
4
3
  ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
5
4
  COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-${ROOT_DIR}}"
@@ -7,40 +6,6 @@ export COZE_WORKSPACE_PATH
7
6
 
8
7
  cd "${COZE_WORKSPACE_PATH}"
9
8
 
10
- is_under_root() {
11
- local path="$1"
12
- local root="$2"
13
- [ -n "$root" ] || return 1
14
- case "$path" in
15
- "$root"|"$root"/*) return 0 ;;
16
- *) return 1 ;;
17
- esac
18
- }
19
-
20
- is_coze_drive_workspace() {
21
- local path="$1"
22
- local root
23
- local candidates=()
24
-
25
- if [ -n "${COZE_DRIVE_ROOT:-}" ]; then
26
- candidates+=("$COZE_DRIVE_ROOT")
27
- fi
28
- candidates+=("/Coze/Drive")
29
-
30
- for root in "${candidates[@]}"; do
31
- if [ -d "$root" ]; then
32
- root="$(cd "$root" && pwd -P)"
33
- else
34
- root="${root%/}"
35
- fi
36
- if is_under_root "$path" "$root"; then
37
- return 0
38
- fi
39
- done
40
-
41
- return 1
42
- }
43
-
44
9
  # build_weapp.sh - 通过 PID 文件精确杀掉自己上次的构建进程
45
10
  PID_FILE="/tmp/coze-build_weapp.pid"
46
11
 
@@ -57,11 +22,7 @@ if [ -f "$PID_FILE" ]; then
57
22
  fi
58
23
 
59
24
  # 用 setsid 创建新的进程组,方便下次整组杀掉
60
- if is_coze_drive_workspace "${COZE_WORKSPACE_PATH}"; then
61
- setsid bash "$ROOT_DIR/.cozeproj/scripts/build-on-local-disk.sh" pnpm build:pack &
62
- else
63
- setsid pnpm build:pack &
64
- fi
25
+ setsid pnpm build:pack &
65
26
  echo $! > "$PID_FILE"
66
27
 
67
28
  echo "构建已启动 (PID: $(cat $PID_FILE))"
@@ -98,6 +98,7 @@ printf '%s\n' "$$" > "$LOCK_DIR/pid"
98
98
  trap release_lock EXIT
99
99
 
100
100
  mkdir -p "$WORK_DIR"
101
+ INSTALL_STATE_FILE="$WORK_DIR/.install-state"
101
102
 
102
103
  # Previous versions mirrored the whole project into WORK_DIR. That gives pnpm
103
104
  # and framework dev servers two paths for the same source tree, which breaks
@@ -118,12 +119,15 @@ cleanup_legacy_workspace() {
118
119
  done < <(find "$workspace_dir" -mindepth 1 -maxdepth 1 -print0)
119
120
  }
120
121
 
122
+ # caches is reserved for the sibling prepare scripts of this project. They keep
123
+ # regenerable framework output there (the Next helper points .next/dev into it), and that
124
+ # output has to outlive an install, so this cleanup must not read it as leftover.
121
125
  cleanup_legacy_shadow_project() {
122
126
  local entry name
123
127
  while IFS= read -r -d '' entry; do
124
128
  name="$(basename "$entry")"
125
129
  case "$name" in
126
- node_modules|client|server) continue ;;
130
+ node_modules|client|server|caches|.install-state) continue ;;
127
131
  esac
128
132
  rm -rf "$entry"
129
133
  done < <(find "$WORK_DIR" -mindepth 1 -maxdepth 1 -print0)
@@ -141,6 +145,162 @@ link_install_entry() {
141
145
  fi
142
146
  }
143
147
 
148
+ hash_install_input() {
149
+ if command -v sha256sum >/dev/null 2>&1; then
150
+ sha256sum | awk '{print $1}'
151
+ elif command -v shasum >/dev/null 2>&1; then
152
+ shasum -a 256 | awk '{print $1}'
153
+ else
154
+ cksum | awk '{print $1 "-" $2}'
155
+ fi
156
+ }
157
+
158
+ append_file_to_install_fingerprint() {
159
+ local relative_path="$1"
160
+ local source_path="$PROJECT_ROOT/$relative_path"
161
+ printf 'path=%s\n' "$relative_path"
162
+ if [ -f "$source_path" ]; then
163
+ cat "$source_path"
164
+ else
165
+ printf '<missing>\n'
166
+ fi
167
+ }
168
+
169
+ append_directory_to_install_fingerprint() {
170
+ local relative_path="$1"
171
+ local source_path="$PROJECT_ROOT/$relative_path"
172
+ local file
173
+ printf 'path=%s\n' "$relative_path"
174
+ if [ ! -d "$source_path" ]; then
175
+ printf '<missing>\n'
176
+ return
177
+ fi
178
+
179
+ while IFS= read -r file; do
180
+ printf 'file=%s\n' "${file#"${PROJECT_ROOT}/"}"
181
+ cat "$file"
182
+ done < <(find -P "$source_path" -type f -print | LC_ALL=C sort)
183
+ }
184
+
185
+ # Everything that can change the resolved dependency tree. The leading version marker
186
+ # invalidates every stored state when this scheme itself changes.
187
+ calculate_install_fingerprint() {
188
+ local filename workspace argument
189
+ {
190
+ printf 'prepare-node-modules-install-state=1\n'
191
+ printf 'node-version=%s\n' "$(node --version)"
192
+ printf 'pnpm-version=%s\n' "$(pnpm --version)"
193
+ printf 'node-env=%s\n' "${NODE_ENV:-}"
194
+ printf 'npm-config-production=%s\n' "${npm_config_production:-}"
195
+ printf 'npm-config-production-upper=%s\n' "${NPM_CONFIG_PRODUCTION:-}"
196
+ printf 'pnpm-config-production=%s\n' "${PNPM_CONFIG_PRODUCTION:-}"
197
+ for argument in "$@"; do
198
+ printf 'install-argument=%s\n' "$argument"
199
+ done
200
+ for filename in package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc; do
201
+ append_file_to_install_fingerprint "$filename"
202
+ done
203
+ for workspace in client server; do
204
+ append_file_to_install_fingerprint "$workspace/package.json"
205
+ done
206
+ append_directory_to_install_fingerprint patches
207
+ } | hash_install_input
208
+ }
209
+
210
+ install_requires_refresh() {
211
+ local argument
212
+ for argument in "$@"; do
213
+ case "$argument" in
214
+ --force|-f|--lockfile-only|--fix-lockfile) return 0 ;;
215
+ esac
216
+ done
217
+ return 1
218
+ }
219
+
220
+ # Reuse skips pnpm install entirely, so it must not skip side effects that live outside
221
+ # node_modules. preinstall is exempt: it only guards which package manager may run, and
222
+ # there is no install to guard when the tree is already in place.
223
+ package_has_install_lifecycle() {
224
+ local package_path="$1"
225
+ node - "$package_path" <<'NODE'
226
+ const fs = require('node:fs');
227
+ const [packagePath] = process.argv.slice(2);
228
+
229
+ try {
230
+ const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
231
+ const scripts = packageJson && typeof packageJson.scripts === 'object' && packageJson.scripts
232
+ ? packageJson.scripts
233
+ : {};
234
+ const hasLifecycle = ['install', 'postinstall', 'prepare'].some(
235
+ name => typeof scripts[name] === 'string' && scripts[name].trim() !== '',
236
+ );
237
+
238
+ process.exit(hasLifecycle ? 0 : 1);
239
+ } catch {
240
+ process.exit(0);
241
+ }
242
+ NODE
243
+ }
244
+
245
+ install_reuse_is_safe() {
246
+ local workspace package_path
247
+ for package_path in "$PROJECT_ROOT/.pnpmfile.cjs" "$PROJECT_ROOT/.pnpmfile.js"; do
248
+ if [ -e "$package_path" ]; then
249
+ echo "[node_modules] Reinstalling because pnpm hooks are present: $package_path"
250
+ return 1
251
+ fi
252
+ done
253
+
254
+ if [ -f "$PROJECT_ROOT/.npmrc" ] && grep -Eq '^[[:space:]]*pnpmfile[[:space:]]*=' "$PROJECT_ROOT/.npmrc"; then
255
+ echo "[node_modules] Reinstalling because .npmrc configures a pnpm hook."
256
+ return 1
257
+ fi
258
+
259
+ for workspace in . client server; do
260
+ package_path="$PROJECT_ROOT/$workspace/package.json"
261
+ [ -f "$package_path" ] || continue
262
+ if package_has_install_lifecycle "$package_path"; then
263
+ echo "[node_modules] Reinstalling because $package_path has an install lifecycle script."
264
+ return 1
265
+ fi
266
+ done
267
+ return 0
268
+ }
269
+
270
+ dependencies_are_ready() {
271
+ local workspace
272
+ [ -f "$WORK_DIR/node_modules/.modules.yaml" ] || return 1
273
+ for workspace in client server; do
274
+ if [ -f "$PROJECT_ROOT/$workspace/package.json" ] &&
275
+ [ ! -f "$WORK_DIR/$workspace/node_modules/.modules.yaml" ]; then
276
+ return 1
277
+ fi
278
+ done
279
+ return 0
280
+ }
281
+
282
+ # 判断顺序按代价从小到大排。指纹要读云盘上的 lockfile 和整个 patches/,所以放最后:只有其余
283
+ # 条件都满足、确实可能复用时才付这份开销。build.sh 和 prepare.sh 不设开关,第一行就返回了。
284
+ install_can_be_reused() {
285
+ local installed_fingerprint=""
286
+
287
+ [ "${COZE_REUSE_NODE_MODULES:-}" = "1" ] || return 1
288
+ install_requires_refresh "$@" && return 1
289
+ [ -f "$INSTALL_STATE_FILE" ] || return 1
290
+ IFS= read -r installed_fingerprint < "$INSTALL_STATE_FILE" || return 1
291
+ dependencies_are_ready || return 1
292
+ install_reuse_is_safe || return 1
293
+ [ "$installed_fingerprint" = "$(calculate_install_fingerprint "$@")" ] || return 1
294
+ return 0
295
+ }
296
+
297
+ write_install_state() {
298
+ local fingerprint="$1" temporary_state
299
+ temporary_state="$(mktemp "$WORK_DIR/.install-state.XXXXXX")"
300
+ printf '%s\n' "$fingerprint" > "$temporary_state"
301
+ mv -f "$temporary_state" "$INSTALL_STATE_FILE"
302
+ }
303
+
144
304
  copy_install_metadata() {
145
305
  local filename source_file target_file workspace
146
306
  for filename in pnpm-lock.yaml pnpm-workspace.yaml .npmrc; do
@@ -190,16 +350,26 @@ link_node_modules() {
190
350
  cleanup_legacy_shadow_project
191
351
  copy_install_metadata
192
352
 
193
- echo "[node_modules] Installing dependencies in $WORK_DIR"
194
- if ! (cd "$WORK_DIR" && pnpm install "$@"); then
195
- exit 1
196
- fi
353
+ if install_can_be_reused "$@"; then
354
+ echo "[node_modules] Reusing unchanged dependencies in $WORK_DIR"
355
+ else
356
+ # A stale state file must not survive a failed install.
357
+ rm -f "$INSTALL_STATE_FILE"
358
+ echo "[node_modules] Installing dependencies in $WORK_DIR"
359
+ if ! (cd "$WORK_DIR" && pnpm install "$@"); then
360
+ exit 1
361
+ fi
362
+
363
+ if [ -f "$WORK_DIR/pnpm-lock.yaml" ]; then
364
+ temporary_lockfile="$(mktemp "$PROJECT_ROOT/.pnpm-lock.yaml.XXXXXX")"
365
+ cp "$WORK_DIR/pnpm-lock.yaml" "$temporary_lockfile"
366
+ chmod 0644 "$temporary_lockfile"
367
+ mv -f "$temporary_lockfile" "$PROJECT_ROOT/pnpm-lock.yaml"
368
+ fi
197
369
 
198
- if [ -f "$WORK_DIR/pnpm-lock.yaml" ]; then
199
- temporary_lockfile="$(mktemp "$PROJECT_ROOT/.pnpm-lock.yaml.XXXXXX")"
200
- cp "$WORK_DIR/pnpm-lock.yaml" "$temporary_lockfile"
201
- chmod 0644 "$temporary_lockfile"
202
- mv -f "$temporary_lockfile" "$PROJECT_ROOT/pnpm-lock.yaml"
370
+ # pnpm may have rewritten the lockfile, so record the fingerprint of the tree as it
371
+ # now stands. Otherwise the next run would see a changed input and reinstall.
372
+ write_install_state "$(calculate_install_fingerprint "$@")"
203
373
  fi
204
374
 
205
375
  link_node_modules "$PROJECT_ROOT" "$WORK_DIR/node_modules"