@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.
Files changed (99) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +40 -3
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +5288 -4415
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
  8. package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
  9. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
  10. package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
  11. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
  12. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
  13. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
  14. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
  15. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
  16. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
  17. package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
  18. package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
  19. package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
  20. package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
  21. package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
  22. package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
  23. package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
  24. package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
  25. package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
  26. package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
  27. package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
  28. package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
  29. package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
  30. package/packages/cli/src/bootstrap/release/README.md +63 -9
  31. package/packages/cli/src/bootstrap/release/cli.ts +94 -16
  32. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
  33. package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
  34. package/packages/cli/src/bootstrap/release/pins.ts +60 -9
  35. package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
  36. package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
  37. package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
  38. package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
  39. package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
  40. package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
  41. package/packages/cli/src/credentials.ts +5 -2
  42. package/packages/cli/src/main.ts +3 -2
  43. package/packages/cli/src/setup/commands.ts +96 -18
  44. package/packages/cli/src/setup/contributors.ts +45 -10
  45. package/packages/cli/src/setup/runtime.ts +25 -6
  46. package/packages/cli/src/setup/self-aside.ts +130 -0
  47. package/packages/codex/package.json +1 -1
  48. package/packages/node/package.json +1 -1
  49. package/packages/node/src/opencode-warm.ts +12 -4
  50. package/packages/preset/package.json +1 -1
  51. package/packages/protocol/package.json +1 -1
  52. package/packages/protocol/src/index.ts +7 -2
  53. package/packages/runtime-openshell/package.json +1 -1
  54. package/packages/runtime-sdk/package.json +1 -1
  55. package/packages/sdk/package.json +1 -1
  56. package/packages/spoke-sdk/package.json +1 -1
  57. package/src/central/config.ts +12 -0
  58. package/src/central/http.ts +10 -0
  59. package/src/central/install/CONTRACT.md +66 -0
  60. package/src/central/install/install.ps1 +349 -0
  61. package/src/central/install/install.sh +266 -0
  62. package/src/central/install/render.ts +146 -0
  63. package/src/central/install/templates.ts +6 -0
  64. package/src/central/install/text-assets.d.ts +9 -0
  65. package/src/central/registry.ts +93 -22
  66. package/src/config.ts +12 -7
  67. package/src/index.ts +28 -0
  68. package/src/spoke/env-inject.ts +17 -2
  69. package/src/spoke/launchers.ts +145 -9
  70. package/src/spoke/spoke.ts +150 -10
  71. package/src/status.ts +70 -14
  72. package/src/win-acl.ts +154 -0
  73. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
  74. package/web/dist/assets/{ArtifactPane-DPHjiWce.js → ArtifactPane-DKwbMTX9.js} +1 -1
  75. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
  76. package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
  77. package/web/dist/assets/{ProfilesDestination-GriuOpiZ.js → ProfilesDestination-BOML2Dgf.js} +1 -1
  78. package/web/dist/assets/{TeamsPage-C-5lYeeM.js → TeamsPage-CNBTE8gB.js} +1 -1
  79. package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
  80. package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
  81. package/web/dist/assets/hub-CrbItiwP.js +13 -0
  82. package/web/dist/assets/index-82Ce0IAf.css +1 -0
  83. package/web/dist/assets/index-D9SP3_0z.js +22 -0
  84. package/web/dist/assets/{live-CDWcyavI.js → live-BNJgy0vg.js} +3 -3
  85. package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
  86. package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
  87. package/web/dist/assets/{renderer2d-CNFdTDTr.js → renderer2d-CYag1CnR.js} +1 -1
  88. package/web/dist/assets/{renderer3d-C45jPvYX.js → renderer3d-BRB6rgDJ.js} +1 -1
  89. package/web/dist/assets/{safe-area-sA9a8vYJ.js → safe-area-fOy0AOuG.js} +1 -1
  90. package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
  91. package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
  92. package/web/dist/index.html +6 -4
  93. package/web/dist/assets/ArtifactPage-CSotAWob.js +0 -1
  94. package/web/dist/assets/ArtifactWorkbench-CkmWnP6L.js +0 -6
  95. package/web/dist/assets/Library-DTLEyekn.js +0 -2
  96. package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
  97. package/web/dist/assets/Watchtower-DOlPsiIy.js +0 -2
  98. package/web/dist/assets/index-6mdQ7eUY.js +0 -34
  99. package/web/dist/assets/index-C5OmwA1y.css +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-spoke-sdk",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -9,6 +9,7 @@ import { isLoopbackHost } from "../loopback"
