@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
403 lines • 16.3 kB
JavaScript
// ========= 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