@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
417 lines • 17.1 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 { GitHubApiError } from "../../../../core/github/client.js";
import { GRAPHQL_PATH } from "../../../../core/github/constants.js";
import { expand } from "../../../../core/github/placeholder.js";
import { IOResult } from "../../../../io/types.js";
import { StreamRefusal } from "../../../../rs/errors.js";
import { concat } from "../../../../utils/bytes.js";
import { isRecord } from "../../../../utils/records.js";
import { FlagView } from "../../../spec/flag_view.js";
import { ghBool, jqLines, readCliFile } from "./accessor.js";
import { HTTP_REASONS } from "./constants.js";
const EMPTY_ARRAY = Symbol('empty-array');
const ENC = new TextEncoder();
function typed(value) {
if (value === 'true')
return true;
if (value === 'false')
return false;
if (value === 'null')
return null;
if (/^-?\d+$/.test(value))
return Number(value);
return value;
}
function split(pair, emptyArray = false) {
const at = pair.indexOf('=');
if (at >= 0)
return [pair.slice(0, at), pair.slice(at + 1)];
if (emptyArray && pair.endsWith('[]'))
return [pair, EMPTY_ARRAY];
throw new Error(`expected "key=value", got "${pair}"`);
}
function keyParts(key) {
const first = key.indexOf('[');
if (first === 0 || (first < 0 && key.includes(']'))) {
throw new Error(`invalid field key: "${key}"`);
}
if (first < 0)
return [key];
const parts = [key.slice(0, first)];
let rest = key.slice(first);
while (rest !== '') {
const close = rest.indexOf(']');
if (!rest.startsWith('[') || close < 0 || rest.slice(1, close).includes('[')) {
throw new Error(`invalid field key: "${key}"`);
}
const item = rest.slice(1, close);
parts.push(item === '' ? null : item);
rest = rest.slice(close + 1);
}
return parts;
}
function put(container, parts, value) {
const token = parts[0];
const tail = parts.slice(1);
if (typeof token === 'string') {
if (container === null || typeof container !== 'object' || Array.isArray(container)) {
throw new Error('field nesting mixes an object and an array');
}
const object = container;
if (tail.length === 0) {
object[token] = value;
return;
}
const wantArray = tail[0] === null;
let child = object[token];
if ((wantArray && !Array.isArray(child)) || (!wantArray && !isRecord(child))) {
child = wantArray ? [] : {};
object[token] = child;
}
put(child, tail, value);
return;
}
if (!Array.isArray(container))
throw new Error('field nesting mixes an object and an array');
if (tail.length === 0) {
if (value !== EMPTY_ARRAY)
container.push(value);
return;
}
const wantArray = tail[0] === null;
let child = container.at(-1);
let reuse = wantArray ? Array.isArray(child) : isRecord(child);
if (reuse && isRecord(child) && typeof tail[0] === 'string') {
const next = tail[0];
reuse = !(next in child) || (tail.length > 1 && tail[1] === null);
}
if (!reuse) {
child = wantArray ? [] : {};
container.push(child);
}
put(child, tail, value);
}
function setField(fields, key, value) {
put(fields, keyParts(key), value);
}
async function fieldValue(accessor, ctx, value) {
const expanded = expand(value, accessor.config);
if (expanded.startsWith('@')) {
return new TextDecoder().decode(await readCliFile(ctx, expanded.slice(1), '--field'));
}
return typed(expanded);
}
async function fields(accessor, ctx, fl) {
const result = {};
for (const pair of fl.asList('raw_field')) {
const [key, value] = split(pair);
setField(result, key, value);
}
for (const pair of fl.asList('field')) {
const [key, value] = split(pair, true);
setField(result, key, value === EMPTY_ARRAY ? value : await fieldValue(accessor, ctx, value));
}
return result;
}
function requestHeaders(fl) {
const result = {};
for (const header of fl.asList('header')) {
const at = header.indexOf(':');
if (at < 1)
throw new Error(`expected "key:value", got "${header}"`);
result[header.slice(0, at).trim()] = header.slice(at + 1).trim();
}
return result;
}
async function inputBody(ctx, fl) {
const raw = fl.raw('input');
if (raw === undefined)
return undefined;
const path = fl.asPaths('input')[0]?.rawPath ?? fl.asStr('input') ?? '';
try {
return JSON.parse(new TextDecoder().decode(await readCliFile(ctx, raw, '--input')));
}
catch (err) {
if (err instanceof SyntaxError)
throw new Error(`invalid JSON in ${path}: ${err.message}`);
throw err;
}
}
function queryValue(value) {
if (value !== null && typeof value === 'object')
return JSON.stringify(value);
return String(value);
}
function nextPath(link, baseUrl) {
if (link === undefined || link === '')
return undefined;
for (const item of link.split(',')) {
const match = /^\s*<([^>]+)>\s*;\s*rel="([^"]+)"/.exec(item);
if (match === null || !(match[2] ?? '').split(/\s+/).includes('next'))
continue;
const target = match[1] ?? '';
let path;
try {
const url = new URL(target);
path = `${url.pathname}${url.search}`;
}
catch {
path = target.startsWith('/') ? target : `/${target}`;
}
const queryAt = path.indexOf('?');
let pathname = queryAt < 0 ? path : path.slice(0, queryAt);
const search = queryAt < 0 ? '' : path.slice(queryAt);
const basePath = baseUrl === undefined ? '' : new URL(baseUrl).pathname.replace(/\/$/, '');
if (basePath !== '' && (pathname === basePath || pathname.startsWith(`${basePath}/`))) {
pathname = pathname.slice(basePath.length) || '/';
}
return `${pathname}${search}`;
}
return undefined;
}
export async function api(accessor, _paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const endpoint = texts[0] ?? '';
if (endpoint === '')
throw new Error('an API endpoint is required');
const values = await fields(accessor, ctx, fl);
const input = await inputBody(ctx, fl);
const hasInput = fl.raw('input') !== undefined;
// An explicit empty -X keeps gh's own branch, where fields ride the body as
// for any method it does not read as GET, and the request goes out as GET,
// which is what Go's net/http sends for an empty method.
const method = fl.asStr('method', Object.keys(values).length > 0 || hasInput ? 'POST' : 'GET');
const upper = method.toUpperCase();
const wire = upper === '' ? 'GET' : upper;
const expanded = expand(endpoint, accessor.config);
const path = expanded === GRAPHQL_PATH ? expanded : expanded.startsWith('/') ? expanded : `/${expanded}`;
let body;
let params;
if (hasInput) {
body = input;
params = Object.fromEntries(Object.entries(values).map(([key, value]) => [key, queryValue(value)]));
}
else if (upper === 'GET') {
params = Object.fromEntries(Object.entries(values).map(([key, value]) => [key, queryValue(value)]));
}
else if (Object.keys(values).length > 0) {
body = values;
}
if (params !== undefined && Object.keys(params).length === 0)
params = undefined;
const pages = [];
const include = ghBool(fl, 'include');
const transport = accessor.transport;
let current = path;
let first = true;
while (current !== undefined) {
const headers = requestHeaders(fl);
let response;
try {
response =
transport.requestWithResponse === undefined
? {
data: await transport.request(wire, current, body, first ? params : undefined, Object.keys(headers).length === 0 ? undefined : headers),
status: 200,
headers: {},
}
: await transport.requestWithResponse(wire, current, body, first ? params : undefined, Object.keys(headers).length === 0 ? undefined : headers);
}
catch (error) {
if (!(error instanceof GitHubApiError))
throw error;
return failed(pages, fl, ctx, { body: error.body, head: include ? responseHead(error.status, error.headers) : '' }, serverError(error.data, error.status) || `HTTP ${String(error.status)}`);
}
const head = include ? responseHead(response.status, response.headers) : '';
if (endpoint === 'graphql') {
const diagnostic = serverError(response.data, response.status);
if (diagnostic !== '') {
return failed(pages, fl, ctx, { body: JSON.stringify(response.data), head }, diagnostic);
}
}
pages.push({ data: response.data, head });
first = false;
current = ghBool(fl, 'paginate')
? nextPath(response.headers.link, accessor.config.baseUrl)
: undefined;
}
return [await renderPages(pages, fl, ctx), new IOResult()];
}
// gh's name for a header: each word capitalized, the way Go canonicalizes one.
function canonical(name) {
return name
.toLowerCase()
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join('-');
}
/**
* The status line and headers `gh api -i` prints before a body.
*
* gh prints the protocol and status Go's client reports, then every header
* but `Status` in name order, each line ending `\r\n`, then a blank `\r\n`
* line. Mirage's clients speak HTTP/1.1, and the reason is Go's phrase for
* the code. The body arrives decoded, so the headers describing its encoded
* form (`Content-Encoding`, `Content-Length`) are dropped, which is what Go's
* transport does when it inflates a body itself.
*/
function responseHead(status, headers) {
const decoded = headers['content-encoding'] !== undefined;
const lines = Object.entries(headers)
.map(([name, value]) => [canonical(name), value])
.filter(([name]) => name !== 'Status' &&
!(decoded && (name === 'Content-Encoding' || name === 'Content-Length')))
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([name, value]) => `${name}: ${value}\r\n`);
return `HTTP/1.1 ${String(status)} ${HTTP_REASONS[status] ?? ''}\n${lines.join('')}\r\n`;
}
/**
* What gh reports from a JSON error body, empty when it names nothing.
*
* gh's `parseErrorResponse`: a string `errors` is the failure, with
* `message` in parentheses; otherwise `message` is, with the status;
* otherwise the messages of an `errors` array, one per line.
*/
function serverError(data, status) {
if (!isRecord(data))
return '';
const message = typeof data.message === 'string' ? data.message : '';
const errors = data.errors;
if (typeof errors === 'string' && errors !== '') {
return message !== '' ? `${errors} (${message})` : errors;
}
if (message !== '')
return `${message} (HTTP ${String(status)})`;
if (!Array.isArray(errors))
return '';
const lines = [];
for (const entry of errors) {
if (typeof entry === 'string')
lines.push(entry);
else if (isRecord(entry))
lines.push(typeof entry.message === 'string' ? entry.message : '');
}
return lines.join('\n');
}
async function failed(pages, fl, ctx, failure, diagnostic) {
return [
await renderPages(pages, fl, ctx, failure),
new IOResult({ exitCode: 1, stderr: ENC.encode(`gh: ${diagnostic}\n`) }),
];
}
/**
* A page's body as gh copies it out: verbatim, with no newline added. The
* body arrives decoded, and the vendor's JSON is compact, so the compact
* spelling of what arrived is the text it sent. A body that is not JSON is
* its own text, bytes that are not text (a run's log archive) are copied
* as they came, and a call that answered with none prints nothing.
*/
function bodyText(page) {
if (page === null)
return '';
if (page instanceof Uint8Array)
return page;
return typeof page === 'string' ? page : JSON.stringify(page);
}
function bytesOf(parts) {
return concat(parts.map((part) => (typeof part === 'string' ? ENC.encode(part) : part)));
}
// `parts` with `between` inserted between each two.
function joined(parts, between) {
return parts.flatMap((part, index) => (index === 0 ? [...part] : [between, ...part]));
}
/**
* The bodies of `--paginate` as gh's paginatedArrayReader streams them.
*
* A JSON array body after the first opens with a comma instead of its
* bracket (an empty one with a space), and one that more pages follow drops
* its closing bracket, so array pages print as one array. Object bodies, and
* bodies that are not JSON, run on as they came. `more` says a failing body
* follows the last page here.
*/
function joinedPages(pages, more) {
return pages.map((page, index) => {
let text = bodyText(page);
if (page === null || typeof page === 'string' || typeof text !== 'string')
return text;
if (index > 0 && text.startsWith('[')) {
text = `${text.startsWith('[]') ? ' ' : ','}${text.slice(1)}`;
}
if ((more || index < pages.length - 1) && text.endsWith(']'))
text = text.slice(0, -1);
return text;
});
}
/**
* Render the completed pages, then a failing response's body.
*
* gh copies the failing body out verbatim, past `--jq`. Under `--slurp`
* that body is still the array's last element, even an empty one or one
* that is not JSON, which is gh's own output.
*
* Under `-i` every response's head goes before its body, a newline goes
* between two responses, and pages print as they came rather than joined
* into one array. `--silent` drops the bodies and keeps the heads, and under
* `--slurp` the array's `[` or `,` goes before a page's head, since gh's
* writer opens each page before the head is printed. `--jq` runs in `ctx`.
*/
async function renderPages(pages, fl, ctx, failure) {
const include = ghBool(fl, 'include');
const between = include ? '\n' : '';
const all = failure === undefined ? pages : [...pages, { data: null, head: failure.head }];
if (ghBool(fl, 'silent'))
return bytesOf(joined(all.map((page) => [page.head]), between));
const slurp = ghBool(fl, 'slurp');
const printed = pages.map((page) => [page.head, bodyText(page.data)]);
const failed = failure === undefined ? [] : [[failure.head, failure.body]];
// gh's jsonArrayWriter: every body in one array, a comma between each.
if (slurp && failure !== undefined) {
return bytesOf(['[', ...joined([...printed, ...failed], `${between},`), ']']);
}
const program = fl.asStr('jq');
if (program !== undefined && program !== '') {
if (!include || slurp) {
const heads = joined(pages.map((page) => [page.head]), between);
const data = pages.map((page) => page.data);
return bytesOf([
...heads,
await jqLines(slurp ? [data] : data, program, ctx),
failure?.body ?? '',
]);
}
const parts = [];
for (const page of pages) {
try {
parts.push([page.head, await jqLines([page.data], program, ctx)]);
}
catch (err) {
if (!(err instanceof StreamRefusal))
throw err;
const done = bytesOf([...joined([...parts, [page.head]], between), err.output]);
throw new StreamRefusal(err.message, done);
}
}
return bytesOf(joined([...parts, ...failed], between));
}
if (slurp)
return bytesOf(['[', ...joined(printed, `${between},`), ']']);
if (include)
return bytesOf(joined([...printed, ...failed], between));
return bytesOf([
...joinedPages(pages.map((page) => page.data), failure !== undefined),
failure?.body ?? '',
]);
}
//# sourceMappingURL=api.js.map