9
9
  import { formatPattern, parsePattern } from "./agent/policy"
10
10
  import { APP_DIR, configBase, legacyPaths, migrateFirst, migrateStateDir, resolveHubStateDir, stateLegacyPaths } from "../migrate"
11
11
  import { updateSpeech, type StoredSpeech } from "./speech/settings"
12
+ import { checkReleaseBase } from "./install/render"
12
13
  import { cspMode, type CspMode } from "./csp"
13
14
 
14
15
  export const GOOGLE_ISSUER = "https://accounts.google.com"
@@ -69,6 +70,11 @@ export interface CentralConfig {
69
70
  readonly launchCaps?: HubLaunches
70
71
  /** hub.json `agents.webhooks.allowPrivate`: webhooks may target http, loopback, private and local hosts (admin opt-in). */
71
72
  readonly webhooksAllowPrivate?: true
73
+ /**
74
+ * HUB_RELEASE_BASE: where /install.sh and /install.ps1 tell machines to download hubctl (default: this version's
75
+ * hub-releases GitHub release). https, or plain http on loopback or a private-network IP address.
76
+ */
77
+ readonly releaseBase?: string
72
78
  /** Cluster mode (design §9): set only when HUB_DATABASE_URL and HUB_VALKEY_URL (or hub.json cluster) are. */
73
79
  readonly cluster?: ClusterConfig
74
80
  }
@@ -473,6 +479,11 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
473
479
  if (!/^https?:\/\/[^\s/]+$/.test(publicURL)) {
474
480
  throw new Error(`HUB_PUBLIC_URL must be an http(s) origin without a path, got ${JSON.stringify(publicURL)}`)
475
481
  }
482
+ const rawReleaseBase = env.HUB_RELEASE_BASE?.trim()
483
+ const releaseBase = rawReleaseBase ? checkReleaseBase(rawReleaseBase) : undefined
484
+ if (rawReleaseBase && !releaseBase) {
485
+ throw new Error(`HUB_RELEASE_BASE must be an https URL (plain http only on loopback or a private-network IP address) without user info, query or quotes, got ${JSON.stringify(rawReleaseBase)}`)
486
+ }
476
487
  const auth = authConfig(env, file.auth)
477
488
  const cluster = clusterConfig(env, auth, file.cluster)
478
489
  return {
@@ -495,6 +506,7 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
495
506
  agentsNever: [...(file.agents?.never ?? [])],
496
507
  ...(file.agents?.launches ? { launchCaps: { ...file.agents.launches } } : {}),
497
508
  ...(file.agents?.webhooks?.allowPrivate === true ? { webhooksAllowPrivate: true as const } : {}),
509
+ ...(releaseBase ? { releaseBase } : {}),
498
510
  ...(cluster ? { cluster } : {}),
499
511
  }
500
512
  }
@@ -38,6 +38,7 @@ import type { Launch } from "./agent/launch-store"
38
38
  import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
39
39
  import { hubDirectory } from "./agent/directory-routes"
40
40
  import type { Directory } from "./agent/directory"
41
+ import { installResponse } from "./install/render"
41
42
  import { agentApi, agentRoutes, agentVerifiers, isAgentPath, wellKnown, type AgentRoute } from "./agent/routes"
42
43
  import { MCP_PATH, mcpHttp } from "./mcp-http"
43
44
  import { SessionTokens } from "./agent/session-tokens"
