@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,403 @@
|
|
|
1
|
+
// Lossless config merging for merge-config (v1.1: JSONC, comment- and formatting-preserving).
|
|
2
|
+
//
|
|
3
|
+
// OpenCode configs are often JSONC: comments, trailing commas, the user's own layout. A merge must keep all of it,
|
|
4
|
+
// so it never re-serializes the document. It parses the text into a concrete syntax tree that remembers where every
|
|
5
|
+
// value starts and ends, decides what changes on the plain JSON value (the same pointer semantics as always: "-"
|
|
6
|
+
// appends unless the value is already there, a missing container is created, a scalar in the way is a conflict),
|
|
7
|
+
// and then splices only the text of the value it sets or the member/item it inserts. Every other byte (comments,
|
|
8
|
+
// trailing commas, key order, whitespace, numbers and strings exactly as written) is carried through untouched,
|
|
9
|
+
// and a document that needs no change is returned byte for byte. After each edit the result is parsed again and must
|
|
10
|
+
// equal the intended value exactly, or the merge is refused.
|
|
11
|
+
//
|
|
12
|
+
// What cannot be edited this way is refused with manual_edit_required (exit 10; the planner turns a known
|
|
13
|
+
// unmergeable format into an unmergeable-format ManualEdit): TOML, text that is not JSON/JSONC, duplicate keys (which
|
|
14
|
+
// one counts is ambiguous). A structural mismatch (the root is not an object, a scalar in the pointer's way, an
|
|
15
|
+
// index out of range) stays a conflict; a prototype key is invalid. No dependency: a small scanner below.
|
|
16
|
+
import { canonicalJSON, type ConfigFormat, type ConfigSet, type Json } from "../contracts/index.ts"
|
|
17
|
+
import { txFail, txOk, type TxResult } from "./result.ts"
|
|
18
|
+
|
|
19
|
+
type Value = null | boolean | number | string | Value[] | { [key: string]: Value }
|
|
20
|
+
|
|
21
|
+
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"])
|
|
22
|
+
const MAX_DEPTH = 512
|
|
23
|
+
|
|
24
|
+
// ---------- concrete syntax tree ----------
|
|
25
|
+
|
|
26
|
+
interface Span {
|
|
27
|
+
/** Offset of the first character. */
|
|
28
|
+
readonly start: number
|
|
29
|
+
/** Offset just past the last character. */
|
|
30
|
+
readonly end: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Member {
|
|
34
|
+
readonly key: string
|
|
35
|
+
readonly keyStart: number
|
|
36
|
+
readonly keyEnd: number
|
|
37
|
+
readonly value: CstNode
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type CstNode =
|
|
41
|
+
| (Span & { readonly kind: "object"; readonly members: readonly Member[] })
|
|
42
|
+
| (Span & { readonly kind: "array"; readonly items: readonly CstNode[] })
|
|
43
|
+
| (Span & { readonly kind: "scalar" })
|
|
44
|
+
|
|
45
|
+
class ParseError extends Error {}
|
|
46
|
+
|
|
47
|
+
/** Skips whitespace and comments from `i`; returns the offset of the next significant character. */
|
|
48
|
+
function skipTrivia(text: string, i: number): number {
|
|
49
|
+
for (;;) {
|
|
50
|
+
while (i < text.length && " \t\n\r\uFEFF".includes(text[i]!)) i++
|
|
51
|
+
if (text.startsWith("//", i)) {
|
|
52
|
+
const nl = text.indexOf("\n", i)
|
|
53
|
+
i = nl < 0 ? text.length : nl
|
|
54
|
+
} else if (text.startsWith("/*", i)) {
|
|
55
|
+
const close = text.indexOf("*/", i + 2)
|
|
56
|
+
if (close < 0) throw new ParseError("an unterminated /* comment")
|
|
57
|
+
i = close + 2
|
|
58
|
+
} else return i
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseCst(text: string): CstNode {
|
|
63
|
+
let i = 0
|
|
64
|
+
const value = (depth: number): CstNode => {
|
|
65
|
+
if (depth > MAX_DEPTH) throw new ParseError("nested too deeply")
|
|
66
|
+
i = skipTrivia(text, i)
|
|
67
|
+
const start = i
|
|
68
|
+
const ch = text[i]
|
|
69
|
+
if (ch === "{") {
|
|
70
|
+
i++
|
|
71
|
+
const members: Member[] = []
|
|
72
|
+
const keys = new Set<string>()
|
|
73
|
+
for (;;) {
|
|
74
|
+
i = skipTrivia(text, i)
|
|
75
|
+
if (text[i] === "}") break
|
|
76
|
+
if (text[i] !== '"') throw new ParseError("expected a key")
|
|
77
|
+
const keyStart = i
|
|
78
|
+
const key = str()
|
|
79
|
+
if (keys.has(key)) throw new ParseError(`duplicate key ${JSON.stringify(key)}`)
|
|
80
|
+
keys.add(key)
|
|
81
|
+
const keyEnd = i
|
|
82
|
+
i = skipTrivia(text, i)
|
|
83
|
+
if (text[i++] !== ":") throw new ParseError("expected ':'")
|
|
84
|
+
members.push({ key, keyStart, keyEnd, value: value(depth + 1) })
|
|
85
|
+
i = skipTrivia(text, i)
|
|
86
|
+
if (text[i] === ",") i++
|
|
87
|
+
else if (text[i] !== "}") throw new ParseError("expected ',' or '}'")
|
|
88
|
+
}
|
|
89
|
+
i++
|
|
90
|
+
return { kind: "object", start, end: i, members }
|
|
91
|
+
}
|
|
92
|
+
if (ch === "[") {
|
|
93
|
+
i++
|
|
94
|
+
const items: CstNode[] = []
|
|
95
|
+
for (;;) {
|
|
96
|
+
i = skipTrivia(text, i)
|
|
97
|
+
if (text[i] === "]") break
|
|
98
|
+
items.push(value(depth + 1))
|
|
99
|
+
i = skipTrivia(text, i)
|
|
100
|
+
if (text[i] === ",") i++
|
|
101
|
+
else if (text[i] !== "]") throw new ParseError("expected ',' or ']'")
|
|
102
|
+
}
|
|
103
|
+
i++
|
|
104
|
+
return { kind: "array", start, end: i, items }
|
|
105
|
+
}
|
|
106
|
+
if (ch === '"') {
|
|
107
|
+
str()
|
|
108
|
+
return { kind: "scalar", start, end: i }
|
|
109
|
+
}
|
|
110
|
+
const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i, i + 400))
|
|
111
|
+
if (m) {
|
|
112
|
+
i += m[0].length
|
|
113
|
+
return { kind: "scalar", start, end: i }
|
|
114
|
+
}
|
|
115
|
+
for (const word of ["true", "false", "null"])
|
|
116
|
+
if (text.startsWith(word, i)) {
|
|
117
|
+
i += word.length
|
|
118
|
+
return { kind: "scalar", start, end: i }
|
|
119
|
+
}
|
|
120
|
+
throw new ParseError("not valid JSON or JSONC")
|
|
121
|
+
}
|
|
122
|
+
const str = (): string => {
|
|
123
|
+
const start = i
|
|
124
|
+
i++
|
|
125
|
+
while (i < text.length && text[i] !== '"') i += text[i] === "\\" ? 2 : 1
|
|
126
|
+
if (i >= text.length) throw new ParseError("an unterminated string")
|
|
127
|
+
i++
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(text.slice(start, i)) as string
|
|
130
|
+
} catch {
|
|
131
|
+
throw new ParseError("an invalid string")
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const root = value(0)
|
|
135
|
+
if (skipTrivia(text, i) !== text.length) throw new ParseError("unexpected text after the document")
|
|
136
|
+
return root
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function toValue(text: string, node: CstNode): Value {
|
|
140
|
+
if (node.kind === "scalar") return JSON.parse(text.slice(node.start, node.end)) as Value
|
|
141
|
+
if (node.kind === "array") return node.items.map((item) => toValue(text, item))
|
|
142
|
+
const out: { [key: string]: Value } = {}
|
|
143
|
+
for (const m of node.members) Object.defineProperty(out, m.key, { value: toValue(text, m.value), enumerable: true, writable: true, configurable: true })
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Parses JSON or JSONC (comments, trailing commas) into its JSON value; duplicate keys are refused. */
|
|
148
|
+
export function parseJsonc(text: string): { readonly ok: true; readonly value: Value } | { readonly ok: false; readonly message: string } {
|
|
149
|
+
try {
|
|
150
|
+
return { ok: true, value: toValue(text, parseCst(text)) }
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return { ok: false, message: error instanceof ParseError ? error.message : "not valid JSON or JSONC" }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------- the merge ----------
|
|
157
|
+
|
|
158
|
+
/** Merges `sets` into the config text (undefined: the file does not exist yet). */
|
|
159
|
+
export function mergeConfigText(text: string | undefined, format: ConfigFormat, sets: readonly ConfigSet[]): TxResult<string> {
|
|
160
|
+
if (format === "toml") return txFail("manual_edit_required", "TOML configs cannot be edited losslessly by this hubctl; make the edit by hand")
|
|
161
|
+
if (text === undefined) {
|
|
162
|
+
const root: Value = {}
|
|
163
|
+
for (const set of sets) {
|
|
164
|
+
const r = applySet(root, set)
|
|
165
|
+
if (!r.ok) return r
|
|
166
|
+
}
|
|
167
|
+
return txOk(`${JSON.stringify(root, null, 2)}\n`)
|
|
168
|
+
}
|
|
169
|
+
let current = text
|
|
170
|
+
for (const set of sets) {
|
|
171
|
+
let cst: CstNode
|
|
172
|
+
try {
|
|
173
|
+
cst = parseCst(current)
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return txFail("manual_edit_required", `the config cannot be edited losslessly (${error instanceof Error ? error.message : "unreadable"}); make the edit by hand`)
|
|
176
|
+
}
|
|
177
|
+
const tree = toValue(current, cst)
|
|
178
|
+
if (!isObject(tree)) return txFail("conflict", "the config is not a JSON object")
|
|
179
|
+
const changed = applySet(tree, set)
|
|
180
|
+
if (!changed.ok) return changed
|
|
181
|
+
if (!changed.value) continue
|
|
182
|
+
const next = editText(current, cst, segments(set.pointer), set.value)
|
|
183
|
+
const check = parseJsonc(next)
|
|
184
|
+
if (!check.ok || canonicalJSON(check.value) !== canonicalJSON(tree))
|
|
185
|
+
return txFail("manual_edit_required", `${set.pointer} could not be edited losslessly in this config; make the edit by hand`)
|
|
186
|
+
current = next
|
|
187
|
+
}
|
|
188
|
+
return txOk(current)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const isObject = (v: unknown): v is { [key: string]: Value } => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
192
|
+
const same = (a: unknown, b: unknown) => canonicalJSON(a) === canonicalJSON(b)
|
|
193
|
+
const clone = (v: Json): Value => JSON.parse(JSON.stringify(v)) as Value
|
|
194
|
+
|
|
195
|
+
function segments(pointer: string): string[] {
|
|
196
|
+
return pointer
|
|
197
|
+
.slice(1)
|
|
198
|
+
.split("/")
|
|
199
|
+
.map((s) => s.replaceAll("~1", "/").replaceAll("~0", "~"))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function setKey(obj: { [key: string]: Value }, key: string, value: Value): void {
|
|
203
|
+
Object.defineProperty(obj, key, { value, enumerable: true, writable: true, configurable: true })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Applies one set to the plain value (the semantics of a merge); ok(true) when the document changed. */
|
|
207
|
+
function applySet(root: Value, set: ConfigSet): TxResult<boolean> {
|
|
208
|
+
if (!set.pointer.startsWith("/")) return txFail("invalid", `${set.pointer} is not a JSON pointer`)
|
|
209
|
+
const segs = segments(set.pointer)
|
|
210
|
+
if (segs.some((s) => FORBIDDEN_KEYS.has(s))) return txFail("invalid", `${set.pointer} names a prototype key`)
|
|
211
|
+
let current: Value = root
|
|
212
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
213
|
+
const seg = segs[i]!
|
|
214
|
+
let next: Value | undefined
|
|
215
|
+
if (Array.isArray(current)) {
|
|
216
|
+
const index = arrayIndex(seg, current.length - 1)
|
|
217
|
+
if (index === undefined) return txFail("conflict", `${set.pointer}: no array element ${seg}`)
|
|
218
|
+
next = current[index]
|
|
219
|
+
} else if (isObject(current)) {
|
|
220
|
+
next = Object.hasOwn(current, seg) ? current[seg] : undefined
|
|
221
|
+
if (next === undefined) {
|
|
222
|
+
// A missing container is created as an array when the pointer appends to it ("-"), else an object.
|
|
223
|
+
next = segs[i + 1] === "-" ? [] : {}
|
|
224
|
+
setKey(current, seg, next)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (typeof next !== "object" || next === null) return txFail("conflict", `${set.pointer}: ${segs.slice(0, i + 1).join("/")} is not an object or array`)
|
|
228
|
+
current = next
|
|
229
|
+
}
|
|
230
|
+
const last = segs[segs.length - 1]!
|
|
231
|
+
const value = clone(set.value)
|
|
232
|
+
if (Array.isArray(current)) {
|
|
233
|
+
if (last === "-") {
|
|
234
|
+
if (current.some((item) => same(item, value))) return txOk(false)
|
|
235
|
+
current.push(value)
|
|
236
|
+
return txOk(true)
|
|
237
|
+
}
|
|
238
|
+
const index = arrayIndex(last, current.length)
|
|
239
|
+
if (index === undefined) return txFail("conflict", `${set.pointer}: index ${last} is out of range`)
|
|
240
|
+
if (index < current.length && same(current[index], value)) return txOk(false)
|
|
241
|
+
current[index] = value
|
|
242
|
+
return txOk(true)
|
|
243
|
+
}
|
|
244
|
+
if (!isObject(current)) return txFail("conflict", `${set.pointer}: the parent is not an object or array`)
|
|
245
|
+
if (Object.hasOwn(current, last) && same(current[last], value)) return txOk(false)
|
|
246
|
+
setKey(current, last, value)
|
|
247
|
+
return txOk(true)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function arrayIndex(seg: string, max: number): number | undefined {
|
|
251
|
+
if (!/^(0|[1-9]\d*)$/.test(seg)) return undefined
|
|
252
|
+
const n = Number(seg)
|
|
253
|
+
return n <= max ? n : undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------- text edits ----------
|
|
257
|
+
|
|
258
|
+
interface Splice {
|
|
259
|
+
readonly at: number
|
|
260
|
+
readonly remove: number
|
|
261
|
+
readonly insert: string
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Applies splices given in text order (at equal offsets, the earlier one ends up first). */
|
|
265
|
+
function applySplices(text: string, splices: readonly Splice[]): string {
|
|
266
|
+
let out = text
|
|
267
|
+
const ordered = splices.map((s, i) => ({ s, i })).sort((a, b) => b.s.at - a.s.at || b.i - a.i)
|
|
268
|
+
for (const { s } of ordered) out = out.slice(0, s.at) + s.insert + out.slice(s.at + s.remove)
|
|
269
|
+
return out
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The whitespace that starts the line holding offset `pos`. */
|
|
273
|
+
function lineIndent(text: string, pos: number): string {
|
|
274
|
+
const lineStart = text.lastIndexOf("\n", pos - 1) + 1
|
|
275
|
+
return /^[ \t]*/.exec(text.slice(lineStart))![0]
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The document's indentation unit: the first indented line's indentation, else two spaces. */
|
|
279
|
+
function indentUnit(text: string): string {
|
|
280
|
+
return /\n([ \t]+)\S/.exec(text)?.[1] ?? " "
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Where a new line may be inserted after offset `from` without separating a value from the comment that follows it
|
|
285
|
+
* on the same line: past spaces and same-line comments, before the newline (or before the next token).
|
|
286
|
+
*/
|
|
287
|
+
function lineEnd(text: string, from: number): number {
|
|
288
|
+
let i = from
|
|
289
|
+
for (;;) {
|
|
290
|
+
while (i < text.length && (text[i] === " " || text[i] === "\t")) i++
|
|
291
|
+
if (text.startsWith("//", i)) {
|
|
292
|
+
const nl = text.indexOf("\n", i)
|
|
293
|
+
return nl < 0 ? text.length : nl
|
|
294
|
+
}
|
|
295
|
+
if (text.startsWith("/*", i)) {
|
|
296
|
+
const close = text.indexOf("*/", i + 2)
|
|
297
|
+
if (close < 0 || text.slice(i, close).includes("\n")) return i
|
|
298
|
+
i = close + 2
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
return i
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const isScalar = (v: unknown) => v === null || typeof v !== "object"
|
|
306
|
+
|
|
307
|
+
/** A value as text: compact when inline; otherwise objects (and arrays holding containers) one entry per line. */
|
|
308
|
+
function render(value: Json, indent: string, unit: string, inline: boolean, colon: string): string {
|
|
309
|
+
if (isScalar(value)) return JSON.stringify(value)
|
|
310
|
+
if (Array.isArray(value)) {
|
|
311
|
+
if (value.length === 0) return "[]"
|
|
312
|
+
if (inline || value.every(isScalar)) return `[${value.map((v) => render(v, indent, unit, true, colon)).join(", ")}]`
|
|
313
|
+
const inner = indent + unit
|
|
314
|
+
return `[\n${value.map((v) => inner + render(v, inner, unit, false, colon)).join(",\n")}\n${indent}]`
|
|
315
|
+
}
|
|
316
|
+
const entries = Object.entries(value as { [key: string]: Json })
|
|
317
|
+
if (entries.length === 0) return "{}"
|
|
318
|
+
if (inline) return `{${entries.map(([k, v]) => `${JSON.stringify(k)}${colon}${render(v, indent, unit, true, colon)}`).join(", ")}}`
|
|
319
|
+
const inner = indent + unit
|
|
320
|
+
return `{\n${entries.map(([k, v]) => `${inner}${JSON.stringify(k)}${colon}${render(v, inner, unit, false, colon)}`).join(",\n")}\n${indent}}`
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The value a missing pointer tail creates ("-" makes an array, any other segment an object). */
|
|
324
|
+
function nested(segs: readonly string[], value: Json): Json {
|
|
325
|
+
if (segs.length === 0) return value
|
|
326
|
+
const inner = nested(segs.slice(1), value)
|
|
327
|
+
return segs[0] === "-" ? [inner] : { [segs[0]!]: inner }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The key/value separator a container already uses (": " unless its members are written tight). */
|
|
331
|
+
function colonOf(text: string, node: CstNode): string {
|
|
332
|
+
if (node.kind !== "object" || node.members.length === 0) return ": "
|
|
333
|
+
const m = node.members[0]!
|
|
334
|
+
return /:\s/.test(text.slice(m.keyEnd, m.value.start)) ? ": " : ":"
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The text after editing one pointer of the (already validated) set. */
|
|
338
|
+
function editText(text: string, root: CstNode, segs: readonly string[], value: Json): string {
|
|
339
|
+
const unit = indentUnit(text)
|
|
340
|
+
let colon = colonOf(text, root)
|
|
341
|
+
let node = root
|
|
342
|
+
for (let i = 0; i < segs.length; i++) {
|
|
343
|
+
const seg = segs[i]!
|
|
344
|
+
const last = i === segs.length - 1
|
|
345
|
+
if (node.kind === "object") {
|
|
346
|
+
colon = colonOf(text, node) === ":" ? ":" : colon
|
|
347
|
+
const member = node.members.find((m) => m.key === seg)
|
|
348
|
+
if (!member) return insertEntry(text, node, `${JSON.stringify(seg)}${colon}`, nested(segs.slice(i + 1), value), unit, colon)
|
|
349
|
+
if (last) return replaceValue(text, member.value, value, lineIndent(text, member.keyStart), unit, colon)
|
|
350
|
+
node = member.value
|
|
351
|
+
} else if (node.kind === "array") {
|
|
352
|
+
if (seg === "-" || Number(seg) === node.items.length) return insertEntry(text, node, "", value, unit, colon)
|
|
353
|
+
const item = node.items[Number(seg)]!
|
|
354
|
+
if (last) return replaceValue(text, item, value, lineIndent(text, item.start), unit, colon)
|
|
355
|
+
node = item
|
|
356
|
+
} else throw new Error("unreachable: the merge already refused a scalar in the way")
|
|
357
|
+
}
|
|
358
|
+
throw new Error("unreachable: an empty pointer")
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function replaceValue(text: string, node: CstNode, value: Json, indent: string, unit: string, colon: string): string {
|
|
362
|
+
const inline = !text.slice(node.start, node.end).includes("\n") && !isScalar(value) && !text.includes("\n")
|
|
363
|
+
return applySplices(text, [{ at: node.start, remove: node.end - node.start, insert: render(value, indent, unit, inline, colon) }])
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Inserts a member (`prefix` is `"key": `) or an array item (no prefix) as the container's last entry. */
|
|
367
|
+
function insertEntry(text: string, container: CstNode, prefix: string, value: Json, unit: string, colon: string): string {
|
|
368
|
+
const entries: { start: number; end: number }[] =
|
|
369
|
+
container.kind === "object" ? container.members.map((m) => ({ start: m.keyStart, end: m.value.end })) : container.kind === "array" ? container.items.map((n) => ({ start: n.start, end: n.end })) : []
|
|
370
|
+
const close = container.end - 1
|
|
371
|
+
const multiline = text.slice(container.start, container.end).includes("\n")
|
|
372
|
+
if (entries.length === 0) {
|
|
373
|
+
const inner = text.slice(container.start + 1, close)
|
|
374
|
+
const commented = /\/[/*]/.test(inner)
|
|
375
|
+
const baseIndent = lineIndent(text, container.start)
|
|
376
|
+
// An empty array, or an empty object nested in a one-line document, gets an inline entry; an empty root object
|
|
377
|
+
// (a fresh `{}` config) or a commented one gets an entry on its own line.
|
|
378
|
+
const singleLineDoc = !text.trimEnd().includes("\n")
|
|
379
|
+
const isRoot = container.start === skipTrivia(text, 0)
|
|
380
|
+
if ((container.kind === "array" || (singleLineDoc && !isRoot)) && !commented) {
|
|
381
|
+
return applySplices(text, [{ at: container.start + 1, remove: inner.length, insert: prefix + render(value, baseIndent, unit, singleLineDoc, colon) }])
|
|
382
|
+
}
|
|
383
|
+
const keep = inner.trimEnd().length
|
|
384
|
+
const entryIndent = baseIndent + unit
|
|
385
|
+
return applySplices(text, [{ at: container.start + 1 + keep, remove: inner.length - keep, insert: `\n${entryIndent}${prefix}${render(value, entryIndent, unit, false, colon)}\n${baseIndent}` }])
|
|
386
|
+
}
|
|
387
|
+
const lastEntry = entries[entries.length - 1]!
|
|
388
|
+
const after = skipTrivia(text, lastEntry.end)
|
|
389
|
+
const trailingComma = text[after] === ","
|
|
390
|
+
if (!multiline) {
|
|
391
|
+
const entry = prefix + render(value, "", unit, true, colon)
|
|
392
|
+
const sep = entries.length > 1 && !/,\s/.test(text.slice(entries[0]!.end, entries[1]!.start)) ? "," : ", "
|
|
393
|
+
if (trailingComma) return applySplices(text, [{ at: after + 1, remove: 0, insert: `${sep === "," ? "" : " "}${entry},` }])
|
|
394
|
+
return applySplices(text, [{ at: lastEntry.end, remove: 0, insert: `${sep}${entry}` }])
|
|
395
|
+
}
|
|
396
|
+
const indent = lineIndent(text, lastEntry.start)
|
|
397
|
+
const entry = prefix + render(value, indent, unit, false, colon)
|
|
398
|
+
if (trailingComma) return applySplices(text, [{ at: lineEnd(text, after + 1), remove: 0, insert: `\n${indent}${entry},` }])
|
|
399
|
+
return applySplices(text, [
|
|
400
|
+
{ at: lastEntry.end, remove: 0, insert: "," },
|
|
401
|
+
{ at: lineEnd(text, lastEntry.end), remove: 0, insert: `\n${indent}${entry}` },
|
|
402
|
+
])
|
|
403
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The apply lock (contract: journal.ts ApplyLock, lockState, LOCK_PATH). One apply or recovery at a time per
|
|
2
|
+
// state directory. The lock is created atomically with complete content: the document is written to a private
|
|
3
|
+
// temporary file and hard-linked into place, which fails if a lock exists (the O_EXCL guarantee without a window
|
|
4
|
+
// where another process could read a half-written lock).
|
|
5
|
+
//
|
|
6
|
+
// held a live apply on this host: refuse (exit locked).
|
|
7
|
+
// foreign another host's lock on a shared home: never broken automatically.
|
|
8
|
+
// stale this host, holder dead or overran: the caller recovers its journal first, then breaks it.
|
|
9
|
+
// corrupt unreadable: never broken automatically (the user removes it after checking).
|
|
10
|
+
import { canonicalJSON, LOCK_MAX_MS, LOCK_SCHEMA, lockState, validateApplyLock, type ApplyLock } from "../contracts/index.ts"
|
|
11
|
+
import type { TxFs } from "./fs.ts"
|
|
12
|
+
|
|
13
|
+
export interface LockIO {
|
|
14
|
+
readonly fs: TxFs
|
|
15
|
+
/** Absolute path of the lock (LOCK_PATH resolved). */
|
|
16
|
+
readonly path: string
|
|
17
|
+
readonly hostFingerprint: string
|
|
18
|
+
readonly pid: number
|
|
19
|
+
readonly now: () => number
|
|
20
|
+
readonly isAlive: (pid: number) => boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type LockAttempt =
|
|
24
|
+
| { readonly state: "acquired"; readonly lock: ApplyLock }
|
|
25
|
+
| { readonly state: "held" | "foreign" | "stale"; readonly lock: ApplyLock }
|
|
26
|
+
| { readonly state: "corrupt" }
|
|
27
|
+
|
|
28
|
+
const errno = (error: unknown) => (error as NodeJS.ErrnoException | undefined)?.code
|
|
29
|
+
|
|
30
|
+
function removeQuietly(fs: TxFs, path: string) {
|
|
31
|
+
try {
|
|
32
|
+
if (fs.lstat(path)) fs.unlink(path)
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (!errno(error)) throw error
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The lock on disk: undefined when there is none, "corrupt" when it cannot be read as a valid lock. */
|
|
39
|
+
export function readLock(io: LockIO): ApplyLock | "corrupt" | undefined {
|
|
40
|
+
if (!io.fs.lstat(io.path)) return undefined
|
|
41
|
+
try {
|
|
42
|
+
const parsed: unknown = JSON.parse(new TextDecoder().decode(io.fs.readFile(io.path)))
|
|
43
|
+
const checked = validateApplyLock(parsed)
|
|
44
|
+
return checked.ok ? checked.value : "corrupt"
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (errno(error) === "ENOENT") return undefined
|
|
47
|
+
return "corrupt"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function newLock(io: LockIO, txID: string): ApplyLock {
|
|
52
|
+
const now = io.now()
|
|
53
|
+
return { schema: LOCK_SCHEMA, version: 1, pid: io.pid, hostFingerprint: io.hostFingerprint, txID, acquiredAt: now, expiresAt: now + LOCK_MAX_MS }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeTemp(io: LockIO, lock: ApplyLock): string {
|
|
57
|
+
const tmp = `${io.path}.${lock.txID}.tmp`
|
|
58
|
+
removeQuietly(io.fs, tmp)
|
|
59
|
+
io.fs.writeExclusive(tmp, `${canonicalJSON(lock)}\n`, 0o600)
|
|
60
|
+
return tmp
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function tryAcquireLock(io: LockIO, txID: string): LockAttempt {
|
|
64
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
65
|
+
const lock = newLock(io, txID)
|
|
66
|
+
const tmp = writeTemp(io, lock)
|
|
67
|
+
try {
|
|
68
|
+
io.fs.link(tmp, io.path)
|
|
69
|
+
return { state: "acquired", lock }
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// ENOENT: our temporary file was swept by the holder; treat like an existing lock and look again.
|
|
72
|
+
if (errno(error) !== "EEXIST" && errno(error) !== "ENOENT") throw error
|
|
73
|
+
} finally {
|
|
74
|
+
removeQuietly(io.fs, tmp)
|
|
75
|
+
}
|
|
76
|
+
const existing = readLock(io)
|
|
77
|
+
if (existing === undefined) continue // released between our attempt and the read
|
|
78
|
+
if (existing === "corrupt") return { state: "corrupt" }
|
|
79
|
+
const state = lockState(existing, { now: io.now(), hostFingerprint: io.hostFingerprint, isAlive: io.isAlive })
|
|
80
|
+
if (state === "free") continue
|
|
81
|
+
return { state, lock: existing }
|
|
82
|
+
}
|
|
83
|
+
const existing = readLock(io)
|
|
84
|
+
return existing && existing !== "corrupt" ? { state: "held", lock: existing } : { state: "corrupt" }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Removes a lock judged stale, but only if it is still exactly the lock that was judged: it is moved aside first
|
|
89
|
+
* (atomic), compared, and put back if another process replaced it meanwhile. True when the lock is gone.
|
|
90
|
+
*/
|
|
91
|
+
export function breakStaleLock(io: LockIO, observed: ApplyLock): boolean {
|
|
92
|
+
const aside = `${io.path}.broken-${observed.txID}-${io.pid}`
|
|
93
|
+
removeQuietly(io.fs, aside)
|
|
94
|
+
try {
|
|
95
|
+
io.fs.rename(io.path, aside)
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (errno(error) === "ENOENT") return true
|
|
98
|
+
throw error
|
|
99
|
+
}
|
|
100
|
+
let same = false
|
|
101
|
+
try {
|
|
102
|
+
same = canonicalJSON(JSON.parse(new TextDecoder().decode(io.fs.readFile(aside)))) === canonicalJSON(observed)
|
|
103
|
+
} catch {}
|
|
104
|
+
if (!same) {
|
|
105
|
+
try {
|
|
106
|
+
io.fs.link(aside, io.path)
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
removeQuietly(io.fs, aside)
|
|
110
|
+
return same
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Extends our lock by LOCK_MAX_MS from now (atomic replace); refuses if the lock is no longer ours. */
|
|
114
|
+
export function renewLock(io: LockIO, lock: ApplyLock): ApplyLock {
|
|
115
|
+
const current = readLock(io)
|
|
116
|
+
if (!current || current === "corrupt" || current.txID !== lock.txID || current.pid !== lock.pid) throw new Error("the apply lock is no longer held by this transaction")
|
|
117
|
+
const renewed = newLock(io, lock.txID)
|
|
118
|
+
const tmp = writeTemp(io, renewed)
|
|
119
|
+
try {
|
|
120
|
+
io.fs.rename(tmp, io.path)
|
|
121
|
+
} catch (error) {
|
|
122
|
+
removeQuietly(io.fs, tmp)
|
|
123
|
+
throw error
|
|
124
|
+
}
|
|
125
|
+
return renewed
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Removes the lock if it is ours. */
|
|
129
|
+
export function releaseLock(io: LockIO, txID: string): void {
|
|
130
|
+
const current = readLock(io)
|
|
131
|
+
if (current && current !== "corrupt" && current.txID === txID) io.fs.unlink(io.path)
|
|
132
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// PathRef → absolute path on this host, refusing anything a planted link or a crafted reference could redirect:
|
|
2
|
+
// traversal and unsafe relative paths (the contract's PathRef rules), unmapped roots, any symlink between the root
|
|
3
|
+
// and the target (checkNoSymlinkEscape), and a regular file where a directory should be. The root itself is the
|
|
4
|
+
// mapping discovery reported and is not checked (contract: paths.ts). v1.1: paths resolve for the execution context
|
|
5
|
+
// apply runs in (contract rootPathIn: inside a container, a root is containerPath ?? path; on the host, path).
|
|
6
|
+
import { checkNoSymlinkEscape, resolvePathRef, rootPathIn, type ExecutionContext, type PathRef, type RootMapping } from "../contracts/index.ts"
|
|
7
|
+
import type { TxFs } from "./fs.ts"
|
|
8
|
+
import { txFail, txOk, type TxResult } from "./result.ts"
|
|
9
|
+
|
|
10
|
+
export interface ResolveOptions {
|
|
11
|
+
/** link-executable manages a symlink at the final component; every component above it is still checked. */
|
|
12
|
+
readonly finalMayBeSymlink?: boolean
|
|
13
|
+
/**
|
|
14
|
+
* A symlink's target: mapped and checked like any PathRef (no traversal, a known root) but never written through,
|
|
15
|
+
* so links on the way (a harness's `current`) are allowed.
|
|
16
|
+
*/
|
|
17
|
+
readonly targetOnly?: boolean
|
|
18
|
+
/** Where apply runs (default: the host). */
|
|
19
|
+
readonly context?: ExecutionContext
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The absolute directory of a root for the execution context, if the host has it. */
|
|
23
|
+
export function rootDir(id: PathRef["root"], roots: readonly RootMapping[], context: ExecutionContext = "host"): string | undefined {
|
|
24
|
+
const root = roots.find((r) => r.id === id)
|
|
25
|
+
return root ? rootPathIn(root, context) : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveTarget(ref: PathRef, roots: readonly RootMapping[], fs: TxFs, options: ResolveOptions = {}): TxResult<string> {
|
|
29
|
+
const resolved = resolvePathRef(ref, roots, options.context)
|
|
30
|
+
if (!resolved.ok) return txFail("invalid", `${ref.root}:${String(ref.rel)}: ${resolved.issues[0]!.message}`)
|
|
31
|
+
if (options.targetOnly) return txOk(resolved.value)
|
|
32
|
+
const root = rootDir(ref.root, roots, options.context)!
|
|
33
|
+
const parts = ref.rel.split("/")
|
|
34
|
+
const checked = options.finalMayBeSymlink ? parts.slice(0, -1).join("/") : ref.rel
|
|
35
|
+
if (checked) {
|
|
36
|
+
const walk = checkNoSymlinkEscape(root, checked, (p) => fs.lstat(p)?.kind ?? "missing")
|
|
37
|
+
if (!walk.ok) return txFail("conflict", `${walk.issues[0]!.message} (make this change by hand)`)
|
|
38
|
+
}
|
|
39
|
+
// Every component above the target that exists must be a directory.
|
|
40
|
+
let current = root === "/" ? "" : root
|
|
41
|
+
for (const part of parts.slice(0, -1)) {
|
|
42
|
+
current = `${current}/${part}`
|
|
43
|
+
const info = fs.lstat(current)
|
|
44
|
+
if (!info) break
|
|
45
|
+
if (info.kind !== "directory") return txFail("conflict", `${current} is not a directory`)
|
|
46
|
+
}
|
|
47
|
+
return txOk(resolved.value)
|
|
48
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Outcomes of the transaction layer. A failure carries one contract ErrorCode (envelope.ts), so the CLI maps it to
|
|
2
|
+
// an exit code with exitFor, and one printable message. Messages name paths and reasons, never file contents or
|
|
3
|
+
// credentials.
|
|
4
|
+
import type { ErrorCode, Result } from "../contracts/index.ts"
|
|
5
|
+
|
|
6
|
+
export interface TxFailure {
|
|
7
|
+
readonly ok: false
|
|
8
|
+
readonly code: ErrorCode
|
|
9
|
+
readonly message: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type TxResult<T> = { readonly ok: true; readonly value: T } | TxFailure
|
|
13
|
+
|
|
14
|
+
export const txOk = <T>(value: T): TxResult<T> => ({ ok: true, value })
|
|
15
|
+
export const txFail = (code: ErrorCode, message: string): TxFailure => ({ ok: false, code, message })
|
|
16
|
+
|
|
17
|
+
/** Contract issue codes that are also error codes keep their name; the rest map to the nearest error code. */
|
|
18
|
+
const ISSUE_TO_ERROR: Readonly<Record<string, ErrorCode>> = {
|
|
19
|
+
symlink: "conflict",
|
|
20
|
+
invalid_path: "invalid",
|
|
21
|
+
unmapped_root: "invalid",
|
|
22
|
+
invalid_value: "invalid",
|
|
23
|
+
missing_field: "invalid",
|
|
24
|
+
unknown_field: "invalid",
|
|
25
|
+
unsupported_version: "invalid",
|
|
26
|
+
inconsistent: "invalid",
|
|
27
|
+
duplicate: "invalid",
|
|
28
|
+
exec_field: "invalid",
|
|
29
|
+
secret: "invalid",
|
|
30
|
+
unmigratable: "invalid",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The first issue of a failed contract Result as a TxFailure. */
|
|
34
|
+
export function fromResult<T>(result: Result<T>, context?: string): TxResult<T> {
|
|
35
|
+
if (result.ok) return txOk(result.value)
|
|
36
|
+
const issue = result.issues[0]!
|
|
37
|
+
const code = ISSUE_TO_ERROR[issue.code] ?? (issue.code as ErrorCode)
|
|
38
|
+
return txFail(code, context ? `${context}: ${issue.message}` : issue.message)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A failure thrown from deep inside a step; the engine turns it back into a TxFailure. */
|
|
42
|
+
export class TxError extends Error {
|
|
43
|
+
constructor(
|
|
44
|
+
readonly code: ErrorCode,
|
|
45
|
+
message: string,
|
|
46
|
+
) {
|
|
47
|
+
super(message)
|
|
48
|
+
this.name = "TxError"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get failure(): TxFailure {
|
|
52
|
+
return txFail(this.code, this.message)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The errno code of a filesystem error (ENOSPC, EEXIST, …), or undefined for anything else. */
|
|
57
|
+
export const errnoOf = (error: unknown): string | undefined => {
|
|
58
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
|
59
|
+
return typeof code === "string" ? code : undefined
|
|
60
|
+
}
|