@huynle/hub 0.7.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 +70 -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/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 +115 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/store.ts +53 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -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/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Which hub and token the CLI uses: HUB_URL + HUB_SESSION_TOKEN (a session's identity, set by the spoke in its
|
|
2
|
+
// shells) win; else the login file written by `hub login` ($XDG_CONFIG_HOME or ~/.config, then hub/cli.json,
|
|
3
|
+
// mode 0600) with an app token.
|
|
4
|
+
import { randomBytes } from "node:crypto"
|
|
5
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import type { Fetch } from "../../sdk/src/index.ts"
|
|
8
|
+
|
|
9
|
+
export type Env = Record<string, string | undefined>
|
|
10
|
+
|
|
11
|
+
export interface Credentials {
|
|
12
|
+
readonly url: string
|
|
13
|
+
readonly token: string
|
|
14
|
+
/** env: HUB_URL + HUB_SESSION_TOKEN; login: the login file; http: the caller of the hub's POST /mcp. */
|
|
15
|
+
readonly source: "env" | "login" | "http"
|
|
16
|
+
/** How to reach the hub (default: the global fetch); POST /mcp dispatches in process. */
|
|
17
|
+
readonly fetch?: Fetch
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Login {
|
|
21
|
+
readonly url: string
|
|
22
|
+
readonly token: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const loginFile = (env: Env, home: string): string => path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "hub", "cli.json")
|
|
26
|
+
|
|
27
|
+
export function readLogin(file: string): Login | undefined {
|
|
28
|
+
let raw: string
|
|
29
|
+
try {
|
|
30
|
+
raw = readFileSync(file, "utf8")
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const value = JSON.parse(raw) as Partial<Login>
|
|
36
|
+
if (typeof value.url === "string" && value.url && typeof value.token === "string" && value.token) return { url: value.url, token: value.token }
|
|
37
|
+
} catch {
|
|
38
|
+
// unreadable: treated as logged out
|
|
39
|
+
}
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Writes the login file, owner-only (0600; its directory 0700 when created), atomically: a private temporary file
|
|
45
|
+
* next to it is renamed over it, so a reader never sees half a file and a failed write leaves the old one.
|
|
46
|
+
*/
|
|
47
|
+
export function writeLogin(file: string, login: Login): void {
|
|
48
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
49
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`
|
|
50
|
+
try {
|
|
51
|
+
writeFileSync(tmp, `${JSON.stringify({ url: login.url, token: login.token }, null, 2)}\n`, { mode: 0o600, flag: "wx" })
|
|
52
|
+
chmodSync(tmp, 0o600)
|
|
53
|
+
renameSync(tmp, file)
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
rmSync(tmp, { force: true })
|
|
56
|
+
throw cause
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Deletes the login file; true when there was one. */
|
|
61
|
+
export function removeLogin(file: string): boolean {
|
|
62
|
+
try {
|
|
63
|
+
readFileSync(file)
|
|
64
|
+
} catch {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
rmSync(file, { force: true })
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveCredentials(env: Env, home: string): Credentials | undefined {
|
|
72
|
+
if (env.HUB_URL && env.HUB_SESSION_TOKEN) return { url: env.HUB_URL, token: env.HUB_SESSION_TOKEN, source: "env" }
|
|
73
|
+
const login = readLogin(loginFile(env, home))
|
|
74
|
+
return login ? { ...login, source: "login" } : undefined
|
|
75
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Decisions in the CLI (design §12.1): the `hub ask @me` options (--option id=label[:safe], --recommend id --why,
|
|
2
|
+
// --urgency, --deadline, --on-timeout, --tier, --about) and the text of a Decision (ask @me, `hub decisions list`).
|
|
3
|
+
// The shapes are packages/sdk's types (src/central/agent/decisions.ts).
|
|
4
|
+
import type { AskOptions, Decision, DecisionOption, DecisionState } from "../../sdk/src/index.ts"
|
|
5
|
+
import { str, UsageError, type FlagKind, type Parsed } from "./args"
|
|
6
|
+
import { oneLine } from "./format"
|
|
7
|
+
import { durationArg, partyText } from "./messaging"
|
|
8
|
+
|
|
9
|
+
/** The ask flags that make a Decision (only for asks to @me). */
|
|
10
|
+
export const DECISION_FLAGS: Readonly<Record<string, FlagKind>> = {
|
|
11
|
+
option: "multi",
|
|
12
|
+
recommend: "string",
|
|
13
|
+
why: "string",
|
|
14
|
+
urgency: "string",
|
|
15
|
+
deadline: "string",
|
|
16
|
+
"on-timeout": "string",
|
|
17
|
+
tier: "string",
|
|
18
|
+
about: "multi",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const URGENCIES = ["low", "normal", "high"]
|
|
22
|
+
const TIERS = ["confirm", "tap"]
|
|
23
|
+
const STATES: readonly DecisionState[] = ["open", "answered", "expired", "closed"]
|
|
24
|
+
export const DECISION_STATES = STATES
|
|
25
|
+
|
|
26
|
+
/** `id=label` or `id=label:safe` (the label may contain colons; only a trailing `:safe` marks it safe). */
|
|
27
|
+
export function optionArg(value: string): DecisionOption {
|
|
28
|
+
const eq = value.indexOf("=")
|
|
29
|
+
if (eq <= 0) throw new UsageError(`--option takes id=label[:safe] (got "${value}")`)
|
|
30
|
+
const id = value.slice(0, eq)
|
|
31
|
+
let label = value.slice(eq + 1)
|
|
32
|
+
const safe = label.endsWith(":safe")
|
|
33
|
+
if (safe) label = label.slice(0, -":safe".length)
|
|
34
|
+
if (!label.trim()) throw new UsageError(`--option ${id}: the label is empty`)
|
|
35
|
+
return { id, label, ...(safe ? { safe: true } : {}) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const oneOf = (value: string | undefined, allowed: readonly string[], flag: string) => {
|
|
39
|
+
if (value !== undefined && !allowed.includes(value)) throw new UsageError(`${flag} is one of ${allowed.join(", ")}`)
|
|
40
|
+
return value
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The Decision fields of `hub ask` (empty when none is given); usage errors for bad ones or a non-@me ask. */
|
|
44
|
+
export function decisionOptions(flags: Parsed["flags"], to: string): Partial<AskOptions> {
|
|
45
|
+
const given = Object.keys(DECISION_FLAGS).filter((flag) => flags[flag] !== undefined)
|
|
46
|
+
if (!given.length) return {}
|
|
47
|
+
if (to !== "@me") throw new UsageError(`${given.map((f) => `--${f}`).join(", ")}: only for asks to @me`)
|
|
48
|
+
const recommend = str(flags, "recommend")
|
|
49
|
+
const why = str(flags, "why")
|
|
50
|
+
if (why !== undefined && recommend === undefined) throw new UsageError("--why explains --recommend")
|
|
51
|
+
const deadline = str(flags, "deadline")
|
|
52
|
+
const urgency = oneOf(str(flags, "urgency"), URGENCIES, "--urgency") as AskOptions["urgency"]
|
|
53
|
+
const tier = oneOf(str(flags, "tier"), TIERS, "--tier") as AskOptions["tier"]
|
|
54
|
+
const onTimeout = str(flags, "on-timeout")
|
|
55
|
+
const options = flags.option as string[] | undefined
|
|
56
|
+
const about = flags.about as string[] | undefined
|
|
57
|
+
return {
|
|
58
|
+
...(options ? { options: options.map(optionArg) } : {}),
|
|
59
|
+
...(recommend !== undefined ? { recommendation: { option: recommend, ...(why !== undefined ? { why } : {}) } } : {}),
|
|
60
|
+
...(urgency ? { urgency } : {}),
|
|
61
|
+
...(tier ? { tier } : {}),
|
|
62
|
+
...(deadline !== undefined ? { deadline: durationArg(deadline, "--deadline") } : {}),
|
|
63
|
+
...(onTimeout !== undefined ? { onTimeout } : {}),
|
|
64
|
+
...(about ? { about } : {}),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The --state filter of `hub decisions list`. */
|
|
69
|
+
export function stateArg(value: string | undefined): DecisionState | undefined {
|
|
70
|
+
return oneOf(value, STATES, "--state") as DecisionState | undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z")
|
|
74
|
+
|
|
75
|
+
/** `go Proceed · hold Hold (safe) — recommended: hold (why)`; empty for a free-text Decision without one. */
|
|
76
|
+
function choicesText(decision: Decision): string {
|
|
77
|
+
const options = decision.options.map((o) => `${o.id} ${oneLine(o.label, 80)}${o.safe ? " (safe)" : ""}`).join(" · ")
|
|
78
|
+
const r = decision.recommendation
|
|
79
|
+
const recommended = r ? `recommended: ${r.option}${r.why ? ` (${oneLine(r.why, 200)})` : ""}` : ""
|
|
80
|
+
return [options, recommended].filter(Boolean).join(" — ")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stateText(decision: Decision): string {
|
|
84
|
+
if (decision.state === "answered") {
|
|
85
|
+
const a = decision.answer
|
|
86
|
+
return `answered${a?.option ? ` ${a.option}` : ""}${a?.answeredBy === "timeout" ? " (timeout)" : ""}`
|
|
87
|
+
}
|
|
88
|
+
if (decision.state === "open" && decision.deadline !== undefined) return `open (deadline ${iso(decision.deadline)})`
|
|
89
|
+
return decision.state
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One Decision: `dec_1 open high from @api Question` and its choices on the next line. */
|
|
93
|
+
export function decisionText(decision: Decision): string {
|
|
94
|
+
const head = `${decision.id} ${stateText(decision)} ${decision.urgency} from ${partyText(decision.from)} ${oneLine(decision.question, 160)}`
|
|
95
|
+
const choices = choicesText(decision)
|
|
96
|
+
return choices ? `${head}\n ${choices}` : head
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const decisionsText = (decisions: readonly Decision[]): string => (decisions.length ? decisions.map(decisionText).join("\n") : "no decisions")
|
|
100
|
+
|
|
101
|
+
/** What `hub ask @me` adds: the Decision it opened. */
|
|
102
|
+
export function openedText(decision: Decision): string {
|
|
103
|
+
const tier = decision.tier === "tap" ? ", tap in the console" : ""
|
|
104
|
+
const choices = choicesText(decision)
|
|
105
|
+
return `decision ${decision.id} (${decision.urgency}${tier}): ${oneLine(decision.question, 160)}${choices ? `\n ${choices}` : ""}\n the user answers it in the console`
|
|
106
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Short human text for the CLI's default output (--json prints the hub's answers unchanged). The shapes are those
|
|
2
|
+
// of src/central/agent/read-routes.ts and directory.ts; unknown fields are ignored, missing ones skipped.
|
|
3
|
+
|
|
4
|
+
export interface Blocker {
|
|
5
|
+
readonly kind: string
|
|
6
|
+
readonly id?: string
|
|
7
|
+
readonly summary: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SessionView {
|
|
11
|
+
readonly id: string
|
|
12
|
+
readonly name?: string
|
|
13
|
+
readonly roles?: readonly string[]
|
|
14
|
+
readonly title?: string
|
|
15
|
+
readonly state: string
|
|
16
|
+
readonly harness?: string
|
|
17
|
+
readonly now?: string
|
|
18
|
+
readonly blockers?: readonly Blocker[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const at = (name: string) => `@${name.replace(/^@+/, "")}`
|
|
22
|
+
export const oneLine = (text: string, max = 160): string => {
|
|
23
|
+
const flat = text.replace(/\s+/g, " ").trim()
|
|
24
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
|
|
25
|
+
}
|
|
26
|
+
const blockerText = (b: Blocker) => `${b.kind}: ${oneLine(b.summary, 100)}`
|
|
27
|
+
|
|
28
|
+
/** `@name ses_… state harness now [kind: summary; …]` */
|
|
29
|
+
export function sessionLine(s: SessionView): string {
|
|
30
|
+
const parts = [[s.name ? at(s.name) : undefined, s.id].filter(Boolean).join(" "), s.state]
|
|
31
|
+
if (s.harness) parts.push(s.harness)
|
|
32
|
+
if (s.now) parts.push(oneLine(s.now, 80))
|
|
33
|
+
if (s.blockers?.length) parts.push(`[${s.blockers.map(blockerText).join("; ")}]`)
|
|
34
|
+
return parts.join(" ")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Snapshot {
|
|
38
|
+
readonly machines: readonly {
|
|
39
|
+
id: string
|
|
40
|
+
name?: string
|
|
41
|
+
online: boolean
|
|
42
|
+
harnesses?: readonly string[]
|
|
43
|
+
projects: readonly { root: string; name: string; sessions: readonly SessionView[] }[]
|
|
44
|
+
}[]
|
|
45
|
+
readonly attention: readonly { kind: string; sessionID: string; name?: string; id?: string; summary: string }[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function statusText(snapshot: Snapshot): string {
|
|
49
|
+
const lines: string[] = []
|
|
50
|
+
for (const m of snapshot.machines) {
|
|
51
|
+
lines.push([m.name ?? m.id, m.online ? "online" : "offline", ...(m.harnesses ?? [])].join(" "))
|
|
52
|
+
for (const p of m.projects) {
|
|
53
|
+
lines.push(` ${p.name}`)
|
|
54
|
+
for (const s of p.sessions) lines.push(` ${sessionLine(s)}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (lines.length === 0) lines.push("no machines")
|
|
58
|
+
if (snapshot.attention.length) {
|
|
59
|
+
lines.push("attention:")
|
|
60
|
+
for (const a of snapshot.attention) lines.push(` ${[a.name ? at(a.name) : undefined, a.sessionID].filter(Boolean).join(" ")} ${a.kind}${a.id ? ` ${a.id}` : ""}: ${oneLine(a.summary, 100)}`)
|
|
61
|
+
}
|
|
62
|
+
return lines.join("\n")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const sessionsText = (sessions: readonly SessionView[]): string => (sessions.length ? sessions.map(sessionLine).join("\n") : "no sessions")
|
|
66
|
+
|
|
67
|
+
interface Shown extends SessionView {
|
|
68
|
+
readonly online?: boolean
|
|
69
|
+
readonly project?: string
|
|
70
|
+
readonly directory?: string
|
|
71
|
+
readonly machineID?: string
|
|
72
|
+
readonly parentID?: string
|
|
73
|
+
readonly lastSeen?: number
|
|
74
|
+
readonly children?: number
|
|
75
|
+
readonly summary?: {
|
|
76
|
+
branch?: string
|
|
77
|
+
context?: { used: number; limit?: number; percent?: number; model?: string }
|
|
78
|
+
changes?: { additions: number; deletions: number; totalFiles: number }
|
|
79
|
+
mcp?: readonly { name: string; status: string }[]
|
|
80
|
+
}
|
|
81
|
+
readonly unsupported?: readonly { feature?: string; harness?: string }[]
|
|
82
|
+
readonly failed?: readonly { feature?: string }[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function showText(s: Shown): string {
|
|
86
|
+
const lines = [[s.name ? at(s.name) : undefined, s.id].filter(Boolean).join(" ")]
|
|
87
|
+
const field = (label: string, value: string | number | undefined) => {
|
|
88
|
+
if (value !== undefined && value !== "") lines.push(`${label}: ${value}`)
|
|
89
|
+
}
|
|
90
|
+
field("title", s.title)
|
|
91
|
+
field("state", [s.state, s.harness].filter(Boolean).join(" "))
|
|
92
|
+
field("project", [s.project, s.directory && s.directory !== s.project ? `(${s.directory})` : undefined].filter(Boolean).join(" ") || undefined)
|
|
93
|
+
field("machine", s.machineID)
|
|
94
|
+
field("parent", s.parentID)
|
|
95
|
+
if (s.roles?.length) field("roles", s.roles.map((r) => `@${at(r)}`).join(" "))
|
|
96
|
+
field("now", s.now)
|
|
97
|
+
if (s.children) field("subagents", s.children)
|
|
98
|
+
if (s.online === false && s.lastSeen !== undefined) field("last seen", new Date(s.lastSeen).toISOString())
|
|
99
|
+
if (s.blockers?.length) {
|
|
100
|
+
lines.push("blockers:")
|
|
101
|
+
for (const b of s.blockers) lines.push(` ${b.kind}${b.id ? ` ${b.id}` : ""}: ${oneLine(b.summary, 200)}`)
|
|
102
|
+
}
|
|
103
|
+
const sum = s.summary
|
|
104
|
+
if (sum) {
|
|
105
|
+
field("branch", sum.branch)
|
|
106
|
+
if (sum.context) field("context", `${sum.context.used} tokens${sum.context.percent !== undefined ? ` (${sum.context.percent}%)` : ""}${sum.context.model ? ` ${sum.context.model}` : ""}`)
|
|
107
|
+
if (sum.changes) field("changes", `${sum.changes.totalFiles} files +${sum.changes.additions} -${sum.changes.deletions}`)
|
|
108
|
+
if (sum.mcp?.length) field("mcp", sum.mcp.map((m) => `${m.name} ${m.status}`).join(", "))
|
|
109
|
+
}
|
|
110
|
+
for (const u of s.unsupported ?? []) lines.push(`unsupported: ${u.feature ?? "?"}${u.harness ? ` (${u.harness})` : ""}`)
|
|
111
|
+
for (const f of s.failed ?? []) lines.push(`failed: ${f.feature ?? "?"}`)
|
|
112
|
+
return lines.join("\n")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface Row {
|
|
116
|
+
readonly kind: string
|
|
117
|
+
readonly text?: string
|
|
118
|
+
readonly name?: string
|
|
119
|
+
readonly state?: string
|
|
120
|
+
readonly title?: string
|
|
121
|
+
readonly at?: number
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const time = (ms: number | undefined) => (ms === undefined ? "--:--:--" : new Date(ms).toISOString().slice(11, 19))
|
|
125
|
+
|
|
126
|
+
/** Timeline rows (the hub sends newest first), printed oldest first. */
|
|
127
|
+
export function logText(rows: readonly Row[]): string {
|
|
128
|
+
if (!rows.length) return "no rows"
|
|
129
|
+
return [...rows]
|
|
130
|
+
.reverse()
|
|
131
|
+
.map((r) => {
|
|
132
|
+
const head = r.kind === "tool" ? `tool ${r.name ?? "?"}${r.state ? ` (${r.state})` : ""}` : r.kind
|
|
133
|
+
const body = oneLine(r.text ?? r.title ?? "", 200)
|
|
134
|
+
return `${time(r.at)} ${head}${body ? ` ${body}` : ""}`
|
|
135
|
+
})
|
|
136
|
+
.join("\n")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface WhoEntry {
|
|
140
|
+
readonly sessionID: string
|
|
141
|
+
readonly name?: string
|
|
142
|
+
readonly project?: string
|
|
143
|
+
readonly title?: string
|
|
144
|
+
readonly state?: string
|
|
145
|
+
readonly online: boolean
|
|
146
|
+
readonly roles: readonly { role: string; project?: string }[]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function whoText(entries: readonly WhoEntry[]): string {
|
|
150
|
+
if (!entries.length) return "no sessions"
|
|
151
|
+
return entries
|
|
152
|
+
.map((e) => {
|
|
153
|
+
const parts = [[e.name ? at(e.name) : undefined, e.sessionID].filter(Boolean).join(" "), e.online ? (e.state ?? "online") : "offline"]
|
|
154
|
+
if (e.roles.length) parts.push(e.roles.map((r) => `@@${r.role}${r.project !== undefined ? `.${r.project}` : ""}`).join(" "))
|
|
155
|
+
if (e.project) parts.push(e.project)
|
|
156
|
+
if (e.title) parts.push(oneLine(e.title, 60))
|
|
157
|
+
return parts.join(" ")
|
|
158
|
+
})
|
|
159
|
+
.join("\n")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A caller as whoami / status `you` report it. */
|
|
163
|
+
export interface Actor {
|
|
164
|
+
readonly kind: string
|
|
165
|
+
readonly sessionID?: string
|
|
166
|
+
readonly machineID?: string
|
|
167
|
+
readonly tokenID?: string
|
|
168
|
+
readonly label?: string
|
|
169
|
+
readonly scopes?: readonly string[]
|
|
170
|
+
readonly deviceID?: string
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function whoamiText(body: { actor: Actor; user: { id: string; name?: string; email?: string; role?: string } }): string {
|
|
174
|
+
const { actor, user } = body
|
|
175
|
+
const lines: string[] = []
|
|
176
|
+
if (actor.kind === "session") lines.push(`session ${actor.sessionID}${actor.machineID ? ` on machine ${actor.machineID}` : ""}`)
|
|
177
|
+
else if (actor.kind === "app") lines.push(`app token ${actor.tokenID}${actor.label ? ` (${actor.label})` : ""}${actor.scopes?.length ? ` scopes ${actor.scopes.join(",")}` : ""}`)
|
|
178
|
+
else lines.push(`user${actor.deviceID ? ` (device ${actor.deviceID})` : ""}`)
|
|
179
|
+
const who = [user.name, user.email ? `<${user.email}>` : undefined].filter(Boolean).join(" ") || user.id
|
|
180
|
+
lines.push(`user ${who}${user.role ? ` (${user.role})` : ""}`)
|
|
181
|
+
return lines.join("\n")
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export const roleText = (r: { role: string; project?: string }) => `@@${r.role}${r.project !== undefined ? `.${r.project}` : ""}`
|
|
185
|
+
export { at as atName }
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// `hub grant list | show | create | revoke | request` (design §5): the spec of `hub grant create` (flags, or --json
|
|
2
|
+
// '{…}' / --json -) and the text of grants. Only the user manages grants (src/central/agent/grant-routes.ts); a
|
|
3
|
+
// session's own credential is refused here without asking the hub, any other the hub refuses (forbidden, exit 5).
|
|
4
|
+
// Agents (a session, an app token with messages:send) `hub grant request` one instead: the same flags (the holder
|
|
5
|
+
// defaults to the requester) and a --why; the user's tap in the console creates it.
|
|
6
|
+
import type { Grant, GrantRequested, GrantRequestSpec, GrantSpec, GrantVerb } from "../../sdk/src/index.ts"
|
|
7
|
+
import { jsonObjectArg, str, UsageError, type FlagKind, type Parsed } from "./args"
|
|
8
|
+
import { CliError, EXIT } from "./http"
|
|
9
|
+
|
|
10
|
+
/** The flags of `hub grant create`. */
|
|
11
|
+
export const GRANT_FLAGS: Readonly<Record<string, FlagKind>> = {
|
|
12
|
+
json: "json",
|
|
13
|
+
holder: "string",
|
|
14
|
+
project: "multi",
|
|
15
|
+
allow: "multi",
|
|
16
|
+
permit: "multi",
|
|
17
|
+
never: "multi",
|
|
18
|
+
expires: "string",
|
|
19
|
+
"max-per-hour": "string",
|
|
20
|
+
"allow-always": "bool",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The flags of `hub grant request`: create's, --permission-allow (as --permit), and the reason the user reads. */
|
|
24
|
+
export const GRANT_REQUEST_FLAGS: Readonly<Record<string, FlagKind>> = { ...GRANT_FLAGS, "permission-allow": "multi", why: "string" }
|
|
25
|
+
|
|
26
|
+
export const GRANT_USER_HINT = "only the user manages grants: use the console, or `hub login` with the user's device token"
|
|
27
|
+
|
|
28
|
+
/** Refuses a session's own credential (HUB_SESSION_TOKEN): a session never creates or revokes a grant. */
|
|
29
|
+
export function userOnly(source: string, what: string): void {
|
|
30
|
+
if (source === "env") throw new CliError(EXIT.forbidden, `only the user can ${what} a grant, not a session`, GRANT_USER_HINT)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A forbidden from the grant routes, with the hint of who may manage grants. */
|
|
34
|
+
export function grantError(error: unknown): unknown {
|
|
35
|
+
if (error instanceof CliError && error.exit === EXIT.forbidden) return new CliError(error.exit, error.message, [error.hint, GRANT_USER_HINT].filter(Boolean).join("; "), error.envelope)
|
|
36
|
+
return error
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Comma-separated and repeated values, in order. */
|
|
40
|
+
const listOf = (flags: Parsed["flags"], name: string): string[] => ((flags[name] as string[] | undefined) ?? []).flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean)
|
|
41
|
+
|
|
42
|
+
/** The grant of `hub grant create`: a JSON spec (--json), or the flags. */
|
|
43
|
+
export async function grantSpecArg(flags: Parsed["flags"], stdin: (() => Promise<string>) | undefined): Promise<GrantSpec> {
|
|
44
|
+
return (await grantArg(flags, stdin, true)) as GrantSpec
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The grant of `hub grant request`: as create's, the holder optional (the requester by default). */
|
|
48
|
+
export function grantRequestArg(flags: Parsed["flags"], stdin: (() => Promise<string>) | undefined): Promise<GrantRequestSpec> {
|
|
49
|
+
return grantArg(flags, stdin, false)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function grantArg(flags: Parsed["flags"], stdin: (() => Promise<string>) | undefined, holderRequired: boolean): Promise<GrantRequestSpec> {
|
|
53
|
+
const json = typeof flags.json === "string" ? flags.json : undefined
|
|
54
|
+
const flagged = Object.keys(GRANT_REQUEST_FLAGS).filter((name) => name !== "json" && name !== "why" && flags[name] !== undefined)
|
|
55
|
+
if (json !== undefined) {
|
|
56
|
+
if (flagged.length) throw new UsageError(`give either --json '{…}' or ${flagged.map((f) => `--${f}`).join(", ")}`)
|
|
57
|
+
return (await jsonObjectArg(json, stdin, "grant")) as unknown as GrantRequestSpec
|
|
58
|
+
}
|
|
59
|
+
const holder = str(flags, "holder")
|
|
60
|
+
if (holder === undefined && holderRequired) throw new UsageError("needs --holder (ses_…, @name, @@orchestrator…, or app:<token id>)")
|
|
61
|
+
const projects = listOf(flags, "project")
|
|
62
|
+
if (!projects.length) throw new UsageError("needs --project <path> (repeatable)")
|
|
63
|
+
const allow = listOf(flags, "allow") as GrantVerb[]
|
|
64
|
+
if (!allow.length) throw new UsageError("needs --allow <verbs> (approve, deny, answer, prompt, stop, roles, launch)")
|
|
65
|
+
const permit = [...listOf(flags, "permit"), ...listOf(flags, "permission-allow")]
|
|
66
|
+
const never = listOf(flags, "never")
|
|
67
|
+
const expires = str(flags, "expires")
|
|
68
|
+
const cap = str(flags, "max-per-hour")
|
|
69
|
+
if (cap !== undefined && !/^\d+$/.test(cap)) throw new UsageError("--max-per-hour is a whole number")
|
|
70
|
+
return {
|
|
71
|
+
...(holder !== undefined ? { holder } : {}),
|
|
72
|
+
scope: { projects },
|
|
73
|
+
allow,
|
|
74
|
+
...(permit.length || never.length ? { permissions: { ...(permit.length ? { allow: permit } : {}), ...(never.length ? { never } : {}) } } : {}),
|
|
75
|
+
...(expires !== undefined ? { expires } : {}),
|
|
76
|
+
...(cap !== undefined ? { maxActionsPerHour: Number(cap) } : {}),
|
|
77
|
+
...(flags["allow-always"] === true ? { allowAlways: true } : {}),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z")
|
|
82
|
+
|
|
83
|
+
/** `g_1 active @@orchestrator approve,deny /p until 2026-…Z` */
|
|
84
|
+
export const grantLine = (g: Grant): string => [g.id, g.state, g.holder, g.allow.join(","), g.scope.projects.join(","), `until ${iso(g.expiresAt)}`].join(" ")
|
|
85
|
+
|
|
86
|
+
export const grantsText = (grants: readonly Grant[]): string => (grants.length ? grants.map(grantLine).join("\n") : "no grants")
|
|
87
|
+
|
|
88
|
+
/** One grant in full. */
|
|
89
|
+
export function grantText(g: Grant): string {
|
|
90
|
+
const list = (values: readonly string[]) => (values.length ? values.join(", ") : "none")
|
|
91
|
+
return [
|
|
92
|
+
`${g.id} ${g.state}`,
|
|
93
|
+
`holder: ${g.holder}${g.holderSessionID && g.holderSessionID !== g.holder ? ` (${g.holderSessionID})` : ""}`,
|
|
94
|
+
`projects: ${list(g.scope.projects)}`,
|
|
95
|
+
`verbs: ${list(g.allow)}`,
|
|
96
|
+
`permissions allow: ${list(g.permissions.allow)}`,
|
|
97
|
+
`permissions never: ${list(g.permissions.never)}`,
|
|
98
|
+
`always: ${g.allowAlways ? "yes" : "no"}`,
|
|
99
|
+
`max actions per hour: ${g.maxActionsPerHour}`,
|
|
100
|
+
g.revokedAt !== undefined ? `revoked: ${iso(g.revokedAt)}` : `expires: ${iso(g.expiresAt)}`,
|
|
101
|
+
].join("\n")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `requested; waiting for the user (Decision dec_…)` */
|
|
105
|
+
export const requestedText = (body: GrantRequested): string => `requested; waiting for the user (Decision ${body.decision.id})`
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The CLI's side of talking to the hub: a HubClient (packages/sdk) for the resolved credentials, and the SDK's
|
|
2
|
+
// errors turned into CliErrors with the exit code of each error code (design §8, plus usage and unauthorized) and
|
|
3
|
+
// a hint for credential errors.
|
|
4
|
+
import { HubClient, HubError, HubTransportError, type Envelope } from "../../sdk/src/index.ts"
|
|
5
|
+
import type { Credentials } from "./credentials"
|
|
6
|
+
|
|
7
|
+
export type { Envelope } from "../../sdk/src/index.ts"
|
|
8
|
+
|
|
9
|
+
export const EXIT = {
|
|
10
|
+
ok: 0,
|
|
11
|
+
other: 1,
|
|
12
|
+
usage: 2,
|
|
13
|
+
notFound: 3,
|
|
14
|
+
needsUser: 4,
|
|
15
|
+
forbidden: 5,
|
|
16
|
+
rateLimited: 6,
|
|
17
|
+
unavailable: 7,
|
|
18
|
+
gone: 8,
|
|
19
|
+
unauthorized: 9,
|
|
20
|
+
} as const
|
|
21
|
+
|
|
22
|
+
const EXIT_OF: Record<string, number> = {
|
|
23
|
+
invalid: EXIT.usage,
|
|
24
|
+
not_found: EXIT.notFound,
|
|
25
|
+
ambiguous: EXIT.notFound,
|
|
26
|
+
name_taken: EXIT.notFound,
|
|
27
|
+
needs_user: EXIT.needsUser,
|
|
28
|
+
forbidden: EXIT.forbidden,
|
|
29
|
+
paused: EXIT.forbidden,
|
|
30
|
+
rate_limited: EXIT.rateLimited,
|
|
31
|
+
unsupported: EXIT.unavailable,
|
|
32
|
+
offline: EXIT.unavailable,
|
|
33
|
+
gone: EXIT.gone,
|
|
34
|
+
unauthorized: EXIT.unauthorized,
|
|
35
|
+
token_expired: EXIT.unauthorized,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A failure to report: message (and hint) on stderr, the envelope on stdout with --json, then exit. */
|
|
39
|
+
export class CliError extends Error {
|
|
40
|
+
constructor(
|
|
41
|
+
readonly exit: number,
|
|
42
|
+
message: string,
|
|
43
|
+
readonly hint?: string,
|
|
44
|
+
readonly envelope?: Envelope,
|
|
45
|
+
) {
|
|
46
|
+
super(message)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const exitOf = (code: string): number => EXIT_OF[code] ?? EXIT.other
|
|
51
|
+
|
|
52
|
+
/** The hub's hint, plus what to do about a credential error from here. */
|
|
53
|
+
function hintFor(envelope: Envelope, credentials: Credentials): string | undefined {
|
|
54
|
+
const own =
|
|
55
|
+
credentials.source === "http"
|
|
56
|
+
? undefined // a POST /mcp caller has no CLI login or shell to fix
|
|
57
|
+
: envelope.code === "token_expired"
|
|
58
|
+
? credentials.source === "env"
|
|
59
|
+
? "session tokens are renewed for new shells: start a new shell"
|
|
60
|
+
: "run `hub login` with a new app token"
|
|
61
|
+
: envelope.code === "unauthorized"
|
|
62
|
+
? credentials.source === "env"
|
|
63
|
+
? "HUB_SESSION_TOKEN is not valid here (is the session still live?)"
|
|
64
|
+
: "run `hub login` again"
|
|
65
|
+
: undefined
|
|
66
|
+
return [envelope.hint, own].filter(Boolean).join("; ") || undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** An SDK error as the CLI reports it; anything else is returned unchanged. */
|
|
70
|
+
export function cliError(error: unknown, credentials: Credentials): unknown {
|
|
71
|
+
if (error instanceof HubError) return new CliError(exitOf(error.code), error.message, hintFor(error.envelope, credentials), error.envelope)
|
|
72
|
+
if (error instanceof HubTransportError) return new CliError(EXIT.other, error.message, error.kind === "unreachable" ? "Check HUB_URL or the URL given to `hub login`" : undefined)
|
|
73
|
+
return error
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const clientFor = (credentials: Credentials): HubClient =>
|
|
77
|
+
new HubClient({ url: credentials.url, token: credentials.token, ...(credentials.fetch ? { fetch: credentials.fetch } : {}) })
|
|
78
|
+
|
|
79
|
+
/** Runs `use` with a client for `credentials`; SDK errors become CliErrors. */
|
|
80
|
+
export async function withHub<T>(credentials: Credentials, use: (hub: HubClient) => Promise<T>): Promise<T> {
|
|
81
|
+
try {
|
|
82
|
+
return await use(clientFor(credentials))
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw cliError(error, credentials)
|
|
85
|
+
}
|
|
86
|
+
}
|