@huynle/hub 0.8.3 → 0.8.5
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 +80 -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/protocol/src/index.ts +7 -2
- 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/central/registry.ts +93 -22
- package/src/config.ts +12 -7
- package/src/index.ts +28 -0
- package/src/spoke/env-inject.ts +17 -2
- package/src/spoke/launchers.ts +145 -9
- package/src/spoke/spoke.ts +150 -10
- package/src/status.ts +70 -14
- package/src/win-acl.ts +154 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
- package/web/dist/assets/{ArtifactPane-DPHjiWce.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-GriuOpiZ.js → ProfilesDestination-BOML2Dgf.js} +1 -1
- package/web/dist/assets/{TeamsPage-C-5lYeeM.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-CDWcyavI.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-CNFdTDTr.js → renderer2d-CYag1CnR.js} +1 -1
- package/web/dist/assets/{renderer3d-C45jPvYX.js → renderer3d-BRB6rgDJ.js} +1 -1
- package/web/dist/assets/{safe-area-sA9a8vYJ.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-CSotAWob.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-CkmWnP6L.js +0 -6
- package/web/dist/assets/Library-DTLEyekn.js +0 -2
- package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
- package/web/dist/assets/Watchtower-DOlPsiIy.js +0 -2
- package/web/dist/assets/index-6mdQ7eUY.js +0 -34
- package/web/dist/assets/index-C5OmwA1y.css +0 -1
|
@@ -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 "installed, not ready yet: finish the next steps above, 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/central/registry.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// holds the connection; remote state is never re-exported.
|
|
13
13
|
import path from "node:path"
|
|
14
14
|
import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type RevertCommitted, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
|
|
15
|
-
import { StatusBoard, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
|
|
15
|
+
import { BLOCKER_ID_MAX, StatusBoard, askBlocker, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
|
|
16
16
|
import { sanitizeLabel } from "./agent/envelope"
|
|
17
17
|
import { DoneWatch } from "./done"
|
|
18
18
|
import { SentenceStream } from "./say-stream"
|
|
@@ -187,6 +187,17 @@ interface PendingPermission {
|
|
|
187
187
|
readonly tenant?: string
|
|
188
188
|
readonly request?: PermissionRequest
|
|
189
189
|
recommendation?: BlockerRecommendation
|
|
190
|
+
/** Its blocker (bounded, askBlocker), to put back on a status board that lost it (see restoreBlockers). */
|
|
191
|
+
readonly blocker?: Blocker
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A pending form and the processes that forwarded it. */
|
|
195
|
+
interface PendingForm {
|
|
196
|
+
connIDs: string[]
|
|
197
|
+
readonly sessionID: string
|
|
198
|
+
readonly tenant?: string
|
|
199
|
+
/** Its blocker (bounded, askBlocker), to put back on a status board that lost it (see restoreBlockers). */
|
|
200
|
+
readonly blocker?: Blocker
|
|
190
201
|
}
|
|
191
202
|
|
|
192
203
|
/** Characters of a recommendation's author and reason on a blocker. */
|
|
@@ -399,6 +410,8 @@ interface WorkerSpeech {
|
|
|
399
410
|
|
|
400
411
|
/** How many spoken texts, utterances and early deliveries are remembered for dedupe and matching. */
|
|
401
412
|
const MEMORY = 1000
|
|
413
|
+
/** Pending permissions, and pending forms, kept per tenant (oldest dropped). */
|
|
414
|
+
export const PENDING_PER_TENANT = 200
|
|
402
415
|
|
|
403
416
|
/** Adds to a bounded insertion-ordered map, dropping the oldest entry when full. */
|
|
404
417
|
function remember<K, V>(map: Map<K, V>, key: K, value: V, limit = MEMORY): void {
|
|
@@ -406,6 +419,12 @@ function remember<K, V>(map: Map<K, V>, key: K, value: V, limit = MEMORY): void
|
|
|
406
419
|
if (map.size > limit) map.delete(map.keys().next().value!)
|
|
407
420
|
}
|
|
408
421
|
|
|
422
|
+
/** Adds an ID to a bounded set of IDs (oldest dropped). */
|
|
423
|
+
function rememberID(set: Set<string>, id: string, limit = 1000): void {
|
|
424
|
+
set.add(id)
|
|
425
|
+
if (set.size > limit) set.delete(set.values().next().value!)
|
|
426
|
+
}
|
|
427
|
+
|
|
409
428
|
const eventSessionID = (event: ForwardedEvent): string | undefined => {
|
|
410
429
|
const data = event.data as Record<string, any>
|
|
411
430
|
const id = event.type === "form.created" ? data.form?.sessionID : data.sessionID
|
|
@@ -440,9 +459,11 @@ export class Registry {
|
|
|
440
459
|
private readonly humanTurnListeners = new Set<(sessionID: string, tenant?: string) => void>()
|
|
441
460
|
private readonly sessionDeletedListeners = new Set<(sessionID: string, tenant: string) => void>()
|
|
442
461
|
/** Pending form ID → its session and every connection that forwarded it, first first (like permissions). */
|
|
443
|
-
private readonly forms = new Map<string,
|
|
444
|
-
/** Form IDs already alerted (forms may be forwarded by several processes). */
|
|
462
|
+
private readonly forms = new Map<string, PendingForm>()
|
|
463
|
+
/** Form IDs already alerted (forms may be forwarded by several processes, and re-announced after a reconnect). */
|
|
445
464
|
private readonly alertedForms = new Set<string>()
|
|
465
|
+
/** Permission IDs already alerted (a reconnecting spoke re-announces what is still pending). */
|
|
466
|
+
private readonly alertedPermissions = new Set<string>()
|
|
446
467
|
/** Sessions whose current run raised a providerError notification (once per run; cleared when a run starts). */
|
|
447
468
|
private readonly providerAlerted = new Set<string>()
|
|
448
469
|
/** Sessions in conversation mode → the connection last told so (re-sent when another one takes over). */
|
|
@@ -719,6 +740,8 @@ export class Registry {
|
|
|
719
740
|
const owner = this.sessionTenant(id)
|
|
720
741
|
if (owner !== undefined && owner !== tenant) return
|
|
721
742
|
}
|
|
743
|
+
// A question or permission whose ID no card can carry is never kept (no card, no answer; unbounded memory).
|
|
744
|
+
if (oversizedBlockerID(event.type, data)) return
|
|
722
745
|
if (sessionID) conn.activity.set(sessionID, this.tick())
|
|
723
746
|
if (sessionID && event.type.startsWith("session.execution.")) {
|
|
724
747
|
if (event.type === "session.execution.interrupted" && data.inferred === true) remember(this.inferredEnds, sessionID, this.now())
|
|
@@ -733,34 +756,46 @@ export class Registry {
|
|
|
733
756
|
if (event.type === "permission.asked" && typeof data.id === "string" && sessionID) {
|
|
734
757
|
const known = this.permissions.get(data.id)
|
|
735
758
|
if (known) {
|
|
736
|
-
// The same request forwarded by another process: one more place to answer it
|
|
759
|
+
// The same request forwarded by another process (or re-announced): one more place to answer it, and its
|
|
760
|
+
// blocker back on a status board that lost it.
|
|
737
761
|
if (!known.connIDs.includes(connID)) known.connIDs.push(connID)
|
|
762
|
+
this.observeBoards(event, tenant)
|
|
738
763
|
return
|
|
739
764
|
}
|
|
740
765
|
const request = permissionRequest(data)
|
|
741
|
-
|
|
742
|
-
this.
|
|
766
|
+
const blocker = askBlocker(event)
|
|
767
|
+
this.permissions.set(data.id, { connIDs: [connID], sessionID, tenant, ...(request ? { request } : {}), ...(blocker ? { blocker } : {}) })
|
|
768
|
+
this.capPending(this.permissions, tenant, (id) => this.dropPermission(id))
|
|
769
|
+
if (!this.alertedPermissions.has(data.id) && this.alert("permission", sessionID, { requestID: data.id })) rememberID(this.alertedPermissions, data.id)
|
|
743
770
|
}
|
|
744
771
|
if (event.type === "form.created" && sessionID) {
|
|
745
772
|
const formID = (data.form as Record<string, unknown> | undefined)?.id
|
|
746
773
|
if (typeof formID === "string") {
|
|
747
774
|
const known = this.forms.get(formID)
|
|
748
775
|
if (known && !known.connIDs.includes(connID)) known.connIDs.push(connID)
|
|
749
|
-
else if (!known)
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
this.alert("question", sessionID)
|
|
776
|
+
else if (!known) {
|
|
777
|
+
const blocker = askBlocker(event)
|
|
778
|
+
this.forms.set(formID, { connIDs: [connID], sessionID, tenant, ...(blocker ? { blocker } : {}) })
|
|
779
|
+
this.capPending(this.forms, tenant, (id) => this.dropForm(id))
|
|
780
|
+
}
|
|
755
781
|
}
|
|
782
|
+
if (typeof formID === "string" && !this.alertedForms.has(formID) && this.alert("question", sessionID)) rememberID(this.alertedForms, formID)
|
|
756
783
|
}
|
|
757
784
|
let closedPermission: [string, PendingPermission] | undefined
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
785
|
+
const permissionClose = event.type === "permission.replied" && typeof data.requestID === "string"
|
|
786
|
+
const formClose = (event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string"
|
|
787
|
+
if (permissionClose || formClose) {
|
|
788
|
+
const id = (permissionClose ? data.requestID : data.id) as string
|
|
789
|
+
const map: Map<string, PendingPermission | PendingForm> = permissionClose ? this.permissions : this.forms
|
|
790
|
+
const origin = map.get(id)
|
|
791
|
+
// Only the tenant that asked closes it (IDs are not secret enough to let another tenant end it).
|
|
792
|
+
const own = origin !== undefined && (origin.tenant === undefined || origin.tenant === tenant)
|
|
793
|
+
if (own && data.unlisted === true) {
|
|
794
|
+
// A spoke found it no longer listed by its own harness: only that process stops holding it.
|
|
795
|
+
origin.connIDs = origin.connIDs.filter((c) => c !== connID)
|
|
796
|
+
if (origin.connIDs.length) return
|
|
797
|
+
}
|
|
798
|
+
if (own && map.delete(id) && permissionClose) closedPermission = [id, origin as PendingPermission]
|
|
764
799
|
}
|
|
765
800
|
|
|
766
801
|
if (sessionID) this.trackRun(event.type, sessionID)
|
|
@@ -1951,6 +1986,33 @@ export class Registry {
|
|
|
1951
1986
|
if (next.length === pending.length) break
|
|
1952
1987
|
pending = next
|
|
1953
1988
|
}
|
|
1989
|
+
this.restoreBlockers()
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
/** Keeps at most PENDING_PER_TENANT entries of the tenant in a pending map: the oldest are dropped (with their blocker). */
|
|
1993
|
+
private capPending(map: Map<string, PendingPermission | PendingForm>, tenant: string, drop: (id: string) => void): void {
|
|
1994
|
+
const own = [...map].filter(([, origin]) => (origin.tenant ?? this.sessionTenant(origin.sessionID)) === tenant)
|
|
1995
|
+
for (const [id] of own.slice(0, Math.max(0, own.length - PENDING_PER_TENANT))) drop(id)
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
/**
|
|
1999
|
+
* Puts pending permissions and forms back on the status boards that lost them: a board recreated after its
|
|
2000
|
+
* session left the reported sessions for a moment, or one created after the question was asked (events reach
|
|
2001
|
+
* only existing boards). OpenCode still waits for them; only a reply, a cancel or their processes going away
|
|
2002
|
+
* end them (production 2026-11-05: a question card vanished while the session still waited).
|
|
2003
|
+
*/
|
|
2004
|
+
private restoreBlockers(): void {
|
|
2005
|
+
const restored = new Set<string>()
|
|
2006
|
+
const restore = ({ sessionID, tenant, blocker }: PendingPermission | PendingForm) => {
|
|
2007
|
+
if (!blocker) return
|
|
2008
|
+
const rootID = this.rootOf(sessionID)
|
|
2009
|
+
const board = rootID === undefined ? undefined : this.boards.get(rootID)
|
|
2010
|
+
if (!rootID || !board || (tenant !== undefined && this.sessionTenant(rootID) !== tenant)) return
|
|
2011
|
+
if (board.restoreBlocker(sessionID, blocker)) restored.add(rootID)
|
|
2012
|
+
}
|
|
2013
|
+
for (const origin of this.permissions.values()) restore(origin)
|
|
2014
|
+
for (const origin of this.forms.values()) restore(origin)
|
|
2015
|
+
for (const rootID of restored) this.emit({ type: "status", sessionID: rootID, sessions: this.live(this.boards.get(rootID)!.snapshot().sessions) })
|
|
1954
2016
|
}
|
|
1955
2017
|
|
|
1956
2018
|
// ---------- cluster (design §9) ----------
|
|
@@ -1978,7 +2040,7 @@ export class Registry {
|
|
|
1978
2040
|
const add = (tenant: string | undefined) => tenant !== undefined && tenants.add(tenant)
|
|
1979
2041
|
for (const rootID of this.boards.keys()) add(this.sessionTenant(rootID))
|
|
1980
2042
|
for (const origin of this.permissions.values()) add(origin.tenant ?? this.sessionTenant(origin.sessionID))
|
|
1981
|
-
for (const origin of this.forms.values()) add(this.sessionTenant(origin.sessionID))
|
|
2043
|
+
for (const origin of this.forms.values()) add(origin.tenant ?? this.sessionTenant(origin.sessionID))
|
|
1982
2044
|
for (const entry of this.presence.values()) add(this.sessionTenant(entry.sessionID))
|
|
1983
2045
|
for (const sessionID of this.active.keys()) add(this.sessionTenant(sessionID))
|
|
1984
2046
|
return [...tenants]
|
|
@@ -2113,11 +2175,12 @@ export class Registry {
|
|
|
2113
2175
|
* Raises a notification for the root of `sessionID`'s tree. Only hand-offs, questions and permissions reach a
|
|
2114
2176
|
* session some device shows (spoken replies check that themselves: the watching device speaks them).
|
|
2115
2177
|
*/
|
|
2116
|
-
|
|
2178
|
+
/** True when it was raised (or skipped because a device shows the session); false for a session on no board. */
|
|
2179
|
+
private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string; unlessPref?: NotificationKind } = {}): boolean {
|
|
2117
2180
|
const entry = [...this.boards].find(([, board]) => board.get(sessionID))
|
|
2118
|
-
if (!entry) return
|
|
2181
|
+
if (!entry) return false
|
|
2119
2182
|
const [rootID, board] = entry
|
|
2120
|
-
if (!ALWAYS_ALERT.has(kind) && this.watched(rootID)) return
|
|
2183
|
+
if (!ALWAYS_ALERT.has(kind) && this.watched(rootID)) return true
|
|
2121
2184
|
const alert: Alert = {
|
|
2122
2185
|
kind,
|
|
2123
2186
|
sessionID: rootID,
|
|
@@ -2130,6 +2193,7 @@ export class Registry {
|
|
|
2130
2193
|
}
|
|
2131
2194
|
const owner = this.sessionTenant(rootID)
|
|
2132
2195
|
for (const listener of this.alertListeners) listener(owner ? { ...alert, owner } : alert)
|
|
2196
|
+
return true
|
|
2133
2197
|
}
|
|
2134
2198
|
|
|
2135
2199
|
private emit(message: RegistryMessage): void {
|
|
@@ -2137,3 +2201,10 @@ export class Registry {
|
|
|
2137
2201
|
for (const listener of this.listeners) listener(message, tenant)
|
|
2138
2202
|
}
|
|
2139
2203
|
}
|
|
2204
|
+
|
|
2205
|
+
/** A permission.asked / form.created (or their closes) with an ID longer than any card keeps. */
|
|
2206
|
+
function oversizedBlockerID(type: string, data: Record<string, unknown>): boolean {
|
|
2207
|
+
const ids =
|
|
2208
|
+
type === "permission.asked" ? [data.id] : type === "form.created" ? [(data.form as Record<string, unknown> | undefined)?.id] : []
|
|
2209
|
+
return ids.some((id) => typeof id === "string" && id.length > BLOCKER_ID_MAX)
|
|
2210
|
+
}
|