@prismshadow/penguin-server 0.2.6 → 0.2.8

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.
Files changed (58) hide show
  1. package/README.md +4 -4
  2. package/dist/api/types.d.ts +393 -21
  3. package/dist/auth-token.d.ts +29 -0
  4. package/dist/auth-token.js +51 -0
  5. package/dist/auth-token.js.map +1 -0
  6. package/dist/{chunk-WKJ3ANJP.js → chunk-622KL7BQ.js} +92 -381
  7. package/dist/chunk-622KL7BQ.js.map +1 -0
  8. package/dist/chunk-K4ZSUT72.js +34 -0
  9. package/dist/chunk-K4ZSUT72.js.map +1 -0
  10. package/dist/chunk-UGAFPYVX.js +374 -0
  11. package/dist/chunk-UGAFPYVX.js.map +1 -0
  12. package/dist/extension/index.d.ts +212 -0
  13. package/dist/extension/index.js +1 -0
  14. package/dist/extension/index.js.map +1 -0
  15. package/dist/index.js +5758 -1310
  16. package/dist/index.js.map +1 -1
  17. package/dist/initial-password.d.ts +9 -12
  18. package/dist/initial-password.js +3 -7
  19. package/dist/install.ps1 +729 -0
  20. package/dist/install.sh +785 -0
  21. package/dist/reset-admin-password.d.ts +4 -7
  22. package/dist/reset-admin-password.js +11 -9
  23. package/dist/reset-admin-password.js.map +1 -1
  24. package/dist/secret-file.d.ts +3 -0
  25. package/dist/secret-file.js +17 -0
  26. package/dist/secret-file.js.map +1 -0
  27. package/package.json +19 -3
  28. package/web-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  29. package/web-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  30. package/web-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  31. package/web-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  32. package/web-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  33. package/web-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  34. package/web-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  35. package/web-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  36. package/web-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  37. package/web-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  38. package/web-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  39. package/web-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/web-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  41. package/web-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  42. package/web-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  43. package/web-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  44. package/web-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  45. package/web-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  46. package/web-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  47. package/web-dist/assets/{addon-fit-CE-3eCvv.js → addon-fit-DHOzI3Ur.js} +1 -1
  48. package/web-dist/assets/{addon-web-links-DeODgawX.js → addon-web-links-DC7759H1.js} +1 -1
  49. package/web-dist/assets/{highlighter-DpnM5FfD.js → highlighter-CGPO6Q4b.js} +1 -1
  50. package/web-dist/assets/index-CaFDHl-w.js +451 -0
  51. package/web-dist/assets/index-D8Bk9LtJ.css +1 -0
  52. package/web-dist/assets/{xterm-BjU-ZsL5.js → xterm-DY5ko-BU.js} +1 -1
  53. package/web-dist/index.html +2 -2
  54. package/dist/chunk-UUJCF46V.js +0 -50
  55. package/dist/chunk-UUJCF46V.js.map +0 -1
  56. package/dist/chunk-WKJ3ANJP.js.map +0 -1
  57. package/web-dist/assets/index-aRsvImZt.css +0 -1
  58. package/web-dist/assets/index-gNzir_kH.js +0 -189