@@ -1509,6 +1510,15 @@ export function startCentral(options: CentralOptions): Central {
1509
1510
  if (method === "GET" && pathname === "/.well-known/hub") {
1510
1511
  return wellKnown({ publicURL, version: options.version ?? "0.0.0", auth: config.auth.mode === "oidc" ? { mode: "oidc", issuer: config.auth.issuer } : { mode: "pairing" } })
1511
1512
  }
1513
+ // The machine setup one-liners (src/central/install/CONTRACT.md): public, no credentials in them.
1514
+ if ((method === "GET" || method === "HEAD") && (pathname === "/install.sh" || pathname === "/install.ps1")) {
1515
+ const res = installResponse(pathname === "/install.sh" ? "sh" : "ps1", url, {
1516
+ publicURL,
1517
+ version: options.version ?? "0.0.0",
1518
+ ...(config.releaseBase ? { releaseBase: config.releaseBase } : {}),
1519
+ })
1520
+ return method === "HEAD" ? new Response(null, { status: res.status, headers: res.headers }) : res
1521
+ }
1512
1522
  if (method === "GET" && pathname === "/login") return login(request, url)
1513
1523
  if (oidc && method === "GET" && pathname === "/auth/login") return await authLogin(url)
1514
1524
  if (oidc && method === "GET" && pathname === "/auth/callback") return await authCallback(request, url)
@@ -0,0 +1,66 @@
1
+ # Install scripts: shared contract (Phase 1)
2
+
3
+ Design: `docs/plans/2026-11-05-machine-setup-installer-design.md`. Three workstreams build against this file; change
4
+ it only through the integrator.
5
+
6
+ ## Routes (server)
7
+
8
+ `GET /install.sh` and `GET /install.ps1`: public (no auth), dispatched in `src/central/http.ts` `handle()` before
9
+ `/api/`, next to `/.well-known/hub`. Answer `200 text/plain; charset=utf-8`, `cache-control: no-store`,
10
+ `x-content-type-options: nosniff`.
11
+
12
+ Query (every key optional, each at most once; any other key → 400):
13
+
14
+ | Key | Values | Default | install.ps1 (Phase 1) |
15
+ |---|---|---|---|
16
+ | `profile` | `session-only` · `launch-node` · `full-harness` | `session-only` | only `session-only`, else 400 |
17
+ | `root` | workspace root for `launch-node`: 1–200 chars of `[A-Za-z0-9._/~-]`, starts with `/` or `~/`, no `..` segment, no `//` | `~/projects` when profile is `launch-node` | not allowed (400) |
18
+ | `activate` | `0` · `1` | `0` | only `0` |
19
+
20
+ `root` with another profile → 400. Errors are `400 text/plain` with a one-line reason (no script).
21
+
22
+ Other answers: `503 text/plain` when this hub has no matching release (see `HUB_RELEASE_BASE`), `400` when the hub's
23
+ public URL is plain `http` on a non-private host.
24
+
25
+ ## Template placeholders
26
+
27
+ Templates are `src/central/install/install.sh` and `src/central/install/install.ps1`, imported as text. The server
28
+ replaces every `{{NAME}}` below (each exactly as given; all values are pre-validated and contain none of
29
+ `'` `"` `` ` `` `$` `\` newline, so templates put them inside single quotes). An unknown or leftover `{{…}}` is a
30
+ server bug (tests check none remain).
31
+
32
+ | Placeholder | Value |
33
+ |---|---|
34
+ | `{{HUB_URL}}` | the hub's public origin, no trailing slash (`https://hub.example.com`) |
35
+ | `{{HUB_VERSION}}` | the hub version, e.g. `0.8.2` |
36
+ | `{{RELEASE_BASE}}` | `https://github.com/huynle/hub-releases/releases/download/v<version>`, or `$HUB_RELEASE_BASE` (server env) when set |
37
+ | `{{PROFILE}}` | the profile |
38
+ | `{{WORKSPACE_ROOT}}` | the root, or empty |
39
+ | `{{ACTIVATE}}` | `0` or `1` |
40
+ | `{{INSECURE_HTTP}}` | `1` when `HUB_URL` is `http://` on loopback or a private-network IP, else `0` |
41
+
42
+ ## Script behaviour (scripts workstream)
43
+
44
+ - All work in a `main` function called on the last line; a truncated download runs nothing.
45
+ - `install.sh`: POSIX `sh`. Environment `HUB_INSTALL_RELEASE_BASE`, when set, overrides `{{RELEASE_BASE}}` (tests;
46
+ the local user controls it anyway). Downloads `hubctl-<target>`, `HUBCTL_SHA256SUMS`, `hubctl-manifest.json`.
47
+ - `install.ps1`: runs under `irm … | iex`, so it must **never** call `exit` (that closes the user's window): every
48
+ failure is caught at the top, printed as one red `hub setup: …` line, and the function returns (the session stays
49
+ open). Windows PowerShell 5.1 and PowerShell 7 both, ASCII only. Phase 2 (`packages/cli/src/bootstrap/PHASE2-CONTRACT.md`):
50
+ x64 only (`PROCESSOR_ARCHITEW6432`, else `PROCESSOR_ARCHITECTURE`, must be `AMD64`); downloads
51
+ `hubctl-windows-x64.exe`, `HUBCTL_SHA256SUMS`, `hubctl-manifest.json` (`HUB_INSTALL_RELEASE_BASE` overrides, as in
52
+ install.sh; redirects followed, https stays https) into a private temp dir, checks the sha256, then runs install.sh's
53
+ hubctl sequence and exit-code handling with an empty stdin. After a successful apply it adds `<home>\.local\bin` to
54
+ the user PATH when missing (`HKCU\Environment` value read and written unexpanded, keeping its kind; a throwaway
55
+ `SetEnvironmentVariable` user variable is set and cleared to broadcast the change) and to the session's PATH.
56
+ `HUB_INSTALL_USER_PATH_FILE` (tests) stands in for the registry value.
57
+
58
+ ## Web (dialog workstream)
59
+
60
+ The console builds commands from `me().publicURL` (fallback `location.origin`):
61
+
62
+ - macOS / Linux: `curl -fsSL '<origin>/install.sh?<query>' | sh`
63
+ - Windows: `irm '<origin>/install.ps1?<query>' | iex`
64
+
65
+ `<query>` uses `URLSearchParams` with defaults left out (no query → bare path); `'` never appears (the allowlists
66
+ exclude it).
@@ -0,0 +1,349 @@
1
+ # Hub machine setup for Windows, served by the hub as GET /install.ps1 (see CONTRACT.md):
2
+ #
3
+ # irm '<hub>/install.ps1' | iex
4
+ #
5
+ # The PowerShell twin of install.sh: downloads the hubctl release this hub pins (hubctl-windows-x64.exe), checks its
6
+ # sha256, enrolls this machine with the hub (device code, approved in the console), then runs `hubctl setup preview`,
7
+ # `setup apply` and `setup verify` for the session-only profile (the only one on Windows for now), and adds
8
+ # %USERPROFILE%\.local\bin to the user PATH. Every hubctl run gets an empty stdin. Re-running it is safe: an enrolled
9
+ # machine is not enrolled again, and apply converges.
10
+ #
11
+ # It runs inside the user's own PowerShell (`iex`), so it never calls exit: a failure prints one red "hub setup:"
12
+ # line and returns. Everything is inside Invoke-HubSetup, called on the last line: a truncated download does not
13
+ # parse, so nothing runs. Windows PowerShell 5.1 and PowerShell 7. ASCII only (5.1 reads a BOM-less script in the
14
+ # ANSI code page).
15
+ #
16
+ # Environment, for tests (the local user controls it anyway): HUB_INSTALL_RELEASE_BASE overrides the release base,
17
+ # as in install.sh; HUB_INSTALL_USER_PATH_FILE, when set, is a file that stands in for the user PATH in the
18
+ # registry (HKCU\Environment), which is then never touched.
19
+ function Invoke-HubSetup {
20
+ $HubUrl = '{{HUB_URL}}'
21
+ $HubVersion = '{{HUB_VERSION}}'
22
+ $ReleaseBase = '{{RELEASE_BASE}}'
23
+ $SetupProfile = '{{PROFILE}}'
24
+ $WorkspaceRoot = '{{WORKSPACE_ROOT}}'
25
+ $Activate = '{{ACTIVATE}}'
26
+ $InsecureHttp = '{{INSECURE_HTTP}}'
27
+
28
+ $ErrorActionPreference = 'Stop'
29
+ $onWindows = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
30
+
31
+ function Write-HubSay([string] $Message, [string] $Color = '') {
32
+ if ($Color) { Write-Host "hub setup: $Message" -ForegroundColor $Color }
33
+ else { Write-Host "hub setup: $Message" }
34
+ }
35
+
36
+ # The hubctl release asset for this machine, or a stop with the reason. A 32-bit PowerShell on 64-bit Windows sees x86
37
+ # in PROCESSOR_ARCHITECTURE and the machine's own architecture in PROCESSOR_ARCHITEW6432.
38
+ function Get-HubAsset {
39
+ $arch = $env:PROCESSOR_ARCHITEW6432
40
+ if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE }
41
+ if (-not $arch) { throw 'this does not look like Windows: on macOS and Linux use the install.sh command from the console' }
42
+ switch ($arch.ToUpperInvariant()) {
43
+ 'AMD64' { return 'hubctl-windows-x64.exe' }
44
+ 'ARM64' { throw 'windows-arm64 has no hubctl build yet: hubctl runs on 64-bit x86 (x64) Windows only for now' }
45
+ 'X86' { throw 'this is 32-bit Windows, which hubctl does not support: it needs 64-bit x86 (x64) Windows' }
46
+ default { throw "this processor ($arch) is not supported: hubctl needs 64-bit x86 (x64) Windows" }
47
+ }
48
+ }
49
+
50
+ # The profile directory hubctl installs into (PHASE2-CONTRACT.md: HOME wins when set and absolute).
51
+ function Get-HubHome {
52
+ if ($env:HOME -and [IO.Path]::IsPathRooted($env:HOME)) { return $env:HOME }
53
+ if ($env:USERPROFILE) { return $env:USERPROFILE }
54
+ return [Environment]::GetFolderPath('UserProfile')
55
+ }
56
+
57
+ # A fresh directory only this user can read (icacls on Windows, chmod elsewhere).
58
+ function Initialize-HubTemp {
59
+ $base = $env:TEMP
60
+ if (-not $base) { $base = [IO.Path]::GetTempPath() }
61
+ $dir = Join-Path $base ('hub-setup-' + [Guid]::NewGuid().ToString('N'))
62
+ New-Item -ItemType Directory -Path $dir | Out-Null
63
+ if ($onWindows) {
64
+ $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
65
+ & icacls.exe $dir /inheritance:r /grant:r "*$($sid):(OI)(CI)(F)" | Out-Null
66
+ if ($LASTEXITCODE -ne 0) { throw "icacls could not restrict $dir to you (code $LASTEXITCODE)" }
67
+ } else {
68
+ & chmod 700 $dir
69
+ }
70
+ return $dir
71
+ }
72
+
73
+ # One HttpClient that follows redirects itself (GitHub release downloads redirect to another host), so that an
74
+ # https download never continues over http.
75
+ function Open-HubClient {
76
+ try { Add-Type -AssemblyName System.Net.Http } catch { Write-Verbose 'System.Net.Http is already loaded' }
77
+ $handler = New-Object System.Net.Http.HttpClientHandler
78
+ $handler.AllowAutoRedirect = $false
79
+ $client = New-Object System.Net.Http.HttpClient($handler)
80
+ $client.Timeout = [TimeSpan]::FromMinutes(10)
81
+ return $client
82
+ }
83
+
84
+ function Save-HubFile($Client, [string] $Url, [string] $File) {
85
+ $uri = New-Object Uri($Url)
86
+ for ($hop = 0; $hop -le 5; $hop++) {
87
+ try {
88
+ $response = $Client.GetAsync($uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult()
89
+ } catch {
90
+ throw "could not download $Url ($($_.Exception.GetBaseException().Message))"
91
+ }
92
+ try {
93
+ $code = [int]$response.StatusCode
94
+ if ($code -ge 300 -and $code -lt 400 -and $response.Headers.Location) {
95
+ $next = New-Object Uri($uri, $response.Headers.Location)
96
+ if ($uri.Scheme -eq 'https' -and $next.Scheme -ne 'https') {
97
+ throw "could not download $Url (it redirected to $($next.Scheme), and an https download follows redirects over https only)"
98
+ }
99
+ $uri = $next
100
+ continue
101
+ }
102
+ if (-not $response.IsSuccessStatusCode) { throw "could not download $Url (HTTP $code)" }
103
+ $out = [IO.File]::Create($File)
104
+ try { $response.Content.CopyToAsync($out).GetAwaiter().GetResult() } finally { $out.Dispose() }
105
+ return
106
+ } finally {
107
+ $response.Dispose()
108
+ }
109
+ }
110
+ throw "could not download $Url (too many redirects)"
111
+ }
112
+
113
+ # The lower-case sha256 for exactly $Name in a HUBCTL_SHA256SUMS file ("<sha256> <file>", a leading * marks
114
+ # binary mode), or a stop.
115
+ function Get-HubExpectedSum([string] $File, [string] $Name) {
116
+ foreach ($line in [IO.File]::ReadAllLines($File)) {
117
+ $fields = @($line.Trim() -split '\s+')
118
+ if ($fields.Count -ne 2) { continue }
119
+ if ($fields[1].TrimStart('*') -ne $Name) { continue }
120
+ $sum = $fields[0].ToLowerInvariant()
121
+ if ($sum -notmatch '^[0-9a-f]{64}$') { throw "HUBCTL_SHA256SUMS has a malformed checksum for $Name; nothing was run" }
122
+ return $sum
123
+ }
124
+ throw "HUBCTL_SHA256SUMS has no checksum for $Name; nothing was run"
125
+ }
126
+
127
+ # One argument quoted for a Windows command line (CommandLineToArgvW rules; .NET parses them the same elsewhere).
128
+ function ConvertTo-HubArgument([string] $Value) {
129
+ if ($Value -ne '' -and $Value -notmatch '[\s"]') { return $Value }
130
+ $quoted = New-Object Text.StringBuilder
131
+ [void]$quoted.Append('"')
132
+ $slashes = 0
133
+ foreach ($c in $Value.ToCharArray()) {
134
+ if ($c -eq [char]'\') { $slashes++; continue }
135
+ if ($c -eq [char]'"') { [void]$quoted.Append('\' * (2 * $slashes + 1)) }
136
+ else { [void]$quoted.Append('\' * $slashes) }
137
+ [void]$quoted.Append($c)
138
+ $slashes = 0
139
+ }
140
+ [void]$quoted.Append('\' * (2 * $slashes)).Append('"')
141
+ return $quoted.ToString()
142
+ }
143
+
144
+ # Runs hubctl with an empty stdin and returns its exit code; with -Capture, an object with Code and Out (stdout;
145
+ # stderr is dropped, as install.sh drops `machine status` stderr). Otherwise its output goes to this console.
146
+ function Invoke-HubTool([string[]] $Arguments, [switch] $Capture) {
147
+ $start = New-Object System.Diagnostics.ProcessStartInfo
148
+ $start.FileName = $hubBin
149
+ $start.Arguments = (@($Arguments | ForEach-Object { ConvertTo-HubArgument $_ }) -join ' ')
150
+ $start.UseShellExecute = $false
151
+ $start.RedirectStandardInput = $true
152
+ if ($Capture) {
153
+ $start.RedirectStandardOutput = $true
154
+ $start.RedirectStandardError = $true
155
+ $start.StandardOutputEncoding = New-Object Text.UTF8Encoding($false)
156
+ }
157
+ $process = [Diagnostics.Process]::Start($start)
158
+ try {
159
+ $process.StandardInput.Close()
160
+ if ($Capture) {
161
+ $stdout = $process.StandardOutput.ReadToEndAsync()
162
+ $stderr = $process.StandardError.ReadToEndAsync()
163
+ $process.WaitForExit()
164
+ [void]$stderr.GetAwaiter().GetResult()
165
+ return New-Object PSObject -Property @{ Code = $process.ExitCode; Out = $stdout.GetAwaiter().GetResult() }
166
+ }
167
+ $process.WaitForExit()
168
+ return $process.ExitCode
169
+ } finally {
170
+ $process.Dispose()
171
+ }
172
+ }
173
+
174
+ function Test-HubSamePath([string] $Entry, [string] $Dir) {
175
+ $expanded = [Environment]::ExpandEnvironmentVariables($Entry.Trim()).TrimEnd('\', '/')
176
+ return $expanded -ieq $Dir.TrimEnd('\', '/')
177
+ }
178
+
179
+ function Test-HubPathHas([string] $List, [string] $Dir) {
180
+ foreach ($entry in ($List -split ';')) {
181
+ if ($entry.Trim() -and (Test-HubSamePath $entry $Dir)) { return $true }
182
+ }
183
+ return $false
184
+ }
185
+
186
+ # Adds $Dir to the user PATH (HKCU\Environment) when it is not there yet; $true when it was added. The value is
187
+ # read and written as stored, so %VARS% in it stay unexpanded (Environment.SetEnvironmentVariable would expand them
188
+ # and store a plain string); setting and clearing a throwaway user variable through SetEnvironmentVariable then
189
+ # tells Windows (and new terminals) that the environment changed.
190
+ function Add-HubUserPath([string] $Dir) {
191
+ $seam = $env:HUB_INSTALL_USER_PATH_FILE
192
+ if ($seam) {
193
+ $current = ''
194
+ if (Test-Path -LiteralPath $seam) { $current = [IO.File]::ReadAllText($seam) }
195
+ } elseif ($onWindows) {
196
+ $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
197
+ if (-not $key) { $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') }
198
+ $current = [string]$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
199
+ } else {
200
+ return $false
201
+ }
202
+ if (Test-HubPathHas $current $Dir) {
203
+ if ($key) { $key.Dispose() }
204
+ return $false
205
+ }
206
+ $next = $current.TrimEnd(';')
207
+ if ($next) { $next = "$next;$Dir" } else { $next = $Dir }
208
+ if ($seam) {
209
+ [IO.File]::WriteAllText($seam, $next)
210
+ return $true
211
+ }
212
+ try {
213
+ $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString
214
+ if ($null -ne $key.GetValue('Path')) { $kind = $key.GetValueKind('Path') }
215
+ if ($kind -ne [Microsoft.Win32.RegistryValueKind]::String) { $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString }
216
+ $key.SetValue('Path', $next, $kind)
217
+ } finally {
218
+ $key.Dispose()
219
+ }
220
+ [Environment]::SetEnvironmentVariable('HUB_SETUP_PATH_CHANGED', '1', 'User')
221
+ [Environment]::SetEnvironmentVariable('HUB_SETUP_PATH_CHANGED', $null, 'User')
222
+ return $true
223
+ }
224
+
225
+ $tmp = $null
226
+ try {
227
+ $HubUrl = $HubUrl.TrimEnd('/')
228
+ if ($SetupProfile -ne 'session-only' -or $Activate -ne '0' -or $WorkspaceRoot -ne '') {
229
+ throw "on Windows only 'OpenCode only' (session-only) is available for now; pick it in the console"
230
+ }
231
+ $release = $env:HUB_INSTALL_RELEASE_BASE
232
+ if (-not $release) { $release = $ReleaseBase }
233
+ $release = $release.TrimEnd('/')
234
+
235
+ Write-HubSay "setting up this machine for $HubUrl (hub $HubVersion, profile $SetupProfile)"
236
+ if ($InsecureHttp -eq '1') {
237
+ Write-HubSay "warning: $HubUrl is plain http: this machine's token travels unencrypted on your network" 'Yellow'
238
+ }
239
+ $asset = Get-HubAsset
240
+ [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
241
+
242
+ $tmp = Initialize-HubTemp
243
+
244
+ # ---------- download and check hubctl ----------
245
+ $hubBin = Join-Path $tmp 'hubctl.exe'
246
+ $manifest = Join-Path $tmp 'hubctl-manifest.json'
247
+ $sums = Join-Path $tmp 'HUBCTL_SHA256SUMS'
248
+ Write-HubSay "downloading $asset from $release"
249
+ $client = Open-HubClient
250
+ try {
251
+ Save-HubFile $client "$release/HUBCTL_SHA256SUMS" $sums
252
+ Save-HubFile $client "$release/$asset" $hubBin
253
+ Save-HubFile $client "$release/hubctl-manifest.json" $manifest
254
+ } finally {
255
+ $client.Dispose()
256
+ }
257
+ $expected = Get-HubExpectedSum $sums $asset
258
+ $actual = (Get-FileHash -LiteralPath $hubBin -Algorithm SHA256).Hash.ToLowerInvariant()
259
+ if ($actual -ne $expected) { throw "the sha256 of $asset does not match HUBCTL_SHA256SUMS (got $actual); nothing was run" }
260
+ if (-not $onWindows) { & chmod 700 $hubBin }
261
+ Write-HubSay 'checksum ok (hubctl checks the release manifest signature itself)'
262
+
263
+ # ---------- enrollment ----------
264
+ # Skipped only when hubctl reports this exact hub; otherwise `machine enroll` decides (it is a no-op for the
265
+ # same hub and refuses another hub without --reenroll). A credential this hub revoked is replaced.
266
+ $enroll = $true
267
+ $reenroll = $false
268
+ $status = Invoke-HubTool @('machine', 'status', '--json') -Capture
269
+ $state = $null
270
+ $server = $null
271
+ try {
272
+ $data = (ConvertFrom-Json $status.Out).data
273
+ $state = [string]$data.state
274
+ $server = [string]$data.server
275
+ } catch {
276
+ Write-Verbose 'hubctl machine status printed no JSON'
277
+ }
278
+ if ($server -eq $HubUrl) {
279
+ if ($status.Code -eq 0 -and @('verified', 'configured', 'insecure') -contains $state) {
280
+ $enroll = $false
281
+ Write-HubSay "this machine is already enrolled with $HubUrl ($state); not enrolling again"
282
+ } elseif ($status.Code -eq 9 -and $state -eq 'revoked') {
283
+ $reenroll = $true
284
+ Write-HubSay "$HubUrl revoked this machine's credential; enrolling it again"
285
+ }
286
+ }
287
+ if ($enroll) {
288
+ $command = @('machine', 'enroll', '--server', $HubUrl)
289
+ if ($InsecureHttp -eq '1') { $command += '--allow-insecure-http' }
290
+ if ($reenroll) { $command += '--reenroll' }
291
+ Write-HubSay 'enrolling: approve the code below in the hub console'
292
+ $rc = Invoke-HubTool $command
293
+ switch ($rc) {
294
+ 0 { break }
295
+ 5 { throw 'the enrollment was denied on the hub; nothing was set up. Run the command again to start over' }
296
+ 4 { throw 'the enrollment did not complete (expired, cancelled, or this machine is enrolled with another hub: see above); nothing was set up' }
297
+ default { throw "enrollment failed (hubctl exit $rc); nothing was set up" }
298
+ }
299
+ }
300
+
301
+ # ---------- preview and apply ----------
302
+ $preview = Join-Path $tmp 'preview.json'
303
+ $rc = Invoke-HubTool @('setup', 'preview', '--profile', $SetupProfile, '--enrollment', 'separate', '--manifest', $manifest, '--out', $preview)
304
+ if ($rc -ne 0) { throw "setup preview failed (hubctl exit $rc); nothing was changed" }
305
+ if (-not (Test-Path -LiteralPath $preview) -or (Get-Item -LiteralPath $preview).Length -eq 0) {
306
+ throw 'setup preview wrote no preview; nothing was changed'
307
+ }
308
+ # Manual edits (a config behind a link, a format hubctl cannot edit losslessly) are blockers, and apply refuses
309
+ # the whole plan while any remains (contracts section 3.12, exit 10). The preview above printed them.
310
+ $blockers = (ConvertFrom-Json ([IO.File]::ReadAllText($preview))).preview.blockers
311
+ if ($null -ne $blockers -and @($blockers).Count -gt 0) {
312
+ throw 'nothing was applied: make the edits listed above by hand (hubctl does not write those files), then run this command again'
313
+ }
314
+
315
+ $rc = Invoke-HubTool @('setup', 'apply', '--preview', $preview, '--manifest', $manifest)
316
+ switch ($rc) {
317
+ 0 { break }
318
+ 10 { throw 'setup apply needs edits by hand or found a conflict (see above); resolve them, then run this command again' }
319
+ default { throw "setup apply failed (hubctl exit $rc); see above" }
320
+ }
321
+
322
+ # ---------- PATH ----------
323
+ $userBin = Join-Path (Join-Path (Get-HubHome) '.local') 'bin'
324
+ if (Add-HubUserPath $userBin) {
325
+ Write-HubSay "added $userBin to your user PATH; open a new terminal to run hub and hubctl by name in other windows"
326
+ }
327
+ $sep = [IO.Path]::PathSeparator
328
+ $inSession = $false
329
+ foreach ($entry in ([string]$env:PATH -split [regex]::Escape($sep))) {
330
+ if ($entry -and (Test-HubSamePath $entry $userBin)) { $inSession = $true }
331
+ }
332
+ if (-not $inSession) { $env:PATH = ([string]$env:PATH).TrimEnd($sep) + $sep + $userBin }
333
+
334
+ # ---------- verify ----------
335
+ $rc = Invoke-HubTool @('setup', 'verify', '--profile', $SetupProfile)
336
+ switch ($rc) {
337
+ 0 { Write-HubSay "done: this machine is set up for $HubUrl" 'Green' }
338
+ 15 { Write-HubSay 'installed, not ready yet: finish the next steps above, then check with: hubctl setup verify' 'Yellow' }
339
+ default { throw "setup verify failed (hubctl exit $rc)" }
340
+ }
341
+ } catch {
342
+ Write-HubSay $_.Exception.Message 'Red'
343
+ } finally {
344
+ if ($tmp -and (Test-Path -LiteralPath $tmp)) {
345
+ try { Remove-Item -LiteralPath $tmp -Recurse -Force } catch { Write-Verbose "could not remove $tmp" }
346
+ }
347
+ }
348
+ }
349
+ Invoke-HubSetup