@astrofoundry/pi-astro 0.31.0 → 0.33.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.
- package/README.md +3 -2
- package/agents/media.md +1 -1
- package/agents/release-reviewer.md +23 -0
- package/extensions/astro-discord/admin.ts +1 -1
- package/extensions/astro-discord/config.ts +55 -20
- package/extensions/astro-discord/daily-report.test.ts +35 -13
- package/extensions/astro-discord/daily-report.ts +0 -57
- package/extensions/astro-discord/discord.test.ts +81 -2
- package/extensions/astro-discord/gateway.ts +9 -2
- package/extensions/astro-discord/index.ts +391 -39
- package/extensions/astro-discord/media.test.ts +1 -0
- package/extensions/astro-discord/media.ts +5 -4
- package/extensions/astro-discord/reports.ts +49 -0
- package/extensions/astro-discord/rest.ts +11 -0
- package/extensions/astro-discord/restarts.ts +109 -0
- package/extensions/astro-discord/tools-report.test.ts +344 -0
- package/extensions/astro-discord/tools-report.ts +669 -0
- package/extensions/astro-subagents/agents.test.ts +1 -0
- package/extensions/astro-subagents/agents.ts +2 -1
- package/extensions/astro-subagents/child.test.ts +6 -0
- package/extensions/astro-subagents/child.ts +2 -1
- package/package.json +1 -1
- package/skills/media/SKILL.md +1 -1
- package/specialists/AGENTS.md +4 -3
- package/specialists/README.md +26 -6
- package/specialists/entry/specialist-cli.sh +2 -1
- package/specialists/entry/specialist-install.sh +459 -0
- package/specialists/entry/specialist-install.test.ts +95 -0
|
@@ -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
|
+
});
|