@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,809 @@
|
|
|
1
|
+
// `hubctl machine enroll|status` and `hubctl setup discover|preview|apply|verify|drift|rollback|recover|ansible`
|
|
2
|
+
// (HBS-13, contracts §7 I1/I3/I7). Each command prints one hub.bootstrap.result envelope with --json (and text for
|
|
3
|
+
// people otherwise) and exits with exitFor(code). The program name (`hub` or `hubctl`) never matters; authority comes
|
|
4
|
+
// only from the machine credential, the profile and the explicit consent flags (--escalate, --confirm-activation,
|
|
5
|
+
// --allow-downgrade), which are never defaults.
|
|
6
|
+
//
|
|
7
|
+
// Preview and apply are built by the same runtime (runtime.ts) from the same profile and choices: `setup preview
|
|
8
|
+
// --out <file>` keeps the preview with those inputs, `setup apply --preview <file>` rediscovers and re-plans with
|
|
9
|
+
// them, revalidates the token against the fresh plan (revalidateApply), and the engine applies that fresh plan.
|
|
10
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
11
|
+
import { hostname } from "node:os"
|
|
12
|
+
import path from "node:path"
|
|
13
|
+
import {
|
|
14
|
+
ENROLLMENT_MODES,
|
|
15
|
+
ENVELOPE_SCHEMA,
|
|
16
|
+
exitFor,
|
|
17
|
+
HARNESS_ORDER,
|
|
18
|
+
harnessReadiness,
|
|
19
|
+
PENDING_STEPS,
|
|
20
|
+
PROFILE_IDS,
|
|
21
|
+
programLayout,
|
|
22
|
+
resolvePathRef,
|
|
23
|
+
rootPathIn,
|
|
24
|
+
executionContextOf,
|
|
25
|
+
validateEnvelope,
|
|
26
|
+
type DiscoverySnapshot,
|
|
27
|
+
type EnrollmentMode,
|
|
28
|
+
type ErrorCode,
|
|
29
|
+
type HarnessID,
|
|
30
|
+
type HarnessReadiness,
|
|
31
|
+
type Issue,
|
|
32
|
+
type Json,
|
|
33
|
+
type PathRef,
|
|
34
|
+
type Profile,
|
|
35
|
+
type ResultEnvelope,
|
|
36
|
+
type SetupPlan,
|
|
37
|
+
type VerifiedManifest,
|
|
38
|
+
} from "../bootstrap/contracts/index.ts"
|
|
39
|
+
import { verifyManifest } from "../bootstrap/contracts/index.ts"
|
|
40
|
+
import { OpencodePlanError } from "../bootstrap/adapters/opencode/plan.ts"
|
|
41
|
+
import { parseJsonc } from "../bootstrap/transaction/json-merge.ts"
|
|
42
|
+
import { renderRoleVars } from "../bootstrap/ansible/render.ts"
|
|
43
|
+
import { createNodeProbe } from "../bootstrap/discovery/node-probe.ts"
|
|
44
|
+
import { credentialStoreFor, defaultMachineName, machineEnroll, machineStatus, renderMachineResult, sharingOffWarning } from "../bootstrap/enrollment/index.ts"
|
|
45
|
+
import { previewSetup, renderPreview, revalidateApply } from "../bootstrap/preview/index.ts"
|
|
46
|
+
import { defaultProfile } from "../bootstrap/profiles/catalog.ts"
|
|
47
|
+
import { detectDrift, managedFields, type Observation } from "../bootstrap/profiles/drift.ts"
|
|
48
|
+
import { rollbackTo } from "../bootstrap/profiles/history.ts"
|
|
49
|
+
import { loadProfile } from "../bootstrap/profiles/versions.ts"
|
|
50
|
+
import { parseManifest } from "../bootstrap/release/verify.ts"
|
|
51
|
+
import { shippedTrustRoot } from "../bootstrap/release/trust-root.ts"
|
|
52
|
+
import { fileState } from "../bootstrap/transaction/state.ts"
|
|
53
|
+
import { nodeFs } from "../bootstrap/transaction/fs.ts"
|
|
54
|
+
import { TxError } from "../bootstrap/transaction/result.ts"
|
|
55
|
+
import { TxStore } from "../bootstrap/transaction/store.ts"
|
|
56
|
+
import type { TxOutcome } from "../bootstrap/transaction/engine.ts"
|
|
57
|
+
import { readNodeConfig, nodeConfigPath } from "../../../node/src/config.ts"
|
|
58
|
+
import { str, UsageError, type FlagKind, type Parsed } from "../args"
|
|
59
|
+
import { resolveCredentials, type Env } from "../credentials"
|
|
60
|
+
import { clientFor } from "../http"
|
|
61
|
+
import { hubLaunchDriver } from "./launch-driver.ts"
|
|
62
|
+
import { errorCodeOf, issuesText, PlanRefusal } from "./contributors.ts"
|
|
63
|
+
import { createSetupRuntime, enrollPromptText, type RuntimePorts, type SetupRuntime } from "./runtime.ts"
|
|
64
|
+
import { BootstrapStateStore, raiseFloors, validateSetupRequest, type BootstrapState, type SetupChoices, type SetupRequest } from "./state.ts"
|
|
65
|
+
|
|
66
|
+
/** Test seams (never set by main.ts): fakes for everything that reaches outside the process. */
|
|
67
|
+
export interface BootstrapSeams {
|
|
68
|
+
readonly ports?: RuntimePorts
|
|
69
|
+
readonly fetch?: typeof fetch
|
|
70
|
+
readonly now?: () => number
|
|
71
|
+
/** The machine identity discovery fingerprints (default: the OS's). */
|
|
72
|
+
readonly machineID?: string
|
|
73
|
+
/** A trust root to verify manifests with, instead of the embedded one (tests only). */
|
|
74
|
+
readonly trustRoot?: string
|
|
75
|
+
/** Floor of the enrollment poll interval. */
|
|
76
|
+
readonly minIntervalMs?: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SetupIo {
|
|
80
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
81
|
+
readonly home: string
|
|
82
|
+
stdout(s: string): void
|
|
83
|
+
stderr(s: string): void
|
|
84
|
+
readonly stdin?: () => Promise<string>
|
|
85
|
+
readonly signal?: AbortSignal
|
|
86
|
+
readonly cwd?: string
|
|
87
|
+
/** What runs this CLI (process.execPath and the real path of argv[1]). */
|
|
88
|
+
readonly program: { readonly execPath: string; readonly entry: string }
|
|
89
|
+
readonly version: string
|
|
90
|
+
readonly bootstrap?: BootstrapSeams
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const CONSENT_FLAGS = { escalate: "bool", "confirm-activation": "bool", "allow-downgrade": "bool" } as const satisfies Record<string, FlagKind>
|
|
94
|
+
|
|
95
|
+
/** The flags of each command (args.ts CommandSpec). */
|
|
96
|
+
export const BOOTSTRAP_FLAGS: Readonly<Record<string, Readonly<Record<string, FlagKind>>>> = {
|
|
97
|
+
"machine enroll": { server: "string", name: "string", reenroll: "bool", "no-verify": "bool" },
|
|
98
|
+
"machine status": { "no-verify": "bool" },
|
|
99
|
+
"setup discover": {},
|
|
100
|
+
"setup preview": { profile: "string", manifest: "string", out: "string", enrollment: "string", "workspace-root": "multi", "verify-launch": "string", server: "string", name: "string", ...CONSENT_FLAGS },
|
|
101
|
+
"setup apply": { preview: "string", manifest: "string", "require-ready": "bool", ...CONSENT_FLAGS },
|
|
102
|
+
"setup verify": { profile: "string" },
|
|
103
|
+
"setup drift": { profile: "string", manifest: "string" },
|
|
104
|
+
"setup rollback": { to: "string", "dry-run": "bool" },
|
|
105
|
+
"setup recover": { resume: "bool", manifest: "string", "require-ready": "bool", ...CONSENT_FLAGS },
|
|
106
|
+
"setup ansible": { profile: "string", plan: "string", manifest: "string", "release-url": "string", "no-require-ready": "bool", "apply-require-ready": "bool", ...CONSENT_FLAGS },
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------- output ----------
|
|
110
|
+
|
|
111
|
+
class Refusal extends Error {
|
|
112
|
+
constructor(
|
|
113
|
+
readonly code: ErrorCode,
|
|
114
|
+
message: string,
|
|
115
|
+
readonly hint?: string,
|
|
116
|
+
readonly data?: unknown,
|
|
117
|
+
) {
|
|
118
|
+
super(message)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const line = (s: string, max = 500) => {
|
|
123
|
+
const one = s.replace(/[\u0000-\u001f\u007f]+/g, " ").trim()
|
|
124
|
+
return one.length <= max ? one : `${one.slice(0, max - 1)}…`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function envelope(command: string, ok: boolean, extra: { data?: unknown; error?: { code: ErrorCode; message: string; hint?: string }; warnings?: readonly string[] }): ResultEnvelope {
|
|
128
|
+
const e: ResultEnvelope = {
|
|
129
|
+
schema: ENVELOPE_SCHEMA,
|
|
130
|
+
version: 1,
|
|
131
|
+
command,
|
|
132
|
+
ok,
|
|
133
|
+
...(extra.data !== undefined ? { data: extra.data } : {}),
|
|
134
|
+
...(extra.error ? { error: { code: extra.error.code, message: line(extra.error.message), ...(extra.error.hint ? { hint: line(extra.error.hint) } : {}) } } : {}),
|
|
135
|
+
...(extra.warnings?.length ? { warnings: extra.warnings.map((w) => line(w)) } : {}),
|
|
136
|
+
}
|
|
137
|
+
// An envelope that fails its contract (a credential slipped in, say) is withheld.
|
|
138
|
+
return validateEnvelope(e).ok ? e : { schema: ENVELOPE_SCHEMA, version: 1, command, ok: false, error: { code: "internal", message: "The result failed validation and was withheld" } }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function emit(io: SetupIo, json: boolean, e: ResultEnvelope, text?: () => string): number {
|
|
142
|
+
if (json) io.stdout(`${JSON.stringify(e, null, 2)}\n`)
|
|
143
|
+
else {
|
|
144
|
+
if (e.ok && text) io.stdout(text().replace(/\n?$/, "\n"))
|
|
145
|
+
if (e.error) io.stderr(`hubctl ${e.command}: ${e.error.message}\n${e.error.hint ? `hint: ${e.error.hint}\n` : ""}`)
|
|
146
|
+
for (const w of e.warnings ?? []) io.stderr(`hubctl: warning: ${w}\n`)
|
|
147
|
+
}
|
|
148
|
+
return e.ok ? 0 : exitFor(e.error!.code)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const issueRefusal = (issues: readonly Issue[], hint?: string) => new Refusal(errorCodeOf(issues[0]!.code), issuesText(issues), hint)
|
|
152
|
+
|
|
153
|
+
// ---------- inputs ----------
|
|
154
|
+
|
|
155
|
+
const readText = (file: string, what: string): string => {
|
|
156
|
+
try {
|
|
157
|
+
return readFileSync(file, "utf8")
|
|
158
|
+
} catch (cause) {
|
|
159
|
+
throw new Refusal("not_found", `cannot read the ${what} ${file} (${(cause as NodeJS.ErrnoException).code ?? "error"})`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const parseJSON = (text: string, what: string, code: ErrorCode = "invalid"): unknown => {
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(text)
|
|
166
|
+
} catch {
|
|
167
|
+
throw new Refusal(code, `the ${what} is not JSON`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** --profile: a catalog profile id, a profile file, or "-" (stdin); version-1 documents are migrated. */
|
|
172
|
+
async function profileArg(io: SetupIo, value: string | undefined, fallback?: Profile): Promise<Profile> {
|
|
173
|
+
if (value === undefined) return fallback ?? unwrapProfile(defaultProfile("session-only"))
|
|
174
|
+
if ((PROFILE_IDS as readonly string[]).includes(value)) return unwrapProfile(defaultProfile(value as Profile["id"]))
|
|
175
|
+
if (value !== "-" && !value.includes("/") && !value.endsWith(".json")) throw new Refusal("usage", `unknown profile ${value}: use ${PROFILE_IDS.join(", ")}, a profile file, or - for stdin`)
|
|
176
|
+
const text = value === "-" ? await (io.stdin?.() ?? Promise.reject(new Refusal("usage", "cannot read the profile from stdin here"))) : readText(path.resolve(io.cwd ?? process.cwd(), value), "profile")
|
|
177
|
+
const loaded = loadProfile(parseJSON(text, "profile"))
|
|
178
|
+
if (!loaded.ok) throw issueRefusal(loaded.issues)
|
|
179
|
+
return loaded.value.profile
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function unwrapProfile(r: ReturnType<typeof defaultProfile>): Profile {
|
|
183
|
+
if (!r.ok) throw issueRefusal(r.issues)
|
|
184
|
+
return r.value
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** --manifest: the release manifest, verified with the trust root this hubctl ships (exit 11 when it fails). */
|
|
188
|
+
function manifestArg(io: SetupIo, value: string | undefined): VerifiedManifest | undefined {
|
|
189
|
+
if (value === undefined) return undefined
|
|
190
|
+
const parsed = parseManifest(readText(path.resolve(io.cwd ?? process.cwd(), value), "manifest"))
|
|
191
|
+
if (!parsed.ok) throw new Refusal("signature_invalid", issuesText(parsed.issues))
|
|
192
|
+
const root = shippedTrustRoot(io.bootstrap?.trustRoot)
|
|
193
|
+
if (!root.ok) throw new Refusal("signature_invalid", issuesText(root.issues), "Use a signed release build of hubctl to verify release manifests")
|
|
194
|
+
const verified = verifyManifest(parsed.value, root.value)
|
|
195
|
+
if (!verified.ok) throw new Refusal(errorCodeOf(verified.issues[0]!.code) === "invalid" ? "signature_invalid" : errorCodeOf(verified.issues[0]!.code), issuesText(verified.issues))
|
|
196
|
+
return verified.value
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function context(io: SetupIo) {
|
|
200
|
+
const probe = createNodeProbe({ env: io.env, home: io.home, ...(io.bootstrap?.machineID ? { machineID: io.bootstrap.machineID } : {}) })
|
|
201
|
+
const now = io.bootstrap?.now ?? Date.now
|
|
202
|
+
return { probe, now }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function stateStore(snapshot: DiscoverySnapshot): BootstrapStateStore {
|
|
206
|
+
return new BootstrapStateStore(snapshot.roots.map((r) => ({ ...r, path: rootPathIn(r, executionContextOf(snapshot)) })))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readState(snapshot: DiscoverySnapshot): BootstrapState {
|
|
210
|
+
try {
|
|
211
|
+
return stateStore(snapshot).read()
|
|
212
|
+
} catch (error) {
|
|
213
|
+
throw new Refusal("recovery_required", (error as Error).message)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The verification launch over the hub launch API, with this shell's hub login (resolved when it starts). */
|
|
218
|
+
function launchDriverFor(io: SetupIo) {
|
|
219
|
+
const store = credentialStoreFor(io.env, io.home)
|
|
220
|
+
const client = () => {
|
|
221
|
+
const credentials = resolveCredentials(io.env as Env, io.home)
|
|
222
|
+
if (!credentials) throw new Error("not logged in to the hub (run `hub login --scope sessions:launch`; an app token launches under a grant with verb launch)")
|
|
223
|
+
return clientFor(credentials)
|
|
224
|
+
}
|
|
225
|
+
return hubLaunchDriver({
|
|
226
|
+
launches: {
|
|
227
|
+
create: (spec, options) => client().launches.create(spec, options),
|
|
228
|
+
get: (id, options) => client().launches.get(id, options),
|
|
229
|
+
stop: (id, options) => client().launches.stop(id, options),
|
|
230
|
+
list: (filter) => client().launches.list(filter),
|
|
231
|
+
},
|
|
232
|
+
machineID: async () => (await store.read())?.machineID,
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function runtimeFor(io: SetupIo, manifest: VerifiedManifest | undefined, snapshot: DiscoverySnapshot): SetupRuntime {
|
|
237
|
+
const { probe, now } = context(io)
|
|
238
|
+
const ports: RuntimePorts = { launchDriver: launchDriverFor(io), ...io.bootstrap?.ports }
|
|
239
|
+
return createSetupRuntime({
|
|
240
|
+
env: io.env,
|
|
241
|
+
home: io.home,
|
|
242
|
+
now,
|
|
243
|
+
probe,
|
|
244
|
+
layout: programLayout(io.program),
|
|
245
|
+
version: io.version,
|
|
246
|
+
...(manifest ? { manifest } : {}),
|
|
247
|
+
store: credentialStoreFor(io.env, io.home),
|
|
248
|
+
state: readState(snapshot),
|
|
249
|
+
...(io.bootstrap?.fetch ? { fetch: io.bootstrap.fetch } : {}),
|
|
250
|
+
...(io.signal ? { signal: io.signal } : {}),
|
|
251
|
+
stderr: io.stderr,
|
|
252
|
+
ports,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function discovered(io: SetupIo): DiscoverySnapshot {
|
|
257
|
+
const { probe, now } = context(io)
|
|
258
|
+
const r = createSetupRuntime({ env: io.env, home: io.home, now, probe, layout: programLayout(io.program), version: io.version, store: credentialStoreFor(io.env, io.home), state: { schema: "hub.bootstrap-state", version: 1, floors: { harnesses: {} } }, stderr: io.stderr }).discover()
|
|
259
|
+
if (!r.ok) throw issueRefusal(r.issues)
|
|
260
|
+
return r.value
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A PathRef for an absolute path under a mapped root (the verification launch's workspace). */
|
|
264
|
+
function refFor(abs: string, snapshot: DiscoverySnapshot): PathRef | undefined {
|
|
265
|
+
const ctx = executionContextOf(snapshot)
|
|
266
|
+
for (const id of ["home"] as const) {
|
|
267
|
+
const root = snapshot.roots.find((r) => r.id === id)
|
|
268
|
+
const base = root ? rootPathIn(root, ctx) : undefined
|
|
269
|
+
if (base && abs.startsWith(`${base}/`)) return { root: id, rel: abs.slice(base.length + 1) }
|
|
270
|
+
}
|
|
271
|
+
return undefined
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function choicesOf(io: SetupIo, flags: Parsed["flags"], profile: Profile, snapshot: DiscoverySnapshot): SetupChoices {
|
|
275
|
+
const enrollment = str(flags, "enrollment")
|
|
276
|
+
if (enrollment !== undefined && !(ENROLLMENT_MODES as readonly string[]).includes(enrollment)) throw new Refusal("usage", `--enrollment is ${ENROLLMENT_MODES.join(" or ")}`)
|
|
277
|
+
let roots = (flags["workspace-root"] as string[] | undefined)?.map((r) => path.resolve(io.cwd ?? process.cwd(), r))
|
|
278
|
+
if (!roots?.length && profile.services.includes("node")) {
|
|
279
|
+
// Without --workspace-root, the workspace roots node.json already lists.
|
|
280
|
+
const existing = readNodeConfig(nodeConfigPath(io.env as Record<string, string | undefined>, io.home)).config.workspaceRoots
|
|
281
|
+
if (existing.length > 0) roots = [...existing]
|
|
282
|
+
}
|
|
283
|
+
const launchHarness = str(flags, "verify-launch")
|
|
284
|
+
let launch: SetupChoices["launch"]
|
|
285
|
+
if (launchHarness !== undefined) {
|
|
286
|
+
if (!(HARNESS_ORDER as readonly string[]).includes(launchHarness)) throw new Refusal("usage", `--verify-launch takes ${HARNESS_ORDER.join(", ")}`)
|
|
287
|
+
const workspace = roots?.[0] ? refFor(roots[0], snapshot) : undefined
|
|
288
|
+
if (!workspace) throw new Refusal("usage", "--verify-launch launches in the first workspace root, which must be under your home directory (--workspace-root)")
|
|
289
|
+
launch = { harness: launchHarness as HarnessID, workspace }
|
|
290
|
+
}
|
|
291
|
+
const server = str(flags, "server")
|
|
292
|
+
return {
|
|
293
|
+
...(enrollment ? { enrollment: enrollment as EnrollmentMode } : {}),
|
|
294
|
+
...(flags["confirm-activation"] === true ? { activate: true } : {}),
|
|
295
|
+
...(flags["allow-downgrade"] === true ? { allowDowngrade: true } : {}),
|
|
296
|
+
...(roots?.length ? { node: { workspaceRoots: roots } } : {}),
|
|
297
|
+
...(launch ? { launch } : {}),
|
|
298
|
+
...(server ? { server: server.replace(/\/+$/, ""), machineName: str(flags, "name") ?? defaultMachineName(hostname()) } : {}),
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Planner refusals thrown by adapters and contributors (OpencodePlanError, PlanRefusal) as envelope errors. */
|
|
303
|
+
function planned<T>(f: () => T): T {
|
|
304
|
+
try {
|
|
305
|
+
return f()
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (error instanceof OpencodePlanError || error instanceof PlanRefusal) throw new Refusal(error.code, error.message)
|
|
308
|
+
throw error
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const usedNonces = (snapshot: DiscoverySnapshot): Set<string> => {
|
|
313
|
+
try {
|
|
314
|
+
return new Set(new TxStore(nodeFs, snapshot.roots, executionContextOf(snapshot)).journals().map((j) => j.nonce))
|
|
315
|
+
} catch {
|
|
316
|
+
return new Set()
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------- machine ----------
|
|
321
|
+
|
|
322
|
+
async function machine(verb: string, flags: Parsed["flags"], io: SetupIo, json: boolean): Promise<number> {
|
|
323
|
+
const verify = flags["no-verify"] !== true
|
|
324
|
+
const fetchImpl = io.bootstrap?.fetch
|
|
325
|
+
if (verb === "status") {
|
|
326
|
+
const r = await machineStatus({ store: credentialStoreFor(io.env, io.home), verify, ...(fetchImpl ? { fetch: fetchImpl } : {}), ...(io.signal ? { signal: io.signal } : {}) })
|
|
327
|
+
return printMachine(io, json, r.envelope, r.exit)
|
|
328
|
+
}
|
|
329
|
+
const server = str(flags, "server")
|
|
330
|
+
if (!server) throw new UsageError("needs --server <hub URL> (https; plain http only for a loopback hub)")
|
|
331
|
+
const r = await machineEnroll({
|
|
332
|
+
server: server.replace(/\/+$/, ""),
|
|
333
|
+
...(str(flags, "name") ? { machineName: str(flags, "name")! } : {}),
|
|
334
|
+
store: credentialStoreFor(io.env, io.home, { migrate: true }),
|
|
335
|
+
...(flags.reenroll === true ? { reenroll: true } : {}),
|
|
336
|
+
verify,
|
|
337
|
+
...(fetchImpl ? { fetch: fetchImpl } : {}),
|
|
338
|
+
...(io.signal ? { signal: io.signal } : {}),
|
|
339
|
+
...(io.bootstrap?.minIntervalMs !== undefined ? { minIntervalMs: io.bootstrap.minIntervalMs } : {}),
|
|
340
|
+
// Prompts go to stderr, so --json keeps stdout to the one envelope.
|
|
341
|
+
onPrompt: (p) => io.stderr(enrollPromptText(p)),
|
|
342
|
+
})
|
|
343
|
+
return printMachine(io, json, r.envelope, r.exit)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function printMachine(io: SetupIo, json: boolean, e: ResultEnvelope, exit: number): number {
|
|
347
|
+
if (json) io.stdout(`${JSON.stringify(e, null, 2)}\n`)
|
|
348
|
+
else (e.ok ? io.stdout : io.stderr)(renderMachineResult(e))
|
|
349
|
+
return exit
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------- setup ----------
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A hint when the plan links commands into user-bin (hub, a harness) but that directory is not on the user's PATH:
|
|
356
|
+
* the links work, but the commands are not found by name until the user adds it.
|
|
357
|
+
*/
|
|
358
|
+
export function pathHint(snapshot: DiscoverySnapshot, plan: SetupPlan, env: Readonly<Record<string, string | undefined>>): string | undefined {
|
|
359
|
+
const linked = plan.actions.flatMap((a) => (a.kind === "link-executable" && a.path.root === "user-bin" ? [a.path.rel] : []))
|
|
360
|
+
if (linked.length === 0) return undefined
|
|
361
|
+
const bin = snapshot.roots.find((r) => r.id === "user-bin")
|
|
362
|
+
if (!bin) return undefined
|
|
363
|
+
const dir = rootPathIn(bin, executionContextOf(snapshot))
|
|
364
|
+
const onPath = (env.PATH ?? "").split(":").some((d) => d.replace(/\/+$/, "") === dir)
|
|
365
|
+
return onPath ? undefined : `${dir} is not on your PATH, so ${linked.join(", ")} will not be found by name; add it to PATH (for example in ~/.profile)`
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function previewWarnings(snapshot: DiscoverySnapshot, plan: SetupPlan, notes: readonly string[], env: Readonly<Record<string, string | undefined>> = {}): string[] {
|
|
369
|
+
const warnings = [...notes]
|
|
370
|
+
const hint = pathHint(snapshot, plan, env)
|
|
371
|
+
if (hint) warnings.push(hint)
|
|
372
|
+
const mcp = plan.actions.some((a) => a.kind === "register-mcp-server")
|
|
373
|
+
const installs = plan.actions.some((a) => a.kind === "install-artifact")
|
|
374
|
+
const bin = resolvePathRef({ root: "user-bin", rel: "hubctl" }, snapshot.roots, executionContextOf(snapshot))
|
|
375
|
+
if (mcp && !installs && bin.ok && !existsSync(bin.value))
|
|
376
|
+
warnings.push(`the hub MCP server runs ${bin.value}, which is not installed yet: install the standalone hubctl there (or preview with --manifest, so the plan installs it)`)
|
|
377
|
+
return warnings
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function preview(io: SetupIo, flags: Parsed["flags"], json: boolean): Promise<number> {
|
|
381
|
+
const snapshot = discovered(io)
|
|
382
|
+
const manifest = manifestArg(io, str(flags, "manifest"))
|
|
383
|
+
const runtime = runtimeFor(io, manifest, snapshot)
|
|
384
|
+
const state = readState(snapshot)
|
|
385
|
+
const profile = await profileArg(io, str(flags, "profile"), state.profile)
|
|
386
|
+
const choices = choicesOf(io, flags, profile, snapshot)
|
|
387
|
+
const bundle = await runtime.plan(profile, choices)
|
|
388
|
+
const { probe, now } = context(io)
|
|
389
|
+
const r = planned(() => previewSetup({ probe, profile, now: now(), ...bundle.sources }))
|
|
390
|
+
if (!r.ok) throw issueRefusal(r.issues)
|
|
391
|
+
const out = str(flags, "out")
|
|
392
|
+
if (out) {
|
|
393
|
+
const request: SetupRequest = { schema: "hub.setup-request", version: 1, preview: r.value, profile, choices }
|
|
394
|
+
const file = path.resolve(io.cwd ?? process.cwd(), out)
|
|
395
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
396
|
+
writeFileSync(tmp, `${JSON.stringify(request, null, 2)}\n`, { mode: 0o600 })
|
|
397
|
+
renameSync(tmp, file)
|
|
398
|
+
}
|
|
399
|
+
const warnings = previewWarnings(snapshot, r.value.plan, bundle.node()?.notes ?? [], io.env)
|
|
400
|
+
return emit(io, json, envelope("setup preview", true, { data: r.value, warnings }), () =>
|
|
401
|
+
[...renderPreview(r.value, io.home), ...(out ? [`Saved for apply: hubctl setup apply --preview ${out}`] : ["Keep it for apply with --out <file>; then: hubctl setup apply --preview <file>"])].join("\n"),
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function readRequest(io: SetupIo, file: string | undefined): SetupRequest {
|
|
406
|
+
if (!file) throw new UsageError("needs --preview <file> (from hubctl setup preview --out <file>)")
|
|
407
|
+
const raw = parseJSON(readText(path.resolve(io.cwd ?? process.cwd(), file), "preview"), "preview", "preview_invalid")
|
|
408
|
+
const r = validateSetupRequest(raw)
|
|
409
|
+
if (!r.ok) throw new Refusal("preview_invalid", `${file} is not a hubctl setup preview: ${issuesText(r.issues)}`, "Preview again: hubctl setup preview --out <file>")
|
|
410
|
+
return r.value
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const reportData = (outcome: TxOutcome) =>
|
|
414
|
+
outcome.report ? { txID: outcome.report.txID, state: outcome.report.state, steps: outcome.report.steps, conflicts: outcome.report.conflicts, kept: outcome.report.kept, evidence: outcome.report.evidence } : undefined
|
|
415
|
+
|
|
416
|
+
/** Directories a rollback left in place because something else wrote into them: informational, not a warning. */
|
|
417
|
+
function noteKept(io: SetupIo, json: boolean, outcome: { readonly report?: TxOutcome["report"] }): void {
|
|
418
|
+
if (json) return
|
|
419
|
+
for (const k of outcome.report?.kept ?? []) io.stderr(`hubctl: note: kept ${k.path ?? k.actionID}: ${k.reason}\n`)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---------- readiness (contract v1.2) ----------
|
|
423
|
+
|
|
424
|
+
/** One harness's readiness as `setup apply` and `setup verify` report it. */
|
|
425
|
+
export interface HarnessReport {
|
|
426
|
+
readonly harness: HarnessID
|
|
427
|
+
readonly ready: boolean
|
|
428
|
+
/** ready; installed-not-ready (only user probes wait: login, spoke); failed (an integrity probe failed). */
|
|
429
|
+
readonly state: "ready" | "installed-not-ready" | "failed"
|
|
430
|
+
readonly probes: HarnessReadiness["probes"]
|
|
431
|
+
readonly nextSteps: readonly string[]
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const READINESS_STATE = { ready: "ready", pending: "installed-not-ready", failed: "failed" } as const
|
|
435
|
+
|
|
436
|
+
export const harnessReport = (r: HarnessReadiness): HarnessReport => ({ harness: r.harness, ready: r.state === "ready", state: READINESS_STATE[r.state], probes: r.probes, nextSteps: r.nextSteps })
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The OpenCode spoke connects only while config.json's `mode` is not "off". When the user turned sharing off, a
|
|
440
|
+
* pending spoke-connected probe waits on that, not on starting OpenCode, so its next step says so.
|
|
441
|
+
*/
|
|
442
|
+
export function withSharingStep(reports: readonly HarnessReport[], sharing: { readonly off: boolean; readonly location: string }): HarnessReport[] {
|
|
443
|
+
if (!sharing.off) return [...reports]
|
|
444
|
+
const start = PENDING_STEPS.opencode["spoke-connected"]
|
|
445
|
+
return reports.map((r) =>
|
|
446
|
+
r.harness === "opencode" && r.probes.some((p) => p.kind === "spoke-connected" && p.status === "pending")
|
|
447
|
+
? { ...r, nextSteps: r.nextSteps.map((s) => (s === start ? sharingOffWarning(sharing.location) : s)) }
|
|
448
|
+
: r,
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function sharingOf(io: SetupIo): { off: boolean; location: string } {
|
|
453
|
+
const store = credentialStoreFor(io.env, io.home)
|
|
454
|
+
try {
|
|
455
|
+
return { off: store.sharingMode() === "off", location: store.location }
|
|
456
|
+
} catch {
|
|
457
|
+
return { off: false, location: store.location }
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const READINESS_TEXT = { ready: "ready", "installed-not-ready": "installed, not ready", failed: "not working" } as const
|
|
462
|
+
|
|
463
|
+
function readinessText(reports: readonly HarnessReport[]): string[] {
|
|
464
|
+
return reports.flatMap((h) => [
|
|
465
|
+
`${h.harness}: ${READINESS_TEXT[h.state]}`,
|
|
466
|
+
...h.probes.map((p) => ` ${({ pass: "ok ", pending: "pending", fail: "FAIL " } as const)[p.status]} ${p.kind}${p.detail ? `: ${p.detail}` : ""}`),
|
|
467
|
+
...h.nextSteps.map((s) => ` next: ${s}`),
|
|
468
|
+
])
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Collects the readiness each verify-harness step reports (the latest per harness, in HARNESS_ORDER). */
|
|
472
|
+
function readinessCollector() {
|
|
473
|
+
const seen = new Map<HarnessID, HarnessReadiness>()
|
|
474
|
+
return {
|
|
475
|
+
onReadiness: (r: HarnessReadiness) => void seen.set(r.harness, r),
|
|
476
|
+
reports: (): HarnessReport[] => HARNESS_ORDER.flatMap((h) => (seen.has(h) ? [harnessReport(seen.get(h)!)] : [])),
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function apply(io: SetupIo, flags: Parsed["flags"], json: boolean): Promise<number> {
|
|
481
|
+
const request = readRequest(io, str(flags, "preview"))
|
|
482
|
+
const snapshot = discovered(io)
|
|
483
|
+
const manifest = manifestArg(io, str(flags, "manifest"))
|
|
484
|
+
const runtime = runtimeFor(io, manifest, snapshot)
|
|
485
|
+
const bundle = await runtime.plan(request.profile, request.choices)
|
|
486
|
+
const allowDowngrade = flags["allow-downgrade"] === true
|
|
487
|
+
const requireReady = flags["require-ready"] === true
|
|
488
|
+
const readiness = readinessCollector()
|
|
489
|
+
// A verification launch needs its harness ready, so --verify-launch keeps that harness strict.
|
|
490
|
+
const strict = request.choices.launch ? [request.choices.launch.harness] : []
|
|
491
|
+
const applier = runtime.applier(snapshot, runtime.executors(bundle, snapshot, { allowDowngrade, requireReady, strict, onReadiness: readiness.onReadiness }))
|
|
492
|
+
const { probe, now } = context(io)
|
|
493
|
+
const outcome = await applier.apply({
|
|
494
|
+
token: request.preview.token,
|
|
495
|
+
consent: { escalate: flags.escalate === true, confirmActivation: flags["confirm-activation"] === true },
|
|
496
|
+
replan: async () => {
|
|
497
|
+
let r: ReturnType<typeof revalidateApply>
|
|
498
|
+
try {
|
|
499
|
+
r = revalidateApply({ token: request.preview.token, probe, profile: request.profile, now: now(), usedNonces: usedNonces(snapshot), ...bundle.sources })
|
|
500
|
+
} catch (error) {
|
|
501
|
+
if (error instanceof OpencodePlanError || error instanceof PlanRefusal) throw new TxError(error.code, error.message)
|
|
502
|
+
throw error
|
|
503
|
+
}
|
|
504
|
+
if (!r.ok) throw new TxError(errorCodeOf(r.issues[0]!.code), issuesText(r.issues))
|
|
505
|
+
return { plan: r.value.plan, snapshot: r.value.snapshot, blockers: r.value.blockers, evidence: r.value.evidence }
|
|
506
|
+
},
|
|
507
|
+
})
|
|
508
|
+
if (!outcome.ok) {
|
|
509
|
+
const report = reportData(outcome)
|
|
510
|
+
const harnesses = withSharingStep(readiness.reports(), sharingOf(io))
|
|
511
|
+
const data = report || harnesses.length > 0 ? { ...report, ...(harnesses.length > 0 ? { ready: false, harnesses } : {}) } : undefined
|
|
512
|
+
const pendingOnly = harnesses.length > 0 && harnesses.every((h) => h.state !== "failed")
|
|
513
|
+
const hint = outcome.code.startsWith("preview_")
|
|
514
|
+
? "Preview again: hubctl setup preview --out <file>"
|
|
515
|
+
: outcome.code === "recovery_required"
|
|
516
|
+
? "Run hubctl setup recover first"
|
|
517
|
+
: outcome.code === "harness_not_ready" && requireReady && pendingOnly
|
|
518
|
+
? "Only your provider login or the harness's spoke is missing; without --require-ready apply installs it as installed, not ready, so you can log in and start it"
|
|
519
|
+
: undefined
|
|
520
|
+
if (!json && harnesses.length > 0) io.stderr(`${readinessText(harnesses).join("\n")}\n`)
|
|
521
|
+
noteKept(io, json, outcome)
|
|
522
|
+
return emit(io, json, envelope("setup apply", false, { ...(data ? { data } : {}), error: { code: outcome.code, message: outcome.message, ...(hint ? { hint } : {}) } }))
|
|
523
|
+
}
|
|
524
|
+
// Record what was applied (secret-free): the profile, its choices and catalog revision, and the floors seen.
|
|
525
|
+
const store = stateStore(snapshot)
|
|
526
|
+
const state = readState(snapshot)
|
|
527
|
+
store.write({
|
|
528
|
+
schema: "hub.bootstrap-state",
|
|
529
|
+
version: 1,
|
|
530
|
+
profile: request.profile,
|
|
531
|
+
choices: request.choices,
|
|
532
|
+
...(request.profile.revision ? { catalogRevision: request.profile.revision } : {}),
|
|
533
|
+
txID: outcome.report.txID,
|
|
534
|
+
planDigest: request.preview.planDigest,
|
|
535
|
+
floors: manifest ? raiseFloors(state.floors, manifest, snapshot.host.target) : state.floors,
|
|
536
|
+
})
|
|
537
|
+
store.writeRequest(request)
|
|
538
|
+
const harnesses = withSharingStep(readiness.reports(), sharingOf(io))
|
|
539
|
+
const data = { ...reportData(outcome)!, ready: harnesses.every((h) => h.ready), harnesses }
|
|
540
|
+
const warnings = [
|
|
541
|
+
...(bundle.node()?.notes ?? []),
|
|
542
|
+
...outcome.report.conflicts.map((c) => `${c.actionID}: ${c.reason}`),
|
|
543
|
+
...harnesses.filter((h) => !h.ready).map((h) => `${h.harness} is installed, not ready: ${h.probes.filter((p) => p.status !== "pass").map((p) => p.kind).join(", ")} waits on you (see the next steps)`),
|
|
544
|
+
...[pathHint(snapshot, request.preview.plan, io.env)].filter((h): h is string => h !== undefined),
|
|
545
|
+
]
|
|
546
|
+
return emit(io, json, envelope("setup apply", true, { data, warnings }), () =>
|
|
547
|
+
[
|
|
548
|
+
`Applied ${outcome.report.steps.length} step(s) (${outcome.report.txID}); undo with hubctl setup rollback`,
|
|
549
|
+
...outcome.report.steps.map((s) => ` ${s.outcome.padEnd(8)} ${s.actionID}`),
|
|
550
|
+
...readinessText(harnesses),
|
|
551
|
+
].join("\n"),
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function verify(io: SetupIo, flags: Parsed["flags"], json: boolean): Promise<number> {
|
|
556
|
+
const snapshot = discovered(io)
|
|
557
|
+
const state = readState(snapshot)
|
|
558
|
+
const profile = await profileArg(io, str(flags, "profile"), state.profile)
|
|
559
|
+
const runtime = runtimeFor(io, undefined, snapshot)
|
|
560
|
+
const now = context(io).now()
|
|
561
|
+
const verifications = await runtime.verify(profile.harnesses)
|
|
562
|
+
const harnesses = withSharingStep(
|
|
563
|
+
verifications.map((v) => harnessReport(harnessReadiness(v, now))),
|
|
564
|
+
sharingOf(io),
|
|
565
|
+
)
|
|
566
|
+
const ready = harnesses.every((h) => h.ready)
|
|
567
|
+
const data = { profile: profile.id, ready, harnesses }
|
|
568
|
+
const text = () => readinessText(harnesses).join("\n")
|
|
569
|
+
if (ready) return emit(io, json, envelope("setup verify", true, { data }), text)
|
|
570
|
+
const failing = harnesses.filter((h) => !h.ready).map((h) => h.harness)
|
|
571
|
+
if (!json) io.stdout(`${text()}\n`)
|
|
572
|
+
return emit(io, json, envelope("setup verify", false, { data, error: { code: "harness_not_ready", message: `not ready: ${failing.join(", ")}`, hint: "Provider and harness logins stay with you on this machine; the failing probes say what is missing" } }))
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** What is in the files the desired plan manages (data for detectDrift; read here, never by the drift module). */
|
|
576
|
+
function observe(plan: SetupPlan, snapshot: DiscoverySnapshot): Observation {
|
|
577
|
+
const fields: NonNullable<Observation["fields"]>[number][] = []
|
|
578
|
+
const registrations: NonNullable<Observation["registrations"]>[number][] = []
|
|
579
|
+
const ctx = executionContextOf(snapshot)
|
|
580
|
+
const read = (ref: PathRef): { exists: boolean; value?: unknown } | undefined => {
|
|
581
|
+
const where = resolvePathRef(ref, snapshot.roots, ctx)
|
|
582
|
+
if (!where.ok) return undefined
|
|
583
|
+
if (!existsSync(where.value)) return { exists: false }
|
|
584
|
+
try {
|
|
585
|
+
const parsed = parseJsonc(readFileSync(where.value, "utf8"))
|
|
586
|
+
return parsed.ok ? { exists: true, value: parsed.value } : undefined
|
|
587
|
+
} catch {
|
|
588
|
+
return undefined
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const at = (value: unknown, pointer: string): { found: boolean; value?: unknown } => {
|
|
592
|
+
let cur = value
|
|
593
|
+
for (const raw of pointer.split("/").slice(1)) {
|
|
594
|
+
const key = raw.replaceAll("~1", "/").replaceAll("~0", "~")
|
|
595
|
+
if (cur === null || typeof cur !== "object" || !(key in (cur as object))) return { found: false }
|
|
596
|
+
cur = (cur as Record<string, unknown>)[key]
|
|
597
|
+
}
|
|
598
|
+
return { found: true, value: cur }
|
|
599
|
+
}
|
|
600
|
+
for (const f of managedFields(plan)) {
|
|
601
|
+
if (f.type === "config-value" || f.type === "array-entry") {
|
|
602
|
+
const file = read(f.path)
|
|
603
|
+
if (!file) continue
|
|
604
|
+
const pointer = f.pointer!.endsWith("/-") ? f.pointer!.slice(0, -2) : f.pointer!
|
|
605
|
+
const found = file.exists ? at(file.value, pointer) : { found: false }
|
|
606
|
+
fields.push(found.found ? { path: f.path, pointer, state: "value", value: found.value as Json } : { path: f.path, pointer, state: "absent" })
|
|
607
|
+
} else if (f.type === "mcp-registration" && f.registration) {
|
|
608
|
+
const file = read(f.path)
|
|
609
|
+
if (!file) continue
|
|
610
|
+
const server = file.exists ? at(file.value, `/mcp/servers/${f.registration.name}`) : { found: false }
|
|
611
|
+
const command = (server.value as { command?: unknown } | undefined)?.command
|
|
612
|
+
const ours = Array.isArray(command) && command[1] === "mcp" && typeof command[0] === "string" && ["hub", "hubctl"].includes(path.basename(command[0]))
|
|
613
|
+
registrations.push({ ...f.registration, state: !server.found ? "absent" : ours ? "ours" : "foreign" })
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return { fields, registrations }
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The fields hub still manages: the fresh plan, plus each applied field step the planner left out because it is
|
|
621
|
+
* already converged (the adapters plan nothing for a config that is as they want it), while the profile still has
|
|
622
|
+
* its component. Without them a converged field would read as "retired".
|
|
623
|
+
*/
|
|
624
|
+
function withConverged(fresh: SetupPlan, applied: SetupPlan, profile: Profile): SetupPlan {
|
|
625
|
+
const keeps = (a: SetupPlan["actions"][number]): boolean => {
|
|
626
|
+
switch (a.kind) {
|
|
627
|
+
case "register-mcp-server":
|
|
628
|
+
return profile.harnesses.includes(a.harness)
|
|
629
|
+
case "merge-config":
|
|
630
|
+
if (a.owner.startsWith("harness:")) return profile.harnesses.includes(a.owner.slice("harness:".length) as HarnessID)
|
|
631
|
+
if (a.path.rel === "hub/node.json") return profile.services.includes("node")
|
|
632
|
+
if (a.path.rel === "hub/claude-code.json") return profile.harnesses.includes("claude-code")
|
|
633
|
+
return true
|
|
634
|
+
case "install-service":
|
|
635
|
+
return profile.services.includes(a.service)
|
|
636
|
+
case "write-file":
|
|
637
|
+
return true
|
|
638
|
+
default:
|
|
639
|
+
return false
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const ids = new Set(fresh.actions.map((a) => a.id))
|
|
643
|
+
const extra = applied.actions.filter((a) => !ids.has(a.id) && keeps(a))
|
|
644
|
+
return extra.length === 0 ? fresh : { ...fresh, actions: [...fresh.actions, ...extra] }
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function drift(io: SetupIo, flags: Parsed["flags"], json: boolean): Promise<number> {
|
|
648
|
+
const snapshot = discovered(io)
|
|
649
|
+
const state = readState(snapshot)
|
|
650
|
+
const profile = await profileArg(io, str(flags, "profile"), state.profile)
|
|
651
|
+
const manifest = manifestArg(io, str(flags, "manifest"))
|
|
652
|
+
const runtime = runtimeFor(io, manifest, snapshot)
|
|
653
|
+
const bundle = await runtime.plan(profile, state.choices ?? {})
|
|
654
|
+
const { probe, now } = context(io)
|
|
655
|
+
const desired = planned(() => previewSetup({ probe, profile, now: now(), ...bundle.sources }))
|
|
656
|
+
if (!desired.ok) throw issueRefusal(desired.issues)
|
|
657
|
+
const store = new TxStore(nodeFs, snapshot.roots, executionContextOf(snapshot))
|
|
658
|
+
const committed = store.journals().filter((j) => j.state === "committed" && j.hostFingerprint === snapshot.host.fingerprint).sort((a, b) => a.startedAt - b.startedAt)
|
|
659
|
+
const last = committed.at(-1)
|
|
660
|
+
const lastPlan = last ? store.readPlan(last) : undefined
|
|
661
|
+
const want = lastPlan ? withConverged(desired.value.plan, lastPlan, profile) : desired.value.plan
|
|
662
|
+
const report = detectDrift({ desired: want, snapshot, ...(last && lastPlan ? { applied: { plan: lastPlan, journal: last } } : {}), observed: observe(want, snapshot) })
|
|
663
|
+
if (!report.ok) throw issueRefusal(report.issues)
|
|
664
|
+
const r = report.value
|
|
665
|
+
return emit(io, json, envelope("setup drift", true, { data: r }), () =>
|
|
666
|
+
r.inSync ? "in sync: every field hub manages is as the profile wants" : r.fields.filter((f) => f.status !== "in-sync").map((f) => `${f.status}: ${f.key}${f.repairable ? " (setup preview/apply repairs it)" : ""}`).join("\n"),
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function rollback(io: SetupIo, flags: Parsed["flags"], json: boolean): Promise<number> {
|
|
671
|
+
const snapshot = discovered(io)
|
|
672
|
+
const to = str(flags, "to")
|
|
673
|
+
const store = new TxStore(nodeFs, snapshot.roots, executionContextOf(snapshot))
|
|
674
|
+
if (flags["dry-run"] === true) {
|
|
675
|
+
const journals = store.journals()
|
|
676
|
+
const plan = rollbackTo(journals, to, (entry) => {
|
|
677
|
+
const where = entry.path ? resolvePathRef(entry.path, snapshot.roots, executionContextOf(snapshot)) : undefined
|
|
678
|
+
return where?.ok ? fileState(nodeFs, where.value, { tree: entry.class === "harness" }) : { exists: false }
|
|
679
|
+
}, { hostFingerprint: snapshot.host.fingerprint, plans: journals.flatMap((j) => store.readPlan(j) ?? []) })
|
|
680
|
+
if (!plan.ok) throw issueRefusal(plan.issues)
|
|
681
|
+
return emit(io, json, envelope("setup rollback", true, { data: plan.value }), () => plan.value.steps.map((s) => `${s.decision}: ${s.actionID}${s.path ? ` (${s.path.root}:${s.path.rel})` : ""}`).join("\n") || "nothing to roll back")
|
|
682
|
+
}
|
|
683
|
+
const state = readState(snapshot)
|
|
684
|
+
const runtime = runtimeFor(io, undefined, snapshot)
|
|
685
|
+
const profile = state.profile ?? unwrapProfile(defaultProfile("session-only"))
|
|
686
|
+
const bundle = await runtime.plan(profile, state.choices ?? {})
|
|
687
|
+
const outcome = await runtime.applier(snapshot, runtime.executors(bundle, snapshot, {})).rollback(to ? { to } : {})
|
|
688
|
+
if (!outcome.ok) return emit(io, json, envelope("setup rollback", false, { error: { code: outcome.code, message: outcome.message } }))
|
|
689
|
+
if (!outcome.report) return emit(io, json, envelope("setup rollback", true, { data: { rolledBack: false } }), () => "nothing to roll back")
|
|
690
|
+
// The state now describes the target transaction (or nothing, after a full rollback).
|
|
691
|
+
const target = to ? store.journals().find((j) => j.txID === to) : undefined
|
|
692
|
+
const request = target ? stateStore(snapshot).readRequest(target.planDigest) : undefined
|
|
693
|
+
stateStore(snapshot).write({
|
|
694
|
+
schema: "hub.bootstrap-state",
|
|
695
|
+
version: 1,
|
|
696
|
+
...(request ? { profile: request.profile, choices: request.choices, ...(request.profile.revision ? { catalogRevision: request.profile.revision } : {}), txID: target!.txID, planDigest: target!.planDigest } : {}),
|
|
697
|
+
floors: state.floors,
|
|
698
|
+
})
|
|
699
|
+
noteKept(io, json, outcome)
|
|
700
|
+
const data = { rolledBack: true, ...reportData(outcome) }
|
|
701
|
+
const warnings = outcome.report.conflicts.map((c) => `${c.actionID}: ${c.reason}`)
|
|
702
|
+
return emit(io, json, envelope("setup rollback", true, { data, warnings }), () => `rolled back${to ? ` to ${to}` : ""}${warnings.length ? ` (${warnings.length} file(s) kept because they changed since)` : ""}`)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function recover(io: SetupIo, flags: Parsed["flags"], json: boolean): Promise<number> {
|
|
706
|
+
const snapshot = discovered(io)
|
|
707
|
+
const store = new TxStore(nodeFs, snapshot.roots, executionContextOf(snapshot))
|
|
708
|
+
let pending
|
|
709
|
+
try {
|
|
710
|
+
pending = store.journals().find((j) => j.state !== "committed" && j.state !== "rolled-back")
|
|
711
|
+
} catch (error) {
|
|
712
|
+
throw new Refusal("recovery_required", (error as Error).message)
|
|
713
|
+
}
|
|
714
|
+
const state = readState(snapshot)
|
|
715
|
+
const request = pending ? stateStore(snapshot).readRequest(pending.planDigest) : undefined
|
|
716
|
+
const manifest = manifestArg(io, str(flags, "manifest"))
|
|
717
|
+
const runtime = runtimeFor(io, manifest, snapshot)
|
|
718
|
+
const profile = request?.profile ?? state.profile ?? unwrapProfile(defaultProfile("session-only"))
|
|
719
|
+
const bundle = await runtime.plan(profile, request?.choices ?? state.choices ?? {})
|
|
720
|
+
const mode = flags.resume === true ? "resume" : "rollback"
|
|
721
|
+
if (mode === "resume" && request) {
|
|
722
|
+
// The units a resumed install-service writes are rendered by the same planner (pinned by digest in the plan).
|
|
723
|
+
const { probe, now } = context(io)
|
|
724
|
+
planned(() => previewSetup({ probe, profile, now: now(), ...bundle.sources }))
|
|
725
|
+
}
|
|
726
|
+
const outcome = await runtime.applier(snapshot, runtime.executors(bundle, snapshot, { allowDowngrade: flags["allow-downgrade"] === true, requireReady: flags["require-ready"] === true })).recover({
|
|
727
|
+
mode,
|
|
728
|
+
consent: { escalate: flags.escalate === true, confirmActivation: flags["confirm-activation"] === true },
|
|
729
|
+
})
|
|
730
|
+
noteKept(io, json, outcome)
|
|
731
|
+
if (!outcome.ok) return emit(io, json, envelope("setup recover", false, { ...(outcome.report ? { data: reportData(outcome) } : {}), error: { code: outcome.code, message: outcome.message } }))
|
|
732
|
+
if (!outcome.report) return emit(io, json, envelope("setup recover", true, { data: { recovered: false } }), () => "nothing to recover")
|
|
733
|
+
return emit(io, json, envelope("setup recover", true, { data: { recovered: true, mode, ...reportData(outcome) } }), () => `recovered ${outcome.report!.txID} (${mode}): ${outcome.report!.state}`)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function ansible(io: SetupIo, flags: Parsed["flags"], json: boolean): Promise<number> {
|
|
737
|
+
const releaseURL = str(flags, "release-url")
|
|
738
|
+
if (!releaseURL) throw new UsageError("needs --release-url <https base URL of the hub release>")
|
|
739
|
+
const manifest = manifestArg(io, str(flags, "manifest"))
|
|
740
|
+
if (!manifest) throw new UsageError("needs --manifest <hubctl-manifest.json of that release> (its signature is checked)")
|
|
741
|
+
const profile = await profileArg(io, str(flags, "profile"))
|
|
742
|
+
const planFile = str(flags, "plan")
|
|
743
|
+
let plan: unknown
|
|
744
|
+
if (planFile) {
|
|
745
|
+
const raw = parseJSON(readText(path.resolve(io.cwd ?? process.cwd(), planFile), "plan"), "plan") as Record<string, unknown>
|
|
746
|
+
plan = raw.schema === "hub.setup-request" ? (raw.preview as Record<string, unknown> | undefined)?.plan : raw.schema === "hub.setup-preview" ? raw.plan : raw
|
|
747
|
+
}
|
|
748
|
+
const r = renderRoleVars({
|
|
749
|
+
profile,
|
|
750
|
+
...(plan !== undefined ? { plan } : {}),
|
|
751
|
+
manifest,
|
|
752
|
+
options: {
|
|
753
|
+
releaseBaseURL: releaseURL,
|
|
754
|
+
escalate: flags.escalate === true,
|
|
755
|
+
confirmActivation: flags["confirm-activation"] === true,
|
|
756
|
+
allowDowngrade: flags["allow-downgrade"] === true,
|
|
757
|
+
requireReady: flags["no-require-ready"] !== true,
|
|
758
|
+
applyRequireReady: flags["apply-require-ready"] === true,
|
|
759
|
+
},
|
|
760
|
+
})
|
|
761
|
+
if (!r.ok) return emit(io, json, envelope("setup ansible", false, { error: { code: r.error.code, message: r.error.message } }))
|
|
762
|
+
if (json) return emit(io, true, envelope("setup ansible", true, { data: r.vars }))
|
|
763
|
+
io.stdout(r.yaml.endsWith("\n") ? r.yaml : `${r.yaml}\n`)
|
|
764
|
+
return 0
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function discoverCommand(io: SetupIo, json: boolean): Promise<number> {
|
|
768
|
+
const snapshot = discovered(io)
|
|
769
|
+
return emit(io, json, envelope("setup discover", true, { data: snapshot }), () => {
|
|
770
|
+
const h = snapshot.harnesses.map((x) => `${x.id}: ${x.installed ? `${x.path}${x.version ? ` (${x.version})` : ""}${x.installMethod ? `, ${x.installMethod}` : ""}` : "not installed"}`)
|
|
771
|
+
return [`host: ${snapshot.host.target}${snapshot.host.supported ? "" : " (unsupported)"}, ${snapshot.host.os.name} ${snapshot.host.os.version}`, `service manager: ${snapshot.managers.service}`, ...h].join("\n")
|
|
772
|
+
})
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Runs `machine <verb>` or `setup <verb>`; the exit code. */
|
|
776
|
+
export async function bootstrapCommand(label: string, parsed: Parsed, io: SetupIo): Promise<number> {
|
|
777
|
+
const json = parsed.flags.json === true
|
|
778
|
+
const [group, verb] = label.split(" ") as [string, string]
|
|
779
|
+
try {
|
|
780
|
+
if (group === "machine") return await machine(verb, parsed.flags, io, json)
|
|
781
|
+
switch (verb) {
|
|
782
|
+
case "discover":
|
|
783
|
+
return await discoverCommand(io, json)
|
|
784
|
+
case "preview":
|
|
785
|
+
return await preview(io, parsed.flags, json)
|
|
786
|
+
case "apply":
|
|
787
|
+
return await apply(io, parsed.flags, json)
|
|
788
|
+
case "verify":
|
|
789
|
+
return await verify(io, parsed.flags, json)
|
|
790
|
+
case "drift":
|
|
791
|
+
return await drift(io, parsed.flags, json)
|
|
792
|
+
case "rollback":
|
|
793
|
+
return await rollback(io, parsed.flags, json)
|
|
794
|
+
case "recover":
|
|
795
|
+
return await recover(io, parsed.flags, json)
|
|
796
|
+
case "ansible":
|
|
797
|
+
return await ansible(io, parsed.flags, json)
|
|
798
|
+
}
|
|
799
|
+
throw new UsageError(`unknown subcommand ${verb}`)
|
|
800
|
+
} catch (error) {
|
|
801
|
+
if (error instanceof UsageError) throw error
|
|
802
|
+
if (error instanceof Refusal) return emit(io, json, envelope(label, false, { ...(error.data !== undefined ? { data: error.data } : {}), error: { code: error.code, message: error.message, ...(error.hint ? { hint: error.hint } : {}) } }))
|
|
803
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
804
|
+
return emit(io, json, envelope(label, false, { error: { code: "internal", message } }))
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export const MACHINE_VERBS = ["enroll", "status"] as const
|
|
809
|
+
export const SETUP_VERBS = ["discover", "preview", "apply", "verify", "drift", "rollback", "recover", "ansible"] as const
|