@@ -0,0 +1,785 @@
1
+ #!/bin/sh
2
+ # PenguinHarness one-line installer.
3
+ #
4
+ # curl -fsSL https://github.com/Prism-Shadow/penguin-harness/releases/latest/download/install.sh | sh
5
+ #
6
+ # Options:
7
+ # PENGUIN_VERSION=vX.Y.Z choose a version (same as --version vX.Y.Z); a published Release
8
+ # installer defaults to its own version, an unstamped source copy to latest
9
+ # PENGUIN_INSTALL_DIR=<dir> install dir; default ~/.penguin
10
+ # PENGUIN_ARCHIVE=<file> install a local Release archive without network access (same as --archive <file>)
11
+ # PENGUIN_DOWNLOAD_SOURCE=auto|oss|github choose the online source; default auto (speed-probed,
12
+ # with the same-version other source as fallback)
13
+ # PENGUIN_DOWNLOAD_SPEED_PROBE=0 disable same-version OSS/GitHub probe timing in auto mode
14
+ # PENGUIN_DOWNLOAD_BASE_URL=<url> exact online asset directory selected by the stable forwarder
15
+ # PENGUIN_DOWNLOAD_FALLBACK_BASE_URL=<url> fallback for PENGUIN_DOWNLOAD_BASE_URL
16
+ # --universal install the universal package (no bundled Node runtime; needs system Node >= 24)
17
+ #
18
+ # Each Release attaches exactly one artifact per target: penguin-<target>.tar.gz, a shallow
19
+ # installer bundle holding this script, the program payload (payload.tar.gz) and the payload's
20
+ # checksum. Online installs download that bundle and verify it against its published .sha256;
21
+ # offline installs transfer the same single file, extract it once and run the bundled
22
+ # ./install.sh, which installs the sibling payload with no network access. Both paths verify
23
+ # the payload checksum sealed inside the bundle before anything is staged. Releases up to
24
+ # v0.1.5 shipped the program tree directly (top-level penguin/); such archives are still
25
+ # accepted, from --version pins and --archive files alike.
26
+ #
27
+ # The data dir (~/.penguin/data) sits under the install home but is never touched by reinstall/upgrade (which only replace bin/lib/web/node).
28
+ #
29
+ # Docs: https://penguin.ooo/docs/quickstart-cli
30
+ set -eu
31
+
32
+ REPO="https://github.com/Prism-Shadow/penguin-harness"
33
+ OSS_ORIGIN="https://penguin-harness-releases.oss-cn-beijing.aliyuncs.com"
34
+ OSS_RELEASE_ROOT="$OSS_ORIGIN/releases"
35
+ GITHUB_RELEASE_ROOT="$REPO/releases/download"
36
+ GITHUB_LATEST_BASE="$REPO/releases/latest/download"
37
+ VERSION="${PENGUIN_VERSION:-}"
38
+ INSTALL_DIR="${PENGUIN_INSTALL_DIR:-$HOME/.penguin}"
39
+ BIN_DIR="$HOME/.local/bin"
40
+ UNIVERSAL=0
41
+ ARCHIVE="${PENGUIN_ARCHIVE:-}"
42
+ SOURCE_MODE="${PENGUIN_DOWNLOAD_SOURCE:-auto}"
43
+ DOWNLOAD_BASE_URL="${PENGUIN_DOWNLOAD_BASE_URL:-}"
44
+ DOWNLOAD_FALLBACK_BASE_URL="${PENGUIN_DOWNLOAD_FALLBACK_BASE_URL:-}"
45
+ DOWNLOAD_SPEED_PROBE="${PENGUIN_DOWNLOAD_SPEED_PROBE:-1}"
46
+ # Auto-mode source selection, one rule shared by install.sh, install.ps1 and the download page on
47
+ # penguin.ooo (packages/landing/src/lib/download-source.ts):
48
+ #
49
+ # 1. Measure GitHub on the release's large probe file. At or above
50
+ # SPEED_PROBE_GITHUB_MIN_BYTES_PER_SECOND it wins outright and OSS is never touched.
51
+ # 2. Only below that is OSS measured, and it takes over only when it is more than
52
+ # SPEED_PROBE_OSS_SWITCH_RATIO_PERCENT of GitHub — a mirror that is merely a little quicker
53
+ # does not justify its bandwidth bill, and a slow GitHub download still resumes.
54
+ #
55
+ # GitHub is the free source, so every tie and every unmeasurable comparison stays there. The two
56
+ # constants are duplicated because these three implementations cannot import from each other — an
57
+ # installer is a standalone file fetched over the network. Change them in all three at once, which
58
+ # scripts/test-installer.sh pins.
59
+ #
60
+ # The total budget covers the whole probe: manifest, the small reachability pair, and up to two
61
+ # large probes. It is deliberately the sum of their caps, so the second large probe always gets its
62
+ # full window rather than being squeezed into declaring a healthy mirror unreachable.
63
+ SPEED_PROBE_MANIFEST_TIMEOUT_SECONDS=5
64
+ SPEED_PROBE_SMALL_TIMEOUT_SECONDS=5
65
+ SPEED_PROBE_LARGE_TIMEOUT_SECONDS=8
66
+ SPEED_PROBE_TOTAL_TIMEOUT_SECONDS=26
67
+ SPEED_PROBE_GITHUB_MIN_BYTES_PER_SECOND=262144
68
+ SPEED_PROBE_OSS_SWITCH_RATIO_PERCENT=150
69
+ SPEED_PROBE_STARTED_AT=0
70
+ PAYLOAD_NAME="payload.tar.gz"
71
+ # The release workflow replaces this token with the immutable tag before publishing both the
72
+ # standalone installer and the copies sealed inside the Linux, macOS and universal bundles.
73
+ EMBEDDED_RELEASE_VERSION="__PENGUIN_RELEASE_VERSION__"
74
+
75
+ fail() {
76
+ echo "error: $1" >&2
77
+ exit 1
78
+ }
79
+
80
+ validate_https_url() {
81
+ case "$2" in
82
+ https://*) ;;
83
+ *) fail "$1 must be an absolute HTTPS URL" ;;
84
+ esac
85
+ }
86
+
87
+ is_release_tag() {
88
+ case "$1" in
89
+ v[0-9A-Za-z]* ) ;;
90
+ *) return 1 ;;
91
+ esac
92
+ case "$1" in
93
+ *[!0-9A-Za-z._-]*) return 1 ;;
94
+ esac
95
+ return 0
96
+ }
97
+
98
+ validate_release_tag() {
99
+ is_release_tag "$1" || fail "invalid release version: $1"
100
+ }
101
+
102
+ download_source_label() {
103
+ case "$1" in
104
+ https://*.aliyuncs.com/*) printf '%s\n' "OSS mirror" ;;
105
+ https://github.com/*) printf '%s\n' "GitHub" ;;
106
+ *) printf '%s\n' "configured mirror" ;;
107
+ esac
108
+ }
109
+
110
+ # --- Parse args (also passable via curl | sh -s -- --universal) ---
111
+ while [ $# -gt 0 ]; do
112
+ case "$1" in
113
+ --version)
114
+ [ $# -ge 2 ] || fail "--version requires a value (e.g. --version v1.0.0)"
115
+ VERSION="$2"
116
+ shift 2
117
+ ;;
118
+ --universal)
119
+ UNIVERSAL=1
120
+ shift
121
+ ;;
122
+ --archive)
123
+ [ $# -ge 2 ] || fail "--archive requires a path to a Release archive"
124
+ ARCHIVE="$2"
125
+ shift 2
126
+ ;;
127
+ *)
128
+ fail "unknown option: $1"
129
+ ;;
130
+ esac
131
+ done
132
+
133
+ # --- Detect platform: Linux/Darwin x64/arm64; other platforms should use the universal package ---
134
+ TARGET="universal"
135
+ if [ "$UNIVERSAL" -eq 0 ]; then
136
+ case "$(uname -s)" in
137
+ Linux) os="linux" ;;
138
+ Darwin) os="darwin" ;;
139
+ *) fail "unsupported OS: $(uname -s). Install Node.js >= 24, then re-run with --universal." ;;
140
+ esac
141
+ case "$(uname -m)" in
142
+ x86_64) arch="x64" ;;
143
+ aarch64 | arm64) arch="arm64" ;;
144
+ *) fail "unsupported architecture: $(uname -m). Install Node.js >= 24, then re-run with --universal." ;;
145
+ esac
146
+ TARGET="$os-$arch"
147
+ fi
148
+ ASSET="penguin-$TARGET.tar.gz"
149
+
150
+ if [ -n "$ARCHIVE" ] && [ -n "$VERSION" ]; then
151
+ fail "--archive/PENGUIN_ARCHIVE cannot be combined with --version/PENGUIN_VERSION"
152
+ fi
153
+ if [ -n "$VERSION" ]; then
154
+ validate_release_tag "$VERSION"
155
+ fi
156
+ case "$SOURCE_MODE" in
157
+ auto | oss | github) ;;
158
+ *) fail "PENGUIN_DOWNLOAD_SOURCE must be auto, oss, or github" ;;
159
+ esac
160
+ case "$DOWNLOAD_SPEED_PROBE" in
161
+ 0 | 1) ;;
162
+ *) fail "PENGUIN_DOWNLOAD_SPEED_PROBE must be 0 or 1" ;;
163
+ esac
164
+ RESOLVED_RELEASE_VERSION="$VERSION"
165
+ if [ -z "$RESOLVED_RELEASE_VERSION" ] && is_release_tag "$EMBEDDED_RELEASE_VERSION"; then
166
+ RESOLVED_RELEASE_VERSION="$EMBEDDED_RELEASE_VERSION"
167
+ fi
168
+ if [ -n "$DOWNLOAD_BASE_URL" ]; then
169
+ DOWNLOAD_BASE_URL="${DOWNLOAD_BASE_URL%/}"
170
+ validate_https_url PENGUIN_DOWNLOAD_BASE_URL "$DOWNLOAD_BASE_URL"
171
+ if [ -n "$DOWNLOAD_FALLBACK_BASE_URL" ]; then
172
+ DOWNLOAD_FALLBACK_BASE_URL="${DOWNLOAD_FALLBACK_BASE_URL%/}"
173
+ validate_https_url PENGUIN_DOWNLOAD_FALLBACK_BASE_URL "$DOWNLOAD_FALLBACK_BASE_URL"
174
+ fi
175
+ else
176
+ DOWNLOAD_FALLBACK_BASE_URL=""
177
+ fi
178
+
179
+ # --- Universal package precheck: system Node >= 24 (platform packages bundle the runtime, so exempt) ---
180
+ if [ "$UNIVERSAL" -eq 1 ]; then
181
+ command -v node >/dev/null 2>&1 \
182
+ || fail "the universal package needs Node.js >= 24 on PATH (none found)."
183
+ node_version="$(node --version)" # e.g. v24.18.0
184
+ v="${node_version#v}"
185
+ major="${v%%.*}"
186
+ if [ "$major" -lt 24 ]; then
187
+ fail "the universal package needs Node.js >= 24, found $node_version."
188
+ fi
189
+ fi
190
+
191
+ TMP="$(mktemp -d)"
192
+ STAGING=""
193
+ OLD_DIR=""
194
+ SWAP_ACTIVE=0
195
+ MOVED_OLD=""
196
+ MOVED_NEW=""
197
+
198
+ # Moves one directory to a new location even when the directory inode itself cannot be renamed
199
+ # — a mount point, a process's CWD, or a filesystem that pins in-use directories (overlayfs
200
+ # under Docker reports EBUSY when `penguin update` replaces the very lib/ its own process runs
201
+ # from). Strategies, in order: plain rename; per-entry renames into a fresh or existing
202
+ # destination; per-entry copy with the source entry removed (POSIX keeps an unlinked-but-open
203
+ # file valid for the process using it). A pinned, now-empty source directory is left in place
204
+ # and reused by the incoming move.
205
+ relocate_dir() {
206
+ rl_src="$1"
207
+ rl_dst="$2"
208
+ if [ ! -e "$rl_dst" ] && mv "$rl_src" "$rl_dst" 2>/dev/null; then
209
+ return 0
210
+ fi
211
+ [ -d "$rl_src" ] || return 1
212
+ mkdir -p "$rl_dst" || return 1
213
+ rl_failed=0
214
+ for rl_entry in "$rl_src"/* "$rl_src"/.[!.]* "$rl_src"/..?*; do
215
+ [ -e "$rl_entry" ] || [ -L "$rl_entry" ] || continue
216
+ if ! mv "$rl_entry" "$rl_dst/" 2>/dev/null; then
217
+ cp -Rp "$rl_entry" "$rl_dst/" || rl_failed=1
218
+ rm -rf "$rl_entry" || rl_failed=1
219
+ fi
220
+ done
221
+ [ "$rl_failed" -eq 0 ] || return 1
222
+ [ -z "$(ls -A "$rl_src" 2>/dev/null)" ] || return 1
223
+ rmdir "$rl_src" 2>/dev/null || :
224
+ return 0
225
+ }
226
+
227
+ rollback_install() {
228
+ rollback_failed=0
229
+ for d in $MOVED_NEW; do
230
+ # Clearing may leave a pinned-but-empty directory husk; the restore below reuses it.
231
+ rm -rf "$INSTALL_DIR/$d" 2>/dev/null || :
232
+ if [ -e "$INSTALL_DIR/$d" ] && [ -n "$(ls -A "$INSTALL_DIR/$d" 2>/dev/null)" ]; then
233
+ rollback_failed=1
234
+ fi
235
+ done
236
+ for d in $MOVED_OLD; do
237
+ if [ -e "$OLD_DIR/$d" ]; then
238
+ relocate_dir "$OLD_DIR/$d" "$INSTALL_DIR/$d" || rollback_failed=1
239
+ fi
240
+ done
241
+ if [ "$rollback_failed" -ne 0 ]; then
242
+ echo "error: automatic rollback was incomplete; previous files remain in $OLD_DIR" >&2
243
+ return 1
244
+ fi
245
+ echo "Previous PenguinHarness installation restored." >&2
246
+ }
247
+
248
+ cleanup() {
249
+ status=$?
250
+ trap - EXIT HUP INT TERM
251
+ set +e
252
+ if [ "$SWAP_ACTIVE" -eq 1 ]; then
253
+ rollback_install
254
+ fi
255
+ rm -rf "$TMP"
256
+ [ -z "$STAGING" ] || rm -rf "$STAGING"
257
+ [ -z "$OLD_DIR" ] || rm -rf "$OLD_DIR"
258
+ exit "$status"
259
+ }
260
+
261
+ trap cleanup EXIT
262
+ trap 'exit 129' HUP
263
+ trap 'exit 130' INT
264
+ trap 'exit 143' TERM
265
+
266
+ # Verifies file $1 against the sha256sum-format file $2 ($3 names the layer in messages).
267
+ # Checksums are never optional: every install path either downloads the published .sha256 or
268
+ # reads the one sealed inside the bundle.
269
+ verify_sha256() {
270
+ vs_expected="$(awk 'NR == 1 { print $1 }' "$2" | tr 'A-F' 'a-f')"
271
+ [ -n "$vs_expected" ] || fail "checksum file is empty or malformed: $2"
272
+ if command -v sha256sum >/dev/null 2>&1; then
273
+ vs_actual="$(sha256sum "$1" | awk '{ print $1 }')"
274
+ elif command -v shasum >/dev/null 2>&1; then
275
+ vs_actual="$(shasum -a 256 "$1" | awk '{ print $1 }')"
276
+ else
277
+ fail "sha256sum or shasum is required for checksum verification"
278
+ fi
279
+ [ "$vs_actual" = "$vs_expected" ] || fail "checksum mismatch for $3."
280
+ echo "$3 checksum OK."
281
+ }
282
+
283
+ sha256_file() {
284
+ if command -v sha256sum >/dev/null 2>&1; then
285
+ sha256sum "$1" | awk '{ print $1 }'
286
+ elif command -v shasum >/dev/null 2>&1; then
287
+ shasum -a 256 "$1" | awk '{ print $1 }'
288
+ else
289
+ fail "sha256sum or shasum is required for checksum verification"
290
+ fi
291
+ }
292
+
293
+ is_positive_integer() {
294
+ case "$1" in
295
+ '' | *[!0-9]*) return 1 ;;
296
+ 0) return 1 ;;
297
+ *) return 0 ;;
298
+ esac
299
+ }
300
+
301
+ speed_probe_now_seconds() {
302
+ date +%s 2>/dev/null || printf '%s\n' 0
303
+ }
304
+
305
+ speed_probe_remaining_seconds() {
306
+ brs_now="$(speed_probe_now_seconds)"
307
+ case "$brs_now:$SPEED_PROBE_STARTED_AT" in
308
+ *[!0-9:]* | :* | *:) printf '%s\n' "$SPEED_PROBE_TOTAL_TIMEOUT_SECONDS"; return 0 ;;
309
+ esac
310
+ brs_elapsed=$((brs_now - SPEED_PROBE_STARTED_AT))
311
+ brs_remaining=$((SPEED_PROBE_TOTAL_TIMEOUT_SECONDS - brs_elapsed))
312
+ if [ "$brs_remaining" -gt 0 ]; then
313
+ printf '%s\n' "$brs_remaining"
314
+ else
315
+ printf '%s\n' 0
316
+ fi
317
+ }
318
+
319
+ speed_probe_curl_timeout() {
320
+ bct_cap="$1"
321
+ bct_remaining="$(speed_probe_remaining_seconds)"
322
+ [ "$bct_remaining" -gt 0 ] || return 1
323
+ if [ "$bct_remaining" -lt "$bct_cap" ]; then
324
+ printf '%s\n' "$bct_remaining"
325
+ else
326
+ printf '%s\n' "$bct_cap"
327
+ fi
328
+ }
329
+
330
+ load_release_download_manifest() {
331
+ lrdm_tag="$1"
332
+ lrdm_manifest="$TMP/release-download-manifest.tsv"
333
+ rm -f "$lrdm_manifest"
334
+ if lrdm_timeout="$(speed_probe_curl_timeout "$SPEED_PROBE_MANIFEST_TIMEOUT_SECONDS")" \
335
+ && curl -fsSL --connect-timeout "$lrdm_timeout" --max-time "$lrdm_timeout" "$OSS_RELEASE_ROOT/$lrdm_tag/release-download-manifest.tsv" -o "$lrdm_manifest" 2>/dev/null; then
336
+ :
337
+ elif lrdm_timeout="$(speed_probe_curl_timeout "$SPEED_PROBE_MANIFEST_TIMEOUT_SECONDS")" \
338
+ && curl -fsSL --connect-timeout "$lrdm_timeout" --max-time "$lrdm_timeout" "$GITHUB_RELEASE_ROOT/$lrdm_tag/release-download-manifest.tsv" -o "$lrdm_manifest" 2>/dev/null; then
339
+ :
340
+ else
341
+ return 1
342
+ fi
343
+
344
+ lrdm_expected_header="$(printf 'penguin-release-download-manifest\t1\t%s' "$lrdm_tag")"
345
+ [ "$(sed -n '1p' "$lrdm_manifest")" = "$lrdm_expected_header" ] || return 1
346
+
347
+ SPEED_PROBE_SMALL_PROBE="$(awk -F '\t' '$1 == "probe" && $2 == "small" { print $3; exit }' "$lrdm_manifest")"
348
+ SPEED_PROBE_SMALL_SIZE="$(awk -F '\t' '$1 == "probe" && $2 == "small" { print $4; exit }' "$lrdm_manifest")"
349
+ SPEED_PROBE_SMALL_HASH="$(awk -F '\t' '$1 == "probe" && $2 == "small" { print $5; exit }' "$lrdm_manifest")"
350
+ SPEED_PROBE_LARGE_PROBE="$(awk -F '\t' '$1 == "probe" && $2 == "large" { print $3; exit }' "$lrdm_manifest")"
351
+ SPEED_PROBE_LARGE_SIZE="$(awk -F '\t' '$1 == "probe" && $2 == "large" { print $4; exit }' "$lrdm_manifest")"
352
+ SPEED_PROBE_LARGE_HASH="$(awk -F '\t' '$1 == "probe" && $2 == "large" { print $5; exit }' "$lrdm_manifest")"
353
+ # Not part of the decision — an integrity check that this manifest belongs to a release which
354
+ # actually carries this target's bundle, so a mismatched manifest cannot steer the probe.
355
+ SPEED_PROBE_ASSET_SIZE="$(awk -F '\t' -v asset="$ASSET" '$1 == "asset" && $2 == asset { print $3; exit }' "$lrdm_manifest")"
356
+
357
+ for lrdm_file in "$SPEED_PROBE_SMALL_PROBE" "$SPEED_PROBE_LARGE_PROBE"; do
358
+ case "$lrdm_file" in
359
+ *[!A-Za-z0-9._+-]* | *..* | '') return 1 ;;
360
+ esac
361
+ done
362
+ is_positive_integer "$SPEED_PROBE_SMALL_SIZE" || return 1
363
+ is_positive_integer "$SPEED_PROBE_LARGE_SIZE" || return 1
364
+ is_positive_integer "$SPEED_PROBE_ASSET_SIZE" || return 1
365
+ case "$SPEED_PROBE_SMALL_HASH:$SPEED_PROBE_LARGE_HASH" in
366
+ *[!0-9a-f:]* | *::* | :* | *:) return 1 ;;
367
+ esac
368
+ [ "${#SPEED_PROBE_SMALL_HASH}" -eq 64 ] || return 1
369
+ [ "${#SPEED_PROBE_LARGE_HASH}" -eq 64 ] || return 1
370
+ return 0
371
+ }
372
+
373
+ probe_download_source() {
374
+ pds_label="$1"
375
+ pds_base="$2"
376
+ pds_file="$3"
377
+ pds_size="$4"
378
+ pds_hash="$5"
379
+ pds_metrics="$6"
380
+ pds_timeout_cap="${7:-$SPEED_PROBE_SMALL_TIMEOUT_SECONDS}"
381
+ pds_body="$TMP/probe-$pds_label-$pds_file"
382
+ pds_write="$pds_metrics.tmp"
383
+ rm -f "$pds_body" "$pds_metrics" "$pds_write"
384
+ pds_timeout="$(speed_probe_curl_timeout "$pds_timeout_cap")" || {
385
+ printf '%s\n' "fail" > "$pds_metrics"
386
+ return 0
387
+ }
388
+ if curl -fsSL --connect-timeout "$pds_timeout" --max-time "$pds_timeout" -H "Accept-Encoding: identity" \
389
+ -w '%{time_starttransfer} %{time_total} %{speed_download}' \
390
+ "$pds_base/$pds_file" -o "$pds_body" > "$pds_write" 2>/dev/null; then
391
+ pds_actual_size="$(wc -c < "$pds_body" | tr -d ' ')"
392
+ pds_actual_hash="$(sha256_file "$pds_body" | tr 'A-F' 'a-f')"
393
+ if [ "$pds_actual_size" = "$pds_size" ] && [ "$pds_actual_hash" = "$pds_hash" ]; then
394
+ read pds_start pds_total pds_speed < "$pds_write" || :
395
+ case "$pds_start:$pds_total" in
396
+ *[!0-9.:]* | :* | *:) printf '%s\n' "fail" > "$pds_metrics" ;;
397
+ *) printf '%s %s %s %s\n' "ok" "$pds_start" "$pds_total" "${pds_speed:-0}" > "$pds_metrics" ;;
398
+ esac
399
+ else
400
+ printf '%s\n' "fail" > "$pds_metrics"
401
+ fi
402
+ else
403
+ printf '%s\n' "fail" > "$pds_metrics"
404
+ fi
405
+ }
406
+
407
+ run_probe_pair() {
408
+ rpp_file="$1"
409
+ rpp_size="$2"
410
+ rpp_hash="$3"
411
+ OSS_PROBE_METRICS="$TMP/probe-oss-$rpp_file.metrics"
412
+ GITHUB_PROBE_METRICS="$TMP/probe-github-$rpp_file.metrics"
413
+ probe_download_source oss "$OSS_SPEED_PROBE_BASE_URL" "$rpp_file" "$rpp_size" "$rpp_hash" "$OSS_PROBE_METRICS" &
414
+ rpp_oss_pid=$!
415
+ probe_download_source github "$GITHUB_SPEED_PROBE_BASE_URL" "$rpp_file" "$rpp_size" "$rpp_hash" "$GITHUB_PROBE_METRICS" &
416
+ rpp_github_pid=$!
417
+ wait "$rpp_oss_pid" 2>/dev/null || :
418
+ wait "$rpp_github_pid" 2>/dev/null || :
419
+ }
420
+
421
+ probe_status() {
422
+ ps_file="$1"
423
+ if [ -f "$ps_file" ]; then
424
+ awk 'NR == 1 { print $1 }' "$ps_file"
425
+ else
426
+ printf '%s\n' "fail"
427
+ fi
428
+ }
429
+
430
+ # Throughput a completed probe measured, in whole bytes per second; 0 when it did not complete,
431
+ # which sorts it below any real measurement in the comparison below.
432
+ probe_bytes_per_second() {
433
+ pbs_metrics="$1"
434
+ if [ ! -f "$pbs_metrics" ]; then
435
+ printf '%s\n' 0
436
+ return 0
437
+ fi
438
+ awk 'NR == 1 && $1 == "ok" && $4 + 0 > 0 { printf "%d\n", $4 + 0; measured = 1 }
439
+ END { if (!measured) print 0 }' "$pbs_metrics"
440
+ }
441
+
442
+ # The shared rule, in one place: GitHub clears the minimum and wins outright, otherwise the mirror
443
+ # has to beat it by the switch ratio to take over. A POSIX shell has no floating point, so the ratio
444
+ # is applied to the GitHub side as an integer percent — scaling the mirror's side instead could
445
+ # overflow a 32-bit shell on a fast link, while this one is bounded by the minimum itself.
446
+ select_speed_probe_source() {
447
+ ssp_github_speed="$1"
448
+ ssp_oss_speed="$2"
449
+ if [ "$ssp_github_speed" -ge "$SPEED_PROBE_GITHUB_MIN_BYTES_PER_SECOND" ]; then
450
+ printf '%s\n' "github"
451
+ elif [ "$ssp_oss_speed" -gt "$((ssp_github_speed * SPEED_PROBE_OSS_SWITCH_RATIO_PERCENT / 100))" ]; then
452
+ printf '%s\n' "oss"
453
+ else
454
+ printf '%s\n' "github"
455
+ fi
456
+ }
457
+
458
+ speed_probe_release_sources() {
459
+ brs_tag="$1"
460
+ SPEED_PROBE_BASE_URL=""
461
+ SPEED_PROBE_FALLBACK_BASE_URL=""
462
+ OSS_SPEED_PROBE_BASE_URL="$OSS_RELEASE_ROOT/$brs_tag"
463
+ GITHUB_SPEED_PROBE_BASE_URL="$GITHUB_RELEASE_ROOT/$brs_tag"
464
+ SPEED_PROBE_STARTED_AT="$(speed_probe_now_seconds)"
465
+
466
+ load_release_download_manifest "$brs_tag" || return 1
467
+ echo "Testing OSS mirror and GitHub download sources ..."
468
+
469
+ run_probe_pair "$SPEED_PROBE_SMALL_PROBE" "$SPEED_PROBE_SMALL_SIZE" "$SPEED_PROBE_SMALL_HASH"
470
+ brs_oss_small="$(probe_status "$OSS_PROBE_METRICS")"
471
+ brs_github_small="$(probe_status "$GITHUB_PROBE_METRICS")"
472
+
473
+ if [ "$brs_oss_small" != "ok" ] && [ "$brs_github_small" = "ok" ]; then
474
+ SPEED_PROBE_BASE_URL="$GITHUB_SPEED_PROBE_BASE_URL"
475
+ SPEED_PROBE_FALLBACK_BASE_URL="$OSS_SPEED_PROBE_BASE_URL"
476
+ echo "Selected GitHub (OSS mirror probe unavailable)."
477
+ return 0
478
+ fi
479
+ if [ "$brs_oss_small" = "ok" ] && [ "$brs_github_small" != "ok" ]; then
480
+ SPEED_PROBE_BASE_URL="$OSS_SPEED_PROBE_BASE_URL"
481
+ SPEED_PROBE_FALLBACK_BASE_URL="$GITHUB_SPEED_PROBE_BASE_URL"
482
+ echo "Selected OSS mirror (GitHub probe unavailable)."
483
+ return 0
484
+ fi
485
+ if [ "$brs_oss_small" != "ok" ] && [ "$brs_github_small" != "ok" ]; then
486
+ return 1
487
+ fi
488
+
489
+ GITHUB_PROBE_METRICS="$TMP/probe-github-$SPEED_PROBE_LARGE_PROBE.metrics"
490
+ probe_download_source github "$GITHUB_SPEED_PROBE_BASE_URL" "$SPEED_PROBE_LARGE_PROBE" "$SPEED_PROBE_LARGE_SIZE" "$SPEED_PROBE_LARGE_HASH" "$GITHUB_PROBE_METRICS" "$SPEED_PROBE_LARGE_TIMEOUT_SECONDS"
491
+ brs_github_speed="$(probe_bytes_per_second "$GITHUB_PROBE_METRICS")"
492
+
493
+ # OSS is measured only once GitHub has failed the minimum: above it GitHub has already won, and
494
+ # the mirror's bandwidth is not spent on a probe that could not change the answer. Probing runs
495
+ # one source at a time on purpose — two concurrent transfers share the link and would each read
496
+ # as half as fast, which an absolute threshold cannot tolerate.
497
+ brs_oss_speed=0
498
+ if [ "$brs_github_speed" -lt "$SPEED_PROBE_GITHUB_MIN_BYTES_PER_SECOND" ]; then
499
+ OSS_PROBE_METRICS="$TMP/probe-oss-$SPEED_PROBE_LARGE_PROBE.metrics"
500
+ probe_download_source oss "$OSS_SPEED_PROBE_BASE_URL" "$SPEED_PROBE_LARGE_PROBE" "$SPEED_PROBE_LARGE_SIZE" "$SPEED_PROBE_LARGE_HASH" "$OSS_PROBE_METRICS" "$SPEED_PROBE_LARGE_TIMEOUT_SECONDS"
501
+ brs_oss_speed="$(probe_bytes_per_second "$OSS_PROBE_METRICS")"
502
+ fi
503
+
504
+ brs_choice="$(select_speed_probe_source "$brs_github_speed" "$brs_oss_speed")"
505
+ if [ "$brs_choice" = "github" ]; then
506
+ SPEED_PROBE_BASE_URL="$GITHUB_SPEED_PROBE_BASE_URL"
507
+ SPEED_PROBE_FALLBACK_BASE_URL="$OSS_SPEED_PROBE_BASE_URL"
508
+ if [ "$brs_github_speed" -ge "$SPEED_PROBE_GITHUB_MIN_BYTES_PER_SECOND" ]; then
509
+ echo "Selected GitHub (meets minimum download speed)."
510
+ else
511
+ echo "Selected GitHub (the OSS mirror was not enough faster to be worth switching)."
512
+ fi
513
+ else
514
+ SPEED_PROBE_BASE_URL="$OSS_SPEED_PROBE_BASE_URL"
515
+ SPEED_PROBE_FALLBACK_BASE_URL="$GITHUB_SPEED_PROBE_BASE_URL"
516
+ echo "Selected OSS mirror (clearly faster than GitHub here)."
517
+ fi
518
+ return 0
519
+ }
520
+
521
+ # Downloads the bundle and its checksum as a pair. Transport failures may try a same-version
522
+ # fallback; checksum failures are handled afterwards and always abort rather than being hidden
523
+ # by a different source.
524
+ download_release_pair() {
525
+ drp_base="$1"
526
+ drp_label="$(download_source_label "$drp_base")"
527
+ echo "Downloading $ASSET from $drp_label ..."
528
+ rm -f "$ARCHIVE_PATH" "$TMP/$ASSET.sha256"
529
+ curl -fSL --progress-bar --connect-timeout 5 --speed-limit 65536 --speed-time 20 "$drp_base/$ASSET" -o "$ARCHIVE_PATH" \
530
+ || return 1
531
+ curl -fsSL --connect-timeout 5 --speed-limit 1024 --speed-time 20 "$drp_base/$ASSET.sha256" -o "$TMP/$ASSET.sha256" \
532
+ || return 1
533
+ return 0
534
+ }
535
+
536
+ # Resolves the OSS mirror's latest immutable tag. The advertised base URL must exactly match
537
+ # the expected bucket path so metadata cannot redirect downloads to an arbitrary host.
538
+ get_oss_latest_tag() {
539
+ gol_manifest="$1"
540
+ rm -f "$gol_manifest"
541
+ curl -fsSL --connect-timeout 3 --max-time 8 "$OSS_ORIGIN/latest.json" -o "$gol_manifest" 2>/dev/null \
542
+ || return 1
543
+ gol_schema_version="$(sed -n 's/.*"schemaVersion":[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$gol_manifest" | head -n 1)"
544
+ gol_candidate_tag="$(sed -n 's/.*"tag":[[:space:]]*"\([^"]*\)".*/\1/p' "$gol_manifest" | head -n 1)"
545
+ gol_candidate_base="$(sed -n 's/.*"releaseBaseUrl":[[:space:]]*"\([^"]*\)".*/\1/p' "$gol_manifest" | head -n 1)"
546
+ [ "$gol_schema_version" = "1" ] \
547
+ && is_release_tag "$gol_candidate_tag" \
548
+ && [ "$gol_candidate_base" = "$OSS_RELEASE_ROOT/$gol_candidate_tag" ] \
549
+ || return 1
550
+ printf '%s\n' "$gol_candidate_tag"
551
+ }
552
+
553
+ # --- Resolve the program payload. Three entries converge on PAYLOAD_PATH:
554
+ # (a) bundled offline: this script sits next to payload.tar.gz in an extracted bundle;
555
+ # (b) --archive <file>: a local installer bundle, or a payload/legacy program archive;
556
+ # (c) online: download penguin-<target>.tar.gz and verify it, then open it.
557
+ # A bundle is recognized by containing payload.tar.gz at its top level; anything else is a
558
+ # program archive (payload.tar.gz itself, or a pre-0.1.6 release archive) whose top level
559
+ # is penguin/. ---
560
+ LOCAL_ARCHIVE=0
561
+ ARCHIVE_SHAPE=""
562
+ ARCHIVE_PATH=""
563
+ ARCHIVE_NAME=""
564
+ PAYLOAD_PATH=""
565
+
566
+ # Sibling pickup engages only for a real file actually named install.sh — the name it carries
567
+ # inside a bundle. A forwarder or `curl | sh` run never satisfies that (no file, or a random
568
+ # temp name), so an online installer cannot be steered by archives someone planted next to a
569
+ # temporary script in a shared directory.
570
+ SIBLING_PAYLOAD=""
571
+ if [ -z "$ARCHIVE" ] && [ -f "$0" ] && [ "$(basename "$0")" = "install.sh" ]; then
572
+ script_dir="$(CDPATH= cd "$(dirname "$0")" && pwd)"
573
+ if [ -f "$script_dir/$PAYLOAD_NAME" ]; then
574
+ SIBLING_PAYLOAD="$script_dir/$PAYLOAD_NAME"
575
+ fi
576
+ fi
577
+
578
+ if [ -n "$SIBLING_PAYLOAD" ]; then
579
+ # (a) Extracted bundle: install the sibling payload; no network access at all.
580
+ [ -z "$VERSION" ] \
581
+ || fail "--version/PENGUIN_VERSION cannot be combined with the bundled offline installer"
582
+ echo "Using bundled payload $SIBLING_PAYLOAD ..."
583
+ [ -f "$SIBLING_PAYLOAD.sha256" ] \
584
+ || fail "offline checksum file not found: $SIBLING_PAYLOAD.sha256"
585
+ verify_sha256 "$SIBLING_PAYLOAD" "$SIBLING_PAYLOAD.sha256" "Payload"
586
+ PAYLOAD_PATH="$SIBLING_PAYLOAD"
587
+ ARCHIVE_NAME="$PAYLOAD_NAME"
588
+ LOCAL_ARCHIVE=1
589
+ elif [ -n "$ARCHIVE" ]; then
590
+ # (b) Explicit local archive; its shape is probed below.
591
+ [ -f "$ARCHIVE" ] || fail "local archive not found: $ARCHIVE"
592
+ archive_name="${ARCHIVE##*/}"
593
+ archive_dir="$(CDPATH= cd "$(dirname "$ARCHIVE")" && pwd)"
594
+ ARCHIVE_PATH="$archive_dir/$archive_name"
595
+ ARCHIVE_NAME="$archive_name"
596
+ LOCAL_ARCHIVE=1
597
+ echo "Using local archive $ARCHIVE_PATH ..."
598
+ else
599
+ # (c) Online: explicit forwarder/configured URLs win. Otherwise a stamped Release installer
600
+ # uses its own immutable version: auto prefers OSS and falls back only to the same GitHub
601
+ # tag. An unstamped source-tree installer resolves latest.json first so it also locks one
602
+ # version before downloading assets.
603
+ FALLBACK_BASE_URL="$DOWNLOAD_FALLBACK_BASE_URL"
604
+ if [ -n "$DOWNLOAD_BASE_URL" ]; then
605
+ BASE_URL="$DOWNLOAD_BASE_URL"
606
+ elif [ "$SOURCE_MODE" = "github" ]; then
607
+ if [ -n "$RESOLVED_RELEASE_VERSION" ]; then
608
+ BASE_URL="$GITHUB_RELEASE_ROOT/$RESOLVED_RELEASE_VERSION"
609
+ else
610
+ BASE_URL="$GITHUB_LATEST_BASE"
611
+ fi
612
+ else
613
+ SELECTED_TAG="$RESOLVED_RELEASE_VERSION"
614
+ if [ -z "$SELECTED_TAG" ]; then
615
+ SELECTED_TAG="$(get_oss_latest_tag "$TMP/latest.json" || :)"
616
+ fi
617
+ if [ -n "$SELECTED_TAG" ]; then
618
+ BASE_URL="$OSS_RELEASE_ROOT/$SELECTED_TAG"
619
+ if [ "$SOURCE_MODE" = "auto" ] && [ -z "$FALLBACK_BASE_URL" ]; then
620
+ FALLBACK_BASE_URL="$GITHUB_RELEASE_ROOT/$SELECTED_TAG"
621
+ fi
622
+ if [ "$SOURCE_MODE" = "auto" ] && [ "$DOWNLOAD_SPEED_PROBE" = "1" ] && [ -z "$DOWNLOAD_BASE_URL" ]; then
623
+ if speed_probe_release_sources "$SELECTED_TAG"; then
624
+ BASE_URL="$SPEED_PROBE_BASE_URL"
625
+ FALLBACK_BASE_URL="$SPEED_PROBE_FALLBACK_BASE_URL"
626
+ else
627
+ echo "Download source test was inconclusive; using OSS with same-version GitHub fallback."
628
+ fi
629
+ fi
630
+ elif [ "$SOURCE_MODE" = "oss" ]; then
631
+ fail "the OSS mirror is unavailable or its release metadata is invalid."
632
+ else
633
+ BASE_URL="$GITHUB_LATEST_BASE"
634
+ fi
635
+ fi
636
+ ARCHIVE_PATH="$TMP/$ASSET"
637
+ ARCHIVE_NAME="$ASSET"
638
+ if ! download_release_pair "$BASE_URL"; then
639
+ if [ -n "$FALLBACK_BASE_URL" ] && [ "$FALLBACK_BASE_URL" != "$BASE_URL" ]; then
640
+ echo "Primary download source unavailable; trying $(download_source_label "$FALLBACK_BASE_URL") ..."
641
+ download_release_pair "$FALLBACK_BASE_URL" \
642
+ || fail "download failed from both the primary source and its fallback. Check your network, then retry."
643
+ else
644
+ fail "download failed from $(download_source_label "$BASE_URL"). Check the version tag and your network, then retry."
645
+ fi
646
+ fi
647
+ verify_sha256 "$ARCHIVE_PATH" "$TMP/$ASSET.sha256" "Bundle"
648
+ fi
649
+
650
+ if [ -z "$PAYLOAD_PATH" ]; then
651
+ if tar -tzf "$ARCHIVE_PATH" 2>/dev/null | head -50 | grep -qE "^(\./)?$PAYLOAD_NAME\$"; then
652
+ ARCHIVE_SHAPE="bundle"
653
+ else
654
+ ARCHIVE_SHAPE="program"
655
+ fi
656
+ if [ "$ARCHIVE_SHAPE" = "bundle" ]; then
657
+ # A local bundle is self-verifying through its sealed payload checksum; when the published
658
+ # outer .sha256 was transferred alongside it, verify that layer too.
659
+ if [ "$LOCAL_ARCHIVE" -eq 1 ] && [ -f "$ARCHIVE_PATH.sha256" ]; then
660
+ verify_sha256 "$ARCHIVE_PATH" "$ARCHIVE_PATH.sha256" "Bundle"
661
+ fi
662
+ BUNDLE_DIR="$TMP/bundle"
663
+ mkdir -p "$BUNDLE_DIR"
664
+ tar -xzf "$ARCHIVE_PATH" -C "$BUNDLE_DIR"
665
+ PAYLOAD_PATH="$BUNDLE_DIR/$PAYLOAD_NAME"
666
+ [ -f "$PAYLOAD_PATH" ] || fail "unexpected bundle layout: $PAYLOAD_NAME missing."
667
+ [ -f "$PAYLOAD_PATH.sha256" ] || fail "unexpected bundle layout: $PAYLOAD_NAME.sha256 missing."
668
+ verify_sha256 "$PAYLOAD_PATH" "$PAYLOAD_PATH.sha256" "Payload"
669
+ else
670
+ # Program archive (payload.tar.gz, or a pre-0.1.6 release archive). A local file needs an
671
+ # adjacent checksum — its own, or the canonical asset checksum next to a renamed legacy
672
+ # file; an online download was already verified against the published .sha256 above.
673
+ if [ "$LOCAL_ARCHIVE" -eq 1 ]; then
674
+ SHA_PATH="$ARCHIVE_PATH.sha256"
675
+ if [ ! -f "$SHA_PATH" ] && [ "$ARCHIVE_NAME" != "$ASSET" ] && [ -f "$archive_dir/$ASSET.sha256" ]; then
676
+ SHA_PATH="$archive_dir/$ASSET.sha256"
677
+ fi
678
+ [ -f "$SHA_PATH" ] || fail "offline checksum file not found: $SHA_PATH"
679
+ verify_sha256 "$ARCHIVE_PATH" "$SHA_PATH" "Payload"
680
+ fi
681
+ PAYLOAD_PATH="$ARCHIVE_PATH"
682
+ fi
683
+ fi
684
+
685
+ # --- Extract and validate in staging before touching the current install. The final same-filesystem
686
+ # swap keeps the previous dirs in .old.$$ until the installed command runs successfully; any
687
+ # move or launch failure restores them automatically. The data dir is never part of the swap. ---
688
+ tar -xzf "$PAYLOAD_PATH" -C "$TMP"
689
+ [ -d "$TMP/penguin" ] || fail "unexpected archive layout: top-level penguin/ missing."
690
+ MANIFEST_PATH="$TMP/penguin/package-manifest.json"
691
+ if [ -f "$MANIFEST_PATH" ]; then
692
+ manifest_target="$(sed -n 's/.*"target"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST_PATH" | head -n 1)"
693
+ [ -n "$manifest_target" ] || fail "package manifest is malformed: target missing."
694
+ [ "$manifest_target" = "$TARGET" ] \
695
+ || fail "package target mismatch: expected $TARGET, found $manifest_target."
696
+ elif [ "$LOCAL_ARCHIVE" -eq 1 ] && [ "$ARCHIVE_SHAPE" = "program" ] && [ "$ARCHIVE_NAME" != "$ASSET" ]; then
697
+ fail "a renamed local archive must contain package-manifest.json; use the original filename for legacy packages."
698
+ fi
699
+ mkdir -p "$INSTALL_DIR"
700
+ STAGING="$INSTALL_DIR/.staging.$$"
701
+ OLD_DIR="$INSTALL_DIR/.old.$$"
702
+ rm -rf "$STAGING"
703
+ rm -rf "$OLD_DIR"
704
+ mkdir -p "$STAGING"
705
+ for d in bin lib web node; do
706
+ if [ -e "$TMP/penguin/$d" ]; then
707
+ mv "$TMP/penguin/$d" "$STAGING/$d"
708
+ fi
709
+ done
710
+ [ -x "$STAGING/bin/penguin" ] || fail "unexpected archive layout: bin/penguin missing."
711
+
712
+ # The launcher resolves lib/, web/ and node/ relative to itself, so staging is a faithful
713
+ # preflight that catches macOS execution policy and runtime/package failures before replacement.
714
+ if candidate_version="$("$STAGING/bin/penguin" --version)"; then
715
+ [ -n "$candidate_version" ] || fail "candidate PenguinHarness returned an empty version."
716
+ else
717
+ candidate_status=$?
718
+ fail "candidate PenguinHarness failed to run (exit status $candidate_status). See the error above."
719
+ fi
720
+
721
+ mkdir -p "$OLD_DIR"
722
+ SWAP_ACTIVE=1
723
+ for d in bin lib web node; do
724
+ if [ -e "$INSTALL_DIR/$d" ]; then
725
+ if relocate_dir "$INSTALL_DIR/$d" "$OLD_DIR/$d"; then
726
+ MOVED_OLD="$MOVED_OLD $d"
727
+ else
728
+ fail "could not move the existing $d directory aside (stop running penguin processes and retry); the previous installation will be restored."
729
+ fi
730
+ fi
731
+ done
732
+ for d in bin lib web node; do
733
+ if [ -e "$STAGING/$d" ]; then
734
+ if relocate_dir "$STAGING/$d" "$INSTALL_DIR/$d"; then
735
+ MOVED_NEW="$MOVED_NEW $d"
736
+ else
737
+ fail "could not install the new $d directory; the previous installation will be restored."
738
+ fi
739
+ fi
740
+ done
741
+ [ -x "$INSTALL_DIR/bin/penguin" ] || fail "install incomplete: $INSTALL_DIR/bin/penguin missing."
742
+
743
+ # Verify again from the final path before deleting the backup. Keep stderr visible so platform
744
+ # policy, permission and runtime errors are not disguised as an "unknown" version.
745
+ if installed_version="$("$INSTALL_DIR/bin/penguin" --version)"; then
746
+ [ -n "$installed_version" ] || fail "installed PenguinHarness returned an empty version."
747
+ else
748
+ version_status=$?
749
+ fail "installed PenguinHarness failed to run (exit status $version_status); the previous installation will be restored. See the error above."
750
+ fi
751
+
752
+ SWAP_ACTIVE=0
753
+ if ! rm -rf "$OLD_DIR"; then
754
+ echo "warning: could not remove the previous installation backup at $OLD_DIR" >&2
755
+ fi
756
+ OLD_DIR=""
757
+ rm -rf "$STAGING"
758
+ STAGING=""
759
+
760
+ # --- Symlink into ~/.local/bin and check PATH only after the install is known to work. ---
761
+ mkdir -p "$BIN_DIR"
762
+ ln -sf "$INSTALL_DIR/bin/penguin" "$BIN_DIR/penguin"
763
+ PATH_MISSING=0
764
+ case ":$PATH:" in
765
+ *":$BIN_DIR:"*) ;;
766
+ *) PATH_MISSING=1 ;;
767
+ esac
768
+
769
+ echo ""
770
+ echo "PenguinHarness $installed_version installed to $INSTALL_DIR"
771
+ if [ "$PATH_MISSING" -eq 1 ]; then
772
+ echo ""
773
+ echo "note: installation succeeded, but $BIN_DIR is not on your PATH. Add it to your shell profile:"
774
+ case "${SHELL:-}" in
775
+ */zsh) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" ;;
776
+ */bash) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.bashrc && source ~/.bashrc" ;;
777
+ */fish) echo " fish_add_path \$HOME/.local/bin" ;;
778
+ *) echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" ;;
779
+ esac
780
+ fi
781
+ echo ""
782
+ echo "Get started:"
783
+ echo " penguin --help # all commands"
784
+ echo " penguin web # start the Web UI at http://127.0.0.1:7364 (a first-login link is printed on first start)"
785
+ echo " penguin server # headless server (PORT / HOST to override)"