@huynle/hub 0.6.1 → 0.7.2
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 +870 -0
- package/Dockerfile +33 -0
- package/README.md +1007 -66
- package/package.json +47 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +156 -0
- package/packages/cli/dist/hub-cli.js +21647 -0
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +123 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +252 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +104 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +95 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +553 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +107 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +70 -0
- package/packages/sdk/src/types.ts +517 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +101 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +152 -0
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +328 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +74 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +385 -0
- package/src/central/agent/routes.ts +281 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +112 -0
- package/src/central/agent/webhook-routes.ts +86 -0
- package/src/central/agent/webhooks.ts +493 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +350 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +116 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +246 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1149 -170
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/mcp-http.ts +116 -0
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +625 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +118 -7
- package/src/index.ts +195 -43
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +174 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +17 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// DiscoverySnapshot: what `hubctl setup` found on this host, read-only, before anything is planned. It separates
|
|
2
|
+
// presence from health (an installed harness stays "unverified" until real probes pass), records the container and
|
|
3
|
+
// shim topology (a `claude` on PATH may be a shim into another environment), the package/runtime/service managers,
|
|
4
|
+
// who owns each config file it would touch, and where each path root lives. It can describe an unsupported host
|
|
5
|
+
// (supported: false) so the refusal is explicit; planning for such a host fails.
|
|
6
|
+
import { CONFIG_OWNERS, type ConfigOwner } from "./actions.ts"
|
|
7
|
+
import { HARNESS_ORDER, PROBE_KINDS, RUNTIME_IDS, type HarnessID, type Probe, type RuntimeID } from "./harness.ts"
|
|
8
|
+
import { digestOf } from "./canonical.ts"
|
|
9
|
+
import { ROOT_IDS, type ExecutionContext, type RootMapping } from "./paths.ts"
|
|
10
|
+
import { checkTarget, targetOf, type Libc, type ServiceManager } from "./platform.ts"
|
|
11
|
+
import { findSecrets } from "./secrets.ts"
|
|
12
|
+
import { absPath, bool, Checker, digest, header, instant, list, oneOf, pointer, semver, shape, subset, text, type Result } from "./validate.ts"
|
|
13
|
+
|
|
14
|
+
export const DISCOVERY_SCHEMA = "hub.discovery-snapshot"
|
|
15
|
+
export const DISCOVERY_VERSION = 1
|
|
16
|
+
|
|
17
|
+
export type ContainerKind = "none" | "docker" | "podman" | "lxc" | "wsl" | "unknown"
|
|
18
|
+
export type ShimKind = "host" | "container-shim" | "wrapper" | "unknown"
|
|
19
|
+
export const PACKAGE_MANAGERS = ["brew", "npm", "bun", "apt", "dnf", "apk", "pacman", "nix"] as const
|
|
20
|
+
export type PackageManager = (typeof PACKAGE_MANAGERS)[number]
|
|
21
|
+
/** hub: installed by hubctl from a pinned artifact. */
|
|
22
|
+
export type InstallMethod = "native" | "npm" | "brew" | "package-manager" | "hub" | "unknown"
|
|
23
|
+
|
|
24
|
+
export interface HostInfo {
|
|
25
|
+
readonly fingerprint: string
|
|
26
|
+
/** process.platform as found (may be unsupported). */
|
|
27
|
+
readonly platform: string
|
|
28
|
+
readonly arch: string
|
|
29
|
+
readonly libc: Libc
|
|
30
|
+
readonly target: string
|
|
31
|
+
/** Exactly whether `target` is in SUPPORTED_TARGETS. */
|
|
32
|
+
readonly supported: boolean
|
|
33
|
+
readonly os: { readonly name: string; readonly version: string }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Shim {
|
|
37
|
+
readonly name: string
|
|
38
|
+
readonly path: string
|
|
39
|
+
readonly kind: ShimKind
|
|
40
|
+
/** For a shim: what it forwards to, when known. */
|
|
41
|
+
readonly resolvesTo?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RuntimeInfo {
|
|
45
|
+
readonly id: "bun" | "node"
|
|
46
|
+
readonly version: string
|
|
47
|
+
readonly path: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface HarnessHealth {
|
|
51
|
+
readonly state: "unverified" | "verified" | "failed"
|
|
52
|
+
readonly probes: readonly Probe[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A harness as discovery sees it. `installed` is presence only; `health` is what probes proved. */
|
|
56
|
+
export interface HarnessPresence {
|
|
57
|
+
readonly id: HarnessID
|
|
58
|
+
readonly installed: boolean
|
|
59
|
+
readonly path?: string
|
|
60
|
+
readonly version?: string
|
|
61
|
+
readonly installMethod?: InstallMethod
|
|
62
|
+
readonly health: HarnessHealth
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RuntimePresence {
|
|
66
|
+
readonly id: RuntimeID
|
|
67
|
+
readonly present: boolean
|
|
68
|
+
readonly version?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ConfigFile {
|
|
72
|
+
readonly path: string
|
|
73
|
+
readonly owner: ConfigOwner | "package-manager"
|
|
74
|
+
readonly format: "json" | "jsonc" | "toml" | "plist" | "unit"
|
|
75
|
+
/** Digest of its bytes now; null when it does not exist. Contents are never recorded. */
|
|
76
|
+
readonly sha256: string | null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DiscoverySnapshot {
|
|
80
|
+
readonly schema: typeof DISCOVERY_SCHEMA
|
|
81
|
+
readonly version: typeof DISCOVERY_VERSION
|
|
82
|
+
readonly capturedAt: number
|
|
83
|
+
readonly host: HostInfo
|
|
84
|
+
readonly topology: { readonly container: ContainerKind; readonly shims: readonly Shim[] }
|
|
85
|
+
readonly managers: { readonly package: readonly PackageManager[]; readonly runtime: readonly RuntimeInfo[]; readonly service: ServiceManager | "none" }
|
|
86
|
+
readonly harnesses: readonly HarnessPresence[]
|
|
87
|
+
readonly runtimes: readonly RuntimePresence[]
|
|
88
|
+
readonly configs: readonly ConfigFile[]
|
|
89
|
+
readonly roots: readonly RootMapping[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const NAME = /^[A-Za-z0-9._+-]{1,64}$/
|
|
93
|
+
const VERSION_TEXT = /^[0-9A-Za-z.+_-]{1,64}$/
|
|
94
|
+
|
|
95
|
+
function checkProbe(c: Checker, p: string, v: unknown) {
|
|
96
|
+
const r = shape(c, p, v, ["kind", "ok", "observedAt"], ["detail"])
|
|
97
|
+
if (!r) return
|
|
98
|
+
oneOf(c, pointer(p, "kind"), r.kind, PROBE_KINDS)
|
|
99
|
+
bool(c, pointer(p, "ok"), r.ok)
|
|
100
|
+
instant(c, pointer(p, "observedAt"), r.observedAt)
|
|
101
|
+
if (r.detail !== undefined) text(c, pointer(p, "detail"), r.detail)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function checkHarness(c: Checker, p: string, v: unknown) {
|
|
105
|
+
const r = shape(c, p, v, ["id", "installed", "health"], ["path", "version", "installMethod"])
|
|
106
|
+
if (!r) return
|
|
107
|
+
oneOf(c, pointer(p, "id"), r.id, HARNESS_ORDER)
|
|
108
|
+
bool(c, pointer(p, "installed"), r.installed)
|
|
109
|
+
if (r.path !== undefined) absPath(c, pointer(p, "path"), r.path)
|
|
110
|
+
if (r.version !== undefined) text(c, pointer(p, "version"), r.version, 64, VERSION_TEXT)
|
|
111
|
+
if (r.installMethod !== undefined) oneOf(c, pointer(p, "installMethod"), r.installMethod, ["native", "npm", "brew", "package-manager", "hub", "unknown"] as const)
|
|
112
|
+
if (r.installed === false && (r.path !== undefined || r.version !== undefined)) c.add(p, "inconsistent", "a harness that is not installed has no path or version")
|
|
113
|
+
const h = shape(c, pointer(p, "health"), r.health, ["state", "probes"])
|
|
114
|
+
if (!h) return
|
|
115
|
+
oneOf(c, pointer(pointer(p, "health"), "state"), h.state, ["unverified", "verified", "failed"] as const)
|
|
116
|
+
list(c, pointer(pointer(p, "health"), "probes"), h.probes, 32, (pp, probe) => checkProbe(c, pp, probe))
|
|
117
|
+
const probes = Array.isArray(h.probes) ? (h.probes as Partial<Probe>[]) : []
|
|
118
|
+
if (h.state === "verified") {
|
|
119
|
+
const real = probes.filter((pr) => pr.kind !== "presence")
|
|
120
|
+
if (real.length === 0 || !probes.every((pr) => pr.ok === true)) c.add(pointer(p, "health"), "inconsistent", "verified needs passing real probes, not presence")
|
|
121
|
+
}
|
|
122
|
+
if (h.state === "failed" && !probes.some((pr) => pr.ok === false)) c.add(pointer(p, "health"), "inconsistent", "failed needs a failing probe")
|
|
123
|
+
if (r.installed === false && h.state !== "unverified") c.add(pointer(p, "health"), "inconsistent", "a harness that is not installed cannot be verified")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* v1.1: the one canonical digest of a snapshot that plans, preview tokens, journals, audits and the Ansible vars
|
|
128
|
+
* pin: the canonical JSON without `capturedAt`, so rediscovering an unchanged host gives the same digest.
|
|
129
|
+
*/
|
|
130
|
+
export const snapshotDigest = (snapshot: DiscoverySnapshot): string => digestOf({ ...snapshot, capturedAt: undefined })
|
|
131
|
+
|
|
132
|
+
/** v1.1: the context a plan made from this snapshot resolves paths in: where discovery (and so apply) ran. */
|
|
133
|
+
export const executionContextOf = (snapshot: Pick<DiscoverySnapshot, "topology">): ExecutionContext => (snapshot.topology.container === "none" ? "host" : "container")
|
|
134
|
+
|
|
135
|
+
export function validateDiscoverySnapshot(value: unknown): Result<DiscoverySnapshot> {
|
|
136
|
+
const c = new Checker()
|
|
137
|
+
const r = shape(c, "", value, ["schema", "version", "capturedAt", "host", "topology", "managers", "harnesses", "runtimes", "configs", "roots"])
|
|
138
|
+
if (!r) return c.result(value)
|
|
139
|
+
header(c, "", r, DISCOVERY_SCHEMA, DISCOVERY_VERSION)
|
|
140
|
+
instant(c, "/capturedAt", r.capturedAt)
|
|
141
|
+
|
|
142
|
+
const host = shape(c, "/host", r.host, ["fingerprint", "platform", "arch", "libc", "target", "supported", "os"])
|
|
143
|
+
if (host) {
|
|
144
|
+
digest(c, "/host/fingerprint", host.fingerprint)
|
|
145
|
+
text(c, "/host/platform", host.platform, 32, NAME)
|
|
146
|
+
text(c, "/host/arch", host.arch, 32, NAME)
|
|
147
|
+
oneOf(c, "/host/libc", host.libc, ["none", "glibc", "musl", "unknown"] as const)
|
|
148
|
+
text(c, "/host/target", host.target, 64, NAME)
|
|
149
|
+
bool(c, "/host/supported", host.supported)
|
|
150
|
+
const os = shape(c, "/host/os", host.os, ["name", "version"])
|
|
151
|
+
if (os) {
|
|
152
|
+
text(c, "/host/os/name", os.name, 64)
|
|
153
|
+
text(c, "/host/os/version", os.version, 64)
|
|
154
|
+
}
|
|
155
|
+
if (typeof host.platform === "string" && typeof host.arch === "string" && typeof host.libc === "string" && host.target !== targetOf(host.platform, host.arch, host.libc as Libc))
|
|
156
|
+
c.add("/host/target", "inconsistent", "target does not match platform, arch and libc")
|
|
157
|
+
if (typeof host.target === "string" && host.supported !== checkTarget(host.target).ok) c.add("/host/supported", "inconsistent", "supported must say exactly whether the target is in the support matrix")
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const topology = shape(c, "/topology", r.topology, ["container", "shims"])
|
|
161
|
+
if (topology) {
|
|
162
|
+
oneOf(c, "/topology/container", topology.container, ["none", "docker", "podman", "lxc", "wsl", "unknown"] as const)
|
|
163
|
+
list(c, "/topology/shims", topology.shims, 64, (p, v) => {
|
|
164
|
+
const s = shape(c, p, v, ["name", "path", "kind"], ["resolvesTo"])
|
|
165
|
+
if (!s) return
|
|
166
|
+
text(c, pointer(p, "name"), s.name, 64, NAME)
|
|
167
|
+
absPath(c, pointer(p, "path"), s.path)
|
|
168
|
+
oneOf(c, pointer(p, "kind"), s.kind, ["host", "container-shim", "wrapper", "unknown"] as const)
|
|
169
|
+
if (s.resolvesTo !== undefined) absPath(c, pointer(p, "resolvesTo"), s.resolvesTo)
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const managers = shape(c, "/managers", r.managers, ["package", "runtime", "service"])
|
|
174
|
+
if (managers) {
|
|
175
|
+
subset(c, "/managers/package", managers.package, PACKAGE_MANAGERS)
|
|
176
|
+
list(c, "/managers/runtime", managers.runtime, 8, (p, v) => {
|
|
177
|
+
const rt = shape(c, p, v, ["id", "version", "path"])
|
|
178
|
+
if (!rt) return
|
|
179
|
+
oneOf(c, pointer(p, "id"), rt.id, ["bun", "node"] as const)
|
|
180
|
+
semver(c, pointer(p, "version"), rt.version)
|
|
181
|
+
absPath(c, pointer(p, "path"), rt.path)
|
|
182
|
+
})
|
|
183
|
+
oneOf(c, "/managers/service", managers.service, ["launchd", "systemd-user", "none"] as const)
|
|
184
|
+
const platform = host?.platform
|
|
185
|
+
if (managers.service === "launchd" && platform !== "darwin") c.add("/managers/service", "inconsistent", "launchd is macOS only")
|
|
186
|
+
if (managers.service === "systemd-user" && platform !== "linux") c.add("/managers/service", "inconsistent", "systemd is Linux only")
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
list(c, "/harnesses", r.harnesses, HARNESS_ORDER.length, (p, v) => checkHarness(c, p, v), (v) => (v as { id?: string })?.id)
|
|
190
|
+
list(c, "/runtimes", r.runtimes, RUNTIME_IDS.length, (p, v) => {
|
|
191
|
+
const rt = shape(c, p, v, ["id", "present"], ["version"])
|
|
192
|
+
if (!rt) return
|
|
193
|
+
oneOf(c, pointer(p, "id"), rt.id, RUNTIME_IDS)
|
|
194
|
+
bool(c, pointer(p, "present"), rt.present)
|
|
195
|
+
if (rt.version !== undefined) text(c, pointer(p, "version"), rt.version, 64, VERSION_TEXT)
|
|
196
|
+
}, (v) => (v as { id?: string })?.id)
|
|
197
|
+
list(c, "/configs", r.configs, 128, (p, v) => {
|
|
198
|
+
const cf = shape(c, p, v, ["path", "owner", "format", "sha256"])
|
|
199
|
+
if (!cf) return
|
|
200
|
+
absPath(c, pointer(p, "path"), cf.path)
|
|
201
|
+
oneOf(c, pointer(p, "owner"), cf.owner, [...CONFIG_OWNERS, "package-manager"])
|
|
202
|
+
oneOf(c, pointer(p, "format"), cf.format, ["json", "jsonc", "toml", "plist", "unit"] as const)
|
|
203
|
+
if (cf.sha256 !== null) digest(c, pointer(p, "sha256"), cf.sha256)
|
|
204
|
+
}, (v) => (v as { path?: string })?.path)
|
|
205
|
+
list(c, "/roots", r.roots, ROOT_IDS.length, (p, v) => {
|
|
206
|
+
const root = shape(c, p, v, ["id", "path"], ["containerPath"])
|
|
207
|
+
if (!root) return
|
|
208
|
+
oneOf(c, pointer(p, "id"), root.id, ROOT_IDS)
|
|
209
|
+
absPath(c, pointer(p, "path"), root.path)
|
|
210
|
+
if (root.containerPath !== undefined) absPath(c, pointer(p, "containerPath"), root.containerPath)
|
|
211
|
+
}, (v) => (v as { id?: string })?.id)
|
|
212
|
+
|
|
213
|
+
for (const p of findSecrets(value)) c.add(p, "secret", "discovery never records credentials")
|
|
214
|
+
return c.result(value)
|
|
215
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The JSON output envelope and exit codes of the bootstrap commands (`hubctl machine …`, `hubctl setup …`). With
|
|
2
|
+
// --json, stdout carries exactly one envelope; without it, people get text and the same exit code. Exit codes 0–9
|
|
3
|
+
// are the CLI's existing ones (http.ts EXIT) with unchanged meanings; the bootstrap adds 10 and up.
|
|
4
|
+
import { EXIT } from "../../http.ts"
|
|
5
|
+
import { findSecrets } from "./secrets.ts"
|
|
6
|
+
import { bool, Checker, header, isRecord, list, oneOf, shape, text, type Result } from "./validate.ts"
|
|
7
|
+
|
|
8
|
+
export const BOOTSTRAP_EXIT = Object.freeze({
|
|
9
|
+
...EXIT,
|
|
10
|
+
/** A file the plan would change was edited by someone else, is not ours, or is a symlink the user must edit themselves: nothing was overwritten. */
|
|
11
|
+
conflict: 10,
|
|
12
|
+
/** A hubctl or harness artifact failed authentication: unpinned, tampered, wrong platform, bad signature or evidence, or a refused downgrade. */
|
|
13
|
+
artifactRejected: 11,
|
|
14
|
+
/** The preview is stale, foreign, expired, replayed or malformed: preview again. */
|
|
15
|
+
previewInvalid: 12,
|
|
16
|
+
/** Another apply holds the lock. */
|
|
17
|
+
locked: 13,
|
|
18
|
+
/** An interrupted transaction must be recovered (`hubctl setup recover`) before anything else applies. */
|
|
19
|
+
recoveryRequired: 14,
|
|
20
|
+
/** A harness or provider failed its real probes. */
|
|
21
|
+
notReady: 15,
|
|
22
|
+
} as const)
|
|
23
|
+
|
|
24
|
+
export const ERROR_CODES = [
|
|
25
|
+
"usage",
|
|
26
|
+
"invalid",
|
|
27
|
+
"unsupported_platform",
|
|
28
|
+
"wrong_platform",
|
|
29
|
+
"integrity_mismatch",
|
|
30
|
+
"signature_invalid",
|
|
31
|
+
"downgrade_rejected",
|
|
32
|
+
"below_security_floor",
|
|
33
|
+
"trust_root_rollback",
|
|
34
|
+
"unpinned_artifact",
|
|
35
|
+
"evidence_failed",
|
|
36
|
+
"preview_invalid",
|
|
37
|
+
"preview_foreign",
|
|
38
|
+
"preview_expired",
|
|
39
|
+
"preview_not_yet_valid",
|
|
40
|
+
"preview_replayed",
|
|
41
|
+
"preview_stale",
|
|
42
|
+
"locked",
|
|
43
|
+
"conflict",
|
|
44
|
+
"manual_edit_required",
|
|
45
|
+
"recovery_required",
|
|
46
|
+
"escalation_required",
|
|
47
|
+
"needs_user",
|
|
48
|
+
"forbidden",
|
|
49
|
+
"unauthorized",
|
|
50
|
+
"revoked",
|
|
51
|
+
"offline",
|
|
52
|
+
"not_found",
|
|
53
|
+
"harness_not_ready",
|
|
54
|
+
// v1.1
|
|
55
|
+
"no_service_manager",
|
|
56
|
+
"enrollment_denied",
|
|
57
|
+
"enrollment_expired",
|
|
58
|
+
"cancelled",
|
|
59
|
+
// v1.3
|
|
60
|
+
"service_failed",
|
|
61
|
+
"internal",
|
|
62
|
+
] as const
|
|
63
|
+
export type ErrorCode = (typeof ERROR_CODES)[number]
|
|
64
|
+
|
|
65
|
+
const EXIT_OF: Readonly<Record<ErrorCode, number>> = {
|
|
66
|
+
usage: EXIT.usage,
|
|
67
|
+
invalid: EXIT.usage,
|
|
68
|
+
unsupported_platform: EXIT.unavailable,
|
|
69
|
+
wrong_platform: BOOTSTRAP_EXIT.artifactRejected,
|
|
70
|
+
integrity_mismatch: BOOTSTRAP_EXIT.artifactRejected,
|
|
71
|
+
signature_invalid: BOOTSTRAP_EXIT.artifactRejected,
|
|
72
|
+
downgrade_rejected: BOOTSTRAP_EXIT.artifactRejected,
|
|
73
|
+
below_security_floor: BOOTSTRAP_EXIT.artifactRejected,
|
|
74
|
+
trust_root_rollback: BOOTSTRAP_EXIT.artifactRejected,
|
|
75
|
+
unpinned_artifact: BOOTSTRAP_EXIT.artifactRejected,
|
|
76
|
+
evidence_failed: BOOTSTRAP_EXIT.artifactRejected,
|
|
77
|
+
preview_invalid: BOOTSTRAP_EXIT.previewInvalid,
|
|
78
|
+
preview_foreign: BOOTSTRAP_EXIT.previewInvalid,
|
|
79
|
+
preview_expired: BOOTSTRAP_EXIT.previewInvalid,
|
|
80
|
+
preview_not_yet_valid: BOOTSTRAP_EXIT.previewInvalid,
|
|
81
|
+
preview_replayed: BOOTSTRAP_EXIT.previewInvalid,
|
|
82
|
+
preview_stale: BOOTSTRAP_EXIT.previewInvalid,
|
|
83
|
+
locked: BOOTSTRAP_EXIT.locked,
|
|
84
|
+
conflict: BOOTSTRAP_EXIT.conflict,
|
|
85
|
+
manual_edit_required: BOOTSTRAP_EXIT.conflict,
|
|
86
|
+
recovery_required: BOOTSTRAP_EXIT.recoveryRequired,
|
|
87
|
+
escalation_required: EXIT.needsUser,
|
|
88
|
+
needs_user: EXIT.needsUser,
|
|
89
|
+
forbidden: EXIT.forbidden,
|
|
90
|
+
unauthorized: EXIT.unauthorized,
|
|
91
|
+
revoked: EXIT.unauthorized,
|
|
92
|
+
offline: EXIT.unavailable,
|
|
93
|
+
not_found: EXIT.notFound,
|
|
94
|
+
harness_not_ready: BOOTSTRAP_EXIT.notReady,
|
|
95
|
+
// v1.1: specific codes for outcomes that already had these exits (the exits are unchanged).
|
|
96
|
+
no_service_manager: EXIT.unavailable,
|
|
97
|
+
enrollment_denied: EXIT.forbidden,
|
|
98
|
+
enrollment_expired: EXIT.needsUser,
|
|
99
|
+
cancelled: EXIT.needsUser,
|
|
100
|
+
// v1.3: an activated service that does not stay running (failed, crash-looping, never settles) is not ready.
|
|
101
|
+
service_failed: BOOTSTRAP_EXIT.notReady,
|
|
102
|
+
internal: EXIT.other,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const exitFor = (code: ErrorCode | string): number => EXIT_OF[code as ErrorCode] ?? EXIT.other
|
|
106
|
+
|
|
107
|
+
export const ENVELOPE_SCHEMA = "hub.bootstrap.result"
|
|
108
|
+
|
|
109
|
+
export interface ResultEnvelope<T = unknown> {
|
|
110
|
+
readonly schema: typeof ENVELOPE_SCHEMA
|
|
111
|
+
readonly version: 1
|
|
112
|
+
/** The command words ("machine status", "setup apply"). */
|
|
113
|
+
readonly command: string
|
|
114
|
+
readonly ok: boolean
|
|
115
|
+
readonly data?: T
|
|
116
|
+
readonly error?: { readonly code: ErrorCode; readonly message: string; readonly hint?: string }
|
|
117
|
+
readonly warnings?: readonly string[]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function validateEnvelope(value: unknown): Result<ResultEnvelope> {
|
|
121
|
+
const c = new Checker()
|
|
122
|
+
const r = shape(c, "", value, ["schema", "version", "command", "ok"], ["data", "error", "warnings"])
|
|
123
|
+
if (!r) return c.result(value)
|
|
124
|
+
header(c, "", r, ENVELOPE_SCHEMA, 1)
|
|
125
|
+
text(c, "/command", r.command, 64, /^[a-z][a-z-]*(?: [a-z][a-z-]*)*$/)
|
|
126
|
+
bool(c, "/ok", r.ok)
|
|
127
|
+
if (r.error !== undefined) {
|
|
128
|
+
const e = shape(c, "/error", r.error, ["code", "message"], ["hint"])
|
|
129
|
+
if (e) {
|
|
130
|
+
oneOf(c, "/error/code", e.code, ERROR_CODES)
|
|
131
|
+
text(c, "/error/message", e.message, 500)
|
|
132
|
+
if (e.hint !== undefined) text(c, "/error/hint", e.hint, 500)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (r.ok === true && r.error !== undefined) c.add("/error", "inconsistent", "a success has no error")
|
|
136
|
+
if (r.ok === false && r.error === undefined) c.add("/error", "inconsistent", "a failure names its error")
|
|
137
|
+
if (r.warnings !== undefined) list(c, "/warnings", r.warnings, 64, (p, w) => text(c, p, w, 500))
|
|
138
|
+
// `setup preview --json` prints the SetupPreview, whose /token is a preview token (no authority, contract §3.4),
|
|
139
|
+
// not a credential; its values are still scanned.
|
|
140
|
+
const allow = isRecord(r.data) && r.data.schema === "hub.setup-preview" ? new Set(["/data/token"]) : new Set<string>()
|
|
141
|
+
for (const p of findSecrets(value, "", allow)) c.add(p, "secret", "output never carries credentials")
|
|
142
|
+
return c.result(value)
|
|
143
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The executable contract. `hub` and `hubctl` are two names for one command surface: the name never changes what a
|
|
2
|
+
// caller may do (credentials, scopes and grants do). The CLI runs either as a standalone compiled binary (a signed
|
|
3
|
+
// hubctl with the Bun runtime and its assets embedded) or as a script under a runtime (the npm package, or the
|
|
4
|
+
// OpenCode binary acting as bun). Services always start it with an argv word list built here, never a shell string,
|
|
5
|
+
// and their unit templates are embedded assets so a standalone binary needs nothing beside it on disk.
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import { isAbsolutePath } from "./validate.ts"
|
|
8
|
+
|
|
9
|
+
export const COMMAND_ALIASES = ["hub", "hubctl"] as const
|
|
10
|
+
export type CommandAlias = (typeof COMMAND_ALIASES)[number]
|
|
11
|
+
|
|
12
|
+
/** The single command surface both names resolve to; undefined for any other program name. */
|
|
13
|
+
export function commandSurface(argv0: string): "hub-cli" | undefined {
|
|
14
|
+
return (COMMAND_ALIASES as readonly string[]).includes(path.basename(argv0)) ? "hub-cli" : undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ProgramLayout =
|
|
18
|
+
/** A compiled hubctl: the executable is the whole program. */
|
|
19
|
+
| { readonly kind: "standalone"; readonly executable: string }
|
|
20
|
+
/** A script (hub-cli.js) run by a runtime (bun, node, or OpenCode with BUN_BE_BUN=1). */
|
|
21
|
+
| { readonly kind: "script"; readonly runtime: string; readonly entry: string }
|
|
22
|
+
|
|
23
|
+
/** Where `bun build --compile` mounts the embedded entry (POSIX and Windows forms). */
|
|
24
|
+
const EMBEDDED_PREFIXES = ["/$bunfs/", "B:/~BUN/", "B:\\~BUN\\"]
|
|
25
|
+
|
|
26
|
+
/** This process's layout from process.execPath and the (real path of) process.argv[1]. */
|
|
27
|
+
export function programLayout(program: { readonly execPath: string; readonly entry: string }): ProgramLayout {
|
|
28
|
+
if (EMBEDDED_PREFIXES.some((p) => program.entry.startsWith(p))) return { kind: "standalone", executable: program.execPath }
|
|
29
|
+
return { kind: "script", runtime: program.execPath, entry: program.entry }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const SERVICE_IDS = ["node", "claude-code", "codex-spoke"] as const
|
|
33
|
+
export type ServiceID = (typeof SERVICE_IDS)[number]
|
|
34
|
+
|
|
35
|
+
/** The subcommand each long-running service runs (after the program words). */
|
|
36
|
+
export const SERVICE_COMMANDS: Readonly<Record<ServiceID, readonly string[]>> = Object.freeze({
|
|
37
|
+
node: ["node", "run"],
|
|
38
|
+
"claude-code": ["claude-code", "daemon"],
|
|
39
|
+
"codex-spoke": ["codex-spoke", "serve"],
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// eslint-disable-next-line no-control-regex
|
|
43
|
+
const CONTROL = /[\u0000-\u001f\u007f]/
|
|
44
|
+
|
|
45
|
+
/** The argv a service unit starts: absolute program words, then the service's subcommand. Throws on unsafe paths. */
|
|
46
|
+
export function serviceArgv(layout: ProgramLayout, service: ServiceID): string[] {
|
|
47
|
+
const program = layout.kind === "standalone" ? [layout.executable] : [layout.runtime, layout.entry]
|
|
48
|
+
for (const word of program) if (!isAbsolutePath(word) || CONTROL.test(word)) throw new Error(`service argv needs absolute, single-line paths, not ${JSON.stringify(word)}`)
|
|
49
|
+
const command = SERVICE_COMMANDS[service]
|
|
50
|
+
if (!command) throw new Error(`unknown service ${String(service)}`)
|
|
51
|
+
return [...program, ...command]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type AssetKind = "launchd-plist" | "systemd-unit"
|
|
55
|
+
|
|
56
|
+
export interface EmbeddedAsset {
|
|
57
|
+
readonly kind: AssetKind
|
|
58
|
+
readonly service: ServiceID
|
|
59
|
+
readonly manager: "launchd" | "systemd-user"
|
|
60
|
+
/** The repository file it is embedded from (the template the source install reads today). */
|
|
61
|
+
readonly source: string
|
|
62
|
+
/** The file name it is installed as. */
|
|
63
|
+
readonly installAs: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every service template a standalone hubctl must carry inside the binary. */
|
|
67
|
+
export const EMBEDDED_ASSETS: Readonly<Record<string, EmbeddedAsset>> = Object.freeze({
|
|
68
|
+
"service/launchd/node": { kind: "launchd-plist", service: "node", manager: "launchd", source: "scripts/io.github.huynle.hub-node.plist", installAs: "io.github.huynle.hub-node.plist" },
|
|
69
|
+
"service/systemd-user/node": { kind: "systemd-unit", service: "node", manager: "systemd-user", source: "scripts/hub-node.service", installAs: "hub-node.service" },
|
|
70
|
+
"service/launchd/claude-code": { kind: "launchd-plist", service: "claude-code", manager: "launchd", source: "scripts/io.github.huynle.hub-claude-code.plist", installAs: "io.github.huynle.hub-claude-code.plist" },
|
|
71
|
+
"service/systemd-user/claude-code": { kind: "systemd-unit", service: "claude-code", manager: "systemd-user", source: "scripts/hub-claude-code.service", installAs: "hub-claude-code.service" },
|
|
72
|
+
"service/launchd/codex-spoke": { kind: "launchd-plist", service: "codex-spoke", manager: "launchd", source: "scripts/io.github.huynle.hub-codex-spoke.plist", installAs: "io.github.huynle.hub-codex-spoke.plist" },
|
|
73
|
+
"service/systemd-user/codex-spoke": { kind: "systemd-unit", service: "codex-spoke", manager: "systemd-user", source: "scripts/hub-codex-spoke.service", installAs: "hub-codex-spoke.service" },
|
|
74
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Harness adapters: how the bootstrap discovers, plans for and verifies each agent harness. An installed binary is
|
|
2
|
+
// not a healthy harness: readiness needs real probes (the binary runs and reports its version on the host, not a
|
|
3
|
+
// container shim; its model provider answers; its spoke is connected to the hub), each fresh and passing. Presence
|
|
4
|
+
// alone never counts. Adapters are pure planners: they return typed Actions and run probes through the context the
|
|
5
|
+
// applier gives them; they never get a shell.
|
|
6
|
+
import type { Action } from "./actions.ts"
|
|
7
|
+
import type { DiscoverySnapshot, HarnessPresence } from "./discovery.ts"
|
|
8
|
+
import type { Profile } from "./profile.ts"
|
|
9
|
+
|
|
10
|
+
/** Supported harnesses, in the order the bootstrap offers, installs and verifies them. */
|
|
11
|
+
export const HARNESS_ORDER = ["opencode", "claude-code", "codex"] as const
|
|
12
|
+
export type HarnessID = (typeof HARNESS_ORDER)[number]
|
|
13
|
+
|
|
14
|
+
/** Sandboxed runtimes: opt-in, discovery and verification only (never installed or configured by the bootstrap). */
|
|
15
|
+
export const RUNTIME_IDS = ["openshell"] as const
|
|
16
|
+
export type RuntimeID = (typeof RUNTIME_IDS)[number]
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Upstream authenticity evidence a pinned harness download may carry beyond hub's signed digest pin. It is always
|
|
20
|
+
* optional extra evidence (recorded as verified, unavailable or deferred); the pin itself is mandatory.
|
|
21
|
+
* gpg-manifest Claude Code's GPG-signed per-release manifest.json
|
|
22
|
+
* macos-notarization Apple notarization / codesign of the macOS binary
|
|
23
|
+
* sigstore a sigstore bundle published next to the asset (Codex, where available)
|
|
24
|
+
*/
|
|
25
|
+
export const EVIDENCE_KINDS = ["gpg-manifest", "macos-notarization", "sigstore"] as const
|
|
26
|
+
export type EvidenceKind = (typeof EVIDENCE_KINDS)[number]
|
|
27
|
+
|
|
28
|
+
/** Which evidence each harness's upstream can offer. OpenCode offers none: only hub's pinned digest vouches for it. */
|
|
29
|
+
export const HARNESS_EVIDENCE: Readonly<Record<HarnessID, readonly EvidenceKind[]>> = Object.freeze({
|
|
30
|
+
opencode: [],
|
|
31
|
+
"claude-code": ["gpg-manifest", "macos-notarization"],
|
|
32
|
+
// v1.1 (HBS-13): Codex's macOS binaries are codesigned; the Codex source records only sigstore so far, so a pin may
|
|
33
|
+
// list macos-notarization only once the source checks it.
|
|
34
|
+
codex: ["sigstore", "macos-notarization"],
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* presence: found on PATH / at a known location (never sufficient). version-exec: executed on the host and printed
|
|
39
|
+
* a version. provider-auth: a real request to its model provider succeeded with the configured credentials.
|
|
40
|
+
* api-health: the harness's local server answered its health endpoint. spoke-connected: the hub sees its spoke.
|
|
41
|
+
*/
|
|
42
|
+
export const PROBE_KINDS = ["presence", "version-exec", "provider-auth", "api-health", "spoke-connected"] as const
|
|
43
|
+
export type ProbeKind = (typeof PROBE_KINDS)[number]
|
|
44
|
+
/** Probes that exercise the harness for real; a verify-harness action may only ask for these. */
|
|
45
|
+
export const REAL_PROBES: readonly Exclude<ProbeKind, "presence">[] = ["version-exec", "provider-auth", "api-health", "spoke-connected"]
|
|
46
|
+
|
|
47
|
+
export interface Probe {
|
|
48
|
+
readonly kind: ProbeKind
|
|
49
|
+
readonly ok: boolean
|
|
50
|
+
readonly observedAt: number
|
|
51
|
+
/** One line for people (a version string, an HTTP status); never a credential. */
|
|
52
|
+
readonly detail?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface HarnessVerification {
|
|
56
|
+
readonly harness: HarnessID
|
|
57
|
+
readonly probes: readonly Probe[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What each harness must pass before it is ready. */
|
|
61
|
+
export const REQUIRED_PROBES: Readonly<Record<HarnessID, readonly ProbeKind[]>> = Object.freeze({
|
|
62
|
+
opencode: ["version-exec", "provider-auth", "spoke-connected"],
|
|
63
|
+
"claude-code": ["version-exec", "provider-auth", "spoke-connected"],
|
|
64
|
+
codex: ["version-exec", "provider-auth", "spoke-connected"],
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/** How old a probe may be and still count. */
|
|
68
|
+
export const PROBE_MAX_AGE_MS = 15 * 60_000
|
|
69
|
+
const CLOCK_SKEW_MS = 60_000
|
|
70
|
+
|
|
71
|
+
/** Every required probe was run, passed, and is fresh; any failing or stale required probe means not ready. */
|
|
72
|
+
export function isHarnessReady(verification: HarnessVerification, now: number): boolean {
|
|
73
|
+
const required = REQUIRED_PROBES[verification.harness]
|
|
74
|
+
if (!required) return false
|
|
75
|
+
return required.every((kind) => {
|
|
76
|
+
const probes = verification.probes.filter((p) => p.kind === kind)
|
|
77
|
+
return probes.length > 0 && probes.every((p) => p.ok && p.observedAt <= now + CLOCK_SKEW_MS && now - p.observedAt <= PROBE_MAX_AGE_MS)
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* v1.2: what a probe that is not passing means. `integrity`: the install itself is wrong (the binary is missing, does
|
|
83
|
+
* not run, is not the pinned version, is a container shim, its local API does not answer), so apply rolls back.
|
|
84
|
+
* `user`: the install is fine but waits on the user (their provider login, a spoke they start), which only the
|
|
85
|
+
* installed harness lets them do, so apply commits it as "installed, not ready" unless --require-ready is given.
|
|
86
|
+
* The class belongs to the probe kind, never to a probe's detail, so it cannot be argued from harness output.
|
|
87
|
+
*/
|
|
88
|
+
export const PROBE_CLASSES = ["integrity", "user"] as const
|
|
89
|
+
export type ProbeClass = (typeof PROBE_CLASSES)[number]
|
|
90
|
+
export const PROBE_CLASS: Readonly<Record<ProbeKind, ProbeClass>> = Object.freeze({
|
|
91
|
+
presence: "integrity",
|
|
92
|
+
"version-exec": "integrity",
|
|
93
|
+
"api-health": "integrity",
|
|
94
|
+
"provider-auth": "user",
|
|
95
|
+
"spoke-connected": "user",
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
/** v1.2: pass (present, passing, fresh); pending (a user probe that is not passing); fail (anything else). */
|
|
99
|
+
export type ProbeStatus = "pass" | "pending" | "fail"
|
|
100
|
+
/** v1.2: ready (every probe passes); pending (only user probes do not); failed (an integrity probe does not). */
|
|
101
|
+
export type ReadinessState = "ready" | "pending" | "failed"
|
|
102
|
+
|
|
103
|
+
export interface ProbeReadiness {
|
|
104
|
+
readonly kind: ProbeKind
|
|
105
|
+
readonly class: ProbeClass
|
|
106
|
+
readonly status: ProbeStatus
|
|
107
|
+
readonly detail?: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface HarnessReadiness {
|
|
111
|
+
readonly harness: HarnessID
|
|
112
|
+
readonly state: ReadinessState
|
|
113
|
+
readonly probes: readonly ProbeReadiness[]
|
|
114
|
+
/** What the user does next, in order (empty when ready); never a command hubctl runs. */
|
|
115
|
+
readonly nextSteps: readonly string[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** v1.2: the user's next step for each pending user probe. */
|
|
119
|
+
export const PENDING_STEPS: Readonly<Record<HarnessID, Readonly<Partial<Record<ProbeKind, string>>>>> = Object.freeze({
|
|
120
|
+
opencode: {
|
|
121
|
+
"provider-auth": "sign in to a model provider on this machine: run `opencode auth login`",
|
|
122
|
+
"spoke-connected": "start OpenCode (`opencode`) so the hub plugin connects its spoke",
|
|
123
|
+
},
|
|
124
|
+
"claude-code": {
|
|
125
|
+
"provider-auth": "log in to Claude Code on this machine: run `claude auth login`",
|
|
126
|
+
"spoke-connected": "start the claude-code spoke (`hub claude-code daemon`, or activate the hub services)",
|
|
127
|
+
},
|
|
128
|
+
codex: {
|
|
129
|
+
"provider-auth": "log in to Codex on this machine: run `codex login`",
|
|
130
|
+
"spoke-connected": "start the Codex spoke (`hub codex-spoke serve`, or activate the hub services)",
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
export const VERIFY_STEP = "then check readiness with `hubctl setup verify`"
|
|
134
|
+
|
|
135
|
+
const fresh = (p: Probe, now: number) => p.ok && p.observedAt <= now + CLOCK_SKEW_MS && now - p.observedAt <= PROBE_MAX_AGE_MS
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* v1.2: classifies a verification. Every REQUIRED_PROBES kind and every kind in `also` (the probes a verify-harness
|
|
139
|
+
* action asks for) must be present; a missing one is a failure (an adapter reports every probe it owes). `ready`
|
|
140
|
+
* implies isHarnessReady.
|
|
141
|
+
*/
|
|
142
|
+
export function harnessReadiness(verification: HarnessVerification, now: number, also: readonly ProbeKind[] = []): HarnessReadiness {
|
|
143
|
+
const required = REQUIRED_PROBES[verification.harness] ?? []
|
|
144
|
+
const kinds = [...required, ...also.filter((k) => !required.includes(k))]
|
|
145
|
+
const probes: ProbeReadiness[] = kinds.map((kind) => {
|
|
146
|
+
const found = verification.probes.filter((p) => p.kind === kind)
|
|
147
|
+
const cls = PROBE_CLASS[kind]
|
|
148
|
+
if (found.length === 0) return { kind, class: cls, status: "fail", detail: "not run" }
|
|
149
|
+
const bad = found.find((p) => !fresh(p, now))
|
|
150
|
+
const detail = (bad ?? found[found.length - 1]!).detail
|
|
151
|
+
const status: ProbeStatus = !bad ? "pass" : cls === "user" ? "pending" : "fail"
|
|
152
|
+
return { kind, class: cls, status, ...(detail !== undefined ? { detail } : {}) }
|
|
153
|
+
})
|
|
154
|
+
const state: ReadinessState = probes.some((p) => p.status === "fail") ? "failed" : probes.some((p) => p.status === "pending") ? "pending" : "ready"
|
|
155
|
+
const steps = state === "pending" ? probes.flatMap((p) => (p.status === "pending" ? (PENDING_STEPS[verification.harness]?.[p.kind] ?? []) : [])) : []
|
|
156
|
+
return { harness: verification.harness, state, probes, nextSteps: state === "pending" ? [...steps, VERIFY_STEP] : [] }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** What an adapter may use: typed, injected capabilities only (the applier implements them). */
|
|
160
|
+
export interface AdapterContext {
|
|
161
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
162
|
+
readonly home: string
|
|
163
|
+
readonly platform: string
|
|
164
|
+
readonly arch: string
|
|
165
|
+
readonly now: () => number
|
|
166
|
+
readonly signal?: AbortSignal
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface PlanInput {
|
|
170
|
+
readonly snapshot: DiscoverySnapshot
|
|
171
|
+
readonly profile: Profile
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface HarnessAdapter {
|
|
175
|
+
readonly id: HarnessID
|
|
176
|
+
/** Presence and configuration only; health stays "unverified" until verify runs real probes. */
|
|
177
|
+
discover(context: AdapterContext): Promise<HarnessPresence>
|
|
178
|
+
/** Deterministic: the same snapshot and profile always give the same actions. */
|
|
179
|
+
plan(input: PlanInput): readonly Action[]
|
|
180
|
+
/** Runs the real probes (REQUIRED_PROBES) and reports each, passing or not. */
|
|
181
|
+
verify(context: AdapterContext): Promise<HarnessVerification>
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface AdapterRegistry {
|
|
185
|
+
register(adapter: HarnessAdapter): void
|
|
186
|
+
get(id: HarnessID): HarnessAdapter | undefined
|
|
187
|
+
/** Registered adapters in HARNESS_ORDER. */
|
|
188
|
+
list(): readonly HarnessAdapter[]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function createAdapterRegistry(): AdapterRegistry {
|
|
192
|
+
const adapters = new Map<HarnessID, HarnessAdapter>()
|
|
193
|
+
return {
|
|
194
|
+
register(adapter) {
|
|
195
|
+
if (!(HARNESS_ORDER as readonly string[]).includes(adapter.id)) throw new Error(`unknown harness ${String(adapter.id)} (one of ${HARNESS_ORDER.join(", ")})`)
|
|
196
|
+
if (adapters.has(adapter.id)) throw new Error(`harness ${adapter.id} is already registered`)
|
|
197
|
+
adapters.set(adapter.id, adapter)
|
|
198
|
+
},
|
|
199
|
+
get: (id) => adapters.get(id),
|
|
200
|
+
list: () => HARNESS_ORDER.flatMap((id) => adapters.get(id) ?? []),
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Bootstrap contracts, version 1 (frozen). The types, validators and pure decision rules every bootstrap layer
|
|
2
|
+
// shares: discovery, planning and preview, profiles, artifact authentication, the executable contract, enrollment
|
|
3
|
+
// status, the transaction journal and lock, harness adapters, and the output envelope. See
|
|
4
|
+
// docs/bootstrap-contracts.md for the threat model and the requirement → check mapping.
|
|
5
|
+
//
|
|
6
|
+
// Changing a contract means a new schema version (and a migration where documents persist); never an in-place
|
|
7
|
+
// change of meaning.
|
|
8
|
+
export const CONTRACT_VERSION = 1
|
|
9
|
+
/** The amendment of the v1 contracts this code implements (docs/bootstrap-contracts.md, items marked "v1.1"/"v1.2"). */
|
|
10
|
+
export const CONTRACT_AMENDMENT = "1.2"
|
|
11
|
+
|
|
12
|
+
export * from "./validate.ts"
|
|
13
|
+
export * from "./canonical.ts"
|
|
14
|
+
export * from "./secrets.ts"
|
|
15
|
+
export * from "./platform.ts"
|
|
16
|
+
export * from "./paths.ts"
|
|
17
|
+
export * from "./exec.ts"
|
|
18
|
+
export * from "./harness.ts"
|
|
19
|
+
export * from "./actions.ts"
|
|
20
|
+
export * from "./discovery.ts"
|
|
21
|
+
export * from "./profile.ts"
|
|
22
|
+
export * from "./plan.ts"
|
|
23
|
+
export * from "./preview.ts"
|
|
24
|
+
export * from "./artifact.ts"
|
|
25
|
+
export * from "./credentials.ts"
|
|
26
|
+
export * from "./journal.ts"
|
|
27
|
+
export * from "./envelope.ts"
|
|
28
|
+
|