@huynle/hub 0.7.1 → 0.7.3
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 +87 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- 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 +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/harness-events.ts +2 -2
- package/packages/protocol/src/index.ts +70 -7
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -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/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +116 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/push.ts +14 -1
- package/src/central/registry.ts +34 -5
- package/src/central/store.ts +60 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/src/spoke/spoke.ts +8 -4
- package/src/spoke/timeline.ts +59 -3
- package/src/spoke/tracker.ts +59 -6
- package/src/status.ts +97 -6
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-Dm8OmkaO.css +1 -0
- package/web/dist/assets/index-FUJbj_Du.js +20 -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 +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +4 -1
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Read-only discovery: what `hubctl setup discover` reports about this host, as a contract DiscoverySnapshot.
|
|
2
|
+
// Everything comes through the injected DiscoveryProbe (reads only). Discovery never runs a harness (running even
|
|
3
|
+
// `--version` may create state), never records file contents or environment values, and never claims health:
|
|
4
|
+
// every harness it finds stays "unverified" until an adapter runs real probes. The snapshot is deterministic: the
|
|
5
|
+
// same host gives the same document, except for capturedAt.
|
|
6
|
+
import {
|
|
7
|
+
checkTarget,
|
|
8
|
+
EMBEDDED_ASSETS,
|
|
9
|
+
fail,
|
|
10
|
+
HARNESS_ORDER,
|
|
11
|
+
hostFingerprint,
|
|
12
|
+
PACKAGE_MANAGERS,
|
|
13
|
+
rootPathIn,
|
|
14
|
+
SEMVER,
|
|
15
|
+
sha256Hex,
|
|
16
|
+
targetOf,
|
|
17
|
+
validateDiscoverySnapshot,
|
|
18
|
+
type ConfigFile,
|
|
19
|
+
type DiscoverySnapshot,
|
|
20
|
+
type ExecutionContext,
|
|
21
|
+
type HarnessPresence,
|
|
22
|
+
type PackageManager,
|
|
23
|
+
type Result,
|
|
24
|
+
type RootID,
|
|
25
|
+
type RootMapping,
|
|
26
|
+
type RuntimeInfo,
|
|
27
|
+
type Shim,
|
|
28
|
+
} from "../contracts/index.ts"
|
|
29
|
+
import { classifyExecutable, HARNESS_BINARIES, installOf, pathDirs, which } from "./executables.ts"
|
|
30
|
+
import { discoverContainer, discoverLibc, discoverOS, discoverRoots, mapContainerRoots, parseMountinfo } from "./host.ts"
|
|
31
|
+
import type { DiscoveryProbe } from "./probe.ts"
|
|
32
|
+
import { decode, join, normalizeAbs } from "./util.ts"
|
|
33
|
+
|
|
34
|
+
export interface DiscoverOptions {
|
|
35
|
+
readonly now: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------- configs ----------
|
|
39
|
+
|
|
40
|
+
interface ConfigCandidate {
|
|
41
|
+
readonly path: string
|
|
42
|
+
readonly owner: ConfigFile["owner"]
|
|
43
|
+
readonly format: ConfigFile["format"]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The config files a setup plan might touch, with their owners, as seen from `context` (where discovery runs). */
|
|
47
|
+
export function configCandidates(platform: string, roots: readonly RootMapping[], context: ExecutionContext = "host"): ConfigCandidate[] {
|
|
48
|
+
const at = (id: RootID, rel: string) => {
|
|
49
|
+
const r = roots.find((x) => x.id === id)
|
|
50
|
+
return r ? join(rootPathIn(r, context), rel) : undefined
|
|
51
|
+
}
|
|
52
|
+
const list: (ConfigCandidate | undefined)[] = []
|
|
53
|
+
const add = (id: RootID, rel: string, owner: ConfigFile["owner"], format: ConfigFile["format"]) => {
|
|
54
|
+
const path = at(id, rel)
|
|
55
|
+
list.push(path ? { path, owner, format } : undefined)
|
|
56
|
+
}
|
|
57
|
+
add("config", "hub/config.json", "hub", "json")
|
|
58
|
+
add("config", "hub/node.json", "hub", "json")
|
|
59
|
+
add("config", "hub/claude-code.json", "hub", "json")
|
|
60
|
+
add("config", "opencode/opencode.json", "harness:opencode", "json")
|
|
61
|
+
add("config", "opencode/opencode.jsonc", "harness:opencode", "jsonc")
|
|
62
|
+
add("home", ".claude.json", "harness:claude-code", "json")
|
|
63
|
+
add("home", ".claude/settings.json", "harness:claude-code", "json")
|
|
64
|
+
add("home", ".codex/config.toml", "harness:codex", "toml")
|
|
65
|
+
for (const asset of Object.values(EMBEDDED_ASSETS)) {
|
|
66
|
+
if (platform === "darwin" && asset.manager === "launchd") add("launch-agents", asset.installAs, "hub", "plist")
|
|
67
|
+
if (platform === "linux" && asset.manager === "systemd-user") add("systemd-user", asset.installAs, "hub", "unit")
|
|
68
|
+
}
|
|
69
|
+
return list.filter((c): c is ConfigCandidate => c !== undefined)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------- snapshot ----------
|
|
73
|
+
|
|
74
|
+
/** Where each harness's binary is inside a hub install root (the adapters' unpackers). */
|
|
75
|
+
const HARNESS_BIN_DIR: Readonly<Record<(typeof HARNESS_ORDER)[number], string>> = { opencode: "/bin", "claude-code": "", codex: "/bin" }
|
|
76
|
+
|
|
77
|
+
/** Discovers this host through `probe`. Fails (instead of recording) if anything found looks like a credential. */
|
|
78
|
+
export function discover(probe: DiscoveryProbe, options: DiscoverOptions): Result<DiscoverySnapshot> {
|
|
79
|
+
let roots = discoverRoots(probe)
|
|
80
|
+
if (!roots) return fail("/roots", "unmapped_root", "this host has no absolute home directory")
|
|
81
|
+
const home = roots[0]!.path
|
|
82
|
+
const libc = discoverLibc(probe)
|
|
83
|
+
const target = targetOf(probe.platform, probe.arch, libc)
|
|
84
|
+
const container = discoverContainer(probe)
|
|
85
|
+
// v1.1: discovery runs where apply will run; inside a container every root is read as containerPath ?? path.
|
|
86
|
+
const context: ExecutionContext = container === "none" ? "host" : "container"
|
|
87
|
+
if (container !== "none") roots = mapContainerRoots(roots, parseMountinfo(decode(probe.readFile("/proc/self/mountinfo", 1024 * 1024))))
|
|
88
|
+
|
|
89
|
+
const dirs = pathDirs(probe)
|
|
90
|
+
const extra = probe.platform === "darwin" ? ["/opt/homebrew/bin", "/usr/local/bin"] : ["/usr/local/sbin", "/usr/sbin", "/sbin", "/nix/var/nix/profiles/default/bin"]
|
|
91
|
+
const managerDirs = [...dirs, ...extra.filter((d) => !dirs.includes(d))]
|
|
92
|
+
const packages: PackageManager[] = PACKAGE_MANAGERS.filter((m) => which(probe, managerDirs, m) !== undefined)
|
|
93
|
+
const runtime: RuntimeInfo[] = probe.runtime && SEMVER.test(probe.runtime.version) && normalizeAbs(probe.runtime.path) ? [{ ...probe.runtime }] : []
|
|
94
|
+
let service: DiscoverySnapshot["managers"]["service"] = "none"
|
|
95
|
+
if (probe.platform === "darwin" && probe.executable("/bin/launchctl")) service = "launchd"
|
|
96
|
+
if (probe.platform === "linux" && probe.lstat("/run/systemd/system") === "directory" && which(probe, [...dirs, "/usr/bin", "/bin"], "systemctl")) service = "systemd-user"
|
|
97
|
+
|
|
98
|
+
const shims: Shim[] = []
|
|
99
|
+
const harnesses: HarnessPresence[] = HARNESS_ORDER.map((id) => {
|
|
100
|
+
const name = HARNESS_BINARIES[id]
|
|
101
|
+
const known = id === "opencode" ? [join(home, ".opencode/bin")] : id === "claude-code" ? [join(home, ".local/bin"), join(home, ".claude/local")] : []
|
|
102
|
+
// A harness hub installed (data:hub/harnesses/<id>/current) is found off PATH too, so a rerun after an install
|
|
103
|
+
// sees it (installMethod "hub") and plans nothing more. PATH still wins.
|
|
104
|
+
const data = roots!.find((r) => r.id === "data")
|
|
105
|
+
if (data) known.push(join(rootPathIn(data, context), `hub/harnesses/${id}/current${HARNESS_BIN_DIR[id]}`))
|
|
106
|
+
const path = which(probe, [...dirs, ...known.filter((d) => !dirs.includes(d))], name)
|
|
107
|
+
if (!path) return { id, installed: false, health: { state: "unverified", probes: [] } }
|
|
108
|
+
const shim = classifyExecutable(probe, name, path)
|
|
109
|
+
shims.push(shim)
|
|
110
|
+
const install = installOf(probe, id, shim, roots!, context)
|
|
111
|
+
return { id, installed: true, path, ...(install.version ? { version: install.version } : {}), installMethod: install.installMethod, health: { state: "unverified", probes: [] } }
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const configs: ConfigFile[] = []
|
|
115
|
+
for (const c of configCandidates(probe.platform, roots, context)) {
|
|
116
|
+
if (probe.stat(c.path) !== "file") continue
|
|
117
|
+
const bytes = probe.readFile(c.path)
|
|
118
|
+
if (!bytes) return fail("/configs", "invalid_value", `${c.path} exists but cannot be read; discovery will not guess its contents`)
|
|
119
|
+
configs.push({ ...c, sha256: `sha256:${sha256Hex(bytes)}` })
|
|
120
|
+
}
|
|
121
|
+
configs.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
122
|
+
|
|
123
|
+
const machineID = probe.machineID()?.trim() || `hostname:${probe.hostname}`
|
|
124
|
+
const snapshot: DiscoverySnapshot = {
|
|
125
|
+
schema: "hub.discovery-snapshot",
|
|
126
|
+
version: 1,
|
|
127
|
+
capturedAt: options.now,
|
|
128
|
+
host: {
|
|
129
|
+
fingerprint: hostFingerprint({ machineID, platform: probe.platform, arch: probe.arch, uid: probe.uid, home }),
|
|
130
|
+
platform: probe.platform,
|
|
131
|
+
arch: probe.arch,
|
|
132
|
+
libc,
|
|
133
|
+
target,
|
|
134
|
+
supported: checkTarget(target).ok,
|
|
135
|
+
os: discoverOS(probe),
|
|
136
|
+
},
|
|
137
|
+
topology: { container, shims },
|
|
138
|
+
managers: { package: packages, runtime, service },
|
|
139
|
+
harnesses,
|
|
140
|
+
runtimes: [{ id: "openshell", present: which(probe, dirs, "openshell") !== undefined }],
|
|
141
|
+
configs,
|
|
142
|
+
roots,
|
|
143
|
+
}
|
|
144
|
+
return validateDiscoverySnapshot(snapshot)
|
|
145
|
+
}
|
|
146
|
+
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Executables on PATH: where a command is, whether it is a native binary, a wrapper script or a shim into a
|
|
2
|
+
// container (docker/podman exec), what it resolves to, and how (and at which version) a harness was installed,
|
|
3
|
+
// read from the install layout. A harness is never run to find out.
|
|
4
|
+
import { rootPathIn, SEMVER, type ExecutionContext, type HarnessID, type InstallMethod, type RootMapping, type Shim, type ShimKind } from "../contracts/index.ts"
|
|
5
|
+
import type { DiscoveryProbe } from "./probe.ts"
|
|
6
|
+
import { decode, join, normalizeAbs } from "./util.ts"
|
|
7
|
+
|
|
8
|
+
/** How much of an executable discovery reads to tell a native binary from a script. */
|
|
9
|
+
const HEAD_BYTES = 4096
|
|
10
|
+
const MAX_PATH_DIRS = 64
|
|
11
|
+
|
|
12
|
+
/** The command each harness is started with. */
|
|
13
|
+
export const HARNESS_BINARIES: Readonly<Record<HarnessID, string>> = Object.freeze({ opencode: "opencode", "claude-code": "claude", codex: "codex" })
|
|
14
|
+
|
|
15
|
+
// ---------- executables ----------
|
|
16
|
+
|
|
17
|
+
export function pathDirs(probe: DiscoveryProbe): string[] {
|
|
18
|
+
const dirs: string[] = []
|
|
19
|
+
for (const raw of (probe.env.PATH ?? "").split(":")) {
|
|
20
|
+
const d = normalizeAbs(raw)
|
|
21
|
+
if (d && !dirs.includes(d)) dirs.push(d)
|
|
22
|
+
if (dirs.length >= MAX_PATH_DIRS) break
|
|
23
|
+
}
|
|
24
|
+
return dirs
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function which(probe: DiscoveryProbe, dirs: readonly string[], name: string): string | undefined {
|
|
28
|
+
for (const d of dirs) {
|
|
29
|
+
const candidate = join(d, name)
|
|
30
|
+
if (probe.executable(candidate)) return candidate
|
|
31
|
+
}
|
|
32
|
+
return undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const CONTAINER_EXEC = /\b(?:docker|podman|nerdctl|distrobox|toolbox|lxc|lxc-attach)\b[^\n]*\b(?:exec|enter|run|attach)\b/
|
|
36
|
+
/** Container-exec options that take a value (the next word is not the program). */
|
|
37
|
+
const VALUE_OPTIONS = new Set(["-w", "--workdir", "-e", "--env", "-u", "--user", "--env-file", "--detach-keys", "--name"])
|
|
38
|
+
|
|
39
|
+
/** The first absolute program path after a container exec (`docker exec -i box /usr/bin/opencode "$@"`). */
|
|
40
|
+
function containerTarget(script: string): string | undefined {
|
|
41
|
+
const line = script.split("\n").find((l) => CONTAINER_EXEC.test(l))
|
|
42
|
+
if (!line) return undefined
|
|
43
|
+
const words = line.trim().split(/\s+/)
|
|
44
|
+
const start = words.findIndex((w, i) => /^(?:exec|enter|run|attach)$/.test(w) && words.slice(0, i).some((x) => /(?:docker|podman|nerdctl|distrobox|toolbox|lxc|lxc-attach)$/.test(x)))
|
|
45
|
+
for (let i = start + 1; start >= 0 && i < words.length; i++) {
|
|
46
|
+
if (VALUE_OPTIONS.has(words[i - 1]!)) continue
|
|
47
|
+
const p = normalizeAbs(words[i])
|
|
48
|
+
if (p) return p
|
|
49
|
+
}
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The absolute program a wrapper script execs (`exec /opt/x/bin/tool "$@"`). */
|
|
54
|
+
function wrapperTarget(script: string): string | undefined {
|
|
55
|
+
for (const line of script.split("\n").slice(1)) {
|
|
56
|
+
const m = /^\s*exec\s+(\S+)/.exec(line)
|
|
57
|
+
const p = m ? normalizeAbs(m[1]) : undefined
|
|
58
|
+
if (p) return p
|
|
59
|
+
}
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MAGIC = [
|
|
64
|
+
[0x7f, 0x45, 0x4c, 0x46], // ELF
|
|
65
|
+
[0xcf, 0xfa, 0xed, 0xfe],
|
|
66
|
+
[0xce, 0xfa, 0xed, 0xfe],
|
|
67
|
+
[0xfe, 0xed, 0xfa, 0xcf],
|
|
68
|
+
[0xfe, 0xed, 0xfa, 0xce],
|
|
69
|
+
[0xca, 0xfe, 0xba, 0xbe], // Mach-O (thin and universal)
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
/** What an executable on PATH is: a native binary, a script wrapper, or a shim into a container. */
|
|
73
|
+
export function classifyExecutable(probe: DiscoveryProbe, name: string, path: string): Shim {
|
|
74
|
+
const isLink = probe.lstat(path) === "symlink"
|
|
75
|
+
const real = isLink ? normalizeAbs(probe.realpath(path)) : undefined
|
|
76
|
+
const head = probe.readFile(path, HEAD_BYTES) ?? new Uint8Array()
|
|
77
|
+
const native = MAGIC.some((m) => m.every((b, i) => head[i] === b))
|
|
78
|
+
let kind: ShimKind = native || isLink ? "host" : "unknown"
|
|
79
|
+
let resolvesTo = real && real !== path ? real : undefined
|
|
80
|
+
if (!native && head[0] === 0x23 && head[1] === 0x21) {
|
|
81
|
+
const script = decode(head)
|
|
82
|
+
if (CONTAINER_EXEC.test(script)) {
|
|
83
|
+
kind = "container-shim"
|
|
84
|
+
resolvesTo = containerTarget(script)
|
|
85
|
+
} else {
|
|
86
|
+
kind = "wrapper"
|
|
87
|
+
resolvesTo ??= wrapperTarget(script)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return resolvesTo ? { name, path, kind, resolvesTo } : { name, path, kind }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function packageVersion(probe: DiscoveryProbe, real: string): string | undefined {
|
|
94
|
+
const m = /^(.*\/node_modules\/(?:@[^/]+\/)?[^/]+)\//.exec(real)
|
|
95
|
+
if (!m) return undefined
|
|
96
|
+
try {
|
|
97
|
+
const pkg = JSON.parse(decode(probe.readFile(`${m[1]}/package.json`, 64 * 1024))) as { version?: unknown }
|
|
98
|
+
return typeof pkg.version === "string" && SEMVER.test(pkg.version) ? pkg.version : undefined
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** How a harness was installed, and its version when the install layout states it (never by running it). */
|
|
105
|
+
export function installOf(probe: DiscoveryProbe, id: HarnessID, shim: Shim, roots: readonly RootMapping[], context: ExecutionContext = "host"): { installMethod: InstallMethod; version?: string } {
|
|
106
|
+
if (shim.kind === "container-shim") return { installMethod: "unknown" }
|
|
107
|
+
const real = shim.kind === "host" || shim.kind === "wrapper" ? (shim.resolvesTo ?? shim.path) : shim.path
|
|
108
|
+
const semver = (v: string | undefined) => (v && SEMVER.test(v) ? v : undefined)
|
|
109
|
+
// Paths are read in this (possibly container) view of each root (v1.1 rootPathIn).
|
|
110
|
+
const local = (root: string) => {
|
|
111
|
+
const r = roots.find((x) => x.id === root)
|
|
112
|
+
return r ? rootPathIn(r, context) : undefined
|
|
113
|
+
}
|
|
114
|
+
const data = local("data")
|
|
115
|
+
const home = local("home")
|
|
116
|
+
const hubLayout = data ? new RegExp(`^${escape(data)}/hub/harnesses/${escape(id)}/([^/]+)(?:/|$)`) : undefined
|
|
117
|
+
let hub = hubLayout?.exec(real) ?? null
|
|
118
|
+
// v1.1 layout: `current` is a link to the version directory in use; the version is where it leads.
|
|
119
|
+
if (hub?.[1] === "current") hub = hubLayout!.exec(normalizeAbs(probe.realpath(real)) ?? "") ?? null
|
|
120
|
+
if (hub && semver(hub[1])) return { installMethod: "hub", version: hub[1] }
|
|
121
|
+
const brew = /\/(?:Cellar|Caskroom)\/[^/]+\/([^/]+)\//.exec(real)
|
|
122
|
+
if (brew) return { installMethod: "brew", ...(semver(brew[1]) ? { version: brew[1] } : {}) }
|
|
123
|
+
if (real.includes("/node_modules/")) {
|
|
124
|
+
const version = packageVersion(probe, real)
|
|
125
|
+
return { installMethod: "npm", ...(version ? { version } : {}) }
|
|
126
|
+
}
|
|
127
|
+
const claude = /\/claude\/versions\/([^/]+)$/.exec(real)
|
|
128
|
+
if (id === "claude-code" && claude) return { installMethod: "native", ...(semver(claude[1]) ? { version: claude[1] } : {}) }
|
|
129
|
+
if (home && id === "opencode" && real.startsWith(`${home}/.opencode/bin/`)) return { installMethod: "native" }
|
|
130
|
+
if (/^\/(?:usr\/)?s?bin\//.test(real)) return { installMethod: "package-manager" }
|
|
131
|
+
return { installMethod: "unknown" }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Host facts: path roots (and their host location inside a container), C library, OS name and version, and
|
|
2
|
+
// container kind. Everything is read through the probe; nothing is executed.
|
|
3
|
+
import { ROOT_IDS, type ContainerKind, type Libc, type RootID, type RootMapping } from "../contracts/index.ts"
|
|
4
|
+
import type { DiscoveryProbe } from "./probe.ts"
|
|
5
|
+
import { decode, join, normalizeAbs, oneLine } from "./util.ts"
|
|
6
|
+
|
|
7
|
+
// ---------- roots ----------
|
|
8
|
+
|
|
9
|
+
export function discoverRoots(probe: DiscoveryProbe): RootMapping[] | undefined {
|
|
10
|
+
const home = normalizeAbs(probe.env.HOME) ?? normalizeAbs(probe.home)
|
|
11
|
+
if (!home) return undefined
|
|
12
|
+
const xdg = (key: string, fallback: string) => normalizeAbs(probe.env[key]) ?? join(home, fallback)
|
|
13
|
+
const config = xdg("XDG_CONFIG_HOME", ".config")
|
|
14
|
+
const paths: Partial<Record<RootID, string>> = {
|
|
15
|
+
home,
|
|
16
|
+
config,
|
|
17
|
+
state: xdg("XDG_STATE_HOME", ".local/state"),
|
|
18
|
+
data: xdg("XDG_DATA_HOME", ".local/share"),
|
|
19
|
+
"user-bin": join(home, ".local/bin"),
|
|
20
|
+
"system-bin": "/usr/local/bin",
|
|
21
|
+
}
|
|
22
|
+
if (probe.platform === "darwin") paths["launch-agents"] = join(home, "Library/LaunchAgents")
|
|
23
|
+
if (probe.platform === "linux") paths["systemd-user"] = join(config, "systemd/user")
|
|
24
|
+
return ROOT_IDS.flatMap((id) => (paths[id] ? [{ id, path: paths[id]! }] : []))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Mount {
|
|
28
|
+
readonly root: string
|
|
29
|
+
readonly point: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const unescapeMount = (s: string) => s.replace(/\\([0-7]{3})/g, (_, o: string) => String.fromCharCode(parseInt(o, 8)))
|
|
33
|
+
|
|
34
|
+
export function parseMountinfo(text: string): Mount[] {
|
|
35
|
+
const mounts: Mount[] = []
|
|
36
|
+
for (const line of text.split("\n")) {
|
|
37
|
+
const f = line.split(" ")
|
|
38
|
+
if (f.length < 5) continue
|
|
39
|
+
const root = normalizeAbs(unescapeMount(f[3]!))
|
|
40
|
+
const point = normalizeAbs(unescapeMount(f[4]!))
|
|
41
|
+
if (root && point) mounts.push({ root, point })
|
|
42
|
+
}
|
|
43
|
+
return mounts
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** In a container: where each root lives on the host, when it sits in a bind mount of a host directory. */
|
|
47
|
+
export function mapContainerRoots(roots: RootMapping[], mounts: readonly Mount[]): RootMapping[] {
|
|
48
|
+
return roots.map((r) => {
|
|
49
|
+
const mount = mounts
|
|
50
|
+
.filter((m) => r.path === m.point || r.path.startsWith(m.point === "/" ? "/" : `${m.point}/`))
|
|
51
|
+
.sort((a, b) => b.point.length - a.point.length)[0]
|
|
52
|
+
if (!mount || mount.root === "/" || mount.root === mount.point) return r
|
|
53
|
+
const host = normalizeAbs(mount.root + r.path.slice(mount.point.length))
|
|
54
|
+
return host ? { id: r.id, path: host, containerPath: r.path } : r
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------- host ----------
|
|
59
|
+
|
|
60
|
+
export function discoverLibc(probe: DiscoveryProbe): Libc {
|
|
61
|
+
if (probe.platform === "darwin") return "none"
|
|
62
|
+
if (probe.platform !== "linux") return "unknown"
|
|
63
|
+
const names = ["/lib", "/lib64", "/usr/lib"].flatMap((d) => probe.readdir(d) ?? [])
|
|
64
|
+
if (names.some((n) => n.startsWith("ld-musl-"))) return "musl"
|
|
65
|
+
if (names.some((n) => n.startsWith("ld-linux"))) return "glibc"
|
|
66
|
+
return "unknown"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function discoverOS(probe: DiscoveryProbe): { name: string; version: string } {
|
|
70
|
+
if (probe.platform === "darwin") {
|
|
71
|
+
const plist = decode(probe.readFile("/System/Library/CoreServices/SystemVersion.plist", 64 * 1024))
|
|
72
|
+
const value = (key: string) => oneLine(new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`).exec(plist)?.[1])
|
|
73
|
+
return { name: value("ProductName") ?? "macOS", version: value("ProductVersion") ?? "unknown" }
|
|
74
|
+
}
|
|
75
|
+
if (probe.platform === "linux") {
|
|
76
|
+
const text = decode(probe.readFile("/etc/os-release", 64 * 1024) ?? probe.readFile("/usr/lib/os-release", 64 * 1024))
|
|
77
|
+
const value = (key: string) => oneLine(new RegExp(`^${key}=(.*)$`, "m").exec(text)?.[1]?.trim().replace(/^(["'])(.*)\1$/, "$2"))
|
|
78
|
+
return { name: value("NAME") ?? "Linux", version: value("VERSION_ID") ?? "unknown" }
|
|
79
|
+
}
|
|
80
|
+
return { name: oneLine(probe.platform, 32) ?? "unknown", version: "unknown" }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function discoverContainer(probe: DiscoveryProbe): ContainerKind {
|
|
84
|
+
if (probe.platform !== "linux") return "none"
|
|
85
|
+
if (probe.lstat("/.dockerenv") !== "missing") return "docker"
|
|
86
|
+
if (probe.lstat("/run/.containerenv") !== "missing") return "podman"
|
|
87
|
+
const env = probe.env.container
|
|
88
|
+
if (env === "docker" || env === "podman" || env === "lxc") return env
|
|
89
|
+
if (env) return "unknown"
|
|
90
|
+
const cgroup = decode(probe.readFile("/proc/1/cgroup", 64 * 1024))
|
|
91
|
+
if (/libpod/.test(cgroup)) return "podman"
|
|
92
|
+
if (/docker|containerd/.test(cgroup)) return "docker"
|
|
93
|
+
if (/\blxc\b/.test(cgroup)) return "lxc"
|
|
94
|
+
if (/microsoft|wsl/i.test(decode(probe.readFile("/proc/sys/kernel/osrelease", 4096)))) return "wsl"
|
|
95
|
+
return "none"
|
|
96
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Read-only discovery (HBS-03): DiscoveryProbe in, contract DiscoverySnapshot out.
|
|
2
|
+
export * from "./probe.ts"
|
|
3
|
+
export * from "./discover.ts"
|
|
4
|
+
export { classifyExecutable, HARNESS_BINARIES } from "./executables.ts"
|
|
5
|
+
export { normalizeAbs } from "./util.ts"
|
|
6
|
+
export { createNodeProbe, MAX_READ_BYTES, type NodeProbeOptions } from "./node-probe.ts"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The real DiscoveryProbe, over node:fs. Every method is a read: lstat/stat, a bounded read of a file opened
|
|
2
|
+
// read-only, readdir, readlink, realpath and an access(X_OK) check. Errors mean "missing" or "unknown", never a
|
|
3
|
+
// throw. The only process it may start is the macOS machine-identity query (`ioreg`, a fixed read-only argv, no
|
|
4
|
+
// shell), and only when no machine ID was injected; Linux reads /etc/machine-id.
|
|
5
|
+
import childProcess from "node:child_process"
|
|
6
|
+
import fs from "node:fs"
|
|
7
|
+
import os from "node:os"
|
|
8
|
+
import { programLayout, type EntryKind } from "../contracts/index.ts"
|
|
9
|
+
import type { DiscoveryProbe, RuntimeFact } from "./probe.ts"
|
|
10
|
+
|
|
11
|
+
export interface NodeProbeOptions {
|
|
12
|
+
/** Defaults to process.env (read, never recorded). */
|
|
13
|
+
readonly env?: Readonly<Record<string, string | undefined>>
|
|
14
|
+
readonly home?: string
|
|
15
|
+
/** Injected machine identity (tests); otherwise read from the OS. */
|
|
16
|
+
readonly machineID?: string
|
|
17
|
+
/** The running runtime; null when hubctl runs standalone. Detected by default. */
|
|
18
|
+
readonly runtime?: RuntimeFact | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Reads without a limit are capped (configs are small; a bigger file is not read at all). */
|
|
22
|
+
export const MAX_READ_BYTES = 16 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
const kindOf = (st: fs.Stats): EntryKind => (st.isSymbolicLink() ? "symlink" : st.isDirectory() ? "directory" : st.isFile() ? "file" : "other")
|
|
25
|
+
|
|
26
|
+
function attempt<T>(f: () => T): T | undefined {
|
|
27
|
+
try {
|
|
28
|
+
return f()
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function currentRuntime(): RuntimeFact | undefined {
|
|
35
|
+
const layout = programLayout({ execPath: process.execPath, entry: process.argv[1] ?? "" })
|
|
36
|
+
if (layout.kind === "standalone") return undefined
|
|
37
|
+
const bun = process.versions.bun
|
|
38
|
+
return bun ? { id: "bun", version: bun, path: process.execPath } : { id: "node", version: process.versions.node, path: process.execPath }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function osMachineID(): string | undefined {
|
|
42
|
+
if (process.platform === "linux")
|
|
43
|
+
for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
44
|
+
const id = attempt(() => fs.readFileSync(p, "utf8").trim())
|
|
45
|
+
if (id) return id
|
|
46
|
+
}
|
|
47
|
+
if (process.platform === "darwin") {
|
|
48
|
+
const out = attempt(() => childProcess.execFileSync("/usr/sbin/ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }))
|
|
49
|
+
return out ? /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out)?.[1] : undefined
|
|
50
|
+
}
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createNodeProbe(options: NodeProbeOptions = {}): DiscoveryProbe {
|
|
55
|
+
let machineID: string | undefined | null = options.machineID ?? null
|
|
56
|
+
const runtime = options.runtime === null ? undefined : (options.runtime ?? currentRuntime())
|
|
57
|
+
return {
|
|
58
|
+
platform: process.platform,
|
|
59
|
+
arch: process.arch,
|
|
60
|
+
uid: process.getuid?.() ?? 0,
|
|
61
|
+
home: options.home ?? os.homedir(),
|
|
62
|
+
hostname: os.hostname(),
|
|
63
|
+
env: options.env ?? process.env,
|
|
64
|
+
...(runtime ? { runtime } : {}),
|
|
65
|
+
lstat: (p) => attempt(() => kindOf(fs.lstatSync(p))) ?? "missing",
|
|
66
|
+
stat: (p) => attempt(() => kindOf(fs.statSync(p))) ?? "missing",
|
|
67
|
+
readFile(p, limit) {
|
|
68
|
+
const max = Math.min(limit ?? MAX_READ_BYTES, MAX_READ_BYTES)
|
|
69
|
+
const fd = attempt(() => fs.openSync(p, "r"))
|
|
70
|
+
if (fd === undefined) return undefined
|
|
71
|
+
try {
|
|
72
|
+
const st = fs.fstatSync(fd)
|
|
73
|
+
if (!st.isFile() || (limit === undefined && st.size > MAX_READ_BYTES)) return undefined
|
|
74
|
+
const buffer = Buffer.alloc(Math.min(max, st.size))
|
|
75
|
+
let read = 0
|
|
76
|
+
while (read < buffer.length) {
|
|
77
|
+
const n = fs.readSync(fd, buffer, read, buffer.length - read, read)
|
|
78
|
+
if (n === 0) break
|
|
79
|
+
read += n
|
|
80
|
+
}
|
|
81
|
+
return new Uint8Array(buffer.subarray(0, read))
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined
|
|
84
|
+
} finally {
|
|
85
|
+
attempt(() => fs.closeSync(fd))
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
readdir: (p) => attempt(() => fs.readdirSync(p).sort()),
|
|
89
|
+
readlink: (p) => attempt(() => fs.readlinkSync(p)),
|
|
90
|
+
realpath: (p) => attempt(() => fs.realpathSync(p)),
|
|
91
|
+
executable: (p) => attempt(() => fs.statSync(p).isFile() && (fs.accessSync(p, fs.constants.X_OK), true)) ?? false,
|
|
92
|
+
machineID() {
|
|
93
|
+
if (machineID === null) machineID = osMachineID()
|
|
94
|
+
return machineID
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The read-only probe discovery and preview run through. It is the only way they see the host: a handful of
|
|
2
|
+
// filesystem reads (lstat, stat, readFile, readdir, readlink, realpath, an executable check), facts about this
|
|
3
|
+
// process, and the machine identity. The interface has no method that writes, deletes, connects or spawns an
|
|
4
|
+
// arbitrary command, so discover and preview cannot change the host or reach the network even by mistake. Tests
|
|
5
|
+
// inject an in-memory probe; the CLI injects createNodeProbe().
|
|
6
|
+
import type { EntryKind } from "../contracts/index.ts"
|
|
7
|
+
|
|
8
|
+
export interface RuntimeFact {
|
|
9
|
+
readonly id: "bun" | "node"
|
|
10
|
+
readonly version: string
|
|
11
|
+
readonly path: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DiscoveryProbe {
|
|
15
|
+
/** process.platform and process.arch as found (may be unsupported). */
|
|
16
|
+
readonly platform: string
|
|
17
|
+
readonly arch: string
|
|
18
|
+
readonly uid: number
|
|
19
|
+
/** The user's home directory (os.homedir()). */
|
|
20
|
+
readonly home: string
|
|
21
|
+
readonly hostname: string
|
|
22
|
+
/** Read, never recorded: discovery looks at PATH, HOME, XDG_* and `container` only. */
|
|
23
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
24
|
+
/** The runtime running this script, when hubctl runs as a script (absent for a standalone binary). */
|
|
25
|
+
readonly runtime?: RuntimeFact
|
|
26
|
+
/** Does not follow a final symlink. */
|
|
27
|
+
lstat(path: string): EntryKind
|
|
28
|
+
/** Follows symlinks. */
|
|
29
|
+
stat(path: string): EntryKind
|
|
30
|
+
/** Up to `limit` bytes of a file (following symlinks), or undefined when it cannot be read. */
|
|
31
|
+
readFile(path: string, limit?: number): Uint8Array | undefined
|
|
32
|
+
/** Entry names of a directory (sorted), or undefined. */
|
|
33
|
+
readdir(path: string): readonly string[] | undefined
|
|
34
|
+
readlink(path: string): string | undefined
|
|
35
|
+
/** The canonical path (every link resolved), or undefined when it does not exist. */
|
|
36
|
+
realpath(path: string): string | undefined
|
|
37
|
+
/** A regular file (after links) this user may execute. */
|
|
38
|
+
executable(path: string): boolean
|
|
39
|
+
/** /etc/machine-id (Linux) or IOPlatformUUID (macOS); never recorded raw. */
|
|
40
|
+
machineID(): string | undefined
|
|
41
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Small path and text helpers shared by discovery and preview.
|
|
2
|
+
import { isAbsolutePath } from "../contracts/index.ts"
|
|
3
|
+
|
|
4
|
+
/** A normalized absolute path (duplicate and trailing slashes removed), or undefined. */
|
|
5
|
+
export function normalizeAbs(value: string | undefined): string | undefined {
|
|
6
|
+
if (typeof value !== "string" || !value.startsWith("/")) return undefined
|
|
7
|
+
const p = value.replace(/\/{2,}/g, "/").replace(/(.)\/+$/, "$1")
|
|
8
|
+
return isAbsolutePath(p) ? p : undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const join = (dir: string, rel: string) => (dir === "/" ? `/${rel}` : `${dir}/${rel}`)
|
|
12
|
+
export const decode = (bytes: Uint8Array | undefined) => (bytes ? new TextDecoder("utf-8", { fatal: false }).decode(bytes) : "")
|
|
13
|
+
export const oneLine = (v: string | undefined, max = 64) => (v && v.length <= max && !/[\u0000-\u001f\u007f]/.test(v) ? v : undefined)
|