@coze-arch/cli 0.1.7 → 0.1.8-alpha.6e99f9

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"
@@ -71,9 +71,14 @@ warn_source_symlinks() {
71
71
  done <<< "${links}"
72
72
  }
73
73
 
74
- # 云盘挂载偶尔会把新建文件的 mtime 设为 Unix epoch。Watchpack 会因此把路由文件当作
75
- # 启动前的旧状态,导致 Next 的首轮扫描漏掉 route/page。只在云盘项目启动前刷新 app/pages
76
- # 的目录和普通文件时间戳;不跟随软链、不改文件内容。
74
+ # 云盘挂载偶尔会把新建文件的 mtime 设为 Unix epoch,这种时间戳会让所有按 mtime 判断新旧的
75
+ # 环节(Next 的首轮路由扫描、webpack timestamp 快照、tsbuildinfo)拿到错误的结论。
76
+ # 只修 mtime 确实异常的那些条目:无条件刷新整棵 app/pages 会让 webpack 每次启动都重算全部
77
+ # 路由文件的 hash,并让云盘把它们全部重传一遍。不跟随软链、不改文件内容。
78
+ # 判定 mtime 是否异常的下界。真实的项目文件不可能早于这个时间,落在它之前的只有被云盘写坏的
79
+ # 时间戳,所以用它把"需要修"和"本来就好"区分开。
80
+ DRIVE_MTIME_FLOOR="1971-01-01"
81
+
77
82
  refresh_drive_route_mtimes() {
78
83
  local drive_root="${COZE_DRIVE_ROOT:-/Coze/Drive}"
79
84
  local project_root route_dir
@@ -91,7 +96,7 @@ refresh_drive_route_mtimes() {
91
96
 
92
97
  for route_dir in "${COZE_WORKSPACE_PATH}/src/app" "${COZE_WORKSPACE_PATH}/src/pages"; do
93
98
  [[ -d "${route_dir}" ]] || continue
94
- if ! find -P "${route_dir}" \( -type f -o -type d \) -exec touch {} + 2>/dev/null; then
99
+ if ! find -P "${route_dir}" \( -type f -o -type d \) ! -newermt "${DRIVE_MTIME_FLOOR}" -exec touch {} + 2>/dev/null; then
95
100
  echo "Warning: failed to refresh Next route timestamps in ${route_dir}." >&2
96
101
  fi
97
102
  done
@@ -100,7 +105,8 @@ refresh_drive_route_mtimes() {
100
105
  <% if (process.env.NODE_ENV === 'test') { %>
101
106
  echo "Clearing port ${DEPLOY_RUN_PORT} before start."
102
107
  kill_port_if_listening
103
- bash "${COZE_WORKSPACE_PATH}/scripts/prepare-node-modules.sh" --prefer-frozen-lockfile --prefer-offline --loglevel debug --reporter=append-only
108
+ COZE_REUSE_NODE_MODULES=1 bash "${COZE_WORKSPACE_PATH}/scripts/prepare-node-modules.sh" --prefer-frozen-lockfile --prefer-offline --loglevel debug --reporter=append-only
109
+ bash "${COZE_WORKSPACE_PATH}/scripts/prepare-next-dev-output.sh"
104
110
  warn_source_symlinks
105
111
  refresh_drive_route_mtimes
106
112
  echo "Starting HTTP service on port ${DEPLOY_RUN_PORT} for dev..."
@@ -294,7 +300,8 @@ fi
294
300
 
295
301
  echo "Clearing port ${DEPLOY_RUN_PORT} before start."
296
302
  kill_port_if_listening
297
- bash "${COZE_WORKSPACE_PATH}/scripts/prepare-node-modules.sh" --prefer-frozen-lockfile --prefer-offline --loglevel debug --reporter=append-only
303
+ COZE_REUSE_NODE_MODULES=1 bash "${COZE_WORKSPACE_PATH}/scripts/prepare-node-modules.sh" --prefer-frozen-lockfile --prefer-offline --loglevel debug --reporter=append-only
304
+ bash "${COZE_WORKSPACE_PATH}/scripts/prepare-next-dev-output.sh"
298
305
  warn_source_symlinks
299
306
  refresh_drive_route_mtimes
300
307
  echo "Starting HTTP service on port ${DEPLOY_RUN_PORT} for dev..."
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env bash
2
+ # Keep Next development output off Coze Drive without moving production output.
3
+ set -euo pipefail
4
+
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
6
+ if [ "$(basename "$(dirname "$SCRIPT_DIR")")" = ".cozeproj" ]; then
7
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
8
+ else
9
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
10
+ fi
11
+
12
+ DRIVE_ROOT="${COZE_DRIVE_ROOT:-/Coze/Drive}"
13
+ if [ -d "$DRIVE_ROOT" ]; then
14
+ DRIVE_ROOT="$(cd "$DRIVE_ROOT" && pwd -P)"
15
+ else
16
+ DRIVE_ROOT="${DRIVE_ROOT%/}"
17
+ fi
18
+ case "$PROJECT_ROOT" in
19
+ "$DRIVE_ROOT"|"$DRIVE_ROOT"/*) ;;
20
+ *) exit 0 ;;
21
+ esac
22
+
23
+ # Next writes every on-demand development build into .next/dev. On Coze Drive each of
24
+ # those writes goes through the sync daemon, so only that subtree moves to local disk.
25
+ # Production output stays in the project directory, which keeps `next build` and
26
+ # deployment unchanged.
27
+ #
28
+ # One local directory per project, shared with prepare-node-modules.sh: node_modules and
29
+ # this cache sit side by side under WORK_DIR. The caches subdirectory is the slot that
30
+ # script reserves for output that must survive an install, so nothing here is cleaned up
31
+ # behind our back. PROJECT_ID has to stay identical to the one it computes.
32
+ NM_ROOT="/tmp/nm"
33
+ PROJECT_ID="$(basename "$PROJECT_ROOT")-$(printf '%s' "$PROJECT_ROOT" | cksum | awk '{print $1}')"
34
+ WORK_DIR="$NM_ROOT/$PROJECT_ID"
35
+ NEXT_ROOT="$PROJECT_ROOT/.next"
36
+ NEXT_DEV="$NEXT_ROOT/dev"
37
+ LEGACY_NEXT_ROOT="$WORK_DIR/.next"
38
+ LOCAL_NEXT_DEV="$WORK_DIR/caches/next-dev"
39
+
40
+ case "$LOCAL_NEXT_DEV" in
41
+ "$DRIVE_ROOT"|"$DRIVE_ROOT"/*)
42
+ echo "[next] local development output must not be on Coze Drive: $LOCAL_NEXT_DEV" >&2
43
+ exit 1
44
+ ;;
45
+ esac
46
+
47
+ # Coze Drive sync does not preserve symlinks: a synced copy of a link arrives as a
48
+ # regular file holding the link target. Recognize the links this scheme creates so a
49
+ # project that came back from sync repairs itself instead of failing to start.
50
+ is_flattened_managed_link() {
51
+ local candidate="$1" content=""
52
+ [ -f "$candidate" ] || return 1
53
+ [ ! -L "$candidate" ] || return 1
54
+ [ "$(wc -c < "$candidate")" -le 4096 ] || return 1
55
+ content="$(tr -d '\r\n' < "$candidate")"
56
+ case "$content" in
57
+ "$NM_ROOT"/*) return 0 ;;
58
+ esac
59
+ return 1
60
+ }
61
+
62
+ # Earlier versions linked the whole .next directory. Bring any production build made
63
+ # back then into the project; from here on only .next/dev is redirected.
64
+ restore_legacy_next_root() {
65
+ local entry
66
+ rm -f "$NEXT_ROOT"
67
+ mkdir -p "$NEXT_ROOT"
68
+ if [ -d "$LEGACY_NEXT_ROOT" ]; then
69
+ while IFS= read -r -d '' entry; do
70
+ mv "$entry" "$NEXT_ROOT/"
71
+ done < <(find -P "$LEGACY_NEXT_ROOT" -mindepth 1 -maxdepth 1 -print0)
72
+ rmdir "$LEGACY_NEXT_ROOT" 2>/dev/null || true
73
+ fi
74
+ }
75
+
76
+ if [ -L "$NEXT_ROOT" ]; then
77
+ case "$(readlink "$NEXT_ROOT")" in
78
+ "$NM_ROOT"/*)
79
+ echo "[next] Restoring legacy .next output to the project directory."
80
+ restore_legacy_next_root
81
+ ;;
82
+ *)
83
+ echo "[next] refusing to replace unmanaged .next symlink: $NEXT_ROOT" >&2
84
+ exit 1
85
+ ;;
86
+ esac
87
+ elif is_flattened_managed_link "$NEXT_ROOT"; then
88
+ echo "[next] Recovering .next flattened by Coze Drive sync."
89
+ restore_legacy_next_root
90
+ elif [ -e "$NEXT_ROOT" ] && [ ! -d "$NEXT_ROOT" ]; then
91
+ echo "[next] refusing to replace non-directory Next output: $NEXT_ROOT" >&2
92
+ exit 1
93
+ fi
94
+
95
+ mkdir -p "$NEXT_ROOT" "$LOCAL_NEXT_DEV"
96
+
97
+ if [ -L "$NEXT_DEV" ]; then
98
+ if [ "$(readlink "$NEXT_DEV")" = "$LOCAL_NEXT_DEV" ]; then
99
+ exit 0
100
+ fi
101
+ case "$(readlink "$NEXT_DEV")" in
102
+ "$NM_ROOT"/*) rm "$NEXT_DEV" ;;
103
+ *)
104
+ echo "[next] refusing to replace unmanaged .next/dev symlink: $NEXT_DEV" >&2
105
+ exit 1
106
+ ;;
107
+ esac
108
+ elif [ -e "$NEXT_DEV" ]; then
109
+ # .next/dev holds development output only, and Next regenerates it on demand.
110
+ rm -rf "$NEXT_DEV"
111
+ fi
112
+
113
+ # A second dev start can win the race between the removal above and this link.
114
+ if ! ln -s "$LOCAL_NEXT_DEV" "$NEXT_DEV" 2>/dev/null; then
115
+ if [ -L "$NEXT_DEV" ] && [ "$(readlink "$NEXT_DEV")" = "$LOCAL_NEXT_DEV" ]; then
116
+ exit 0
117
+ fi
118
+ echo "[next] failed to link development output: $NEXT_DEV" >&2
119
+ exit 1
120
+ fi
121
+
122
+ echo "[next] Development output linked to $LOCAL_NEXT_DEV"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
package/lib/cli.js CHANGED
@@ -2114,7 +2114,7 @@ const EventBuilder = {
2114
2114
  };
2115
2115
 
2116
2116
  var name = "@coze-arch/cli";
2117
- var version = "0.1.7";
2117
+ var version = "0.1.8-alpha.6e99f9";
2118
2118
  var description = "coze coding devtools cli";
2119
2119
  var license = "MIT";
2120
2120
  var author = "fanwenjie.fe@bytedance.com";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coze-arch/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.8-alpha.6e99f9",
4
4
  "private": false,
5
5
  "description": "coze coding devtools cli",
6
6
  "license": "MIT",