|
| 1 | +// @ts-check |
| 2 | + |
| 3 | +// Programmatic embedding surface for host applications (e.g. a signed |
| 4 | +// Electron app that bundles hypaware and drives it in-process instead of |
| 5 | +// spawning the `hyp` binary). Every helper here boots the kernel through |
| 6 | +// the same `dispatch()` the CLI uses, captures stdout/stderr into buffers, |
| 7 | +// and returns a structured result — so callers get a typed object and real |
| 8 | +// thrown errors instead of parsing JSON out of a child process's stdout. |
| 9 | +// |
| 10 | +// The long-running daemon is intentionally NOT exposed here: it must run as |
| 11 | +// its own process (the launchd/systemd unit), not inside the host. |
| 12 | + |
| 13 | +import { dispatch } from './dispatch.js' |
| 14 | + |
| 15 | +/** |
| 16 | + * @import { IntegrationOptions, CommandResult, ClientResult } from './integration.d.ts' |
| 17 | + */ |
| 18 | + |
| 19 | +/** |
| 20 | + * Raised when an embedded command exits non-zero or reports a non-`ok` |
| 21 | + * status. Carries the exit code, captured streams, and any parsed JSON so |
| 22 | + * callers can branch on the details rather than scrape a message. |
| 23 | + */ |
| 24 | +export class HypAwareCommandError extends Error { |
| 25 | + /** |
| 26 | + * @param {string} message |
| 27 | + * @param {{ code: number, stdout: string, stderr: string, json: unknown }} detail |
| 28 | + */ |
| 29 | + constructor(message, detail) { |
| 30 | + super(message) |
| 31 | + this.name = 'HypAwareCommandError' |
| 32 | + this.code = detail.code |
| 33 | + this.stdout = detail.stdout |
| 34 | + this.stderr = detail.stderr |
| 35 | + this.json = detail.json |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +/** A string sink that accumulates everything written to it. */ |
| 40 | +function makeBuffer() { |
| 41 | + let value = '' |
| 42 | + return { |
| 43 | + /** @param {unknown} chunk */ |
| 44 | + write(chunk) { |
| 45 | + value += String(chunk) |
| 46 | + return true |
| 47 | + }, |
| 48 | + text() { |
| 49 | + return value |
| 50 | + }, |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +/** |
| 55 | + * Parse the last non-empty line of `text` as JSON. Commands invoked with |
| 56 | + * `--json` emit a single JSON object per affected target on its own line; |
| 57 | + * the final line is the one callers care about for a single target. |
| 58 | + * |
| 59 | + * @param {string} text |
| 60 | + * @returns {unknown} |
| 61 | + */ |
| 62 | +function parseLastJsonLine(text) { |
| 63 | + const lines = text.split('\n') |
| 64 | + for (let i = lines.length - 1; i >= 0; i--) { |
| 65 | + const line = lines[i].trim() |
| 66 | + if (!line) continue |
| 67 | + try { |
| 68 | + return JSON.parse(line) |
| 69 | + } catch { |
| 70 | + return null |
| 71 | + } |
| 72 | + } |
| 73 | + return null |
| 74 | +} |
| 75 | + |
| 76 | +/** |
| 77 | + * Build the dispatch env, overlaying `HYP_HOME` when supplied so the call |
| 78 | + * targets a specific state dir regardless of the ambient environment. |
| 79 | + * |
| 80 | + * @param {IntegrationOptions} opts |
| 81 | + * @returns {NodeJS.ProcessEnv} |
| 82 | + */ |
| 83 | +function resolveEnv(opts) { |
| 84 | + const base = opts.env ?? process.env |
| 85 | + return opts.hypHome ? { ...base, HYP_HOME: opts.hypHome } : base |
| 86 | +} |
| 87 | + |
| 88 | +/** |
| 89 | + * Run a hypaware command in-process and return its structured result. This |
| 90 | + * is the low-level escape hatch; prefer {@link attach}/{@link detach}/ |
| 91 | + * {@link join} for the common flows. |
| 92 | + * |
| 93 | + * @param {string[]} argv |
| 94 | + * @param {IntegrationOptions} [opts] |
| 95 | + * @returns {Promise<CommandResult>} |
| 96 | + */ |
| 97 | +export async function run(argv, opts = {}) { |
| 98 | + const stdout = makeBuffer() |
| 99 | + const stderr = makeBuffer() |
| 100 | + const code = await dispatch(argv, { |
| 101 | + stdout, |
| 102 | + stderr, |
| 103 | + env: resolveEnv(opts), |
| 104 | + cwd: opts.cwd, |
| 105 | + // Test/advanced flows may inject a pre-built registry/kernel (the same |
| 106 | + // escape hatch `dispatch` itself documents); forward when present. |
| 107 | + registry: /** @type {any} */ (opts).registry, |
| 108 | + kernel: /** @type {any} */ (opts).kernel, |
| 109 | + }) |
| 110 | + const outText = stdout.text() |
| 111 | + return { |
| 112 | + code, |
| 113 | + json: parseLastJsonLine(outText), |
| 114 | + stdout: outText, |
| 115 | + stderr: stderr.text(), |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +/** |
| 120 | + * Run `attach`/`detach` for a client with `--json`, returning the parsed |
| 121 | + * result. Throws {@link HypAwareCommandError} on a non-zero exit or a |
| 122 | + * non-`ok` status. |
| 123 | + * |
| 124 | + * @param {'attach'|'detach'} verb |
| 125 | + * @param {string} client |
| 126 | + * @param {IntegrationOptions} opts |
| 127 | + * @returns {Promise<ClientResult>} |
| 128 | + */ |
| 129 | +async function runClient(verb, client, opts) { |
| 130 | + const argv = [verb, client, '--json'] |
| 131 | + if (opts.dryRun) argv.push('--dry-run') |
| 132 | + const result = await run(argv, opts) |
| 133 | + const json = /** @type {ClientResult | null} */ (result.json) |
| 134 | + if (result.code !== 0 || !json || json.status !== 'ok') { |
| 135 | + const reason = |
| 136 | + (json && typeof json === 'object' && 'error' in json && typeof json.error === 'string' |
| 137 | + ? json.error |
| 138 | + : '') || |
| 139 | + result.stderr.trim() || |
| 140 | + `hyp ${verb} ${client} exited with code ${result.code}` |
| 141 | + throw new HypAwareCommandError(`hyp ${verb} ${client}: ${reason}`, { |
| 142 | + code: result.code, |
| 143 | + stdout: result.stdout, |
| 144 | + stderr: result.stderr, |
| 145 | + json: result.json, |
| 146 | + }) |
| 147 | + } |
| 148 | + return json |
| 149 | +} |
| 150 | + |
| 151 | +/** |
| 152 | + * Attach a client (e.g. `'claude'` or `'codex'`) to the local gateway by |
| 153 | + * editing its settings file. Resolves the gateway port from the effective |
| 154 | + * config / live daemon; no port argument is needed. |
| 155 | + * |
| 156 | + * @param {string} [client] |
| 157 | + * @param {IntegrationOptions} [opts] |
| 158 | + * @returns {Promise<ClientResult>} |
| 159 | + */ |
| 160 | +export function attach(client = 'claude', opts = {}) { |
| 161 | + return runClient('attach', client, opts) |
| 162 | +} |
| 163 | + |
| 164 | +/** |
| 165 | + * Detach a previously attached client, restoring its prior settings. |
| 166 | + * |
| 167 | + * @param {string} [client] |
| 168 | + * @param {IntegrationOptions} [opts] |
| 169 | + * @returns {Promise<ClientResult>} |
| 170 | + */ |
| 171 | +export function detach(client = 'claude', opts = {}) { |
| 172 | + return runClient('detach', client, opts) |
| 173 | +} |
| 174 | + |
| 175 | +/** |
| 176 | + * Join a central hypaware server, writing the join seed under |
| 177 | + * `<HYP_HOME>/hypaware/config-control/`. With `noDaemon` (the default for |
| 178 | + * embedded hosts that own their own daemon) it performs no network call and |
| 179 | + * installs no launchd/systemd unit — the host's already-running daemon |
| 180 | + * picks up the seed and pulls its configuration. |
| 181 | + * |
| 182 | + * @param {string} url |
| 183 | + * @param {string} token |
| 184 | + * @param {IntegrationOptions & { noDaemon?: boolean }} [opts] |
| 185 | + * @returns {Promise<CommandResult>} |
| 186 | + */ |
| 187 | +export async function join(url, token, opts = {}) { |
| 188 | + const argv = ['join', url] |
| 189 | + if (token) argv.push(token) |
| 190 | + if (opts.noDaemon ?? true) argv.push('--no-daemon') |
| 191 | + const result = await run(argv, opts) |
| 192 | + if (result.code !== 0) { |
| 193 | + const reason = result.stderr.trim() || `hyp join exited with code ${result.code}` |
| 194 | + throw new HypAwareCommandError(`hyp join: ${reason}`, { |
| 195 | + code: result.code, |
| 196 | + stdout: result.stdout, |
| 197 | + stderr: result.stderr, |
| 198 | + json: result.json, |
| 199 | + }) |
| 200 | + } |
| 201 | + return result |
| 202 | +} |
0 commit comments