UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

518 lines • 22.4 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 { NOOPAccessor } from "../../../../accessor/base.js"; import { FlagView } from "../../../spec/flag_view.js"; import { createRepo, deleteRepo, editRepo, forkRepo, listRepos, listRepositoryFields, login, parseRepo, readReadme, renameRepo, repoHost, repoTopics, repositoryFields, setRepoTopics, viewRepo, } from "../../../../core/github/repo.js"; import { UsageError } from "../../../errors.js"; import { IOResult } from "../../../../io/types.js"; import { camel, checkHost, csvValues, ghBool, ghRepo, jsonFields, listLimit, textOut, textValue, typedOut, webOrigin, } from "./accessor.js"; import { REPO_EDIT_FIELDS } from "./constants.js"; import { parseCommand, parseToKwargs } from "../../../spec/parser.js"; import { findNode } from "../../walk.js"; import { GIT } from "../git/index.js"; import { clone as gitClone } from "../git/clone.js"; import { splitMarked } from "../git/util.js"; import { flagKwargName } from "../../../spec/constants.js"; import { exported, listOf, pointer, struct } from "./shape.js"; const OWNER = struct(['id', 'string'], ['login', 'string']); const USER = struct(['id', 'string'], ['login', 'string'], ['name', 'string'], ['databaseId', 'int']); const COUNT = struct(['totalCount', 'int']); // gh prints a related repository (a fork's parent, a template) as three facts. const RELATED = pointer(['id', 'string'], ['name', 'string'], ['owner', OWNER]); function plain(name, shape) { return [name, { select: name, shape }]; } /** * Every field `gh repo view --json` and `gh repo list --json` accept in gh * 2.85, each with the selection gh put on the wire for it (captured with * `GH_DEBUG=api`) and the shape of gh's own `Repository` type. */ const REPO_FIELD_TABLE = new Map([ plain('archivedAt', 'raw'), [ 'assignableUsers', { select: 'assignableUsers(first:100){nodes{id,login,name}}', shape: listOf(USER), unwrap: 'nodes', }, ], [ 'codeOfConduct', { select: 'codeOfConduct{key,name,url}', shape: pointer(['key', 'string'], ['name', 'string'], ['url', 'string']), }, ], [ 'contactLinks', { select: 'contactLinks{about,name,url}', shape: listOf(struct(['about', 'string'], ['name', 'string'], ['url', 'string'])), }, ], plain('createdAt', 'time'), ['defaultBranchRef', { select: 'defaultBranchRef{name}', shape: struct(['name', 'string']) }], plain('deleteBranchOnMerge', 'bool'), plain('description', 'string'), plain('diskUsage', 'int'), plain('forkCount', 'int'), [ 'fundingLinks', { select: 'fundingLinks{platform,url}', shape: listOf(struct(['platform', 'string'], ['url', 'string'])), }, ], plain('hasDiscussionsEnabled', 'bool'), plain('hasIssuesEnabled', 'bool'), plain('hasProjectsEnabled', 'bool'), plain('hasWikiEnabled', 'bool'), plain('homepageUrl', 'string'), plain('id', 'string'), plain('isArchived', 'bool'), plain('isBlankIssuesEnabled', 'bool'), plain('isEmpty', 'bool'), plain('isFork', 'bool'), plain('isInOrganization', 'bool'), plain('isMirror', 'bool'), plain('isPrivate', 'bool'), plain('isSecurityPolicyEnabled', 'bool'), plain('isTemplate', 'bool'), plain('isUserConfigurationRepository', 'bool'), [ 'issueTemplates', { select: 'issueTemplates{name,title,body,about}', shape: listOf(struct(['name', 'string'], ['title', 'string'], ['body', 'string'], ['about', 'string'])), }, ], ['issues', { select: 'issues(states:OPEN){totalCount}', shape: COUNT }], [ 'labels', { select: 'labels(first:100){nodes{id,color,name,description}}', shape: listOf(struct(['id', 'string'], ['name', 'string'], ['description', 'string'], ['color', 'string'])), unwrap: 'nodes', }, ], [ 'languages', { select: 'languages(first:100){edges{size,node{name}}}', shape: listOf(struct(['size', 'int'], ['node', struct(['name', 'string'])])), unwrap: 'edges', }, ], [ 'latestRelease', { select: 'latestRelease{publishedAt,tagName,name,url}', shape: pointer(['name', 'string'], ['tagName', 'string'], ['url', 'string'], ['publishedAt', 'time']), }, ], [ 'licenseInfo', { select: 'licenseInfo{key,name,nickname}', shape: pointer(['key', 'string'], ['name', 'string'], ['nickname', 'string']), }, ], [ 'mentionableUsers', { select: 'mentionableUsers(first:100){nodes{id,login,name}}', shape: listOf(USER), unwrap: 'nodes', }, ], plain('mergeCommitAllowed', 'bool'), [ 'milestones', { select: 'milestones(first:100,states:OPEN){nodes{number,title,description,dueOn}}', shape: listOf(struct(['number', 'int'], ['title', 'string'], ['description', 'string'], ['dueOn', 'raw'])), unwrap: 'nodes', }, ], plain('mirrorUrl', 'string'), plain('name', 'string'), plain('nameWithOwner', 'string'), plain('openGraphImageUrl', 'string'), ['owner', { select: 'owner{id,login}', shape: OWNER }], ['parent', { select: 'parent{id,name,owner{id,login}}', shape: RELATED }], ['primaryLanguage', { select: 'primaryLanguage{name}', shape: pointer(['name', 'string']) }], [ 'projects', { select: 'projects(first:100,states:OPEN){nodes{id,name,number,body,resourcePath}}', shape: listOf(struct(['id', 'string'], ['name', 'string'], ['number', 'int'], ['resourcePath', 'string'])), unwrap: 'nodes', }, ], // gh has no flattening for this one, so it prints its Go struct as is: // the untagged `Nodes` field under its own capitalised name. [ 'projectsV2', { select: 'projectsV2(first:100,query:"is:open"){nodes{id,number,title,resourcePath,closed,url}}', shape: struct([ 'Nodes', listOf(struct(['id', 'string'], ['title', 'string'], ['number', 'int'], ['resourcePath', 'string'], ['closed', 'bool'], ['url', 'string'])), 'nodes', ]), }, ], [ 'pullRequestTemplates', { select: 'pullRequestTemplates{body,filename}', shape: listOf(struct(['filename', 'string'], ['body', 'string'])), }, ], ['pullRequests', { select: 'pullRequests(states:OPEN){totalCount}', shape: COUNT }], plain('pushedAt', 'raw'), plain('rebaseMergeAllowed', 'bool'), [ 'repositoryTopics', { select: 'repositoryTopics(first:100){nodes{topic{name}}}', shape: listOf(struct(['name', 'string'])), unwrap: 'topics', }, ], plain('securityPolicyUrl', 'string'), plain('squashMergeAllowed', 'bool'), plain('sshUrl', 'string'), plain('stargazerCount', 'int'), ['templateRepository', { select: 'templateRepository{id,name,owner{id,login}}', shape: RELATED }], plain('updatedAt', 'time'), plain('url', 'string'), plain('usesCustomOpenGraphImage', 'bool'), plain('viewerCanAdminister', 'bool'), plain('viewerDefaultCommitEmail', 'string'), plain('viewerDefaultMergeMethod', 'string'), plain('viewerHasStarred', 'bool'), plain('viewerPermission', 'string'), plain('viewerPossibleCommitEmails', listOf('string')), plain('viewerSubscription', 'string'), plain('visibility', 'string'), ['watchers', { select: 'watchers{totalCount}', shape: COUNT }], ]); export const REPO_FIELDS = [...REPO_FIELD_TABLE.keys()]; /** The GraphQL selection for the fields a line asked for, in its order. */ function repoSelection(fields) { return [...new Set(fields)].map((field) => REPO_FIELD_TABLE.get(field)?.select ?? field).join(','); } /** One repository's answer as gh exports the fields asked for. */ function exportedRepo(node, fields) { const row = {}; for (const field of fields) { const spec = REPO_FIELD_TABLE.get(field); if (spec === undefined) continue; let value = node[field]; const connection = value !== null && typeof value === 'object' ? value : null; if (spec.unwrap === 'nodes') value = connection?.nodes; if (spec.unwrap === 'edges') value = connection?.edges; if (spec.unwrap === 'topics') { const topics = Array.isArray(connection?.nodes) ? connection.nodes.map((item) => item.topic) : []; value = topics.length > 0 ? topics : null; } row[field] = exported(value, spec.shape); } return row; } function repo(value) { const row = camel(value); const result = row !== null && typeof row === 'object' ? row : {}; if ('fullName' in result) { result.nameWithOwner = result.fullName; delete result.fullName; } if ('defaultBranch' in result) { result.defaultBranchRef = { name: result.defaultBranch }; delete result.defaultBranch; } if ('private' in result) { result.isPrivate = result.private; delete result.private; } if ('fork' in result) { result.isFork = result.fork; delete result.fork; } return result; } /** * gh's own text view of a repository: two tab-separated header lines and * then the README verbatim, with the `--` separator omitted entirely when * there is no README. Probed against gh 2.85, whose description line is * present and empty for a repository that has none. */ export function summary(repo, readme) { const fields = (repo ?? {}); const name = typeof fields.full_name === 'string' ? fields.full_name : ''; const description = typeof fields.description === 'string' ? fields.description : ''; const head = `name:\t${name}\ndescription:\t${description}\n`; return readme === null ? head : `${head}--\n${readme}`; } /** * `gh repo view`. With `--json` it asks GraphQL for exactly the fields named, * the way gh does, so every field gh accepts is answered in gh's own shape; * the text view reads the REST object and the README. */ export async function view(accessor, _paths, texts, ctx) { const fl = new FlagView(ctx.flags); const transport = accessor.transport; const ref = ghRepo(accessor, texts[0] ?? fl.asStr('repo')); const fields = jsonFields(fl, REPO_FIELDS); if (fields !== null) { const node = await repositoryFields(transport, ref, repoSelection(fields)); return typedOut(exportedRepo(node, fields), fl, '', REPO_FIELDS, ctx); } const value = await viewRepo(transport, ref); return typedOut(value, fl, summary(value, await readReadme(transport, ref)), REPO_FIELDS, ctx); } /** `gh repo list`, over GraphQL for `--json` as `view` is. */ export async function listCmd(accessor, _paths, texts, ctx) { const fl = new FlagView(ctx.flags); const transport = accessor.transport; const limit = listLimit(fl, 30); const fields = jsonFields(fl, REPO_FIELDS); if (fields !== null) { const nodes = await listRepositoryFields(transport, texts[0], limit, repoSelection(fields)); return typedOut(nodes.map((node) => exportedRepo(node, fields)), fl, '', REPO_FIELDS, ctx); } const rows = (await listRepos(transport, texts[0], limit)).map(repo); const human = rows .map((row) => `${textValue(row.nameWithOwner)}\t${textValue(row.description)}\t${textValue(row.visibility)}\t${textValue(row.updatedAt)}\n`) .join(''); return typedOut(rows, fl, human, REPO_FIELDS, ctx); } export async function createCmd(accessor, _paths, texts, ctx) { const fl = new FlagView(ctx.flags); const spec = texts[0] ?? ''; if (spec === '') throw new Error('a repository name is required in noninteractive mode'); const parts = spec.split('/'); if (parts.length > 2 || parts.some((part) => part === '')) { throw new Error(`invalid repository name: "${spec}"`); } if (ghBool(fl, 'public') && ghBool(fl, 'private')) { throw new Error('--public and --private are mutually exclusive'); } const owner = parts.length === 2 ? parts[0] : undefined; const body = { name: parts.at(-1) ?? '', private: ghBool(fl, 'private'), auto_init: ghBool(fl, 'add_readme'), }; const description = fl.asStr('description'); const homepage = fl.asStr('homepage'); if (description !== undefined) body.description = description; if (homepage !== undefined) body.homepage = homepage; const created = repo(await createRepo(accessor.transport, owner, body)); return textOut(`${textValue(created.url)}\n`); } /** * The Authorization git sends GitHub for the install's token: Basic with the * token as the password, what gh's credential helper hands git. */ function tokenHeader(accessor) { return { Authorization: `Basic ${btoa(`x-access-token:${accessor.config.token}`)}` }; } /** * `gh repo clone`: mirage's `git clone` of the repository. As gh does, the * words after the repository that are not gh's own flags go to git: the first * one, when it has no leading dash, is the directory, put after the URL unless * it is empty, and the rest go before the URL. The token rides only as the * request's Authorization, never on the line, in the config or in the output, * and only when git clones from the install's origin, as gh's credential * helper is scoped to it; a fork's `upstream` remote is not added. */ export async function cloneCmd(accessor, _paths, texts, ctx) { const [names, gitflags] = splitMarked(texts, ctx.argv ?? []); const spec = names[0]; if (spec === undefined) throw new Error('cannot clone: repository argument required'); checkHost(accessor, repoHost(spec)); const ref = !spec.includes('/') && !spec.includes(':') ? { owner: await login(accessor.transport), repo: spec } : parseRepo(spec); const origin = webOrigin(accessor); const url = `${origin}/${ref.owner}/${ref.repo}.git`; const rest = [...names.slice(1), ...gitflags]; const target = rest[0] !== undefined && !rest[0].startsWith('-') ? rest.slice(0, 1) : []; const leaf = findNode(GIT.spec, ['clone'])?.node ?? GIT.spec; const words = [...rest.slice(target.length), url, ...target.filter(Boolean)]; const parsed = parseCommand(leaf, words, ctx.cwd.virtual, 'git clone', ctx.env, true); const git = { argv: ['clone', ...words], cwd: ctx.cwd, flags: { ...parseToKwargs(parsed), C: ctx.cwd }, stdin: ctx.stdin, ...(ctx.env !== undefined ? { env: ctx.env } : {}), ...(ctx.vfs !== undefined ? { vfs: ctx.vfs } : {}), ...(ctx.session !== undefined ? { session: ctx.session } : {}), ...(ctx.processes !== undefined ? { processes: ctx.processes } : {}), spec: leaf, }; const operands = parsed.args.map(([word]) => word); const sameOrigin = operands[0]?.startsWith(`${origin}/`) ?? false; return gitClone(new NOOPAccessor(), [], operands, git, sameOrigin ? tokenHeader(accessor) : {}); } /** * `gh repo fork`. gh clones a fork, or adds a remote for one, into the local * checkout, which a workspace does not have: `--clone` is refused, and so is * `--remote` without a repository, where it would name that checkout. gh * ignores `--remote` beside a repository, and `--clone=false` or * `--remote=false` asks for what this fork does anyway. */ export async function fork(accessor, _paths, texts, ctx) { const fl = new FlagView(ctx.flags); const org = fl.asStr('org'); if (org === '') throw new Error('--org cannot be blank'); if (fl.asStr('remote_name') === '') throw new Error('--remote-name cannot be blank'); if (ghBool(fl, 'clone')) { throw new Error('--clone is not supported: there is no local checkout to clone into'); } if (ghBool(fl, 'remote') && texts[0] === undefined) { throw new Error('--remote is not supported: there is no local checkout to add a remote to'); } const transport = accessor.transport; let source; try { source = ghRepo(accessor, texts[0]); } catch (err) { if (texts[0] === undefined || !(err instanceof Error)) throw err; throw new Error(`did not understand argument: ${err.message}`); } const name = fl.asNonEmpty('fork_name'); const body = {}; if (name !== undefined) body.name = name; if (org !== undefined) body.organization = org; if (ghBool(fl, 'default_branch_only')) body.default_branch_only = true; const forked = (await forkRepo(transport, source, body)); const full = forked.full_name ?? `${await login(transport)}/${name ?? source.repo}`; return textOut(`✓ Created fork ${full}\n`); } export async function rename(accessor, _paths, texts, ctx) { const fl = new FlagView(ctx.flags); const transport = accessor.transport; // gh takes the *new name* as the operand and the repository to rename as // -R, which is the reverse of what the shape of the line suggests. const target = ghRepo(accessor, fl.asStr('repo') ?? undefined); const name = texts[0] ?? ''; if (name === '') throw new Error('a new repository name is required'); const renamed = (await renameRepo(transport, target, name)); return textOut(`✓ Renamed repository ${renamed.full_name ?? name}\n`); } /** * `gh repo edit`: the settings named on the line in one `PATCH`, and topics * read and replaced whole when `--add-topic` or `--remove-topic` changes * them. With nothing to edit gh would prompt, so it refuses instead, and a * visibility change needs `--accept-visibility-change-consequences`. Like gh * writing to anything but a terminal, success prints nothing. */ export async function editCmd(accessor, _paths, texts, ctx) { const fl = new FlagView(ctx.flags, ctx.spec); const ref = ghRepo(accessor, texts[0]); const body = {}; const security = {}; for (const field of REPO_EDIT_FIELDS) { const dest = flagKwargName(field.flag); if (fl.raw(dest) === undefined) continue; if (field.kind === 'value') { body[field.field] = fl.asStr(dest); } else { const enabled = ghBool(fl, dest); if (field.kind === 'security') { security[field.field] = { status: enabled ? 'enabled' : 'disabled' }; } else { body[field.field] = enabled; } } } const adds = csvValues(fl.asList('add_topic')); const removes = csvValues(fl.asList('remove_topic')); const accepted = ghBool(fl, 'accept_visibility_change_consequences'); const named = Object.keys(body).length + Object.keys(security).length + adds.length + removes.length > 0; if (!named && !accepted) { throw new UsageError('specify properties to edit when not running interactively', 1); } if (body.visibility !== undefined && !accepted) { throw new UsageError('use of --visibility flag requires --accept-visibility-change-consequences flag', 1); } const transport = accessor.transport; if (Object.keys(security).length > 0) { const node = await repositoryFields(transport, ref, 'viewerCanAdminister'); if (node.viewerCanAdminister !== true) { throw new Error('you do not have sufficient permissions to edit repository security and analysis features'); } body.security_and_analysis = security; } if (Object.keys(body).length > 0) await editRepo(transport, ref, body); if (adds.length > 0 || removes.length > 0) { const old = await repoTopics(transport, ref); const next = [...new Set([...old, ...adds])].filter((topic) => !removes.includes(topic)); const same = next.length === old.length && next.every((topic) => old.includes(topic)); if (!same) await setRepoTopics(transport, ref, next); } return [new Uint8Array(0), new IOResult()]; } /** * `gh repo delete REPO --yes`. A name with no owner is the viewer's, as gh * reads it. The current repository is never deleted by default: gh ignores * `--yes` there and prompts, so without a terminal it refuses. `--confirm` * is gh's deprecated spelling of `--yes`, and it warns the way cobra does. */ export async function deleteCmd(accessor, _paths, texts, ctx) { const fl = new FlagView(ctx.flags); const confirmed = ghBool(fl, 'yes') || ghBool(fl, 'confirm'); const spec = texts[0]; if (spec === undefined && confirmed) { throw new UsageError('cannot non-interactively delete current repository. Please specify a repository or run interactively', 1); } if (!confirmed) throw new UsageError('--yes required when not running interactively', 1); const transport = accessor.transport; const named = spec ?? ''; const ref = ghRepo(accessor, named.includes('/') ? named : `${await login(transport)}/${named}`); await deleteRepo(transport, ref); const warning = ghBool(fl, 'confirm') ? 'Flag --confirm has been deprecated, use `--yes` instead\n' : ''; return [new Uint8Array(0), new IOResult({ stderr: new TextEncoder().encode(warning) })]; } //# sourceMappingURL=repo.js.map