@astrofoundry/pi-astro 0.30.0 → 0.32.0

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.
@@ -0,0 +1,459 @@
1
+ #!/bin/sh
2
+ # /usr/local/sbin/specialist-install: installs the specialist user's Node, wrappers,
3
+ # arcane-cli, and trendarr from downloads it checks itself. Installed root-owned 0755.
4
+ # Runs as root through
5
+ # cortex ALL=(root) NOPASSWD: /usr/local/sbin/specialist-install
6
+ # It runs only system binaries and the Node it installed, never anything the cortex
7
+ # user can write (all of Homebrew), and it touches the specialist's home only as that
8
+ # user, so links the specialist plants there cannot redirect a root write.
9
+ #
10
+ # specialist-install node|wrappers|arcane-cli|trendarr <version>
11
+ # specialist-install rollback node|wrappers|arcane-cli|trendarr
12
+ # specialist-install status
13
+ set -eu
14
+ PATH=/usr/bin:/bin:/usr/sbin:/sbin
15
+ export PATH
16
+ umask 077
17
+ cd /
18
+
19
+ SPECIALIST=specialist
20
+ NODE_BASE=/usr/local/libexec/specialist-node
21
+ # Same list as the allowlist in specialist-cli; a new specialist needs both reinstalled.
22
+ SERVICES="arcane identity network dns edge security backup proxmox inference media"
23
+
24
+ usage() {
25
+ echo "usage: specialist-install node|wrappers|arcane-cli|trendarr <version> | rollback node|wrappers|arcane-cli|trendarr | status" >&2
26
+ exit 2
27
+ }
28
+
29
+ fail() {
30
+ echo "specialist-install: $*" >&2
31
+ exit 1
32
+ }
33
+
34
+ # x.y.z with plain decimal parts; the character check also rules out newlines.
35
+ valid_version() {
36
+ case "$1" in '' | *[!0-9.]*) return 1 ;; esac
37
+ printf '%s\n' "$1" | /usr/bin/grep -Eq '^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})$'
38
+ }
39
+
40
+ # True when version $1 is older than version $2.
41
+ version_lt() {
42
+ old_ifs=$IFS
43
+ IFS=.
44
+ set -- $1 $2
45
+ IFS=$old_ifs
46
+ [ "$1" -lt "$4" ] && return 0
47
+ [ "$1" -gt "$4" ] && return 1
48
+ [ "$2" -lt "$5" ] && return 0
49
+ [ "$2" -gt "$5" ] && return 1
50
+ [ "$3" -lt "$6" ]
51
+ }
52
+
53
+ component_ok() {
54
+ case "$1" in node | wrappers | arcane-cli | trendarr) return 0 ;; esac
55
+ return 1
56
+ }
57
+
58
+ command=${1:-}
59
+ case "$command" in
60
+ node | wrappers | arcane-cli | trendarr)
61
+ [ $# -eq 2 ] || usage
62
+ valid_version "$2" || usage
63
+ ;;
64
+ rollback)
65
+ [ $# -eq 2 ] || usage
66
+ component_ok "$2" || usage
67
+ ;;
68
+ status) [ $# -eq 1 ] || usage ;;
69
+ *) usage ;;
70
+ esac
71
+
72
+ [ "$(/usr/bin/id -u)" = 0 ] || fail "must run as root: sudo /usr/local/sbin/specialist-install $*"
73
+ [ "$(/usr/bin/uname -s)" = Darwin ] && [ "$(/usr/bin/uname -m)" = arm64 ] || fail "only macOS on arm64 is supported"
74
+ /usr/bin/id -u "$SPECIALIST" >/dev/null 2>&1 || fail "user $SPECIALIST does not exist"
75
+
76
+ work=$(/usr/bin/mktemp -d /private/var/tmp/specialist-install.XXXXXX)
77
+ trap '/bin/rm -rf "$work"' EXIT
78
+ trap 'exit 1' HUP INT TERM
79
+
80
+ # A directory only root can change: owned by uid 0, not a link, writable by nobody else.
81
+ check_root_dir() {
82
+ [ -d "$1" ] && [ ! -L "$1" ] || fail "$1 is not a directory"
83
+ set -- "$1" $(/usr/bin/stat -f '%u %Lp' "$1")
84
+ [ "$2" = 0 ] || fail "$1 is not owned by root"
85
+ case "$3" in *[2367] | *[2367]?) fail "$1 is writable by group or others" ;; esac
86
+ }
87
+
88
+ ensure_root_dir() {
89
+ [ -e "$1" ] || { /bin/mkdir -m 0755 "$1" && /usr/sbin/chown root:wheel "$1"; }
90
+ check_root_dir "$1"
91
+ }
92
+
93
+ check_node_base() {
94
+ check_root_dir /
95
+ check_root_dir /usr
96
+ check_root_dir /usr/local
97
+ ensure_root_dir /usr/local/libexec
98
+ ensure_root_dir "$NODE_BASE"
99
+ }
100
+
101
+ fetch() {
102
+ /usr/bin/curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL --retry 2 --max-time 900 -o "$2" "$1" || fail "download failed: $1"
103
+ }
104
+
105
+ json_field() {
106
+ /usr/bin/plutil -extract "$2" raw -o - "$1" 2>/dev/null
107
+ }
108
+
109
+ # $1 directory, $2 file name in it, $3 a "<sha256> <name>" list.
110
+ check_sha256() {
111
+ want=$(/usr/bin/awk -v f="$2" '$2 == f && length($1) == 64 { print $1; exit }' "$3")
112
+ [ -n "$want" ] || fail "no checksum for $2"
113
+ got=$(/usr/bin/shasum -a 256 "$1/$2" | /usr/bin/awk '{ print $1 }')
114
+ [ "$got" = "$want" ] || fail "checksum mismatch for $2"
115
+ }
116
+
117
+ # Downloads an npm package tarball after checking it against the registry's sha512 integrity.
118
+ fetch_npm() {
119
+ meta="$work/$3.meta.json"
120
+ fetch "https://registry.npmjs.org/$1/$2" "$meta"
121
+ [ "$(json_field "$meta" version)" = "$2" ] || fail "$1@$2 is not in the npm registry"
122
+ tarball=$(json_field "$meta" dist.tarball) || fail "$1@$2 has no tarball"
123
+ integrity=$(json_field "$meta" dist.integrity) || fail "$1@$2 has no integrity"
124
+ case "$tarball" in https://registry.npmjs.org/*) ;; *) fail "unexpected tarball location for $1@$2" ;; esac
125
+ fetch "$tarball" "$work/$3.tgz"
126
+ [ "sha512-$(/usr/bin/openssl dgst -sha512 -binary "$work/$3.tgz" | /usr/bin/base64)" = "$integrity" ] || fail "integrity mismatch for $1@$2"
127
+ }
128
+
129
+ # Runs a script as the specialist user with fixed arguments; the script never sees root's environment.
130
+ as_specialist() {
131
+ script=$1
132
+ shift
133
+ /usr/bin/sudo -n -u "$SPECIALIST" -H /bin/sh -c "set -eu; umask 077; PATH=/usr/bin:/bin:/usr/sbin:/sbin; cd \"\$HOME\"; $script" specialist-install "$@"
134
+ }
135
+
136
+ # Root-owned staging for files the specialist then copies into its home.
137
+ make_stage() {
138
+ /bin/chmod 0711 "$work"
139
+ stage="$work/stage"
140
+ /bin/mkdir -m 0700 "$stage"
141
+ }
142
+
143
+ hand_stage() {
144
+ /usr/sbin/chown -R "$SPECIALIST:$(/usr/bin/id -gn "$SPECIALIST")" "$stage"
145
+ }
146
+
147
+ node_link() {
148
+ target=$(/usr/bin/readlink "$NODE_BASE/$1" 2>/dev/null) || return 0
149
+ valid_version "$target" && [ -x "$NODE_BASE/$target/bin/node" ] && printf '%s' "$target"
150
+ return 0
151
+ }
152
+
153
+ switch_node_link() {
154
+ /bin/rm -f "$NODE_BASE/.$1.tmp"
155
+ (umask 022 && /bin/ln -s "$2" "$NODE_BASE/.$1.tmp")
156
+ /bin/mv -h "$NODE_BASE/.$1.tmp" "$NODE_BASE/$1"
157
+ }
158
+
159
+ current_node() {
160
+ node=$(node_link current)
161
+ [ -n "$node" ] || fail "no specialist Node yet: run specialist-install node <version> first"
162
+ printf '%s' "$NODE_BASE/$node/bin/node"
163
+ }
164
+
165
+ # Specialist side: every installed wrapper answers --help with Node $1; nothing to check before the first install.
166
+ SMOKE_INSTALLED='node=$1; shift
167
+ bin="$HOME/.specialists/bin"
168
+ [ -d "$bin/lib" ] || exit 0
169
+ for s in "$@"; do
170
+ "$node" --disable-warning=ExperimentalWarning "$bin/$s/run.ts" --caller specialist-install -- --help >/dev/null 2>&1 || { echo "$s --help failed with $node" >&2; exit 1; }
171
+ done'
172
+
173
+ smoke_installed_wrappers() {
174
+ as_specialist "$SMOKE_INSTALLED" "$1" $SERVICES
175
+ }
176
+
177
+ install_node() {
178
+ version=$1
179
+ check_node_base
180
+ current=$(node_link current)
181
+ if [ -n "$current" ] && version_lt "$version" "$current"; then fail "node $version is older than the installed $current; use rollback"; fi
182
+ [ "$current" != "$version" ] || { echo "node $version is already current"; return 0; }
183
+
184
+ fetch https://nodejs.org/dist/index.json "$work/index.json"
185
+ i=0
186
+ found=""
187
+ while [ "$i" -lt 400 ]; do
188
+ v=$(json_field "$work/index.json" "$i.version") || break
189
+ if [ "$v" = "v$version" ]; then found=$i; break; fi
190
+ i=$((i + 1))
191
+ done
192
+ [ -n "$found" ] || fail "node $version is not among the 400 newest releases"
193
+ [ "$(json_field "$work/index.json" "$found.lts")" != false ] || fail "node $version is not an LTS release"
194
+ /usr/bin/plutil -extract "$found.files" json -o - "$work/index.json" | /usr/bin/grep -q '"osx-arm64-tar"' || fail "node $version has no macOS arm64 build"
195
+
196
+ name="node-v$version-darwin-arm64.tar.gz"
197
+ fetch "https://nodejs.org/dist/v$version/$name" "$work/$name"
198
+ fetch "https://nodejs.org/dist/v$version/SHASUMS256.txt" "$work/SHASUMS256.txt"
199
+ check_sha256 "$work" "$name" "$work/SHASUMS256.txt"
200
+
201
+ dest="$NODE_BASE/$version"
202
+ /bin/rm -rf "$dest.partial"
203
+ /bin/mkdir -m 0755 "$dest.partial"
204
+ /usr/bin/tar -xzf "$work/$name" -C "$dest.partial" --strip-components 1
205
+ /usr/sbin/chown -R root:wheel "$dest.partial"
206
+ /bin/chmod -R u=rwX,go=rX "$dest.partial"
207
+ [ "$("$dest.partial/bin/node" --version)" = "v$version" ] || fail "node $version does not run"
208
+ /bin/rm -rf "$dest"
209
+ /bin/mv "$dest.partial" "$dest"
210
+ smoke_installed_wrappers "$dest/bin/node" || fail "the installed wrappers fail with node $version; current stays ${current:-unset}"
211
+
212
+ [ -z "$current" ] || switch_node_link previous "$current"
213
+ switch_node_link current "$version"
214
+ for d in "$NODE_BASE"/*; do
215
+ n=${d##*/}
216
+ case "$n" in current | previous | "$version" | "$current") continue ;; esac
217
+ /bin/rm -rf "$d"
218
+ done
219
+ echo "node $version is current${current:+; previous $current}"
220
+ }
221
+
222
+ rollback_node() {
223
+ check_node_base
224
+ current=$(node_link current)
225
+ previous=$(node_link previous)
226
+ [ -n "$current" ] && [ -n "$previous" ] || fail "no previous node to roll back to"
227
+ smoke_installed_wrappers "$NODE_BASE/$previous/bin/node" || fail "the installed wrappers fail with node $previous"
228
+ switch_node_link current "$previous"
229
+ switch_node_link previous "$current"
230
+ echo "node $previous is current; previous $current"
231
+ }
232
+
233
+ WRAPPER_VERSION='v=$(cat "$HOME/.specialists/bin/VERSION" 2>/dev/null) || exit 0; printf "%s" "$v"'
234
+
235
+ # Moves the new wrapper files in and the old ones aside, two renames per item, then
236
+ # checks every wrapper; on failure the old files come back.
237
+ SWAP_WRAPPERS='node=$1; stage=$2; shift 2
238
+ bin="$HOME/.specialists/bin"; back="$HOME/.specialists/rollback/wrappers"
239
+ items="lib provision.sh VERSION $*"
240
+ smoke() { d=$1; shift; for s in "$@"; do "$node" --disable-warning=ExperimentalWarning "$d/$s/run.ts" --caller specialist-install -- --help >/dev/null 2>&1 || { echo "$s --help failed" >&2; return 1; }; done; }
241
+ smoke "$stage" "$@" || exit 1
242
+ mkdir -p "$bin" "$HOME/.specialists/rollback"
243
+ rm -rf "$back.new"; mkdir "$back.new"
244
+ for i in $items; do rm -rf "$bin/.new-$i"; cp -R "$stage/$i" "$bin/.new-$i"; done
245
+ for i in $items; do
246
+ [ ! -e "$bin/$i" ] || mv "$bin/$i" "$back.new/$i"
247
+ mv "$bin/.new-$i" "$bin/$i"
248
+ done
249
+ chmod -R go-rwx "$bin"
250
+ if ! smoke "$bin" "$@"; then
251
+ for i in $items; do rm -rf "$bin/$i"; [ ! -e "$back.new/$i" ] || mv "$back.new/$i" "$bin/$i"; done
252
+ rm -rf "$back.new"
253
+ echo "restored the previous wrappers" >&2
254
+ exit 1
255
+ fi
256
+ rm -rf "$back"; mv "$back.new" "$back"'
257
+
258
+ install_wrappers() {
259
+ version=$1
260
+ node=$(current_node)
261
+ current=$(as_specialist "$WRAPPER_VERSION")
262
+ valid_version "$current" || current=""
263
+ if [ -n "$current" ] && version_lt "$version" "$current"; then fail "wrappers $version are older than the installed $current; use rollback"; fi
264
+
265
+ fetch_npm @astrofoundry/pi-astro "$version" pi-astro
266
+ /bin/mkdir "$work/pkg"
267
+ /usr/bin/tar -xzf "$work/pi-astro.tgz" -C "$work/pkg" --strip-components 1
268
+ src="$work/pkg/specialists"
269
+ [ -d "$src/lib" ] && [ -f "$src/provision/provision.sh" ] || fail "pi-astro $version has no specialists/lib or provision.sh"
270
+ make_stage
271
+ /bin/cp -R "$src/lib" "$stage/lib"
272
+ for s in $SERVICES; do
273
+ [ -f "$src/$s/run.ts" ] || fail "pi-astro $version has no $s wrapper"
274
+ /bin/mkdir "$stage/$s"
275
+ /bin/cp "$src/$s/run.ts" "$stage/$s/run.ts"
276
+ done
277
+ /bin/cp "$src/provision/provision.sh" "$stage/provision.sh"
278
+ /usr/bin/find "$stage" -type f -name '*.test.ts' -delete
279
+ printf '%s\n' "$version" > "$stage/VERSION"
280
+ hand_stage
281
+ as_specialist "$SWAP_WRAPPERS" "$node" "$stage" $SERVICES || fail "wrappers $version failed their check; ${current:-no} previous version kept"
282
+ echo "wrappers $version installed${current:+; previous $current}"
283
+ }
284
+
285
+ ROLLBACK_WRAPPERS='node=$1; shift
286
+ bin="$HOME/.specialists/bin"; back="$HOME/.specialists/rollback/wrappers"
287
+ [ -f "$back/VERSION" ] || { echo "no previous wrappers to roll back to" >&2; exit 1; }
288
+ items="lib provision.sh VERSION $*"
289
+ swap() { for i in $items; do
290
+ [ ! -e "$bin/$i" ] || mv "$bin/$i" "$bin/.old-$i"
291
+ [ ! -e "$back/$i" ] || mv "$back/$i" "$bin/$i"
292
+ [ ! -e "$bin/.old-$i" ] || mv "$bin/.old-$i" "$back/$i"
293
+ done; }
294
+ swap
295
+ for s in "$@"; do
296
+ "$node" --disable-warning=ExperimentalWarning "$bin/$s/run.ts" --caller specialist-install -- --help >/dev/null 2>&1 || { swap; echo "$s --help failed; nothing changed" >&2; exit 1; }
297
+ done
298
+ cat "$bin/VERSION"'
299
+
300
+ rollback_wrappers() {
301
+ node=$(current_node)
302
+ restored=$(as_specialist "$ROLLBACK_WRAPPERS" "$node" $SERVICES) || fail "wrapper rollback failed"
303
+ echo "wrappers $restored restored"
304
+ }
305
+
306
+ ARCANE_VERSION='out=$("$HOME/.specialists/bin/arcane-cli" --version 2>/dev/null) || exit 0
307
+ printf "%s\n" "$out" | awk '"'"'{ for (i = 1; i < NF; i++) if ($i == "version") { print $(i + 1); exit } }'"'"''
308
+
309
+ INSTALL_ARCANE='stage=$1; v=$2
310
+ bin="$HOME/.specialists/bin"
311
+ check() { "$1" --version 2>/dev/null | awk -v v="$v" '"'"'{ for (i = 1; i < NF; i++) if ($i == "version" && $(i + 1) == v) f = 1 } END { exit !f }'"'"'; }
312
+ mkdir -p "$bin"
313
+ cp "$stage/arcane-cli" "$bin/.arcane-cli.new"
314
+ chmod 0700 "$bin/.arcane-cli.new"
315
+ check "$bin/.arcane-cli.new" || { rm -f "$bin/.arcane-cli.new"; echo "the new arcane-cli does not report version $v" >&2; exit 1; }
316
+ had=no
317
+ if [ -e "$bin/arcane-cli" ]; then mv "$bin/arcane-cli" "$bin/arcane-cli.previous"; had=yes; fi
318
+ mv "$bin/.arcane-cli.new" "$bin/arcane-cli"
319
+ if ! check "$bin/arcane-cli"; then
320
+ rm -f "$bin/arcane-cli"
321
+ [ "$had" = no ] || mv "$bin/arcane-cli.previous" "$bin/arcane-cli"
322
+ echo "restored the previous arcane-cli" >&2
323
+ exit 1
324
+ fi'
325
+
326
+ install_arcane_cli() {
327
+ version=$1
328
+ current=$(as_specialist "$ARCANE_VERSION")
329
+ valid_version "$current" || current=""
330
+ if [ -n "$current" ] && version_lt "$version" "$current"; then fail "arcane-cli $version is older than the installed $current; use rollback"; fi
331
+
332
+ asset=arcane-cli_darwin_arm64.tar.gz
333
+ base="https://github.com/getarcaneapp/arcane/releases/download/v$version"
334
+ fetch "$base/$asset" "$work/$asset"
335
+ fetch "$base/arcane_${version}_checksums.txt" "$work/checksums.txt"
336
+ check_sha256 "$work" "$asset" "$work/checksums.txt"
337
+ /bin/mkdir "$work/acli"
338
+ /usr/bin/tar -xzf "$work/$asset" -C "$work/acli"
339
+ cli=$(/usr/bin/find "$work/acli" -type f -name arcane-cli | /usr/bin/head -n 1)
340
+ [ -n "$cli" ] || fail "no arcane-cli binary in $asset"
341
+ make_stage
342
+ /bin/cp "$cli" "$stage/arcane-cli"
343
+ hand_stage
344
+ as_specialist "$INSTALL_ARCANE" "$stage" "$version" || fail "arcane-cli $version failed its check"
345
+ echo "arcane-cli $version installed${current:+; previous $current}"
346
+ }
347
+
348
+ ROLLBACK_ARCANE='bin="$HOME/.specialists/bin"
349
+ [ -e "$bin/arcane-cli.previous" ] || { echo "no previous arcane-cli to roll back to" >&2; exit 1; }
350
+ mv "$bin/arcane-cli" "$bin/.arcane-cli.old"
351
+ mv "$bin/arcane-cli.previous" "$bin/arcane-cli"
352
+ mv "$bin/.arcane-cli.old" "$bin/arcane-cli.previous"
353
+ "$bin/arcane-cli" --version'
354
+
355
+ rollback_arcane_cli() {
356
+ out=$(as_specialist "$ROLLBACK_ARCANE") || fail "arcane-cli rollback failed"
357
+ echo "restored $out"
358
+ }
359
+
360
+ # trendarr lives in versioned directories behind the stable link bin/trendarr, so
361
+ # media.json's trendarrCli path never changes; bin/trendarr.previous keeps the last one.
362
+ TRENDARR_COMMON='bin="$HOME/.specialists/bin"
363
+ cli() { printf "%s/node_modules/@astrofoundry/trendarr/dist/cli.js" "$1"; }
364
+ version_of() { plutil -extract version raw -o - "$1/node_modules/@astrofoundry/trendarr/package.json" 2>/dev/null; }
365
+ link() { rm -f "$bin/.$1.tmp"; ln -s "$2" "$bin/.$1.tmp"; mv -h "$bin/.$1.tmp" "$bin/$1"; }'
366
+
367
+ TRENDARR_VERSION="$TRENDARR_COMMON"'
368
+ [ -e "$bin/trendarr" ] || exit 0
369
+ version_of "$bin/trendarr" || exit 0'
370
+
371
+ # A hand-installed bin/trendarr directory (the first media setup) becomes trendarr-<version>
372
+ # behind the link; one without a readable package moves aside to bin/trendarr.unknown.
373
+ INSTALL_TRENDARR="$TRENDARR_COMMON"'
374
+ node=$1; npm=$2; v=$3
375
+ if [ -d "$bin/trendarr" ] && [ ! -L "$bin/trendarr" ]; then
376
+ old=$(version_of "$bin/trendarr" || true)
377
+ case "$old" in
378
+ "" | *[!0-9.]*) rm -rf "$bin/trendarr.unknown"; mv "$bin/trendarr" "$bin/trendarr.unknown" ;;
379
+ *) rm -rf "$bin/trendarr-$old"; mv "$bin/trendarr" "$bin/trendarr-$old"; link trendarr "trendarr-$old" ;;
380
+ esac
381
+ fi
382
+ dir="$bin/trendarr-$v"
383
+ mkdir -p "$bin"
384
+ rm -rf "$dir.partial"
385
+ "$node" "$npm" install --prefix "$dir.partial" --omit=dev --ignore-scripts --no-audit --no-fund --no-update-notifier "@astrofoundry/trendarr@$v" >/dev/null
386
+ [ "$("$node" "$(cli "$dir.partial")" --version)" = "$v" ] || { rm -rf "$dir.partial"; echo "trendarr $v does not report its version" >&2; exit 1; }
387
+ before=$(readlink "$bin/trendarr" 2>/dev/null || true)
388
+ rm -rf "$dir"
389
+ mv "$dir.partial" "$dir"
390
+ link trendarr "trendarr-$v"
391
+ if [ "$("$node" "$(cli "$bin/trendarr")" --version)" != "$v" ]; then
392
+ if [ -n "$before" ]; then link trendarr "$before"; else rm -f "$bin/trendarr"; fi
393
+ echo "restored the previous trendarr" >&2
394
+ exit 1
395
+ fi
396
+ if [ -n "$before" ] && [ "$before" != "trendarr-$v" ]; then link trendarr.previous "$before"; fi
397
+ keep=$(readlink "$bin/trendarr.previous" 2>/dev/null || true)
398
+ for d in "$bin"/trendarr-*; do
399
+ [ -d "$d" ] || continue
400
+ n=${d##*/}
401
+ [ "$n" = "trendarr-$v" ] || [ "$n" = "$keep" ] || rm -rf "$d"
402
+ done'
403
+
404
+ install_trendarr() {
405
+ version=$1
406
+ node=$(current_node)
407
+ npm="${node%/bin/node}/lib/node_modules/npm/bin/npm-cli.js"
408
+ [ -f "$npm" ] || fail "the specialist Node has no npm"
409
+ current=$(as_specialist "$TRENDARR_VERSION")
410
+ valid_version "$current" || current=""
411
+ if [ -n "$current" ] && version_lt "$version" "$current"; then fail "trendarr $version is older than the installed $current; use rollback"; fi
412
+ [ "$current" != "$version" ] || { echo "trendarr $version is already current"; return 0; }
413
+ as_specialist "$INSTALL_TRENDARR" "$node" "$npm" "$version" || fail "trendarr $version failed its check"
414
+ echo "trendarr $version installed${current:+; previous $current}"
415
+ }
416
+
417
+ ROLLBACK_TRENDARR="$TRENDARR_COMMON"'
418
+ node=$1
419
+ previous=$(readlink "$bin/trendarr.previous" 2>/dev/null) || { echo "no previous trendarr to roll back to" >&2; exit 1; }
420
+ current=$(readlink "$bin/trendarr")
421
+ [ -d "$bin/$previous" ] || { echo "$previous is missing" >&2; exit 1; }
422
+ link trendarr "$previous"
423
+ link trendarr.previous "$current"
424
+ "$node" "$(cli "$bin/trendarr")" --version'
425
+
426
+ rollback_trendarr() {
427
+ node=$(current_node)
428
+ out=$(as_specialist "$ROLLBACK_TRENDARR" "$node") || fail "trendarr rollback failed"
429
+ echo "trendarr $out restored"
430
+ }
431
+
432
+ json_version() {
433
+ if valid_version "$1"; then printf '"%s"' "$1"; else printf 'null'; fi
434
+ }
435
+
436
+ status() {
437
+ wrappers=$(as_specialist "$WRAPPER_VERSION" || true)
438
+ arcane=$(as_specialist "$ARCANE_VERSION" || true)
439
+ trendarr=$(as_specialist "$TRENDARR_VERSION" || true)
440
+ printf '{"node":{"current":%s,"previous":%s},"wrappers":%s,"arcaneCli":%s,"trendarr":%s}\n' \
441
+ "$(json_version "$(node_link current)")" "$(json_version "$(node_link previous)")" \
442
+ "$(json_version "$wrappers")" "$(json_version "$arcane")" "$(json_version "$trendarr")"
443
+ }
444
+
445
+ case "$command" in
446
+ node) install_node "$2" ;;
447
+ wrappers) install_wrappers "$2" ;;
448
+ arcane-cli) install_arcane_cli "$2" ;;
449
+ trendarr) install_trendarr "$2" ;;
450
+ rollback)
451
+ case "$2" in
452
+ node) rollback_node ;;
453
+ wrappers) rollback_wrappers ;;
454
+ arcane-cli) rollback_arcane_cli ;;
455
+ trendarr) rollback_trendarr ;;
456
+ esac
457
+ ;;
458
+ status) status ;;
459
+ esac
@@ -0,0 +1,95 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ const SCRIPT = join(import.meta.dirname, "specialist-install.sh");
7
+ const ENTRY = join(import.meta.dirname, "specialist-cli.sh");
8
+ // As root the script would really install; these tests only exercise what runs before the root check.
9
+ const isRoot = process.getuid?.() === 0;
10
+
11
+ function install(...args: string[]) {
12
+ return spawnSync("/bin/sh", [SCRIPT, ...args], { encoding: "utf-8" });
13
+ }
14
+
15
+ /** Runs one shell function of the script on its own, followed by `call`. */
16
+ function shellFunction(name: string, call: string) {
17
+ const match = new RegExp(`^${name}\\(\\) \\{\\n[\\s\\S]*?^\\}`, "m").exec(readFileSync(SCRIPT, "utf-8"));
18
+ if (!match) throw new Error(`${name} not found in the script`);
19
+ return spawnSync("/bin/sh", ["-c", `${match[0]}\n${call}`], { encoding: "utf-8" });
20
+ }
21
+
22
+ describe("specialist-install", () => {
23
+ it("parses as POSIX sh", () => {
24
+ expect(spawnSync("/bin/sh", ["-n", SCRIPT]).status).toBe(0);
25
+ });
26
+
27
+ it.each([
28
+ [[]],
29
+ [["upgrade", "1.2.3"]],
30
+ [["node"]],
31
+ [["node", "24.22"]],
32
+ [["node", "v24.22.0"]],
33
+ [["node", "024.22.0"]],
34
+ [["node", "24.22.0\nrm"]],
35
+ [["node", "24.22.0", "extra"]],
36
+ [["wrappers", "../../1.2.3"]],
37
+ [["arcane-cli", "2.16.1-beta"]],
38
+ [["trendarr", ""]],
39
+ [["rollback"]],
40
+ [["rollback", "pi"]],
41
+ [["rollback", "node", "extra"]],
42
+ [["status", "now"]],
43
+ ])("refuses %j with a usage error before anything else", (args) => {
44
+ const result = install(...args);
45
+ expect(result.status).toBe(2);
46
+ expect(result.stderr).toMatch(/^usage: specialist-install/);
47
+ });
48
+
49
+ it.skipIf(isRoot).each([[["node", "24.22.0"]], [["wrappers", "0.31.0"]], [["arcane-cli", "2.16.1"]], [["trendarr", "0.6.0"]], [["rollback", "trendarr"]], [["status"]]])(
50
+ "accepts %j and then requires root",
51
+ (args) => {
52
+ const result = install(...args);
53
+ expect(result.status).toBe(1);
54
+ expect(result.stderr).toContain("must run as root");
55
+ },
56
+ );
57
+
58
+ it.each(["0.0.0", "1.2.3", "24.22.0", "2.16.1", "100000.0.1"])("valid_version accepts %s", (v) => {
59
+ expect(shellFunction("valid_version", `valid_version '${v}'`).status).toBe(0);
60
+ });
61
+
62
+ it.each(["", "1.2", "1.2.3.4", "01.2.3", "1.2.3a", "-1.2.3", "1..3", "1000000.0.0"])("valid_version rejects %j", (v) => {
63
+ expect(shellFunction("valid_version", `valid_version '${v}'`).status).toBe(1);
64
+ });
65
+
66
+ it.each([
67
+ ["24.21.0", "24.22.0", true],
68
+ ["24.22.0", "24.22.0", false],
69
+ ["24.22.1", "24.22.0", false],
70
+ ["2.9.9", "2.16.1", true],
71
+ ["10.0.0", "9.99.99", false],
72
+ ["0.6.0", "0.31.0", true],
73
+ ])("version_lt %s < %s is %s", (a, b, older) => {
74
+ expect(shellFunction("version_lt", `version_lt ${a} ${b}`).status).toBe(older ? 0 : 1);
75
+ });
76
+
77
+ it("installs the same services specialist-cli accepts", () => {
78
+ const services = /^SERVICES="([^"]+)"$/m.exec(readFileSync(SCRIPT, "utf-8"))?.[1].split(" ");
79
+ const allowed = /^ {2}([a-z|]+)\) ;;$/m.exec(readFileSync(ENTRY, "utf-8"))?.[1].split("|");
80
+ expect(services).toBeDefined();
81
+ expect(services).toEqual(allowed);
82
+ });
83
+
84
+ it("never runs Homebrew binaries", () => {
85
+ expect(readFileSync(SCRIPT, "utf-8")).not.toContain("/opt/homebrew");
86
+ });
87
+ });
88
+
89
+ describe("specialist-cli", () => {
90
+ it("runs the wrappers with the root-owned Node only", () => {
91
+ const text = readFileSync(ENTRY, "utf-8");
92
+ expect(text).toContain("exec /usr/local/libexec/specialist-node/current/bin/node ");
93
+ expect(text).not.toContain("/opt/homebrew/bin/node");
94
+ });
95
+ });
@@ -11,9 +11,9 @@ bin="$root/bin"
11
11
 
12
12
  [ -d "$src/specialists/lib" ] || { echo "not a pi-astro package: $src" >&2; exit 2; }
13
13
  mkdir -p "$bin" "$root/config" "$root/secrets" "$root/work"
14
- rm -rf "$bin/lib" "$bin/arcane" "$bin/identity" "$bin/network" "$bin/dns" "$bin/edge" "$bin/security" "$bin/backup" "$bin/proxmox" "$bin/inference"
14
+ rm -rf "$bin/lib" "$bin/arcane" "$bin/identity" "$bin/network" "$bin/dns" "$bin/edge" "$bin/security" "$bin/backup" "$bin/proxmox" "$bin/inference" "$bin/media"
15
15
  cp -R "$src/specialists/lib" "$bin/lib"
16
- for s in arcane identity network dns edge security backup proxmox inference; do
16
+ for s in arcane identity network dns edge security backup proxmox inference media; do
17
17
  mkdir -p "$bin/$s"
18
18
  cp "$src/specialists/$s/run.ts" "$bin/$s/run.ts"
19
19
  done