@huynle/hub 0.8.4 → 0.8.6
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/CHANGELOG.md +55 -0
- package/README.md +40 -3
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +5288 -4415
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
- package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
- package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
- package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
- package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
- package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
- package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
- package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
- package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
- package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
- package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
- package/packages/cli/src/bootstrap/release/README.md +63 -9
- package/packages/cli/src/bootstrap/release/cli.ts +94 -16
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
- package/packages/cli/src/bootstrap/release/pins.ts +60 -9
- package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
- package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
- package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
- package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
- package/packages/cli/src/credentials.ts +5 -2
- package/packages/cli/src/main.ts +3 -2
- package/packages/cli/src/setup/commands.ts +96 -18
- package/packages/cli/src/setup/contributors.ts +45 -10
- package/packages/cli/src/setup/runtime.ts +25 -6
- package/packages/cli/src/setup/self-aside.ts +130 -0
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/opencode-warm.ts +12 -4
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/config.ts +12 -0
- package/src/central/http.ts +10 -0
- package/src/central/install/CONTRACT.md +66 -0
- package/src/central/install/install.ps1 +349 -0
- package/src/central/install/install.sh +266 -0
- package/src/central/install/render.ts +146 -0
- package/src/central/install/templates.ts +6 -0
- package/src/central/install/text-assets.d.ts +9 -0
- package/src/config.ts +12 -7
- package/src/spoke/env-inject.ts +17 -2
- package/src/spoke/herdr.ts +3 -2
- package/src/spoke/launchers.ts +145 -9
- package/src/spoke/localhub.ts +1 -0
- package/src/win-acl.ts +154 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
- package/web/dist/assets/{ArtifactPane-XTnwXl-Y.js → ArtifactPane-DKwbMTX9.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
- package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
- package/web/dist/assets/{ProfilesDestination-dZa3vioh.js → ProfilesDestination-BOML2Dgf.js} +1 -1
- package/web/dist/assets/{TeamsPage-DJt-Kk84.js → TeamsPage-CNBTE8gB.js} +1 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
- package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
- package/web/dist/assets/hub-CrbItiwP.js +13 -0
- package/web/dist/assets/index-82Ce0IAf.css +1 -0
- package/web/dist/assets/index-D9SP3_0z.js +22 -0
- package/web/dist/assets/{live-D-RZbeUi.js → live-BNJgy0vg.js} +3 -3
- package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
- package/web/dist/assets/{renderer2d-DILxLFEq.js → renderer2d-CYag1CnR.js} +1 -1
- package/web/dist/assets/{renderer3d-PvN9Qbt2.js → renderer3d-BRB6rgDJ.js} +1 -1
- package/web/dist/assets/{safe-area-BS6ii3M7.js → safe-area-fOy0AOuG.js} +1 -1
- package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
- package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
- package/web/dist/index.html +6 -4
- package/web/dist/assets/ArtifactPage-Cb1DcrQf.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-C37tZZ3e.js +0 -6
- package/web/dist/assets/Library-CZSSHvOJ.js +0 -2
- package/web/dist/assets/Watchtower-BXaUaXO1.js +0 -2
- package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
- package/web/dist/assets/index-DVSkeAvn.css +0 -1
- package/web/dist/assets/index-DifgVuKS.js +0 -34
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Hub machine setup for macOS and Linux, served by the hub as GET /install.sh (see CONTRACT.md):
|
|
3
|
+
#
|
|
4
|
+
# curl -fsSL '<hub>/install.sh?<choices>' | sh
|
|
5
|
+
#
|
|
6
|
+
# Downloads the hubctl release this hub pins, checks its sha256, enrolls this machine with the hub (device code,
|
|
7
|
+
# approved in the console), then runs `hubctl setup preview`, `setup apply` and `setup verify` for the chosen
|
|
8
|
+
# profile. Nothing runs until the last line: a truncated download only defines functions. Every child process reads
|
|
9
|
+
# /dev/null, never the script on stdin. Re-running it is safe: an enrolled machine is not enrolled again, and apply
|
|
10
|
+
# converges.
|
|
11
|
+
|
|
12
|
+
hub_say() {
|
|
13
|
+
printf 'hub setup: %s\n' "$*" >&2
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# hub_die <exit code> <message...>
|
|
17
|
+
hub_die() {
|
|
18
|
+
hub_code=$1
|
|
19
|
+
shift
|
|
20
|
+
hub_say "$*"
|
|
21
|
+
exit "$hub_code"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
hub_has() {
|
|
25
|
+
command -v "$1" >/dev/null 2>&1
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
hub_is_musl() {
|
|
29
|
+
if ldd --version 2>&1 </dev/null | grep -qi musl; then
|
|
30
|
+
return 0
|
|
31
|
+
fi
|
|
32
|
+
for hub_f in /lib/ld-musl-* /usr/lib/ld-musl-*; do
|
|
33
|
+
if [ -e "$hub_f" ]; then
|
|
34
|
+
return 0
|
|
35
|
+
fi
|
|
36
|
+
done
|
|
37
|
+
return 1
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Sets HUB_TARGET (darwin-arm64, darwin-x64, linux-x64, linux-arm64) or stops with the reason.
|
|
41
|
+
hub_target() {
|
|
42
|
+
hub_os=$(uname -s 2>/dev/null || echo unknown)
|
|
43
|
+
hub_arch=$(uname -m 2>/dev/null || echo unknown)
|
|
44
|
+
case "$hub_os" in
|
|
45
|
+
Darwin) hub_os=darwin ;;
|
|
46
|
+
Linux) hub_os=linux ;;
|
|
47
|
+
*) hub_die 7 "this system ($hub_os) is not supported: use macOS or Linux (on Windows, use the PowerShell command)" ;;
|
|
48
|
+
esac
|
|
49
|
+
case "$hub_arch" in
|
|
50
|
+
x86_64 | amd64) hub_arch=x64 ;;
|
|
51
|
+
arm64 | aarch64) hub_arch=arm64 ;;
|
|
52
|
+
*) hub_die 7 "this processor ($hub_arch) is not supported: hubctl is built for x86_64 and arm64" ;;
|
|
53
|
+
esac
|
|
54
|
+
if [ "$hub_os" = linux ]; then
|
|
55
|
+
if hub_is_musl; then
|
|
56
|
+
hub_die 7 "this Linux uses musl libc (Alpine?), which hubctl does not support yet: use a glibc distribution"
|
|
57
|
+
fi
|
|
58
|
+
if [ -r /proc/version ] && grep -qi microsoft /proc/version && ! grep -qi wsl2 /proc/version; then
|
|
59
|
+
hub_die 7 "this is WSL 1, which hubctl does not support: convert the distribution to WSL 2 (wsl --set-version <distro> 2)"
|
|
60
|
+
fi
|
|
61
|
+
fi
|
|
62
|
+
HUB_TARGET="$hub_os-$hub_arch"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# hub_fetch <url> <file>: https stays https across redirects.
|
|
66
|
+
hub_fetch() {
|
|
67
|
+
case "$1" in
|
|
68
|
+
https://*) hub_tls=1 ;;
|
|
69
|
+
*) hub_tls=0 ;;
|
|
70
|
+
esac
|
|
71
|
+
if hub_has curl; then
|
|
72
|
+
if [ "$hub_tls" = 1 ]; then
|
|
73
|
+
curl -fsSL --proto '=https' --proto-redir '=https' -o "$2" "$1" </dev/null
|
|
74
|
+
else
|
|
75
|
+
curl -fsSL -o "$2" "$1" </dev/null
|
|
76
|
+
fi
|
|
77
|
+
elif hub_has wget; then
|
|
78
|
+
if [ "$hub_tls" = 1 ]; then
|
|
79
|
+
wget -q --https-only -O "$2" "$1" </dev/null
|
|
80
|
+
else
|
|
81
|
+
wget -q -O "$2" "$1" </dev/null
|
|
82
|
+
fi
|
|
83
|
+
else
|
|
84
|
+
hub_die 1 "needs curl or wget to download hubctl"
|
|
85
|
+
fi
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# hub_sha256 <file>: the lower-case hex digest on stdout.
|
|
89
|
+
hub_sha256() {
|
|
90
|
+
if hub_has sha256sum; then
|
|
91
|
+
sha256sum "$1" </dev/null | awk '{print tolower($1)}'
|
|
92
|
+
elif hub_has shasum; then
|
|
93
|
+
shasum -a 256 "$1" </dev/null | awk '{print tolower($1)}'
|
|
94
|
+
else
|
|
95
|
+
hub_die 1 "needs sha256sum or shasum to check the hubctl download; nothing was run"
|
|
96
|
+
fi
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# hub_json_field <name> <file>: the first string value of "name" (hubctl's --json output is one field per line).
|
|
100
|
+
hub_json_field() {
|
|
101
|
+
sed -n "s/^[[:space:]]*\"$1\":[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" "$2" | head -n 1
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
hub_on_path() {
|
|
105
|
+
case ":$PATH:" in
|
|
106
|
+
*":$1:"* | *":$1/:"*) return 0 ;;
|
|
107
|
+
esac
|
|
108
|
+
return 1
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# hub_run <command...>: runs hubctl with stdin from /dev/null; its exit code in hub_rc (set -e never trips).
|
|
112
|
+
hub_run() {
|
|
113
|
+
if "$hub_bin" "$@" </dev/null; then
|
|
114
|
+
hub_rc=0
|
|
115
|
+
else
|
|
116
|
+
hub_rc=$?
|
|
117
|
+
fi
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
main() {
|
|
121
|
+
set -eu
|
|
122
|
+
umask 077
|
|
123
|
+
|
|
124
|
+
HUB_URL='{{HUB_URL}}'
|
|
125
|
+
HUB_VERSION='{{HUB_VERSION}}'
|
|
126
|
+
RELEASE_BASE='{{RELEASE_BASE}}'
|
|
127
|
+
PROFILE='{{PROFILE}}'
|
|
128
|
+
# A leading ~/ is kept literal here and expanded to $HOME before preview.
|
|
129
|
+
# shellcheck disable=SC2088
|
|
130
|
+
WORKSPACE_ROOT='{{WORKSPACE_ROOT}}'
|
|
131
|
+
ACTIVATE='{{ACTIVATE}}'
|
|
132
|
+
INSECURE_HTTP='{{INSECURE_HTTP}}'
|
|
133
|
+
|
|
134
|
+
hub_release=${HUB_INSTALL_RELEASE_BASE:-$RELEASE_BASE}
|
|
135
|
+
hub_release=${hub_release%/}
|
|
136
|
+
|
|
137
|
+
hub_say "setting up this machine for $HUB_URL (hub $HUB_VERSION, profile $PROFILE)"
|
|
138
|
+
if [ "$INSECURE_HTTP" = 1 ]; then
|
|
139
|
+
hub_say "warning: $HUB_URL is plain http: this machine's token travels unencrypted on your network"
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
hub_target
|
|
143
|
+
|
|
144
|
+
HUB_TMP=$(mktemp -d 2>/dev/null || mktemp -d -t hub-setup)
|
|
145
|
+
if [ -z "$HUB_TMP" ] || [ ! -d "$HUB_TMP" ]; then
|
|
146
|
+
hub_die 1 "cannot create a private temporary directory"
|
|
147
|
+
fi
|
|
148
|
+
trap 'rm -rf "$HUB_TMP"' EXIT
|
|
149
|
+
trap 'exit 130' INT
|
|
150
|
+
trap 'exit 143' TERM HUP
|
|
151
|
+
|
|
152
|
+
# ---------- download and check hubctl ----------
|
|
153
|
+
hub_asset="hubctl-$HUB_TARGET"
|
|
154
|
+
hub_bin="$HUB_TMP/hubctl"
|
|
155
|
+
hub_manifest="$HUB_TMP/hubctl-manifest.json"
|
|
156
|
+
hub_say "downloading $hub_asset from $hub_release"
|
|
157
|
+
hub_fetch "$hub_release/HUBCTL_SHA256SUMS" "$HUB_TMP/HUBCTL_SHA256SUMS" || hub_die 1 "could not download $hub_release/HUBCTL_SHA256SUMS"
|
|
158
|
+
hub_fetch "$hub_release/$hub_asset" "$hub_bin" || hub_die 1 "could not download $hub_release/$hub_asset"
|
|
159
|
+
hub_fetch "$hub_release/hubctl-manifest.json" "$hub_manifest" || hub_die 1 "could not download $hub_release/hubctl-manifest.json"
|
|
160
|
+
|
|
161
|
+
# "<sha256> <file>" lines (sha256sum or shasum; a leading * marks binary mode); exactly this file's name.
|
|
162
|
+
hub_expected=$(awk -v f="$hub_asset" '{ n = $2; sub(/^\*/, "", n) } NF == 2 && n == f { print tolower($1); exit }' "$HUB_TMP/HUBCTL_SHA256SUMS")
|
|
163
|
+
case "$hub_expected" in
|
|
164
|
+
"") hub_die 11 "HUBCTL_SHA256SUMS has no checksum for $hub_asset; nothing was run" ;;
|
|
165
|
+
*[!0-9a-f]*) hub_die 11 "HUBCTL_SHA256SUMS has a malformed checksum for $hub_asset; nothing was run" ;;
|
|
166
|
+
esac
|
|
167
|
+
if [ "${#hub_expected}" -ne 64 ]; then
|
|
168
|
+
hub_die 11 "HUBCTL_SHA256SUMS has a malformed checksum for $hub_asset; nothing was run"
|
|
169
|
+
fi
|
|
170
|
+
hub_actual=$(hub_sha256 "$hub_bin")
|
|
171
|
+
if [ "$hub_actual" != "$hub_expected" ]; then
|
|
172
|
+
hub_die 11 "the sha256 of $hub_asset does not match HUBCTL_SHA256SUMS (got $hub_actual); nothing was run"
|
|
173
|
+
fi
|
|
174
|
+
chmod 700 "$hub_bin"
|
|
175
|
+
hub_say "checksum ok (hubctl checks the release manifest signature itself)"
|
|
176
|
+
|
|
177
|
+
# ---------- enrollment ----------
|
|
178
|
+
# Skipped only when hubctl reports this exact hub; otherwise `machine enroll` decides (it is a no-op for the
|
|
179
|
+
# same hub and refuses another hub without --reenroll). A credential this hub revoked is replaced.
|
|
180
|
+
hub_enroll=1
|
|
181
|
+
hub_reenroll=0
|
|
182
|
+
hub_run machine status --json >"$HUB_TMP/status.json" 2>"$HUB_TMP/status.err"
|
|
183
|
+
hub_state=$(hub_json_field state "$HUB_TMP/status.json")
|
|
184
|
+
hub_server=$(hub_json_field server "$HUB_TMP/status.json")
|
|
185
|
+
if [ "$hub_server" = "$HUB_URL" ]; then
|
|
186
|
+
case "$hub_rc:$hub_state" in
|
|
187
|
+
0:verified | 0:configured | 0:insecure)
|
|
188
|
+
hub_enroll=0
|
|
189
|
+
hub_say "this machine is already enrolled with $HUB_URL ($hub_state); not enrolling again"
|
|
190
|
+
;;
|
|
191
|
+
9:revoked)
|
|
192
|
+
hub_reenroll=1
|
|
193
|
+
hub_say "$HUB_URL revoked this machine's credential; enrolling it again"
|
|
194
|
+
;;
|
|
195
|
+
esac
|
|
196
|
+
fi
|
|
197
|
+
if [ "$hub_enroll" = 1 ]; then
|
|
198
|
+
set -- machine enroll --server "$HUB_URL"
|
|
199
|
+
if [ "$INSECURE_HTTP" = 1 ]; then
|
|
200
|
+
set -- "$@" --allow-insecure-http
|
|
201
|
+
fi
|
|
202
|
+
if [ "$hub_reenroll" = 1 ]; then
|
|
203
|
+
set -- "$@" --reenroll
|
|
204
|
+
fi
|
|
205
|
+
hub_say "enrolling: approve the code below in the hub console"
|
|
206
|
+
hub_run "$@"
|
|
207
|
+
case "$hub_rc" in
|
|
208
|
+
0) ;;
|
|
209
|
+
5) hub_die 5 "the enrollment was denied on the hub; nothing was set up. Run the command again to start over" ;;
|
|
210
|
+
4) hub_die 4 "the enrollment did not complete (expired, cancelled, or this machine is enrolled with another hub: see above); nothing was set up" ;;
|
|
211
|
+
*) hub_die "$hub_rc" "enrollment failed (hubctl exit $hub_rc); nothing was set up" ;;
|
|
212
|
+
esac
|
|
213
|
+
fi
|
|
214
|
+
|
|
215
|
+
# ---------- preview and apply ----------
|
|
216
|
+
case "$WORKSPACE_ROOT" in
|
|
217
|
+
\~) WORKSPACE_ROOT=$HOME ;;
|
|
218
|
+
\~/*) WORKSPACE_ROOT="$HOME/${WORKSPACE_ROOT#\~/}" ;;
|
|
219
|
+
esac
|
|
220
|
+
hub_preview="$HUB_TMP/preview.json"
|
|
221
|
+
set -- setup preview --profile "$PROFILE" --enrollment separate --manifest "$hub_manifest" --out "$hub_preview"
|
|
222
|
+
if [ -n "$WORKSPACE_ROOT" ]; then
|
|
223
|
+
set -- "$@" --workspace-root "$WORKSPACE_ROOT"
|
|
224
|
+
fi
|
|
225
|
+
if [ "$ACTIVATE" = 1 ]; then
|
|
226
|
+
set -- "$@" --confirm-activation
|
|
227
|
+
fi
|
|
228
|
+
hub_run "$@"
|
|
229
|
+
if [ "$hub_rc" != 0 ]; then
|
|
230
|
+
hub_die "$hub_rc" "setup preview failed (hubctl exit $hub_rc); nothing was changed"
|
|
231
|
+
fi
|
|
232
|
+
if [ ! -s "$hub_preview" ]; then
|
|
233
|
+
hub_die 1 "setup preview wrote no preview; nothing was changed"
|
|
234
|
+
fi
|
|
235
|
+
|
|
236
|
+
# Manual edits (a config behind a symlink, a format hubctl cannot edit losslessly) are blockers, and apply
|
|
237
|
+
# refuses the whole plan while any remains (contracts §3.12, exit 10). The preview above printed them.
|
|
238
|
+
if grep -Eq '"blockers":[[:space:]]*\[[[:space:]]*$' "$hub_preview"; then
|
|
239
|
+
hub_die 10 "nothing was applied: make the edits listed above by hand (hubctl does not write those files), then run this command again"
|
|
240
|
+
fi
|
|
241
|
+
|
|
242
|
+
set -- setup apply --preview "$hub_preview" --manifest "$hub_manifest"
|
|
243
|
+
if [ "$ACTIVATE" = 1 ]; then
|
|
244
|
+
set -- "$@" --confirm-activation
|
|
245
|
+
fi
|
|
246
|
+
hub_run "$@"
|
|
247
|
+
case "$hub_rc" in
|
|
248
|
+
0) ;;
|
|
249
|
+
10) hub_die 10 "setup apply needs edits by hand or found a conflict (see above); resolve them, then run this command again" ;;
|
|
250
|
+
*) hub_die "$hub_rc" "setup apply failed (hubctl exit $hub_rc); see above" ;;
|
|
251
|
+
esac
|
|
252
|
+
|
|
253
|
+
# ---------- verify ----------
|
|
254
|
+
hub_run setup verify --profile "$PROFILE"
|
|
255
|
+
if ! hub_on_path "$HOME/.local/bin"; then
|
|
256
|
+
hub_say "note: $HOME/.local/bin is not on your PATH; add it (for example in ~/.profile) to run hub and hubctl by name"
|
|
257
|
+
fi
|
|
258
|
+
case "$hub_rc" in
|
|
259
|
+
0) hub_say "done: this machine is set up for $HUB_URL" ;;
|
|
260
|
+
15) hub_say "set up for $HUB_URL; to finish, do the next steps above (usually: start OpenCode with \`opencode\` and keep it open, so this machine shows online), then check with: hubctl setup verify" ;;
|
|
261
|
+
*) hub_die "$hub_rc" "setup verify failed (hubctl exit $hub_rc)" ;;
|
|
262
|
+
esac
|
|
263
|
+
return 0
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
main "$@"
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// GET /install.sh and /install.ps1 (CONTRACT.md): the query allowlist, the values put into a template, and the
|
|
2
|
+
// rendering. Pure: the route in http.ts passes the hub's public URL, version and HUB_RELEASE_BASE.
|
|
3
|
+
//
|
|
4
|
+
// Every value is checked against UNSAFE before it reaches a template: the templates put them inside single quotes,
|
|
5
|
+
// so none may contain ' " ` $ \ or a line break. A value that does is a server bug and throws (the route answers 500),
|
|
6
|
+
// never a script.
|
|
7
|
+
import { checkServerURL, isLoopbackServer, isPrivateNetworkHost } from "../../../packages/cli/src/bootstrap/enrollment/policy.ts"
|
|
8
|
+
import { INSTALL_TEMPLATES } from "./templates"
|
|
9
|
+
|
|
10
|
+
export type InstallScript = "sh" | "ps1"
|
|
11
|
+
export type InstallProfile = "session-only" | "launch-node" | "full-harness"
|
|
12
|
+
|
|
13
|
+
export interface InstallChoices {
|
|
14
|
+
readonly profile: InstallProfile
|
|
15
|
+
/** The workspace root for launch-node, else "". */
|
|
16
|
+
readonly root: string
|
|
17
|
+
readonly activate: "0" | "1"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const PLACEHOLDERS = ["HUB_URL", "HUB_VERSION", "RELEASE_BASE", "PROFILE", "WORKSPACE_ROOT", "ACTIVATE", "INSECURE_HTTP"] as const
|
|
21
|
+
export type Placeholder = (typeof PLACEHOLDERS)[number]
|
|
22
|
+
|
|
23
|
+
const PROFILES: readonly InstallProfile[] = ["session-only", "launch-node", "full-harness"]
|
|
24
|
+
const KEYS = new Set(["profile", "root", "activate"])
|
|
25
|
+
const DEFAULT_ROOT = "~/projects"
|
|
26
|
+
const ROOT = /^(?:\/|~\/)[A-Za-z0-9._/~-]*$/
|
|
27
|
+
const ROOT_MAX = 200
|
|
28
|
+
const SEMVER = /^\d+\.\d+\.\d+$/
|
|
29
|
+
const UNSAFE = /['"`$\\\r\n]/
|
|
30
|
+
const RELEASES = "https://github.com/huynle/hub-releases/releases/download"
|
|
31
|
+
|
|
32
|
+
type Parsed = { readonly ok: true; readonly choices: InstallChoices } | { readonly ok: false; readonly reason: string }
|
|
33
|
+
|
|
34
|
+
const refuse = (reason: string): Parsed => ({ ok: false, reason })
|
|
35
|
+
|
|
36
|
+
function rootProblem(root: string): string | undefined {
|
|
37
|
+
if (root.length < 1 || root.length > ROOT_MAX) return `root must be 1-${ROOT_MAX} characters`
|
|
38
|
+
if (!ROOT.test(root)) return "root must start with / or ~/ and use only letters, digits and . _ / ~ -"
|
|
39
|
+
if (root.includes("//")) return "root must not contain //"
|
|
40
|
+
if (root.split("/").includes("..")) return "root must not contain a .. segment"
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The choices of an install query, or why it is refused (a one-line reason for the 400). */
|
|
45
|
+
export function parseInstallQuery(script: InstallScript, search: URLSearchParams): Parsed {
|
|
46
|
+
const seen = new Map<string, string>()
|
|
47
|
+
for (const [key, value] of search) {
|
|
48
|
+
if (!KEYS.has(key)) return refuse(`unknown query parameter ${JSON.stringify(key.slice(0, 40))} (allowed: profile, root, activate)`)
|
|
49
|
+
if (seen.has(key)) return refuse(`query parameter ${key} given more than once`)
|
|
50
|
+
seen.set(key, value)
|
|
51
|
+
}
|
|
52
|
+
const profile = seen.get("profile") ?? "session-only"
|
|
53
|
+
if (!PROFILES.includes(profile as InstallProfile)) return refuse(`profile must be one of ${PROFILES.join(", ")}`)
|
|
54
|
+
const activate = seen.get("activate") ?? "0"
|
|
55
|
+
if (activate !== "0" && activate !== "1") return refuse("activate must be 0 or 1")
|
|
56
|
+
const root = seen.get("root")
|
|
57
|
+
if (script === "ps1") {
|
|
58
|
+
if (profile !== "session-only") return refuse("install.ps1 supports only profile=session-only for now")
|
|
59
|
+
if (root !== undefined) return refuse("install.ps1 takes no root (no launch-node on Windows yet)")
|
|
60
|
+
if (activate !== "0") return refuse("install.ps1 supports only activate=0 for now")
|
|
61
|
+
}
|
|
62
|
+
if (root !== undefined && profile !== "launch-node") return refuse("root is only for profile=launch-node")
|
|
63
|
+
if (root !== undefined) {
|
|
64
|
+
const problem = rootProblem(root)
|
|
65
|
+
if (problem) return refuse(problem)
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, choices: { profile: profile as InstallProfile, root: profile === "launch-node" ? (root ?? DEFAULT_ROOT) : "", activate } }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* HUB_RELEASE_BASE, normalized (no trailing slash), or undefined when not allowed: https, or plain http on loopback
|
|
72
|
+
* or a private-network IP address (the enrollment policy's rule, policy.ts); no user info, query, fragment or
|
|
73
|
+
* whitespace, and none of the characters a template cannot quote.
|
|
74
|
+
*/
|
|
75
|
+
export function checkReleaseBase(value: string): string | undefined {
|
|
76
|
+
if (UNSAFE.test(value)) return undefined
|
|
77
|
+
const base = checkServerURL(value, { allowInsecureHTTP: true })
|
|
78
|
+
return base && !UNSAFE.test(base) ? base : undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface InstallContext {
|
|
82
|
+
/** The hub's public URL (config.publicURL). */
|
|
83
|
+
readonly publicURL: string
|
|
84
|
+
/** The hub version (as /api/me reports it). */
|
|
85
|
+
readonly version: string
|
|
86
|
+
/** HUB_RELEASE_BASE, already checked (checkReleaseBase). */
|
|
87
|
+
readonly releaseBase?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type Values = { readonly ok: true; readonly values: Record<Placeholder, string> } | { readonly ok: false; readonly status: 400 | 503; readonly reason: string }
|
|
91
|
+
|
|
92
|
+
/** The placeholder values for these choices, or why there is no script (400 plain-http hub, 503 no release). */
|
|
93
|
+
export function installValues(input: InstallContext & { readonly choices: InstallChoices }): Values {
|
|
94
|
+
const hub = checkServerURL(input.publicURL, { allowInsecureHTTP: true })
|
|
95
|
+
if (!hub) {
|
|
96
|
+
return { ok: false, status: 400, reason: "this hub's public URL must be https to install from it (plain http only on loopback or a private-network IP address)" }
|
|
97
|
+
}
|
|
98
|
+
const insecure = hub.startsWith("http:") && (isLoopbackServer(hub) || isPrivateNetworkHost(new URL(hub).hostname))
|
|
99
|
+
const release = input.releaseBase ?? (SEMVER.test(input.version) && input.version !== "0.0.0" ? `${RELEASES}/v${input.version}` : undefined)
|
|
100
|
+
if (!release) {
|
|
101
|
+
return { ok: false, status: 503, reason: `this hub (version ${input.version.slice(0, 40)}) has no published release to install; set HUB_RELEASE_BASE on the hub` }
|
|
102
|
+
}
|
|
103
|
+
const values: Record<Placeholder, string> = {
|
|
104
|
+
HUB_URL: hub,
|
|
105
|
+
HUB_VERSION: input.version,
|
|
106
|
+
RELEASE_BASE: release,
|
|
107
|
+
PROFILE: input.choices.profile,
|
|
108
|
+
WORKSPACE_ROOT: input.choices.root,
|
|
109
|
+
ACTIVATE: input.choices.activate,
|
|
110
|
+
INSECURE_HTTP: insecure ? "1" : "0",
|
|
111
|
+
}
|
|
112
|
+
assertSafe(values)
|
|
113
|
+
return { ok: true, values }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function assertSafe(values: Readonly<Record<string, string>>): void {
|
|
117
|
+
for (const [name, value] of Object.entries(values)) {
|
|
118
|
+
if (UNSAFE.test(value)) throw new Error(`install script value ${name} contains a character the template cannot quote`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The template with every {{NAME}} replaced. Throws on an unsafe value, or a placeholder without a value or left over. */
|
|
123
|
+
export function renderTemplate(template: string, values: Readonly<Record<string, string>>): string {
|
|
124
|
+
assertSafe(values)
|
|
125
|
+
const out = template.replace(/\{\{([A-Z_]+)\}\}/g, (match, name: string) => {
|
|
126
|
+
const value = values[name]
|
|
127
|
+
if (value === undefined) throw new Error(`install template placeholder {{${name}}} has no value`)
|
|
128
|
+
return value
|
|
129
|
+
})
|
|
130
|
+
const left = /\{\{[^}\n]*\}\}/.exec(out)
|
|
131
|
+
if (left) throw new Error(`install template has a leftover placeholder ${left[0].slice(0, 60)}`)
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const TEXT_HEADERS = { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" }
|
|
136
|
+
|
|
137
|
+
const text = (status: number, body: string): Response => new Response(body, { status, headers: TEXT_HEADERS })
|
|
138
|
+
|
|
139
|
+
/** The answer to GET /install.sh or /install.ps1: the rendered script, or a 400/503 one-line reason. */
|
|
140
|
+
export function installResponse(script: InstallScript, url: URL, ctx: InstallContext & { readonly template?: string }): Response {
|
|
141
|
+
const parsed = parseInstallQuery(script, url.searchParams)
|
|
142
|
+
if (!parsed.ok) return text(400, `${parsed.reason}\n`)
|
|
143
|
+
const values = installValues({ ...ctx, choices: parsed.choices })
|
|
144
|
+
if (!values.ok) return text(values.status, `${values.reason}\n`)
|
|
145
|
+
return text(200, renderTemplate(ctx.template ?? INSTALL_TEMPLATES[script], values.values))
|
|
146
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The install script templates (CONTRACT.md), imported as text so `bun build --compile` and the npm package carry
|
|
2
|
+
// them. The bodies belong to the scripts workstream; the server only fills their {{…}} placeholders (render.ts).
|
|
3
|
+
import sh from "./install.sh" with { type: "text" }
|
|
4
|
+
import ps1 from "./install.ps1" with { type: "text" }
|
|
5
|
+
|
|
6
|
+
export const INSTALL_TEMPLATES: { readonly sh: string; readonly ps1: string } = { sh, ps1 }
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Per-machine spoke configuration, shared by every OpenCode process on the machine and edited
|
|
2
|
-
// from the /hub dialog. Holds the machine token, so it is written with mode 0600.
|
|
2
|
+
// from the /hub dialog. Holds the machine token, so it is written with mode 0600 (on Windows: an owner-only ACL).
|
|
3
3
|
import { randomBytes } from "node:crypto"
|
|
4
4
|
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs"
|
|
5
5
|
import { homedir } from "node:os"
|
|
@@ -7,6 +7,7 @@ import path from "node:path"
|
|
|
7
7
|
import { hubEnv, type Env } from "./env"
|
|
8
8
|
import { APP_DIR, configBase, legacyPaths, migrateFirst } from "./migrate"
|
|
9
9
|
import { isHubURL } from "./protocol"
|
|
10
|
+
import { defaultWinAcl, isWin32, writeOwnerOnlyFile, type SecretFileOptions } from "./win-acl"
|
|
10
11
|
|
|
11
12
|
export type Mode = "off" | "always" | "session"
|
|
12
13
|
|
|
@@ -169,8 +170,12 @@ export function readRawConfig(file: string): RawConfig | undefined {
|
|
|
169
170
|
return parsed
|
|
170
171
|
}
|
|
171
172
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Writes `content` to `file` atomically (a private temp file in the same directory, fsync, rename), mode 0600. On
|
|
175
|
+
* Windows the temporary file is restricted to the current user (an ACL) before the content is written (win-acl.ts).
|
|
176
|
+
*/
|
|
177
|
+
function writePrivate(file: string, content: string, options?: SecretFileOptions): void {
|
|
178
|
+
if (isWin32(options)) return writeOwnerOnlyFile(file, content, options?.acl ?? defaultWinAcl())
|
|
174
179
|
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
175
180
|
const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`
|
|
176
181
|
let fd: number | undefined
|
|
@@ -196,22 +201,22 @@ const serialize = (raw: RawConfig) => `${JSON.stringify(raw, null, 2)}\n`
|
|
|
196
201
|
* returns the next one, written atomically with 0600. Refuses (throws, file untouched) when the file is not a JSON
|
|
197
202
|
* object, so a hand-edited config is never replaced.
|
|
198
203
|
*/
|
|
199
|
-
export function updateRawConfig(file: string, update: (raw: RawConfig) => RawConfig): void {
|
|
200
|
-
writePrivate(file, serialize(update(readRawConfig(file) ?? {})))
|
|
204
|
+
export function updateRawConfig(file: string, update: (raw: RawConfig) => RawConfig, options?: SecretFileOptions): void {
|
|
205
|
+
writePrivate(file, serialize(update(readRawConfig(file) ?? {})), options)
|
|
201
206
|
}
|
|
202
207
|
|
|
203
208
|
/**
|
|
204
209
|
* Atomic write with 0600 permissions. Lossless: keys config.json holds that VoiceConfig does not know are kept; the
|
|
205
210
|
* known keys come from `config` (a known key it lacks is removed). A file that is not a JSON object is replaced.
|
|
206
211
|
*/
|
|
207
|
-
export function writeConfig(file: string, config: VoiceConfig): void {
|
|
212
|
+
export function writeConfig(file: string, config: VoiceConfig, options?: SecretFileOptions): void {
|
|
208
213
|
let previous: RawConfig = {}
|
|
209
214
|
try {
|
|
210
215
|
previous = readRawConfig(file) ?? {}
|
|
211
216
|
} catch {}
|
|
212
217
|
const next: RawConfig = { ...previous }
|
|
213
218
|
for (const key of CONFIG_KEYS) delete next[key]
|
|
214
|
-
writePrivate(file, serialize({ ...next, ...config }))
|
|
219
|
+
writePrivate(file, serialize({ ...next, ...config }), options)
|
|
215
220
|
}
|
|
216
221
|
|
|
217
222
|
/** Changing the server URL drops the machine credentials, which belong to the old server. */
|
package/src/spoke/env-inject.ts
CHANGED
|
@@ -114,13 +114,28 @@ export function stripInheritedHubEnv(env: Env): void {
|
|
|
114
114
|
for (const name of HUB_VARS) delete env[name]
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/**
|
|
118
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Strips inherited HUB_*, sets the session's identity when it has one, and puts `binDir` first on PATH once. On
|
|
119
|
+
* win32 the variable is case-insensitive (usually `Path`) and ';'-separated: the one key keeps its name, entries
|
|
120
|
+
* compare without case, and no second `PATH` key is added (which one a child sees would be undefined).
|
|
121
|
+
*/
|
|
122
|
+
export function applyHubEnv(env: Env, identity: Identity | undefined, binDir: string, platform: string = process.platform): void {
|
|
119
123
|
stripInheritedHubEnv(env)
|
|
120
124
|
if (identity) {
|
|
121
125
|
if (identity.url) env.HUB_URL = identity.url
|
|
122
126
|
env.HUB_SESSION_TOKEN = identity.token
|
|
123
127
|
}
|
|
128
|
+
if (platform === "win32") {
|
|
129
|
+
const keys = Object.keys(env).filter((k) => k.toLowerCase() === "path")
|
|
130
|
+
const key = keys.find((k) => env[k] !== undefined) ?? keys[0] ?? "Path"
|
|
131
|
+
const value = env[key]
|
|
132
|
+
for (const k of keys) delete env[k]
|
|
133
|
+
const same = (dir: string) => dir.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === binDir.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()
|
|
134
|
+
const root = env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows"
|
|
135
|
+
const rest = value === undefined ? [`${root}\\System32`, root] : value.split(";").filter((dir) => dir && !same(dir))
|
|
136
|
+
env[key] = [binDir, ...rest].join(";")
|
|
137
|
+
return
|
|
138
|
+
}
|
|
124
139
|
const rest = env.PATH === undefined ? DEFAULT_PATH : env.PATH.split(path.delimiter).filter((dir) => dir !== binDir)
|
|
125
140
|
env.PATH = [binDir, ...rest].join(path.delimiter)
|
|
126
141
|
}
|
package/src/spoke/herdr.ts
CHANGED
|
@@ -17,9 +17,10 @@ export const AGENT_START_TIMEOUT_MS = 20_000
|
|
|
17
17
|
|
|
18
18
|
const STDERR_MAX = 300
|
|
19
19
|
|
|
20
|
-
export function herdrRunner(bin = "herdr"): HerdrRunner {
|
|
20
|
+
export function herdrRunner(bin = "herdr", spawn: typeof Bun.spawn = Bun.spawn): HerdrRunner {
|
|
21
21
|
return async (args, timeoutMs) => {
|
|
22
|
-
|
|
22
|
+
// windowsHide: on Windows each poll would otherwise flash a console window (no-op elsewhere).
|
|
23
|
+
const proc = spawn([bin, ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe", windowsHide: true })
|
|
23
24
|
const what = `herdr ${args.slice(0, 2).join(" ")}`
|
|
24
25
|
const stdout = collect(proc.stdout)
|
|
25
26
|
const stderr = collect(proc.stderr)
|