UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

403 lines • 16.3 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { API_BASE } from "../../../../core/github/constants.js"; import { parseRepo, repoHost } from "../../../../core/github/repo.js"; import { Engine, localZone } from "../../../../core/jq/index.js"; import { UsageError } from "../../../errors.js"; import { StreamRefusal } from "../../../../rs/errors.js"; import { IOResult, materialize } from "../../../../io/types.js"; import { PathSpec } from "../../../../types.js"; import { fsStrerror, isEnoent, isEnotdir } from "../../../../errors/fs.js"; import { compareCodePoints } from "../../../../utils/sort.js"; import { CONNECT_HINT, GITHUB_HOST, GOJQ_RAISED, INVALID_LIMIT } from "./constants.js"; const ENC = new TextEncoder(); /** * Refuse a repository on a host this install cannot reach. Real gh sends the * request to whatever host a repository argument names; this one answers * github.com, whose subdomains go-gh reads as github.com, and the host its * `baseUrl` names. Any other host is one it cannot connect to, and it says so * in gh's words (pinned against gh 2.85.0) rather than asking its own host for * a repository of the same name. */ export function checkHost(accessor, host) { if (host === null) return; const name = host.toLowerCase(); if (name === GITHUB_HOST || name.endsWith(`.${GITHUB_HOST}`)) return; const base = accessor.config.baseUrl; if (base !== undefined && URL.canParse(base) && new URL(base).hostname === name) return; throw new Error(`error connecting to ${name}\n${CONNECT_HINT}`); } /** * The origin the install's GitHub serves its pages and git from: github.com * for GitHub's own API, else the API host's own origin, as a GitHub Enterprise * server and a local stand-in serve them. Throws `Invalid URL` unless the API * base is an http(s) URL with a host. */ export function webOrigin(accessor) { const base = accessor.config.baseUrl; if (base === undefined || base.replace(/\/+$/, '') === API_BASE) { return `https://${GITHUB_HOST}`; } let url; try { url = new URL(base); } catch (err) { throw new TypeError('Invalid URL', { cause: err }); } if (!['http:', 'https:'].includes(url.protocol)) throw new TypeError('Invalid URL'); return url.origin; } /** * The repository a line is about: the operand if it named one, the * install's own otherwise. gh resolves this from the current git remote, * which a workspace has no equivalent of, so the config carries it. */ export function ghRepo(accessor, spec) { const named = spec ?? accessor.config.repo; if (named === undefined || named === '') { throw new Error('no repository given; pass one or set `repo` on the install'); } const ref = parseRepo(named); checkHost(accessor, repoHost(named)); return ref; } // gh's exporter writes with Go's encoding/json, which escapes U+2028 and // U+2029 where JSON.stringify writes them raw. Every other character comes // out the same (Go 1.22 and later spell \b and \f short, as JSON.stringify // does), `<`, `>` and `&` raw too, since gh turns HTML escaping off. const SEPARATORS = /[\u{2028}\u{2029}]/gu; /** One value as gh's exporter prints it: Go's compact JSON. */ function goJson(value) { return JSON.stringify(value).replace(SEPARATORS, (separator) => `\\u${separator.charCodeAt(0).toString(16)}`); } /** * `--json` output as gh writes it where stdout is not a terminal, which in a * workspace it never is: one compact line. */ export function jsonOut(value) { const text = value === null ? '' : `${goJson(value)}\n`; const out = ENC.encode(text); return [out, new IOResult()]; } export function textOut(text) { const out = ENC.encode(text); return [out, new IOResult()]; } /** Whether a gh boolean flag is on: given bare, or as `=true`. */ export function ghBool(fl, name) { return fl.asBool(name) || fl.asStr(name) === 'true'; } /** * A list verb's `--limit`, refused below 1 the way gh refuses it: gh answers a * limit under 1 with a flag error before any request, and `release list` * takes any value. An explicit zero is never the fallback. Mirrors Python's * `list_limit`. */ export function listLimit(fl, fallback, refusal = INVALID_LIMIT) { const value = fl.asInt('limit', fallback); if (refusal !== null && value < 1) throw new UsageError(`${refusal}: ${String(value)}`, 1); return value; } export function repoFor(accessor, fl) { return ghRepo(accessor, fl.asStr('repo') ?? undefined); } export function repoNumber(accessor, fl, value, label, urlKind) { const raw = value ?? ''; if (/^\d+$/.test(raw)) return [repoFor(accessor, fl), Number(raw)]; const match = /^https?:\/\/[^/]+\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)\/?$/.exec(raw); if (match?.[3] !== urlKind) throw new Error(`a ${label} number is required`); checkHost(accessor, new URL(raw).hostname); return [parseRepo(`${match[1] ?? ''}/${match[2] ?? ''}`), Number(match[4])]; } export function csvValues(values) { return values.flatMap((value) => value .split(',') .map((item) => item.trim()) .filter(Boolean)); } export async function readCliFile(ctx, raw, option) { if (!(raw instanceof PathSpec) && typeof raw !== 'string') { throw new Error(`${option} expects a file`); } const path = raw instanceof PathSpec ? raw.rawPath : raw; if (path === '-') { if (ctx.stdin === null) throw new Error(`${option} needs standard input`); return materialize(ctx.stdin); } const vfs = ctx.vfs; if (vfs === undefined) throw new Error(`${option} needs a workspace to read files from`); const spec = PathSpec.fromStrPath(raw, undefined, ctx.cwd); try { return await vfs.read(spec); } catch (err) { const strerror = isEnoent(err) || isEnotdir(err) ? fsStrerror(err) : null; if (strerror !== null) throw new Error(`read ${path}: ${strerror}`); throw err; } } export async function bodyValue(ctx, fl, opts = {}) { const value = opts.value ?? 'body'; const file = opts.file ?? 'body_file'; const inline = fl.asStr(value); const source = fl.raw(file); const valueFlag = `--${value.replaceAll('_', '-')}`; const fileFlag = `--${file.replaceAll('_', '-')}`; if (inline !== undefined && source !== undefined) { throw new UsageError(`${valueFlag} and ${fileFlag} are mutually exclusive`); } if (inline !== undefined) return inline; if (source !== undefined) return new TextDecoder().decode(await readCliFile(ctx, source, fileFlag)); if (opts.required === true) throw new Error(`${valueFlag} or ${fileFlag} is required`); return undefined; } function camelKey(key) { const [head = '', ...tail] = key.split('_'); return head + tail.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)).join(''); } export function camel(value) { if (Array.isArray(value)) return value.map(camel); if (value === null || typeof value !== 'object') return value; const result = {}; for (const [key, item] of Object.entries(value)) result[camelKey(key)] = camel(item); if ('htmlUrl' in result) { result.url = result.htmlUrl; delete result.htmlUrl; } if ('user' in result) { result.author = result.user; delete result.user; } return result; } export function textValue(value) { if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); return ''; } // Go's json.Marshal escapes <, > and & for HTML and U+2028 and U+2029 for // JavaScript on top of the escapes JSON.stringify shares with it; gojq's own // encoder escapes none of them, but does escape DEL. const MARSHAL_ESCAPES = /[<>&\u{2028}\u{2029}]/gu; /** * A number as Go's JSON encoders, gojq's among them, write a float64, which is * how ES6 spells it: the shortest digits that read back the same, in exponent * form below 1e-6 and from 1e21 on, anything past the largest finite float at * that float. */ function goNumber(number) { return String(Math.min(Math.max(number, -Number.MAX_VALUE), Number.MAX_VALUE)); } /** A string as Go's json.Marshal writes it. */ function marshalString(text) { return JSON.stringify(text).replace(MARSHAL_ESCAPES, (escaped) => `\\u${escaped.charCodeAt(0).toString(16).padStart(4, '0')}`); } /** A string as gojq's encoder writes it into an error. */ function gojqString(text) { return JSON.stringify(text).replace(/\x7f/g, '\\u007f'); } /** * A decoded JSON value as Go writes it back out: compact, object keys sorted * as a Go map's are, every number a float64 (see goNumber) and every string as * `quote` writes it. */ function goEncoded(value, quote) { if (Array.isArray(value)) return `[${value.map((item) => goEncoded(item, quote)).join(',')}]`; if (value !== null && typeof value === 'object') { const record = value; const pairs = Object.keys(record) .sort(compareCodePoints) .map((key) => `${quote(key)}:${goEncoded(record[key], quote)}`); return `{${pairs.join(',')}}`; } if (typeof value === 'string') return quote(value); if (typeof value === 'number') return goNumber(value); return JSON.stringify(value); } /** * A number as go-gh prints a float64 on its own line: in fixed notation, with * no decimals when it is whole and two otherwise, rounded half to even as * strconv rounds. An infinity counts as the largest finite float, which is * what jq_eval hands over for one. */ function fixedNumber(number) { const finite = Math.min(Math.max(number, -Number.MAX_VALUE), Number.MAX_VALUE); if (Number.isInteger(finite)) return BigInt(finite).toString(); const text = finite.toFixed(2); // toFixed breaks an exact tie away from zero where strconv breaks it to // the even digit, and a number ties at two places exactly when its eighths // are odd. const eighths = finite * 8; const last = Number(text.slice(-1)); if (Number.isInteger(eighths) && eighths % 2 !== 0 && last % 2 === 1) { return `${text.slice(0, -1)}${String(last - 1)}`; } return text; } /** * One `--jq` output as go-gh prints it: a string raw, null as an empty line, a * boolean as its word, a number in fixed notation (see fixedNumber), and * anything else as Go's json.Marshal writes it. */ function jqLine(value) { if (value === null || value === undefined) return ''; if (typeof value === 'string') return value; if (typeof value === 'boolean') return String(value); if (typeof value === 'number') return fixedNumber(value); return goEncoded(value, marshalString); } /** * Each row cut to the fields asked for, keys in sorted order: gh exports a * Go map, which its JSON encoder always writes sorted. */ function select(value, fields) { const rows = Array.isArray(value) ? value : [value]; const keys = [...new Set(fields)].sort(compareCodePoints); const selected = rows.map((row) => { const source = row !== null && typeof row === 'object' ? row : {}; return Object.fromEntries(keys.map((field) => [field, source[field] ?? null])); }); return Array.isArray(value) ? selected : selected[0]; } /** * The `--json` fields a line asked for, null without `--json`. * * Checked before any request, as gh checks them: a field gh does not export * is refused with gh's own message and every field it does, sorted, exit 1. */ export function jsonFields(fl, allowed) { const spelled = fl.asStr('json'); if (spelled === undefined) return null; const fields = csvValues([spelled]); const listing = [...allowed].sort(compareCodePoints).map((field) => ` ${field}`); if (fields.length === 0) { throw new UsageError(['Specify one or more comma-separated fields for `--json`:', ...listing].join('\n'), 1); } const known = new Set(allowed); const unknown = fields.find((field) => !known.has(field)); if (unknown !== undefined) { throw new UsageError([`Unknown JSON field: ${JSON.stringify(unknown)}`, 'Available fields:', ...listing].join('\n'), 1); } return fields; } /** * A value jq printed, the way gojq's errors print it: a string as it is, * anything else in gojq's own JSON. */ function gojqText(text, string) { return string ? text : goEncoded(JSON.parse(text), gojqString); } /** * The message go-gh fails with when a run stopped early, or null when that * stop ends the output without failing. * * gojq reports an error the program raised with `error` as `error: <value>`, * and a builtin's in gojq's own words, which mirage's jq does not share, so * jq 1.8.2's stand; the builtins gojq writes in jq raise through `error` too * (GOJQ_RAISED). A `halt_error` whose value is not null fails as * `halt error: <value>`. */ function jqFailure(run) { const stop = run.stop; if (stop === null) return null; if (stop.kind === 'halt') { return stop.message === null ? null : `halt error: ${gojqText(stop.message, stop.string)}`; } if (stop.own === true) return `error: ${gojqText(stop.text, stop.string)}`; const raised = GOJQ_RAISED.get(stop.text); return raised === undefined ? stop.text : `error: ${raised}`; } /** * The lines `--jq` prints for each value in turn, the way go-gh's jq * evaluates them. `halt`, and `halt_error` on null, end that value's output * there. An error, or any other `halt_error`, fails the command after the * lines printed before it (see jqFailure): a StreamRefusal carries them. * * The program runs in mirage-rs's jq_eval, a bounded number of steps at a * time, so one that never ends still yields and the signal stops it. * `input` finds no more input, `$ENV` is the command's environment, and what * `debug` and `stderr` write is not printed. Throws JqCompileError for a * program jq refuses. */ export async function jqLines(values, program, ctx) { const env = JSON.stringify(ctx.env ?? {}); const zone = localZone(ctx.env); const engine = await Engine.open(program, env, zone, ctx.signal); const lines = []; try { for (const value of values) { const ending = { stop: null }; const outputs = []; for await (const text of engine.run(JSON.stringify(value), ending)) { outputs.push(JSON.parse(text)); } const run = { outputs, stop: ending.stop }; for (const item of run.outputs) lines.push(`${jqLine(item)}\n`); const failure = jqFailure(run); if (failure !== null) { throw new StreamRefusal(failure, new TextEncoder().encode(lines.join(''))); } } } finally { await engine.close(); } return lines.join(''); } export async function typedOut(value, fl, human, allowed, ctx) { const program = fl.asStr('jq'); const fields = jsonFields(fl, allowed); if (fields === null) { if (program !== undefined && program !== '') throw new UsageError('--jq requires --json'); return textOut(human); } const selected = select(value, fields); if (program !== undefined && program !== '') { return textOut(await jqLines([selected], program, ctx)); } return jsonOut(selected); } //# sourceMappingURL=accessor.js.map