From bb329f9d5d6809108d6644d5444a5d69cef43b53 Mon Sep 17 00:00:00 2001 From: DanhezCode Date: Sun, 18 Oct 2026 16:02:32 -0400 Subject: [PATCH 01/10] chore: start GitHub plugin From 51889f27d694fe36cbf8ad90264e52a03c9f3ece Mon Sep 17 00:00:00 2001 From: DanhezCode Date: Sun, 18 Oct 2026 16:43:16 -0400 Subject: [PATCH 02/10] feat: resolve GitHub repository files --- src/github-api.ts | 255 +++++++++++++++++++++++++++++++++++++++ src/index.ts | 117 +++++++++++++++++- src/reference.ts | 37 ++++++ tests/demo.test.ts | 9 -- tests/github-api.test.ts | 141 ++++++++++++++++++++++ tests/reference.test.ts | 33 +++++ 6 files changed, 579 insertions(+), 13 deletions(-) create mode 100644 src/github-api.ts create mode 100644 src/reference.ts delete mode 100644 tests/demo.test.ts create mode 100644 tests/github-api.test.ts create mode 100644 tests/reference.test.ts diff --git a/src/github-api.ts b/src/github-api.ts new file mode 100644 index 0000000..89099f2 --- /dev/null +++ b/src/github-api.ts @@ -0,0 +1,255 @@ +import type { GitHubSourceReference } from "./reference.js"; + +const API_URL = "https://api.github.com"; +const API_VERSION = "2022-11-28"; +interface GitReference { + object: { sha: string; type: string }; +} +interface TreeResponse { + truncated: boolean; + tree: Array<{ path: string; mode: string; type: string; sha: string; size?: number }>; +} +interface RefResponse { + name: string; +} +interface BlobResponse { + encoding: string; + content: string; +} +export interface GitHubFile { + path: string; + sha: string; + size?: number; +} +export interface ResolvedGitHubReference { + owner: string; + repository: string; + ref: string; + commit: string; + path: string; + blob: GitHubFile; +} +const treeCache = new Map>(); +const contentCache = new Map>(); +const repoKey = (reference: Pick) => + `${reference.owner}/${reference.repository}`; + +async function request(path: string, token?: string): Promise { + const response = await fetch(`${API_URL}${path}`, { + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": API_VERSION, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + if (!response.ok) { + const retryAfter = response.headers.get("retry-after"); + const limited = + response.status === 429 || + (response.status === 403 && + (response.headers.get("x-ratelimit-remaining") === "0" || retryAfter !== null)); + const reason = + response.status === 401 + ? "Authentication failed. Check GITHUB_TOKEN." + : response.status === 404 + ? "Repository, ref, or file was not found, or the token cannot access it." + : limited + ? `GitHub rate limit reached.${retryAfter ? ` Retry after ${retryAfter} seconds.` : " Wait for the rate limit to reset."}` + : response.status === 403 + ? "GitHub denied access. Check token repository access and Contents: Read-only / Metadata: Read-only permissions." + : `GitHub API request failed with HTTP ${response.status}.`; + throw new Error(`${reason} (${response.status} ${response.statusText}; ${path}).`); + } + // The response shape is endpoint-specific and defined at each call site. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + return (await response.json()) as T; +} + +async function getCommit( + reference: GitHubSourceReference, + ref: string, + token?: string, +): Promise { + const encoded = ref.split("/").map(encodeURIComponent).join("/"); + const result = await request( + `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/ref/heads/${encoded}`, + token, + ).catch(async (error: unknown) => + request( + `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/ref/tags/${encoded}`, + token, + ).catch(() => { + throw error; + }), + ); + if (result.object.type === "commit") return result.object.sha; + if (result.object.type === "tag") { + const tag = await request<{ object: { sha: string; type: string } }>( + `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/tags/${result.object.sha}`, + token, + ); + if (tag.object.type === "commit") return tag.object.sha; + } + throw new Error(`GitHub ref ${JSON.stringify(ref)} does not resolve to a commit.`); +} + +async function getRefNames( + reference: GitHubSourceReference, + kind: "heads" | "tags", + token?: string, +): Promise { + const refs = await request( + `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/matching-refs/${kind}/`, + token, + ); + const prefix = `refs/${kind}/`; + return refs + .map(({ name }) => (name.startsWith(prefix) ? name.slice(prefix.length) : name)) + .filter(Boolean) + .toSorted((a, b) => b.length - a.length || a.localeCompare(b)); +} + +async function resolveRef( + reference: GitHubSourceReference, + token?: string, +): Promise<{ ref: string; path: string; commit: string }> { + if (reference.ref === undefined) { + const repository = await request<{ default_branch: string }>( + `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}`, + token, + ); + const ref = repository.default_branch; + return { ref, path: reference.path, commit: await getCommit(reference, ref, token) }; + } + const [branches, tags] = await Promise.all([ + getRefNames(reference, "heads", token), + getRefNames(reference, "tags", token), + ]); + const branchSet = new Set(branches); + const tagSet = new Set(tags); + const candidates = [...reference.ref.split("/"), ...reference.path.split("/")]; + let length = 0; + for (let index = candidates.length; index > 0; index--) { + const candidate = candidates.slice(0, index).join("/"); + if (branchSet.has(candidate) || tagSet.has(candidate)) { + length = index; + break; + } + } + if (!length) + throw new Error( + `GitHub ref ${JSON.stringify(reference.ref)} was not found in ${repoKey(reference)}. Check the branch or tag name.`, + ); + const ref = candidates.slice(0, length).join("/"); + const path = candidates.slice(length).join("/"); + return { ref, path, commit: await getCommit(reference, ref, token) }; +} + +async function getTree( + reference: GitHubSourceReference, + commit: string, + token?: string, +): Promise { + const key = `${repoKey(reference)}@${commit}`; + let pending = treeCache.get(key); + if (!pending) { + pending = request( + `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/trees/${commit}?recursive=1`, + token, + ) + .then((result) => { + if (result.truncated) + throw new Error( + `GitHub returned a truncated file tree for ${repoKey(reference)}@${commit}; the requested file cannot be verified safely.`, + ); + return result.tree + .filter((entry) => entry.type === "blob" && entry.mode !== "120000") + .map(({ path, sha, size }) => { + const file: GitHubFile = { path, sha }; + if (size !== undefined) file.size = size; + return file; + }); + }) + .catch((error: unknown) => { + treeCache.delete(key); + throw error; + }); + treeCache.set(key, pending); + } + return await pending; +} + +async function getText( + reference: GitHubSourceReference, + blob: GitHubFile, + token?: string, +): Promise { + const key = `${repoKey(reference)}@${blob.sha}`; + let pending = contentCache.get(key); + if (!pending) { + pending = request( + `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/blobs/${blob.sha}`, + token, + ) + .then((result) => { + if (result.encoding !== "base64") + throw new Error( + `GitHub returned unsupported blob encoding ${JSON.stringify(result.encoding)} for ${blob.path}.`, + ); + const bytes = Uint8Array.from(atob(result.content.replace(/\s/g, "")), (character) => + character.charCodeAt(0), + ); + if (bytes.includes(0)) + throw new Error( + `GitHub file ${JSON.stringify(blob.path)} is binary and cannot be used as text.`, + ); + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error(`GitHub file ${JSON.stringify(blob.path)} is not valid UTF-8 text.`); + } + }) + .catch((error: unknown) => { + contentCache.delete(key); + throw error; + }); + contentCache.set(key, pending); + } + return await pending; +} + +export async function prepareGitHubFile( + reference: GitHubSourceReference, + token?: string, +): Promise<{ resolved: ResolvedGitHubReference; text: string }> { + const selected = await resolveRef(reference, token); + if ( + !selected.path || + selected.path.startsWith("/") || + selected.path.split("/").some((part) => !part || part === "." || part === "..") + ) { + throw new Error(`Unsafe GitHub file path ${JSON.stringify(selected.path)}.`); + } + const tree = await getTree(reference, selected.commit, token); + const blob = tree.find((file) => file.path === selected.path); + if (!blob) + throw new Error( + `File ${JSON.stringify(selected.path)} was not found in ${repoKey(reference)}@${selected.ref}. Check the path and ref.`, + ); + return { + resolved: { + owner: reference.owner, + repository: reference.repository, + ref: selected.ref, + commit: selected.commit, + path: selected.path, + blob, + }, + text: await getText(reference, blob, token), + }; +} + +export function clearGitHubCaches(): void { + treeCache.clear(); + contentCache.clear(); +} diff --git a/src/index.ts b/src/index.ts index 7a37a21..eec1524 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,117 @@ +import type { PluginContribution, ResourceContext, SourceReferenceContext } from "write-once"; + +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { prepareGitHubFile } from "./github-api.js"; +import { isGitHubReference, parseGitHubReference } from "./reference.js"; + +interface PreparedFile { + path: string; + owner: string; + repository: string; + ref: string; + commit: string; + sourcePath: string; + blobSha: string; +} +const PLUGIN_ID = "write-once-github"; +function isPreparedFile(value: unknown): value is PreparedFile { + if (typeof value !== "object" || value === null) return false; + return ["path", "owner", "repository", "ref", "commit", "sourcePath", "blobSha"].every( + (key) => typeof Reflect.get(value, key) === "string", + ); +} +function getPreparedFile(source: string, context: ResourceContext): PreparedFile { + const files = context.preparation?.result.metadata?.["files"]; + if (typeof files !== "object" || files === null) + throw new Error( + `GitHub source ${JSON.stringify(source)} was not included in the assigned preparation batch.`, + ); + const file: unknown = Reflect.get(files, source); + if (!isPreparedFile(file)) + throw new Error( + `GitHub source ${JSON.stringify(source)} was not included in the assigned preparation batch.`, + ); + return file; +} + /** - * Returns a greeting for the provided name. + * Creates the GitHub repository provider for Write Once. * - * Replace this example with the package public API. + * References use `repo:owner/repository/path` or `repo:owner/repository@ref/path`. + * Public repositories work anonymously; private repositories use `GITHUB_TOKEN`. */ -export function greet(name: string): string { - return `Hello, ${name}!`; +export function githubPlugin(): PluginContribution { + return { + name: PLUGIN_ID, + preparers: [ + { + id: PLUGIN_ID, + canPrepare: (reference: SourceReferenceContext) => isGitHubReference(reference.source), + async prepare(references, context) { + const token = process.env["GITHUB_TOKEN"]; + const root = context.workingDirectory; + await mkdir(root, { recursive: true }); + const prepared = await Promise.all( + references.map(async (reference, index) => { + const { resolved, text } = await prepareGitHubFile( + parseGitHubReference(reference.source), + token, + ); + const path = `files/${index}.txt`; + const destination = join(root, path); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, text, "utf8"); + return { + source: reference.source, + metadata: { + path, + owner: resolved.owner, + repository: resolved.repository, + ref: resolved.ref, + commit: resolved.commit, + sourcePath: resolved.path, + blobSha: resolved.blob.sha, + } satisfies PreparedFile, + }; + }), + ); + const files: Record = {}; + for (const file of prepared) files[file.source] = file.metadata; + return { resources: [{ root, metadata: { provider: "github" } }], metadata: { files } }; + }, + }, + ], + resolvers: [ + { + id: PLUGIN_ID, + canResolve(source, context) { + return isGitHubReference(source) && context.preparation?.preparerId === PLUGIN_ID; + }, + resolve(source, context) { + const preparation = context.preparation; + if (!preparation) + throw new Error(`No GitHub preparation was assigned to ${JSON.stringify(source)}.`); + const file = getPreparedFile(source, context); + return { + root: preparation.result.resources[0]!.root, + path: file.path, + metadata: { + provider: "github", + repository: `${file.owner}/${file.repository}`, + ref: file.ref, + commit: file.commit, + sourcePath: file.sourcePath, + blobSha: file.blobSha, + }, + }; + }, + }, + ], + }; } + +export { clearGitHubCaches, prepareGitHubFile } from "./github-api.js"; +export { isGitHubReference, parseGitHubReference } from "./reference.js"; +export type { GitHubSourceReference } from "./reference.js"; diff --git a/src/reference.ts b/src/reference.ts new file mode 100644 index 0000000..f124589 --- /dev/null +++ b/src/reference.ts @@ -0,0 +1,37 @@ +export interface GitHubSourceReference { + owner: string; + repository: string; + path: string; + ref?: string; +} + +export function isGitHubReference(source: string): boolean { + return source.startsWith("repo:"); +} + +export function parseGitHubReference(source: string): GitHubSourceReference { + if (!isGitHubReference(source)) + throw new Error(`Unsupported GitHub source reference: ${JSON.stringify(source)}.`); + const segments = source.slice("repo:".length).split("/"); + const owner = segments[0]; + const repositoryAndRef = segments[1]; + if (!owner || !repositoryAndRef || owner === "." || owner === "..") { + throw new Error(`Invalid GitHub source reference: ${JSON.stringify(source)}.`); + } + const at = repositoryAndRef.indexOf("@"); + const repository = at < 0 ? repositoryAndRef : repositoryAndRef.slice(0, at); + const ref = at < 0 ? undefined : repositoryAndRef.slice(at + 1); + const path = segments.slice(2).join("/"); + if ( + !repository || + repository === "." || + repository === ".." || + !path || + path.includes("\\") || + segments.slice(2).some((segment) => !segment || segment === "." || segment === "..") || + ref === "" + ) { + throw new Error(`Invalid GitHub source reference: ${JSON.stringify(source)}.`); + } + return { owner, repository, path, ...(ref === undefined ? {} : { ref }) }; +} diff --git a/tests/demo.test.ts b/tests/demo.test.ts deleted file mode 100644 index 19ea725..0000000 --- a/tests/demo.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { greet } from "@/index"; - -describe("greet", () => { - it("returns a greeting for the provided name", () => { - expect(greet("TypeScript")).toBe("Hello, TypeScript!"); - }); -}); diff --git a/tests/github-api.test.ts b/tests/github-api.test.ts new file mode 100644 index 0000000..6fb01fc --- /dev/null +++ b/tests/github-api.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { clearGitHubCaches, prepareGitHubFile } from "@/github-api"; +import { parseGitHubReference } from "@/reference"; +const repoUrl = "https://api.github.com/repos/owner/repo"; +const commit = "commit-sha"; +const blob = "blob-sha"; +function json(data: unknown, status = 200, headers?: Record): Response { + return new Response(JSON.stringify(data), { status, headers }); +} +function mockApi( + options: { branches?: string[]; tags?: string[]; blobContent?: string; status?: number } = {}, +) { + const calls: Array<{ url: string; headers: Headers }> = []; + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : input.toString(); + calls.push({ url, headers: new Headers(init?.headers) }); + if (options.status) + return json({}, options.status, { "retry-after": "60", "x-ratelimit-remaining": "0" }); + if (url === repoUrl) return json({ default_branch: "main" }); + if (url.includes("/git/matching-refs/heads/")) + return json( + (options.branches ?? ["main", "feature/nested"]).map((name) => ({ + name: `refs/heads/${name}`, + })), + ); + if (url.includes("/git/matching-refs/tags/")) + return json((options.tags ?? ["v1.0.0"]).map((name) => ({ name: `refs/tags/${name}` }))); + if (url.includes("/git/ref/heads/")) { + const ref = decodeURIComponent(url.split("/git/ref/heads/")[1] ?? ""); + return (options.branches ?? ["main", "feature/nested"]).includes(ref) + ? json({ object: { sha: commit, type: "commit" } }) + : json({}, 404); + } + if (url.includes("/git/ref/tags/")) { + const ref = decodeURIComponent(url.split("/git/ref/tags/")[1] ?? ""); + return (options.tags ?? ["v1.0.0"]).includes(ref) + ? json({ object: { sha: commit, type: "commit" } }) + : json({}, 404); + } + if (url.includes(`/git/trees/${commit}?recursive=1`)) + return json({ + truncated: false, + tree: [ + { path: "src/index.ts", mode: "100644", type: "blob", sha: blob }, + { path: "docs/guide.md", mode: "100644", type: "blob", sha: blob }, + { path: "image.png", mode: "100644", type: "blob", sha: "binary-sha" }, + ], + }); + if (url.endsWith(`/git/blobs/${blob}`)) + return json({ encoding: "base64", content: options.blobContent ?? btoa("hello world\n") }); + if (url.endsWith("/git/blobs/binary-sha")) + return json({ encoding: "base64", content: btoa("a\0b") }); + return json({}, 404); + }); + vi.stubGlobal("fetch", fetchMock); + return { calls, fetchMock }; +} +afterEach(() => { + vi.unstubAllGlobals(); + clearGitHubCaches(); +}); +describe("prepareGitHubFile", () => { + it("discovers default branch and uses tree/blob APIs", async () => { + const { calls } = mockApi(); + const result = await prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")); + expect(result.resolved).toMatchObject({ + ref: "main", + commit, + path: "src/index.ts", + blob: { sha: blob }, + }); + expect(result.text).toBe("hello world\n"); + expect(calls.some(({ url }) => url.includes("/git/trees/"))).toBe(true); + }); + it("supports refs containing slashes", async () => { + mockApi(); + expect( + (await prepareGitHubFile(parseGitHubReference("repo:owner/repo@feature/nested/src/index.ts"))) + .resolved, + ).toMatchObject({ ref: "feature/nested", path: "src/index.ts" }); + }); + it("resolves tags", async () => { + const { calls } = mockApi(); + expect( + (await prepareGitHubFile(parseGitHubReference("repo:owner/repo@v1.0.0/src/index.ts"))) + .resolved.ref, + ).toBe("v1.0.0"); + expect(calls.some(({ url }) => url.includes("/git/ref/tags/v1.0.0"))).toBe(true); + }); + it("prefers branches on branch/tag name collision", async () => { + const { calls } = mockApi({ branches: ["release"], tags: ["release"] }); + await prepareGitHubFile(parseGitHubReference("repo:owner/repo@release/src/index.ts")); + expect(calls.some(({ url }) => url.includes("/git/ref/heads/release"))).toBe(true); + }); + it("caches tree metadata and duplicate blob downloads", async () => { + const { calls } = mockApi(); + await prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")); + await prepareGitHubFile(parseGitHubReference("repo:owner/repo/docs/guide.md")); + expect(calls.filter(({ url }) => url.endsWith(`/git/blobs/${blob}`))).toHaveLength(1); + expect(calls.filter(({ url }) => url.includes("/git/trees/"))).toHaveLength(1); + }); + it("sends tokens for private repository access", async () => { + const { calls } = mockApi(); + await prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts"), "secret"); + expect(calls.every(({ headers }) => headers.get("authorization") === "Bearer secret")).toBe( + true, + ); + }); + it("reports missing files", async () => { + mockApi(); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo/missing.ts")), + ).rejects.toThrow("was not found in owner/repo@main"); + }); + it("reports binary content", async () => { + mockApi(); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo/image.png")), + ).rejects.toThrow("is binary"); + }); + it("reports invalid UTF-8", async () => { + mockApi({ blobContent: btoa(String.fromCharCode(255, 254)) }); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")), + ).rejects.toThrow("not valid UTF-8"); + }); + it("explains authentication failures", async () => { + const { fetchMock } = mockApi(); + fetchMock.mockImplementation(async () => json({}, 401)); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")), + ).rejects.toThrow("Check GITHUB_TOKEN"); + }); + it("includes Retry-After guidance", async () => { + mockApi({ status: 429 }); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")), + ).rejects.toThrow("Retry after 60 seconds"); + }); +}); diff --git a/tests/reference.test.ts b/tests/reference.test.ts new file mode 100644 index 0000000..afdb49c --- /dev/null +++ b/tests/reference.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { isGitHubReference, parseGitHubReference } from "@/reference"; + +describe("GitHub references", () => { + it("parses default branch references", () => + expect(parseGitHubReference("repo:owner/repo/src/index.ts")).toEqual({ + owner: "owner", + repository: "repo", + path: "src/index.ts", + })); + it("parses explicit refs", () => + expect(parseGitHubReference("repo:owner/repo@develop/src/index.ts")).toEqual({ + owner: "owner", + repository: "repo", + ref: "develop", + path: "src/index.ts", + })); + it.each([ + "repo:owner/repo", + "repo:owner/repo/../secret", + "repo:owner/repo/src/../secret", + "repo:owner/repo/src\\secret", + "repo:owner/repo@/src/index.ts", + "repo:/repo/src/index.ts", + ])("rejects invalid reference %s", (source) => + expect(() => parseGitHubReference(source)).toThrow(), + ); + it("recognizes provider references", () => { + expect(isGitHubReference("repo:owner/repo/a.ts")).toBe(true); + expect(isGitHubReference("./a.ts")).toBe(false); + }); +}); From 9d984eacbf15f94783739705376a74d500776f81 Mon Sep 17 00:00:00 2001 From: DanhezCode Date: Sun, 18 Oct 2026 16:43:26 -0400 Subject: [PATCH 03/10] docs: document GitHub provider setup --- README.md | 45 +++++++++++++++++++++++----- bun.lock | 76 +++++++++++++++++++++++++++++++++++++++--------- cspell.config.ts | 5 +--- package.json | 13 +++++++-- 4 files changed, 110 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 297edf8..ef8ce22 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,42 @@ # write-once-github -Official GitHub repository integration for [write-once](https://github.com/opinionated-ts/write-once). +GitHub REST API provider for [Write Once](https://github.com/opinionated-ts/write-once). Resolves repository files without the GitHub CLI or a GitHub SDK. -This package is under development. Its repository reference syntax, ref resolution behavior, and public API will be documented here as the integration is implemented. +## Install + +```sh +bun add write-once write-once-github +``` + +Register it in `write-once.config.ts`: + +```ts +import { defineConfig } from "write-once"; +import { githubPlugin } from "write-once-github"; + +export default defineConfig({ plugins: [githubPlugin] }); +``` + +## References + +- `repo:owner/repository/path/to/file.ts` uses the repository's actual default branch. +- `repo:owner/repository@develop/path/to/file.ts` uses an explicit branch or tag. Branch names containing slashes are supported; branches take precedence over same-named tags. + +The core remains responsible for whole-file, named-fragment, and range selection. + +## Authentication + +- Public repositories work anonymously. +- Private repositories require `GITHUB_TOKEN` in the process environment. +- Use a token with access to the repository and fine-grained `Contents: Read-only` and `Metadata: Read-only` permissions. +- The plugin does not read `.env` files. + +## Behavior + +- Uses Git Trees metadata and existing blob SHAs; caches trees by repository and commit, and text by repository and blob SHA. +- Rejects unsafe paths, binary files, invalid UTF-8, and truncated tree responses. +- Reports actionable errors for missing resources, authentication/permission failures, and rate limits, including `Retry-After` guidance. +- Leaves temporary-resource cleanup to Write Once. ## Development @@ -10,10 +44,5 @@ This package is under development. Its repository reference syntax, ref resoluti bun install bun test bun check -bun build +bun run build ``` - -## Related projects - -- [write-once](https://github.com/opinionated-ts/write-once) — core resource preparation, resolution, and content extraction pipeline. -- [Opinionated TS](https://github.com/opinionated-ts) — the ecosystem this package belongs to. diff --git a/bun.lock b/bun.lock index 63cba64..de6981a 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,10 @@ "tsdown": "^0.23.1", "typescript": "^7.0.2", "vitest": "^5.0.3", + "write-once": "file:../write-once", + }, + "peerDependencies": { + "write-once": "*", }, }, }, @@ -274,6 +278,12 @@ "@opinionated-ts/config": ["@opinionated-ts/config@1.9.1", "", { "dependencies": { "@commitlint/cli": "^21.2.3", "@commitlint/config-conventional": "^21.2.3", "@cspell/dict-es-es": "^3.0.9", "@types/bun": "^1.4.2", "@types/node": "^26.6.4", "cspell": "^10.3.6", "lefthook": "^2.1.17", "oxfmt": "^0.72.0", "oxlint": "^1.87.0", "oxlint-tsgolint": "^7.0.2003", "semantic-release": "^25.0.9" } }, "sha512-5N/oUg/UmgSQafzrBuFVvmX10i/CVhz2oIia25+j/G0wUbUAGDC7m4omH5ls0baaahBX7YQmwgTKr6NMjjwpkg=="], + "@opinionated-ts/error-factory": ["@opinionated-ts/error-factory@1.0.1", "", { "dependencies": { "@opinionated-ts/primitives": "^0.1.0" }, "peerDependencies": { "typescript": "^7.0.2" } }, "sha512-7Z0769abDhBg7XUZuwzeF19zsMI17aAli4tiZ7n0K/0ZMGgWMCfQGtw9CGQ1sciwk8Yl/s2Ehvuiy5lXreaikA=="], + + "@opinionated-ts/primitives": ["@opinionated-ts/primitives@0.1.0", "", { "peerDependencies": { "typescript": "^7.0.2" } }, "sha512-LRihuymnbn97Zc9jhIIqkAXU//ThUAvBzf52lQ590EEUY5WxNPlEqS5qg+IO1c7i0AZHfG0ftwQ8DjFRV+EFIg=="], + + "@opinionated-ts/result": ["@opinionated-ts/result@1.0.0", "", {}, "sha512-uoMfFVHYd9bKBDFWoVtSo2ZZBpNILIMqOEEGwji2O2STdFOw7i3IWt1Pid85qUKEaYBdRaiwRsqlZFu+zjy8MA=="], + "@oxc-project/types": ["@oxc-project/types@0.153.0", "", {}, "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.72.0", "", { "os": "android", "cpu": "arm" }, "sha512-u4U1uaDOVBuorGNC/07NLm48nECHOiJJka002YGML16Md2DRvf38RGBt01lH60EOGvNlFVmbLoksQedNBoZHKw=="], @@ -410,6 +420,8 @@ "@semantic-release/error": ["@semantic-release/error@4.0.0", "", {}, "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ=="], + "@semantic-release/exec": ["@semantic-release/exec@7.1.0", "", { "dependencies": { "@semantic-release/error": "^4.0.0", "aggregate-error": "^3.0.0", "debug": "^4.0.0", "execa": "^9.0.0", "lodash-es": "^4.17.21", "parse-json": "^8.0.0" }, "peerDependencies": { "semantic-release": ">=24.1.0" } }, "sha512-4ycZ2atgEUutspPZ2hxO6z8JoQt4+y/kkHvfZ1cZxgl9WKJId1xPj+UadwInj+gMn2Gsv+fLnbrZ4s+6tK2TFQ=="], + "@semantic-release/github": ["@semantic-release/github@12.0.9", "", { "dependencies": { "@octokit/core": "^7.0.0", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-retry": "^8.0.0", "@octokit/plugin-throttling": "^11.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "debug": "^4.3.4", "dir-glob": "^3.0.1", "http-proxy-agent": "^9.0.0", "https-proxy-agent": "^9.0.0", "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", "p-filter": "^4.0.0", "tinyglobby": "^0.2.14", "undici": "^7.0.0", "url-join": "^5.0.0" }, "peerDependencies": { "semantic-release": ">=24.1.0" } }, "sha512-ODIqb0V3QqndipryEEiaBxUQCFjvv7Oese5Dt4omMGa60YRNEW0Sx3K+zri0uac2Y6S9nOlMehciWIzvvRCTGQ=="], "@semantic-release/npm": ["@semantic-release/npm@13.1.5", "", { "dependencies": { "@actions/core": "^3.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^9.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg=="], @@ -558,6 +570,8 @@ "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="], + "cac": ["cac@7.0.1", "", {}, "sha512-86diuUyeeWM8CrqhpGCaDafrzjS1s++7sWy2AbAPoFNz/xKv7ID57CR4pZsMuGCh60PtsnEx4z68b+Vgt07VPw=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], @@ -572,8 +586,12 @@ "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], + "clean-stack": ["clean-stack@5.3.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg=="], "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], @@ -592,6 +610,8 @@ "compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], "content-type": ["content-type@3.0.0", "", {}, "sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw=="], @@ -644,12 +664,16 @@ "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "dts-resolver": ["dts-resolver@3.0.0", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q=="], "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], @@ -686,6 +710,8 @@ "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "exsolve": ["exsolve@1.1.3", "", {}, "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-equals": ["fast-equals@6.0.2", "", {}, "sha512-sAjhj9ZhOxYCGiNMnZLaucOqf5ZeFnHNoKoAZiD9thhJ0N8RP85qJK759/97C/3L7NzzmGVB5uiX9AUpySZmUQ=="], @@ -726,6 +752,8 @@ "get-tsconfig": ["get-tsconfig@5.0.0-beta.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw=="], + "giget": ["giget@3.3.1", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg=="], + "git-log-parser": ["git-log-parser@1.2.1", "", { "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", "split2": "~1.0.0", "stream-combiner2": "~1.1.1", "through2": "~2.0.0", "traverse": "0.6.8" } }, "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ=="], "global-directory": ["global-directory@5.0.0", "", { "dependencies": { "ini": "6.0.0" } }, "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w=="], @@ -750,6 +778,8 @@ "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + "ignore": ["ignore@7.0.12", "", {}, "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg=="], + "import-fresh": ["import-fresh@4.0.0", "", {}, "sha512-Fpi660c7VPDM3fPKYovStd9IP1CPOikf6v/dGxJJMmHPcwYQIMJ4W7kO1avBYEpMqkCh+Dx3Ln6H7VYqgztLjw=="], "import-from-esm": ["import-from-esm@2.0.0", "", { "dependencies": { "debug": "^4.3.4", "import-meta-resolve": "^4.0.0" } }, "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g=="], @@ -928,6 +958,8 @@ "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + "ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="], + "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], "oxfmt": ["oxfmt@0.72.0", "", { "dependencies": { "tinypool": "2.2.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.72.0", "@oxfmt/binding-android-arm64": "0.72.0", "@oxfmt/binding-darwin-arm64": "0.72.0", "@oxfmt/binding-darwin-x64": "0.72.0", "@oxfmt/binding-freebsd-x64": "0.72.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.72.0", "@oxfmt/binding-linux-arm-musleabihf": "0.72.0", "@oxfmt/binding-linux-arm64-gnu": "0.72.0", "@oxfmt/binding-linux-arm64-musl": "0.72.0", "@oxfmt/binding-linux-ppc64-gnu": "0.72.0", "@oxfmt/binding-linux-riscv64-gnu": "0.72.0", "@oxfmt/binding-linux-riscv64-musl": "0.72.0", "@oxfmt/binding-linux-s390x-gnu": "0.72.0", "@oxfmt/binding-linux-x64-gnu": "0.72.0", "@oxfmt/binding-linux-x64-musl": "0.72.0", "@oxfmt/binding-openharmony-arm64": "0.72.0", "@oxfmt/binding-win32-arm64-msvc": "0.72.0", "@oxfmt/binding-win32-ia32-msvc": "0.72.0", "@oxfmt/binding-win32-x64-msvc": "0.72.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-16OQeL0uhPZbfmT9S9WWM0Nv9+BmVi8Ra1uTYaSKj/qSCs6FB30KOhz9JoNYw6thT+K5bVxsk/tSEOu497i34g=="], @@ -956,7 +988,7 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], @@ -970,6 +1002,10 @@ "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], @@ -978,6 +1014,8 @@ "pkg-conf": ["pkg-conf@2.1.0", "", { "dependencies": { "find-up": "^2.0.0", "load-json-file": "^4.0.0" } }, "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g=="], + "pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="], + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], @@ -992,12 +1030,16 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "rc9": ["rc9@3.1.0", "", { "dependencies": { "defu": "^6.1.7", "destr": "^2.0.5" } }, "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q=="], + "read-package-up": ["read-package-up@12.0.0", "", { "dependencies": { "find-up-simple": "^1.0.1", "read-pkg": "^10.0.0", "type-fest": "^5.2.0" } }, "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw=="], "read-pkg": ["read-pkg@10.1.0", "", { "dependencies": { "@types/normalize-package-data": "^2.4.4", "normalize-package-data": "^8.0.0", "parse-json": "^8.3.0", "type-fest": "^5.4.4", "unicorn-magic": "^0.4.0" } }, "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg=="], "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], + "registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "^3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], @@ -1158,6 +1200,8 @@ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "write-once": ["write-once@file:../write-once", { "dependencies": { "@opinionated-ts/error-factory": "^1.0.1", "@opinionated-ts/result": "^1.0.0", "c12": "^3.3.3", "citty": "^0.2.2", "ignore": "^7.0.12", "picocolors": "^1.1.1" }, "devDependencies": { "@opinionated-ts/config": "^1.9.1", "@semantic-release/exec": "^7.1.0", "@types/bun": "^1.4.2", "@types/node": "^26.6.4", "@vitest/coverage-istanbul": "5.0.3", "skills": "^1.7.1", "tsdown": "^0.23.1", "typescript": "^7.0.2", "vitest": "^5.0.3", "zod": "^4.6.6" }, "bin": { "write-once": "dist/cli.mjs" } }], + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], @@ -1182,6 +1226,8 @@ "yuku-parser": ["yuku-parser@0.18.1", "", { "dependencies": { "@yuku-toolchain/types": "^0.18.1", "yuku-core": "0.18.1" } }, "sha512-t5pANCF6k9rgJre7jk8DMAU4lEoI1oWWZgXk0fcvmCYQquFVWv0rf6Awwt56nnPiHb0E24dhtrpfgf9ChTCbrQ=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + "@actions/http-client/undici": ["undici@6.28.1", "", {}, "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA=="], "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.6", "", {}, "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ=="], @@ -1216,6 +1262,8 @@ "@semantic-release/commit-analyzer/conventional-commits-parser": ["conventional-commits-parser@6.4.0", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" }, "bin": { "conventional-commits-parser": "dist/cli/index.js" } }, "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw=="], + "@semantic-release/exec/aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], + "@semantic-release/release-notes-generator/conventional-commits-parser": ["conventional-commits-parser@6.4.0", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" }, "bin": { "conventional-commits-parser": "dist/cli/index.js" } }, "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw=="], "@semantic-release/release-notes-generator/read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="], @@ -1236,6 +1284,8 @@ "cosmiconfig/import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "crypto-random-string/type-fest": ["type-fest@1.4.0", "", {}, "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA=="], "env-ci/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], @@ -1544,9 +1594,11 @@ "parse-json/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + "parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], - "read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], + "pkg-types/confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], "rolldown-plugin-dts/obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], @@ -1598,6 +1650,10 @@ "@semantic-release/commit-analyzer/conventional-commits-parser/@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="], + "@semantic-release/exec/aggregate-error/clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], + + "@semantic-release/exec/aggregate-error/indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "@semantic-release/release-notes-generator/conventional-commits-parser/@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="], "@semantic-release/release-notes-generator/read-package-up/read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="], @@ -1620,6 +1676,8 @@ "cosmiconfig/import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "cosmiconfig/parse-json/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + "env-ci/execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], "env-ci/execa/human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], @@ -1636,10 +1694,6 @@ "parse-json/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "read-pkg/parse-json/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - - "read-pkg/parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - "signale/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "signale/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], @@ -1680,8 +1734,6 @@ "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], - "@semantic-release/release-notes-generator/read-package-up/read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], - "@semantic-release/release-notes-generator/read-package-up/read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], "cli-highlight/chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -1696,20 +1748,18 @@ "cli-table3/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "cosmiconfig/parse-json/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "env-ci/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "npm/minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "npm/minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "read-pkg/parse-json/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "signale/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], - "@semantic-release/release-notes-generator/read-package-up/read-pkg/parse-json/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "cli-highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "cli-highlight/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1720,8 +1770,6 @@ "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "@semantic-release/release-notes-generator/read-package-up/read-pkg/parse-json/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "cli-highlight/yargs/cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "cli-highlight/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], diff --git a/cspell.config.ts b/cspell.config.ts index 2f65f52..9d6e2cf 100644 --- a/cspell.config.ts +++ b/cspell.config.ts @@ -14,10 +14,7 @@ export default defineConfig({ // Custom import paths here ], - words: [ - ...cspellConfig.words, - // Custom words here - ], + words: [...cspellConfig.words, "ratelimit", "unstub"], // Any additional custom configuration here }); diff --git a/package.json b/package.json index a4f05e2..cf4eb91 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,13 @@ { "name": "write-once-github", - "description": "Official GitHub repository integration for write-once.", + "description": "GitHub REST API provider for resolving repository files in Write Once.", "keywords": [ "github", + "github-api", "integration", "opinionated-ts", "plugin", + "remote-files", "repository", "write-once" ], @@ -46,7 +48,8 @@ "audit": "bun audit fix --ignore GHSA-vfj7-8cjw-p6xm", "build": "tsdown", "build:watch": "tsdown --watch", - "start": "bun run build && bun ./dist/index.mjs" + "start": "bun run build && bun ./dist/index.mjs", + "prepack": "bun run build" }, "devDependencies": { "@opinionated-ts/config": "^1.9.1", @@ -56,7 +59,11 @@ "skills": "^1.7.1", "tsdown": "^0.23.1", "typescript": "^7.0.2", - "vitest": "^5.0.3" + "vitest": "^5.0.3", + "write-once": "file:../write-once" + }, + "peerDependencies": { + "write-once": "*" }, "engines": { "bun": "^1.4.0" From acc52a644649f7b6bd98d5b3290a4a8d0e2263ab Mon Sep 17 00:00:00 2001 From: DanhezCode Date: Sun, 18 Oct 2026 16:44:19 -0400 Subject: [PATCH 04/10] fix: paginate GitHub reference discovery --- src/github-api.ts | 24 ++++++++++++++++-------- src/index.ts | 2 +- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/github-api.ts b/src/github-api.ts index 89099f2..60ae51e 100644 --- a/src/github-api.ts +++ b/src/github-api.ts @@ -98,15 +98,23 @@ async function getRefNames( kind: "heads" | "tags", token?: string, ): Promise { - const refs = await request( - `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/matching-refs/${kind}/`, - token, - ); const prefix = `refs/${kind}/`; - return refs - .map(({ name }) => (name.startsWith(prefix) ? name.slice(prefix.length) : name)) - .filter(Boolean) - .toSorted((a, b) => b.length - a.length || a.localeCompare(b)); + const names: string[] = []; + for (let page = 1; ; page++) { + // GitHub caps pages at 100; keep requesting while a full page is returned. + // oxlint-disable-next-line eslint/no-await-in-loop + const refs = await request( + `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/matching-refs/${kind}/?per_page=100&page=${page}`, + token, + ); + names.push( + ...refs + .map(({ name }) => (name.startsWith(prefix) ? name.slice(prefix.length) : name)) + .filter(Boolean), + ); + if (refs.length < 100) break; + } + return names.toSorted((a, b) => b.length - a.length || a.localeCompare(b)); } async function resolveRef( diff --git a/src/index.ts b/src/index.ts index eec1524..fc4ce30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -112,6 +112,6 @@ export function githubPlugin(): PluginContribution { }; } -export { clearGitHubCaches, prepareGitHubFile } from "./github-api.js"; +export { prepareGitHubFile } from "./github-api.js"; export { isGitHubReference, parseGitHubReference } from "./reference.js"; export type { GitHubSourceReference } from "./reference.js"; From 6c1421b6a1282974279ab84668e890284b0f8374 Mon Sep 17 00:00:00 2001 From: DanhezCode Date: Sun, 18 Oct 2026 17:13:52 -0400 Subject: [PATCH 05/10] perf: prepare duplicate GitHub sources once --- src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index fc4ce30..9f48496 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,8 +53,12 @@ export function githubPlugin(): PluginContribution { const token = process.env["GITHUB_TOKEN"]; const root = context.workingDirectory; await mkdir(root, { recursive: true }); + // A source can feed multiple destinations; prepare its content only once per run. + const uniqueReferences = new Map( + references.map((reference) => [reference.source, reference]), + ); const prepared = await Promise.all( - references.map(async (reference, index) => { + [...uniqueReferences.values()].map(async (reference, index) => { const { resolved, text } = await prepareGitHubFile( parseGitHubReference(reference.source), token, From 3cbe9794a0dcf87aeba17a87095c8e29d3fe6fe6 Mon Sep 17 00:00:00 2001 From: DanhezCode Date: Sun, 18 Oct 2026 17:23:15 -0400 Subject: [PATCH 06/10] refactor: align GitHub plugin factory contract --- src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9f48496..69a7d74 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import type { PluginContribution, ResourceContext, SourceReferenceContext } from "write-once"; +import type { PluginFactory, ResourceContext, SourceReferenceContext } from "write-once"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; @@ -42,7 +42,7 @@ function getPreparedFile(source: string, context: ResourceContext): PreparedFile * References use `repo:owner/repository/path` or `repo:owner/repository@ref/path`. * Public repositories work anonymously; private repositories use `GITHUB_TOKEN`. */ -export function githubPlugin(): PluginContribution { +export const githubPlugin: PluginFactory = () => { return { name: PLUGIN_ID, preparers: [ @@ -114,7 +114,7 @@ export function githubPlugin(): PluginContribution { }, ], }; -} +}; export { prepareGitHubFile } from "./github-api.js"; export { isGitHubReference, parseGitHubReference } from "./reference.js"; From b6fc10be8c4ec297201ec9f1fbba0d3c4edc4e69 Mon Sep 17 00:00:00 2001 From: DanhezCode Date: Sun, 18 Oct 2026 17:23:30 -0400 Subject: [PATCH 07/10] fix: resolve nested annotated GitHub tags --- src/github-api.ts | 20 +++- tests/github-api.test.ts | 214 +++++++++++++++++++++++++++++++++++++-- tests/reference.test.ts | 5 + 3 files changed, 225 insertions(+), 14 deletions(-) diff --git a/src/github-api.ts b/src/github-api.ts index 60ae51e..2b93bd6 100644 --- a/src/github-api.ts +++ b/src/github-api.ts @@ -82,14 +82,24 @@ async function getCommit( throw error; }), ); - if (result.object.type === "commit") return result.object.sha; - if (result.object.type === "tag") { - const tag = await request<{ object: { sha: string; type: string } }>( - `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/tags/${result.object.sha}`, + + let object = result.object; + const visited = new Set(); + while (object.type === "tag") { + if (visited.has(object.sha)) { + throw new Error(`GitHub tag ${JSON.stringify(ref)} contains a circular tag reference.`); + } + visited.add(object.sha); + // Annotated tags may point to another tag instead of directly to a commit. + // oxlint-disable-next-line eslint/no-await-in-loop + const tag = await request( + `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/tags/${object.sha}`, token, ); - if (tag.object.type === "commit") return tag.object.sha; + object = tag.object; } + + if (object.type === "commit") return object.sha; throw new Error(`GitHub ref ${JSON.stringify(ref)} does not resolve to a commit.`); } diff --git a/tests/github-api.test.ts b/tests/github-api.test.ts index 6fb01fc..89ae83f 100644 --- a/tests/github-api.test.ts +++ b/tests/github-api.test.ts @@ -1,6 +1,12 @@ +import type { SourceReferenceContext } from "write-once"; + +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { clearGitHubCaches, prepareGitHubFile } from "@/github-api"; +import { githubPlugin } from "@/index"; import { parseGitHubReference } from "@/reference"; const repoUrl = "https://api.github.com/repos/owner/repo"; const commit = "commit-sha"; @@ -9,23 +15,44 @@ function json(data: unknown, status = 200, headers?: Record): Re return new Response(JSON.stringify(data), { status, headers }); } function mockApi( - options: { branches?: string[]; tags?: string[]; blobContent?: string; status?: number } = {}, + options: { + branches?: string[]; + tags?: string[]; + blobContent?: string; + status?: number; + rateLimited?: boolean; + annotatedTag?: boolean; + truncated?: boolean; + blobEncoding?: string; + } = {}, ) { const calls: Array<{ url: string; headers: Headers }> = []; const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = input instanceof Request ? input.url : input.toString(); calls.push({ url, headers: new Headers(init?.headers) }); if (options.status) - return json({}, options.status, { "retry-after": "60", "x-ratelimit-remaining": "0" }); + return json( + {}, + options.status, + options.rateLimited === false ? {} : { "retry-after": "60", "x-ratelimit-remaining": "0" }, + ); if (url === repoUrl) return json({ default_branch: "main" }); - if (url.includes("/git/matching-refs/heads/")) + if (url.includes("/git/matching-refs/heads/")) { + const branches = options.branches ?? ["main", "feature/nested"]; + const page = Number(new URL(url).searchParams.get("page") ?? 1); return json( - (options.branches ?? ["main", "feature/nested"]).map((name) => ({ + branches.slice((page - 1) * 100, page * 100).map((name) => ({ name: `refs/heads/${name}`, })), ); - if (url.includes("/git/matching-refs/tags/")) - return json((options.tags ?? ["v1.0.0"]).map((name) => ({ name: `refs/tags/${name}` }))); + } + if (url.includes("/git/matching-refs/tags/")) { + const tags = options.tags ?? ["v1.0.0"]; + const page = Number(new URL(url).searchParams.get("page") ?? 1); + return json( + tags.slice((page - 1) * 100, page * 100).map((name) => ({ name: `refs/tags/${name}` })), + ); + } if (url.includes("/git/ref/heads/")) { const ref = decodeURIComponent(url.split("/git/ref/heads/")[1] ?? ""); return (options.branches ?? ["main", "feature/nested"]).includes(ref) @@ -35,12 +62,20 @@ function mockApi( if (url.includes("/git/ref/tags/")) { const ref = decodeURIComponent(url.split("/git/ref/tags/")[1] ?? ""); return (options.tags ?? ["v1.0.0"]).includes(ref) - ? json({ object: { sha: commit, type: "commit" } }) + ? json({ + object: options.annotatedTag + ? { sha: "tag-sha", type: "tag" } + : { sha: commit, type: "commit" }, + }) : json({}, 404); } + if (url.endsWith("/git/tags/tag-sha")) + return json({ object: { sha: "nested-tag-sha", type: "tag" } }); + if (url.endsWith("/git/tags/nested-tag-sha")) + return json({ object: { sha: commit, type: "commit" } }); if (url.includes(`/git/trees/${commit}?recursive=1`)) return json({ - truncated: false, + truncated: options.truncated ?? false, tree: [ { path: "src/index.ts", mode: "100644", type: "blob", sha: blob }, { path: "docs/guide.md", mode: "100644", type: "blob", sha: blob }, @@ -48,7 +83,10 @@ function mockApi( ], }); if (url.endsWith(`/git/blobs/${blob}`)) - return json({ encoding: "base64", content: options.blobContent ?? btoa("hello world\n") }); + return json({ + encoding: options.blobEncoding ?? "base64", + content: options.blobContent ?? btoa("hello world\n"), + }); if (url.endsWith("/git/blobs/binary-sha")) return json({ encoding: "base64", content: btoa("a\0b") }); return json({}, 404); @@ -58,8 +96,106 @@ function mockApi( } afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); clearGitHubCaches(); }); + +function sourceReference(source: string, destination: string): SourceReferenceContext { + const location = { + resource: destination, + start: { line: 1, column: 1 }, + end: { line: 1, column: 1 }, + }; + const token = { + raw: "", + attributes: { source }, + span: { start: 0, end: 0 }, + lineSpan: { start: 0, end: 0 }, + location, + }; + return { + source, + destination, + selector: { kind: "whole" }, + marker: { + opening: { ...token, kind: "open" }, + closing: { ...token, kind: "close" }, + span: { start: 0, end: 0 }, + bodySpan: { start: 0, end: 0 }, + }, + }; +} +describe("githubPlugin", () => { + it("prepares each unique source once and resolves its prepared resource", async () => { + const { calls } = mockApi(); + vi.stubEnv("GITHUB_TOKEN", ""); + const workingDirectory = await mkdtemp(join(tmpdir(), "write-once-github-test-")); + try { + const plugin = githubPlugin(); + const preparer = plugin.preparers![0]!; + const resolver = plugin.resolvers![0]!; + expect(plugin.extractors).toBeUndefined(); + const source = "repo:owner/repo/src/index.ts"; + const references = [sourceReference(source, "README.md"), sourceReference(source, "docs.md")]; + expect(preparer.canPrepare(references[0]!, { cwd: workingDirectory })).toBe(true); + const result = await preparer.prepare(references, { + cwd: workingDirectory, + workingDirectory, + }); + + expect(result.resources).toHaveLength(1); + expect(calls.filter(({ url }) => url.includes("/git/trees/")).length).toBe(1); + expect(calls.filter(({ url }) => url.endsWith(`/git/blobs/${blob}`))).toHaveLength(1); + expect(await readFile(join(workingDirectory, "files/0.txt"), "utf8")).toBe("hello world\n"); + + const resolved = resolver.resolve(source, { + cwd: workingDirectory, + preparation: { preparerId: "write-once-github", workingDirectory, result }, + }); + expect(resolved).toMatchObject({ + root: workingDirectory, + path: "files/0.txt", + metadata: { provider: "github", repository: "owner/repo", sourcePath: "src/index.ts" }, + }); + expect(resolver.canResolve("./local.ts", { cwd: workingDirectory })).toBe(false); + expect( + resolver.canResolve(source, { + cwd: workingDirectory, + preparation: { preparerId: "another-plugin", workingDirectory, result }, + }), + ).toBe(false); + expect(() => resolver.resolve(source, { cwd: workingDirectory })).toThrow( + "No GitHub preparation was assigned", + ); + expect(() => + resolver.resolve(source, { + cwd: workingDirectory, + preparation: { + preparerId: "write-once-github", + workingDirectory, + result: { resources: [{ root: workingDirectory }] }, + }, + }), + ).toThrow("was not included in the assigned preparation batch"); + expect(() => + resolver.resolve(source, { + cwd: workingDirectory, + preparation: { + preparerId: "write-once-github", + workingDirectory, + result: { + resources: [{ root: workingDirectory }], + metadata: { files: { [source]: { path: 1 } } }, + }, + }, + }), + ).toThrow("was not included in the assigned preparation batch"); + } finally { + await rm(workingDirectory, { recursive: true, force: true }); + } + }); +}); + describe("prepareGitHubFile", () => { it("discovers default branch and uses tree/blob APIs", async () => { const { calls } = mockApi(); @@ -88,6 +224,28 @@ describe("prepareGitHubFile", () => { ).toBe("v1.0.0"); expect(calls.some(({ url }) => url.includes("/git/ref/tags/v1.0.0"))).toBe(true); }); + it("follows nested annotated tags to their commit", async () => { + const { calls } = mockApi({ annotatedTag: true }); + const result = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo@v1.0.0/src/index.ts"), + ); + expect(result.resolved.commit).toBe(commit); + expect( + calls.filter(({ url }) => url.includes("/git/tags/")).map(({ url }) => url.split("/").at(-1)), + ).toEqual(["tag-sha", "nested-tag-sha"]); + }); + it("paginates branch and tag discovery beyond 100 refs", async () => { + const branches = Array.from({ length: 100 }, (_, index) => `branch-${index}`); + branches.push("feature/nested"); + const { calls } = mockApi({ branches }); + const result = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo@feature/nested/src/index.ts"), + ); + expect(result.resolved.ref).toBe("feature/nested"); + expect( + calls.some(({ url }) => url.includes("/git/matching-refs/heads/?per_page=100&page=2")), + ).toBe(true); + }); it("prefers branches on branch/tag name collision", async () => { const { calls } = mockApi({ branches: ["release"], tags: ["release"] }); await prepareGitHubFile(parseGitHubReference("repo:owner/repo@release/src/index.ts")); @@ -100,6 +258,26 @@ describe("prepareGitHubFile", () => { expect(calls.filter(({ url }) => url.endsWith(`/git/blobs/${blob}`))).toHaveLength(1); expect(calls.filter(({ url }) => url.includes("/git/trees/"))).toHaveLength(1); }); + it("reuses blob downloads across refs that point to the same content", async () => { + const { calls } = mockApi(); + await prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")); + await prepareGitHubFile(parseGitHubReference("repo:owner/repo@v1.0.0/docs/guide.md")); + expect(calls.filter(({ url }) => url.endsWith(`/git/blobs/${blob}`))).toHaveLength(1); + }); + it("reports missing refs", async () => { + mockApi({ branches: ["main"], tags: ["v1.0.0"] }); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo@missing/src/index.ts")), + ).rejects.toThrow('GitHub ref "missing" was not found in owner/repo'); + }); + it("explains permission failures", async () => { + mockApi({ status: 403, rateLimited: false }); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")), + ).rejects.toThrow( + "Check token repository access and Contents: Read-only / Metadata: Read-only permissions.", + ); + }); it("sends tokens for private repository access", async () => { const { calls } = mockApi(); await prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts"), "secret"); @@ -107,6 +285,24 @@ describe("prepareGitHubFile", () => { true, ); }); + it("rejects truncated repository trees", async () => { + mockApi({ truncated: true }); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")), + ).rejects.toThrow("truncated file tree"); + }); + it("rejects unsafe paths even when called with a manually constructed reference", async () => { + mockApi(); + await expect( + prepareGitHubFile({ owner: "owner", repository: "repo", path: "../secret" }), + ).rejects.toThrow('Unsafe GitHub file path "../secret"'); + }); + it("reports unsupported blob encodings", async () => { + mockApi({ blobEncoding: "utf-8" }); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")), + ).rejects.toThrow('unsupported blob encoding "utf-8"'); + }); it("reports missing files", async () => { mockApi(); await expect( diff --git a/tests/reference.test.ts b/tests/reference.test.ts index afdb49c..7ebe668 100644 --- a/tests/reference.test.ts +++ b/tests/reference.test.ts @@ -26,6 +26,11 @@ describe("GitHub references", () => { ])("rejects invalid reference %s", (source) => expect(() => parseGitHubReference(source)).toThrow(), ); + it("rejects sources owned by another resolver", () => { + expect(() => parseGitHubReference("./src/index.ts")).toThrow( + "Unsupported GitHub source reference", + ); + }); it("recognizes provider references", () => { expect(isGitHubReference("repo:owner/repo/a.ts")).toBe(true); expect(isGitHubReference("./a.ts")).toBe(false); From b8ea6e5f592518c051d1276fcf7a02422f1f5bdd Mon Sep 17 00:00:00 2001 From: DanhezCode Date: Sun, 18 Oct 2026 17:23:43 -0400 Subject: [PATCH 08/10] docs: complete GitHub plugin package guidance --- README.md | 14 ++++++++++---- package.json | 4 ++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ef8ce22..c9dcf89 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,16 @@ # write-once-github -GitHub REST API provider for [Write Once](https://github.com/opinionated-ts/write-once). Resolves repository files without the GitHub CLI or a GitHub SDK. +[![npm version](https://img.shields.io/npm/v/write-once-github)](https://www.npmjs.com/package/write-once-github) +[![npm downloads](https://img.shields.io/npm/dm/write-once-github)](https://www.npmjs.com/package/write-once-github) +[![CI](https://img.shields.io/github/actions/workflow/status/opinionated-ts/write-once-github/check-and-release.yml?label=CI)](https://github.com/opinionated-ts/write-once-github/actions/workflows/check-and-release.yml) +[![License](https://img.shields.io/github/license/opinionated-ts/write-once-github)](https://github.com/opinionated-ts/write-once-github/blob/main/LICENSE) + +A [Write Once](https://github.com/opinionated-ts/write-once) plugin that resolves GitHub repository files through the REST API, without the GitHub CLI or an SDK. ## Install ```sh -bun add write-once write-once-github +bun add -d write-once write-once-github ``` Register it in `write-once.config.ts`: @@ -20,9 +25,10 @@ export default defineConfig({ plugins: [githubPlugin] }); ## References - `repo:owner/repository/path/to/file.ts` uses the repository's actual default branch. -- `repo:owner/repository@develop/path/to/file.ts` uses an explicit branch or tag. Branch names containing slashes are supported; branches take precedence over same-named tags. +- `repo:owner/repository@develop/path/to/file.ts` uses an explicit branch or tag. +- `repo:owner/repository@feature/nested/path/to/file.ts` supports branch names containing slashes. -The core remains responsible for whole-file, named-fragment, and range selection. +Branches take precedence over same-named tags. The core remains responsible for whole-file, named-fragment, and range selection. ## Authentication diff --git a/package.json b/package.json index cf4eb91..bb28700 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,14 @@ "dist" ], "type": "module", + "types": "./dist/index.d.mts", "exports": { ".": "./dist/index.mjs", "./package.json": "./package.json" }, + "publishConfig": { + "access": "public" + }, "scripts": { "cspell": "cspell lint .", "fmt": "oxfmt --check .", From be90828a268d1f6b0bec79efaecb7dc113f69dcf Mon Sep 17 00:00:00 2001 From: DanhezCode Date: Mon, 19 Oct 2026 08:10:42 -0400 Subject: [PATCH 09/10] feat(github): use reusable local git checkouts --- README.md | 40 +-- cspell.config.ts | 2 +- src/github-api.ts | 466 ++++++++++++++++--------------- src/index.ts | 50 ++-- src/repository-url.ts | 11 + tests/github-api.test.ts | 589 ++++++++++++++++++++++----------------- tests/reference.test.ts | 12 +- vitest.config.ts | 1 + 8 files changed, 629 insertions(+), 542 deletions(-) create mode 100644 src/repository-url.ts diff --git a/README.md b/README.md index c9dcf89..4489aa8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/opinionated-ts/write-once-github/check-and-release.yml?label=CI)](https://github.com/opinionated-ts/write-once-github/actions/workflows/check-and-release.yml) [![License](https://img.shields.io/github/license/opinionated-ts/write-once-github)](https://github.com/opinionated-ts/write-once-github/blob/main/LICENSE) -A [Write Once](https://github.com/opinionated-ts/write-once) plugin that resolves GitHub repository files through the REST API, without the GitHub CLI or an SDK. +A [Write Once](https://github.com/opinionated-ts/write-once) plugin for resolving files from GitHub repositories through local Git checkouts. ## Install @@ -13,7 +13,7 @@ A [Write Once](https://github.com/opinionated-ts/write-once) plugin that resolve bun add -d write-once write-once-github ``` -Register it in `write-once.config.ts`: +Register the plugin in `write-once.config.ts`: ```ts import { defineConfig } from "write-once"; @@ -24,31 +24,19 @@ export default defineConfig({ plugins: [githubPlugin] }); ## References -- `repo:owner/repository/path/to/file.ts` uses the repository's actual default branch. -- `repo:owner/repository@develop/path/to/file.ts` uses an explicit branch or tag. -- `repo:owner/repository@feature/nested/path/to/file.ts` supports branch names containing slashes. - -Branches take precedence over same-named tags. The core remains responsible for whole-file, named-fragment, and range selection. - -## Authentication - -- Public repositories work anonymously. -- Private repositories require `GITHUB_TOKEN` in the process environment. -- Use a token with access to the repository and fine-grained `Contents: Read-only` and `Metadata: Read-only` permissions. -- The plugin does not read `.env` files. +```text +repo:owner/repository/path/to/file.ts +repo:owner/repository@develop/path/to/file.ts +repo:owner/repository@feature/nested/path/to/file.ts +``` -## Behavior +Without `@ref`, the repository's default branch is used. Explicit references can be branches, tags, or commit SHAs. Branches take precedence over same-named tags. -- Uses Git Trees metadata and existing blob SHAs; caches trees by repository and commit, and text by repository and blob SHA. -- Rejects unsafe paths, binary files, invalid UTF-8, and truncated tree responses. -- Reports actionable errors for missing resources, authentication/permission failures, and rate limits, including `Retry-After` guidance. -- Leaves temporary-resource cleanup to Write Once. +The GitHub plugin only resolves the repository file. Whole-file, named-fragment, and range selection are handled by Write Once. -## Development +## Authentication -```sh -bun install -bun test -bun check -bun run build -``` +- Public repositories require no credentials. +- Private repositories use `GITHUB_TOKEN` from the process environment. +- The token should have access to the repository with `Contents: Read-only` and `Metadata: Read-only` permissions. +- `GITHUB_GIT_PROTOCOL=ssh` uses SSH instead of HTTPS when SSH credentials are already configured. diff --git a/cspell.config.ts b/cspell.config.ts index 9d6e2cf..d9ad72a 100644 --- a/cspell.config.ts +++ b/cspell.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ // Custom import paths here ], - words: [...cspellConfig.words, "ratelimit", "unstub"], + words: [...cspellConfig.words, "extraheader", "gpgsign", "ratelimit", "symref", "unstub"], // Any additional custom configuration here }); diff --git a/src/github-api.ts b/src/github-api.ts index 2b93bd6..025bbcb 100644 --- a/src/github-api.ts +++ b/src/github-api.ts @@ -1,273 +1,287 @@ +import { execFile } from "node:child_process"; +import { access, lstat, mkdir, rename, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; + import type { GitHubSourceReference } from "./reference.js"; -const API_URL = "https://api.github.com"; -const API_VERSION = "2022-11-28"; -interface GitReference { - object: { sha: string; type: string }; -} -interface TreeResponse { - truncated: boolean; - tree: Array<{ path: string; mode: string; type: string; sha: string; size?: number }>; -} -interface RefResponse { - name: string; -} -interface BlobResponse { - encoding: string; - content: string; -} -export interface GitHubFile { - path: string; - sha: string; - size?: number; -} -export interface ResolvedGitHubReference { - owner: string; - repository: string; +import { getRepositoryUrl } from "./repository-url.js"; + +const execFileAsync = promisify(execFile); +const preparations = new Map>(); + +interface RemoteRef { ref: string; commit: string; + kind: "branch" | "tag" | "commit"; path: string; - blob: GitHubFile; } -const treeCache = new Map>(); -const contentCache = new Map>(); -const repoKey = (reference: Pick) => - `${reference.owner}/${reference.repository}`; -async function request(path: string, token?: string): Promise { - const response = await fetch(`${API_URL}${path}`, { - headers: { - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": API_VERSION, - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }); - if (!response.ok) { - const retryAfter = response.headers.get("retry-after"); - const limited = - response.status === 429 || - (response.status === 403 && - (response.headers.get("x-ratelimit-remaining") === "0" || retryAfter !== null)); - const reason = - response.status === 401 - ? "Authentication failed. Check GITHUB_TOKEN." - : response.status === 404 - ? "Repository, ref, or file was not found, or the token cannot access it." - : limited - ? `GitHub rate limit reached.${retryAfter ? ` Retry after ${retryAfter} seconds.` : " Wait for the rate limit to reset."}` - : response.status === 403 - ? "GitHub denied access. Check token repository access and Contents: Read-only / Metadata: Read-only permissions." - : `GitHub API request failed with HTTP ${response.status}.`; - throw new Error(`${reason} (${response.status} ${response.statusText}; ${path}).`); +interface PreparedGitHubFile { + root: string; + path: string; + resolved: { + owner: string; + repository: string; + ref: string; + commit: string; + path: string; + }; +} + +function gitEnvironment(token?: string): NodeJS.ProcessEnv { + const env = { ...process.env }; + // Git authentication uses an HTTP header so credentials are not embedded in the remote URL. + if (token) { + env["GIT_CONFIG_COUNT"] = "1"; + env["GIT_CONFIG_KEY_0"] = "http.extraheader"; + env["GIT_CONFIG_VALUE_0"] = + `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`; } - // The response shape is endpoint-specific and defined at each call site. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - return (await response.json()) as T; + return env; } -async function getCommit( - reference: GitHubSourceReference, - ref: string, - token?: string, +async function git( + args: string[], + options: { cwd?: string; token?: string } = {}, ): Promise { - const encoded = ref.split("/").map(encodeURIComponent).join("/"); - const result = await request( - `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/ref/heads/${encoded}`, - token, - ).catch(async (error: unknown) => - request( - `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/ref/tags/${encoded}`, - token, - ).catch(() => { - throw error; - }), - ); + try { + const { stdout } = await execFileAsync("git", args, { + cwd: options.cwd, + env: gitEnvironment(options.token), + maxBuffer: 10 * 1024 * 1024, + windowsHide: true, + }); + return stdout.trim(); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Git command failed: ${detail}`, { cause: error }); + } +} - let object = result.object; - const visited = new Set(); - while (object.type === "tag") { - if (visited.has(object.sha)) { - throw new Error(`GitHub tag ${JSON.stringify(ref)} contains a circular tag reference.`); - } - visited.add(object.sha); - // Annotated tags may point to another tag instead of directly to a commit. - // oxlint-disable-next-line eslint/no-await-in-loop - const tag = await request( - `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/tags/${object.sha}`, - token, - ); - object = tag.object; +function safeSegment(value: string): string { + return encodeURIComponent(value); +} + +async function getRemoteRef(reference: GitHubSourceReference, token?: string): Promise { + const url = getRepositoryUrl(reference); + const requested = reference.ref; + if (!requested) { + const output = await git(["ls-remote", "--symref", url, "HEAD"], { token }); + const lines = output.split("\n").filter(Boolean); + const symbolicHead = lines.find((line) => line.startsWith("ref: ") && line.endsWith("\tHEAD")); + const headRef = symbolicHead?.slice(5).split("\t", 1)[0]; + const headSha = lines + .find((line) => line.endsWith("\tHEAD") && !line.startsWith("ref: ")) + ?.split("\t", 1)[0]; + if (!headRef || !headSha) + throw new Error( + `Could not determine the default branch for ${reference.owner}/${reference.repository}.`, + ); + return { + ref: headRef.slice("refs/heads/".length), + commit: headSha, + kind: "branch", + path: reference.path, + }; } - if (object.type === "commit") return object.sha; - throw new Error(`GitHub ref ${JSON.stringify(ref)} does not resolve to a commit.`); + if (/^(?:[\da-f]{40}|[\da-f]{64})$/i.test(requested)) + return { ref: requested, commit: requested, kind: "commit", path: reference.path }; + + // A slash can belong to either a branch name or the file path, so match the longest remote ref prefix. + const pathParts = reference.path.split("/"); + const candidates = [ + requested, + ...pathParts + .slice(0, -1) + .map((_, index) => [requested, ...pathParts.slice(0, index + 1)].join("/")), + ]; + const patterns = candidates.flatMap((candidate) => [ + `refs/heads/${candidate}`, + `refs/tags/${candidate}`, + `refs/tags/${candidate}^{}`, + ]); + const output = await git(["ls-remote", url, ...patterns], { token }); + const refs = new Map(); + for (const line of output.split("\n").filter(Boolean)) { + const [sha, name] = line.split("\t"); + if (sha && name) refs.set(name, sha); + } + + for (let index = candidates.length - 1; index >= 0; index--) { + const candidate = candidates[index]!; + const branch = refs.get(`refs/heads/${candidate}`); + if (branch) { + return { + ref: candidate, + commit: branch, + kind: "branch", + path: pathParts.slice(index).join("/"), + }; + } + const tag = refs.get(`refs/tags/${candidate}^{}`) ?? refs.get(`refs/tags/${candidate}`); + if (tag) { + return { + ref: candidate, + commit: tag, + kind: "tag", + path: pathParts.slice(index).join("/"), + }; + } + } + throw new Error( + `Git reference ${JSON.stringify(requested)} was not found in ${reference.owner}/${reference.repository}.`, + ); } -async function getRefNames( - reference: GitHubSourceReference, - kind: "heads" | "tags", - token?: string, -): Promise { - const prefix = `refs/${kind}/`; - const names: string[] = []; - for (let page = 1; ; page++) { - // GitHub caps pages at 100; keep requesting while a full page is returned. - // oxlint-disable-next-line eslint/no-await-in-loop - const refs = await request( - `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/matching-refs/${kind}/?per_page=100&page=${page}`, - token, - ); - names.push( - ...refs - .map(({ name }) => (name.startsWith(prefix) ? name.slice(prefix.length) : name)) - .filter(Boolean), - ); - if (refs.length < 100) break; +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; } - return names.toSorted((a, b) => b.length - a.length || a.localeCompare(b)); } -async function resolveRef( - reference: GitHubSourceReference, - token?: string, -): Promise<{ ref: string; path: string; commit: string }> { - if (reference.ref === undefined) { - const repository = await request<{ default_branch: string }>( - `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}`, - token, - ); - const ref = repository.default_branch; - return { ref, path: reference.path, commit: await getCommit(reference, ref, token) }; +async function assertSafeFile(root: string, filePath: string): Promise { + if ( + !filePath || + filePath.startsWith("/") || + filePath.includes("\\") || + filePath.split("/").some((part) => !part || part === "." || part === "..") + ) { + throw new Error(`Unsafe GitHub file path ${JSON.stringify(filePath)}.`); } - const [branches, tags] = await Promise.all([ - getRefNames(reference, "heads", token), - getRefNames(reference, "tags", token), - ]); - const branchSet = new Set(branches); - const tagSet = new Set(tags); - const candidates = [...reference.ref.split("/"), ...reference.path.split("/")]; - let length = 0; - for (let index = candidates.length; index > 0; index--) { - const candidate = candidates.slice(0, index).join("/"); - if (branchSet.has(candidate) || tagSet.has(candidate)) { - length = index; - break; - } + + const parts = filePath.split("/"); + const entries = await Promise.all( + parts.map(async (_, index) => { + const path = join(root, ...parts.slice(0, index + 1)); + try { + return await lstat(path); + } catch { + throw new Error( + `File ${JSON.stringify(filePath)} was not found in the prepared GitHub repository.`, + ); + } + }), + ); + for (const [index, info] of entries.entries()) { + if (info.isSymbolicLink()) + throw new Error(`GitHub file path ${JSON.stringify(filePath)} traverses a symbolic link.`); + if (index < entries.length - 1 && !info.isDirectory()) + throw new Error( + `GitHub file path ${JSON.stringify(filePath)} contains a non-directory parent.`, + ); + if (index === entries.length - 1 && !info.isFile()) + throw new Error(`GitHub path ${JSON.stringify(filePath)} is not a regular file.`); } - if (!length) - throw new Error( - `GitHub ref ${JSON.stringify(reference.ref)} was not found in ${repoKey(reference)}. Check the branch or tag name.`, - ); - const ref = candidates.slice(0, length).join("/"); - const path = candidates.slice(length).join("/"); - return { ref, path, commit: await getCommit(reference, ref, token) }; } -async function getTree( - reference: GitHubSourceReference, - commit: string, - token?: string, -): Promise { - const key = `${repoKey(reference)}@${commit}`; - let pending = treeCache.get(key); - if (!pending) { - pending = request( - `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/trees/${commit}?recursive=1`, - token, - ) - .then((result) => { - if (result.truncated) - throw new Error( - `GitHub returned a truncated file tree for ${repoKey(reference)}@${commit}; the requested file cannot be verified safely.`, - ); - return result.tree - .filter((entry) => entry.type === "blob" && entry.mode !== "120000") - .map(({ path, sha, size }) => { - const file: GitHubFile = { path, sha }; - if (size !== undefined) file.size = size; - return file; - }); - }) - .catch((error: unknown) => { - treeCache.delete(key); - throw error; - }); - treeCache.set(key, pending); +async function isReusable(path: string, commit: string, token?: string): Promise { + if (!(await pathExists(join(path, ".git")))) return false; + try { + const [head, status] = await Promise.all([ + git(["rev-parse", "HEAD"], { cwd: path, token }), + git(["status", "--porcelain"], { cwd: path, token }), + ]); + return head === commit && status === ""; + } catch { + return false; } - return await pending; } -async function getText( +async function checkoutRepository( reference: GitHubSourceReference, - blob: GitHubFile, + root: string, + remote: RemoteRef, token?: string, -): Promise { - const key = `${repoKey(reference)}@${blob.sha}`; - let pending = contentCache.get(key); - if (!pending) { - pending = request( - `/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repository)}/git/blobs/${blob.sha}`, - token, - ) - .then((result) => { - if (result.encoding !== "base64") - throw new Error( - `GitHub returned unsupported blob encoding ${JSON.stringify(result.encoding)} for ${blob.path}.`, - ); - const bytes = Uint8Array.from(atob(result.content.replace(/\s/g, "")), (character) => - character.charCodeAt(0), - ); - if (bytes.includes(0)) - throw new Error( - `GitHub file ${JSON.stringify(blob.path)} is binary and cannot be used as text.`, - ); - try { - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch { - throw new Error(`GitHub file ${JSON.stringify(blob.path)} is not valid UTF-8 text.`); - } - }) - .catch((error: unknown) => { - contentCache.delete(key); - throw error; - }); - contentCache.set(key, pending); +): Promise { + const parent = dirname(root); + await mkdir(parent, { recursive: true }); + const temporary = `${root}.tmp-${process.pid}-${crypto.randomUUID()}`; + await rm(temporary, { recursive: true, force: true }); + try { + await git( + [ + "clone", + "--filter=blob:none", + "--no-checkout", + "--", + getRepositoryUrl(reference), + temporary, + ], + { token }, + ); + const fetchRef = + remote.kind === "branch" + ? `refs/heads/${remote.ref}` + : remote.kind === "tag" + ? `refs/tags/${remote.ref}` + : remote.commit; + await git(["fetch", "--depth=1", "origin", fetchRef], { cwd: temporary, token }); + await git(["checkout", "--detach", remote.commit], { cwd: temporary, token }); + await rm(root, { recursive: true, force: true }); + await rename(temporary, root); + } catch (error) { + await rm(temporary, { recursive: true, force: true }); + throw error; } - return await pending; } +/** Prepares a local checkout and returns the requested file's path within it. */ export async function prepareGitHubFile( reference: GitHubSourceReference, + workingDirectory: string, token?: string, -): Promise<{ resolved: ResolvedGitHubReference; text: string }> { - const selected = await resolveRef(reference, token); +): Promise { if ( - !selected.path || - selected.path.startsWith("/") || - selected.path.split("/").some((part) => !part || part === "." || part === "..") + !reference.path || + reference.path.startsWith("/") || + reference.path.includes("\\") || + reference.path.split("/").some((part) => !part || part === "." || part === "..") ) { - throw new Error(`Unsafe GitHub file path ${JSON.stringify(selected.path)}.`); + throw new Error(`Unsafe GitHub file path ${JSON.stringify(reference.path)}.`); } - const tree = await getTree(reference, selected.commit, token); - const blob = tree.find((file) => file.path === selected.path); - if (!blob) - throw new Error( - `File ${JSON.stringify(selected.path)} was not found in ${repoKey(reference)}@${selected.ref}. Check the path and ref.`, - ); + const remote = await getRemoteRef(reference, token); + const state = + remote.kind === "commit" + ? `commit-${remote.commit}` + : `${remote.kind}-${safeSegment(remote.ref)}`; + const root = join(workingDirectory, reference.owner, reference.repository, state); + const key = `${root}\0${remote.commit}`; + let pending = preparations.get(key); + if (!pending) { + pending = (async () => { + if (!(await isReusable(root, remote.commit, token))) + await checkoutRepository(reference, root, remote, token); + return root; + })().catch((error: unknown) => { + if (preparations.get(key) === pending) preparations.delete(key); + throw error; + }); + preparations.set(key, pending); + } + try { + await pending; + } finally { + if (preparations.get(key) === pending) preparations.delete(key); + } + await assertSafeFile(root, remote.path); return { + root, + path: remote.path, resolved: { owner: reference.owner, repository: reference.repository, - ref: selected.ref, - commit: selected.commit, - path: selected.path, - blob, + ref: remote.ref, + commit: remote.commit, + path: remote.path, }, - text: await getText(reference, blob, token), }; } export function clearGitHubCaches(): void { - treeCache.clear(); - contentCache.clear(); + preparations.clear(); } diff --git a/src/index.ts b/src/index.ts index 69a7d74..b302fcc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,24 +1,21 @@ import type { PluginFactory, ResourceContext, SourceReferenceContext } from "write-once"; -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; - import { prepareGitHubFile } from "./github-api.js"; import { isGitHubReference, parseGitHubReference } from "./reference.js"; interface PreparedFile { + root: string; path: string; owner: string; repository: string; ref: string; commit: string; sourcePath: string; - blobSha: string; } const PLUGIN_ID = "write-once-github"; function isPreparedFile(value: unknown): value is PreparedFile { if (typeof value !== "object" || value === null) return false; - return ["path", "owner", "repository", "ref", "commit", "sourcePath", "blobSha"].every( + return ["root", "path", "owner", "repository", "ref", "commit", "sourcePath"].every( (key) => typeof Reflect.get(value, key) === "string", ); } @@ -52,38 +49,42 @@ export const githubPlugin: PluginFactory = () => { async prepare(references, context) { const token = process.env["GITHUB_TOKEN"]; const root = context.workingDirectory; - await mkdir(root, { recursive: true }); - // A source can feed multiple destinations; prepare its content only once per run. + + // A source can feed multiple destinations; materialize it only once per run. const uniqueReferences = new Map( references.map((reference) => [reference.source, reference]), ); - const prepared = await Promise.all( - [...uniqueReferences.values()].map(async (reference, index) => { - const { resolved, text } = await prepareGitHubFile( + const preparedFiles = await Promise.all( + [...uniqueReferences.values()].map(async (reference) => { + const prepared = await prepareGitHubFile( parseGitHubReference(reference.source), + root, token, ); - const path = `files/${index}.txt`; - const destination = join(root, path); - await mkdir(dirname(destination), { recursive: true }); - await writeFile(destination, text, "utf8"); return { source: reference.source, metadata: { - path, - owner: resolved.owner, - repository: resolved.repository, - ref: resolved.ref, - commit: resolved.commit, - sourcePath: resolved.path, - blobSha: resolved.blob.sha, + root: prepared.root, + path: prepared.path, + owner: prepared.resolved.owner, + repository: prepared.resolved.repository, + ref: prepared.resolved.ref, + commit: prepared.resolved.commit, + sourcePath: prepared.resolved.path, } satisfies PreparedFile, }; }), ); const files: Record = {}; - for (const file of prepared) files[file.source] = file.metadata; - return { resources: [{ root, metadata: { provider: "github" } }], metadata: { files } }; + const resources = new Map(); + for (const file of preparedFiles) { + files[file.source] = file.metadata; + resources.set(file.metadata.root, { + root: file.metadata.root, + metadata: { provider: "github" }, + }); + } + return { resources: [...resources.values()], metadata: { files } }; }, }, ], @@ -99,7 +100,7 @@ export const githubPlugin: PluginFactory = () => { throw new Error(`No GitHub preparation was assigned to ${JSON.stringify(source)}.`); const file = getPreparedFile(source, context); return { - root: preparation.result.resources[0]!.root, + root: file.root, path: file.path, metadata: { provider: "github", @@ -107,7 +108,6 @@ export const githubPlugin: PluginFactory = () => { ref: file.ref, commit: file.commit, sourcePath: file.sourcePath, - blobSha: file.blobSha, }, }; }, diff --git a/src/repository-url.ts b/src/repository-url.ts new file mode 100644 index 0000000..00c450d --- /dev/null +++ b/src/repository-url.ts @@ -0,0 +1,11 @@ +import type { GitHubSourceReference } from "./reference.js"; + +/** Builds the Git remote URL without placing credentials in the URL itself. */ +export function getRepositoryUrl(reference: GitHubSourceReference): string { + const repository = `${reference.owner}/${reference.repository}.git`; + const baseUrl = process.env["GITHUB_GIT_BASE_URL"]; + if (baseUrl) return `${baseUrl.replace(/\/$/, "")}/${repository}`; + return process.env["GITHUB_GIT_PROTOCOL"] === "ssh" + ? `git@github.com:${repository}` + : `https://github.com/${repository}`; +} diff --git a/tests/github-api.test.ts b/tests/github-api.test.ts index 89ae83f..0507600 100644 --- a/tests/github-api.test.ts +++ b/tests/github-api.test.ts @@ -1,6 +1,7 @@ import type { SourceReferenceContext } from "write-once"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile, mkdir, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -8,96 +9,62 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { clearGitHubCaches, prepareGitHubFile } from "@/github-api"; import { githubPlugin } from "@/index"; import { parseGitHubReference } from "@/reference"; -const repoUrl = "https://api.github.com/repos/owner/repo"; -const commit = "commit-sha"; -const blob = "blob-sha"; -function json(data: unknown, status = 200, headers?: Record): Response { - return new Response(JSON.stringify(data), { status, headers }); +import { getRepositoryUrl } from "@/repository-url"; + +let temporary: string | undefined; + +function git(args: string[], cwd?: string): string { + return execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { + cwd, + encoding: "utf8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); } -function mockApi( - options: { - branches?: string[]; - tags?: string[]; - blobContent?: string; - status?: number; - rateLimited?: boolean; - annotatedTag?: boolean; - truncated?: boolean; - blobEncoding?: string; - } = {}, -) { - const calls: Array<{ url: string; headers: Headers }> = []; - const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - calls.push({ url, headers: new Headers(init?.headers) }); - if (options.status) - return json( - {}, - options.status, - options.rateLimited === false ? {} : { "retry-after": "60", "x-ratelimit-remaining": "0" }, - ); - if (url === repoUrl) return json({ default_branch: "main" }); - if (url.includes("/git/matching-refs/heads/")) { - const branches = options.branches ?? ["main", "feature/nested"]; - const page = Number(new URL(url).searchParams.get("page") ?? 1); - return json( - branches.slice((page - 1) * 100, page * 100).map((name) => ({ - name: `refs/heads/${name}`, - })), - ); - } - if (url.includes("/git/matching-refs/tags/")) { - const tags = options.tags ?? ["v1.0.0"]; - const page = Number(new URL(url).searchParams.get("page") ?? 1); - return json( - tags.slice((page - 1) * 100, page * 100).map((name) => ({ name: `refs/tags/${name}` })), - ); - } - if (url.includes("/git/ref/heads/")) { - const ref = decodeURIComponent(url.split("/git/ref/heads/")[1] ?? ""); - return (options.branches ?? ["main", "feature/nested"]).includes(ref) - ? json({ object: { sha: commit, type: "commit" } }) - : json({}, 404); - } - if (url.includes("/git/ref/tags/")) { - const ref = decodeURIComponent(url.split("/git/ref/tags/")[1] ?? ""); - return (options.tags ?? ["v1.0.0"]).includes(ref) - ? json({ - object: options.annotatedTag - ? { sha: "tag-sha", type: "tag" } - : { sha: commit, type: "commit" }, - }) - : json({}, 404); - } - if (url.endsWith("/git/tags/tag-sha")) - return json({ object: { sha: "nested-tag-sha", type: "tag" } }); - if (url.endsWith("/git/tags/nested-tag-sha")) - return json({ object: { sha: commit, type: "commit" } }); - if (url.includes(`/git/trees/${commit}?recursive=1`)) - return json({ - truncated: options.truncated ?? false, - tree: [ - { path: "src/index.ts", mode: "100644", type: "blob", sha: blob }, - { path: "docs/guide.md", mode: "100644", type: "blob", sha: blob }, - { path: "image.png", mode: "100644", type: "blob", sha: "binary-sha" }, - ], - }); - if (url.endsWith(`/git/blobs/${blob}`)) - return json({ - encoding: options.blobEncoding ?? "base64", - content: options.blobContent ?? btoa("hello world\n"), - }); - if (url.endsWith("/git/blobs/binary-sha")) - return json({ encoding: "base64", content: btoa("a\0b") }); - return json({}, 404); - }); - vi.stubGlobal("fetch", fetchMock); - return { calls, fetchMock }; + +async function createRepository() { + temporary = await mkdtemp(join(tmpdir(), "write-once-github-test-")); + const remoteRoot = join(temporary, "remotes"); + const remote = join(remoteRoot, "owner", "repo.git"); + const working = join(temporary, "source"); + await mkdir(join(remoteRoot, "owner"), { recursive: true }); + git(["init", "--bare", "--initial-branch=main", remote]); + git(["init", "--initial-branch=main", working]); + git(["config", "user.name", "Test"], working); + git(["config", "user.email", "test@example.com"], working); + await mkdir(join(working, "src"), { recursive: true }); + await mkdir(join(working, "docs"), { recursive: true }); + await writeFile(join(working, "src", "index.ts"), "export const value = 'main';\n"); + await writeFile(join(working, "docs", "guide.md"), "main guide\n"); + await symlink("src/index.ts", join(working, "linked.ts")); + git(["add", "."], working); + git(["commit", "-m", "initial"], working); + const mainCommit = git(["rev-parse", "HEAD"], working); + git(["tag", "v1.0.0"], working); + git(["tag", "-a", "v2.0.0", "-m", "annotated release", "main"], working); + git(["checkout", "-b", "feature/nested", "main"], working); + git(["push", remote, "feature/nested"], working); + git(["checkout", "-b", "develop"], working); + await writeFile(join(working, "src", "index.ts"), "export const value = 'develop';\n"); + git(["commit", "-am", "develop"], working); + git(["push", remote, "main", "develop", "--tags"], working); + git(["symbolic-ref", "HEAD", "refs/heads/main"], remote); + vi.stubEnv("GITHUB_GIT_BASE_URL", `file://${remoteRoot}`); + vi.stubEnv("GITHUB_GIT_PROTOCOL", "https"); + return { working, remote, remoteRoot, mainCommit, root: join(temporary, "plugin-work") }; } -afterEach(() => { - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); + +afterEach(async () => { clearGitHubCaches(); + vi.unstubAllEnvs(); + if (temporary) await rm(temporary, { recursive: true, force: true }); + temporary = undefined; }); function sourceReference(source: string, destination: string): SourceReferenceContext { @@ -125,213 +92,315 @@ function sourceReference(source: string, destination: string): SourceReferenceCo }, }; } -describe("githubPlugin", () => { - it("prepares each unique source once and resolves its prepared resource", async () => { - const { calls } = mockApi(); - vi.stubEnv("GITHUB_TOKEN", ""); - const workingDirectory = await mkdtemp(join(tmpdir(), "write-once-github-test-")); - try { - const plugin = githubPlugin(); - const preparer = plugin.preparers![0]!; - const resolver = plugin.resolvers![0]!; - expect(plugin.extractors).toBeUndefined(); - const source = "repo:owner/repo/src/index.ts"; - const references = [sourceReference(source, "README.md"), sourceReference(source, "docs.md")]; - expect(preparer.canPrepare(references[0]!, { cwd: workingDirectory })).toBe(true); - const result = await preparer.prepare(references, { - cwd: workingDirectory, - workingDirectory, - }); - expect(result.resources).toHaveLength(1); - expect(calls.filter(({ url }) => url.includes("/git/trees/")).length).toBe(1); - expect(calls.filter(({ url }) => url.endsWith(`/git/blobs/${blob}`))).toHaveLength(1); - expect(await readFile(join(workingDirectory, "files/0.txt"), "utf8")).toBe("hello world\n"); +describe("getRepositoryUrl", () => { + afterEach(() => vi.unstubAllEnvs()); - const resolved = resolver.resolve(source, { - cwd: workingDirectory, - preparation: { preparerId: "write-once-github", workingDirectory, result }, - }); - expect(resolved).toMatchObject({ - root: workingDirectory, - path: "files/0.txt", - metadata: { provider: "github", repository: "owner/repo", sourcePath: "src/index.ts" }, - }); - expect(resolver.canResolve("./local.ts", { cwd: workingDirectory })).toBe(false); - expect( - resolver.canResolve(source, { - cwd: workingDirectory, - preparation: { preparerId: "another-plugin", workingDirectory, result }, - }), - ).toBe(false); - expect(() => resolver.resolve(source, { cwd: workingDirectory })).toThrow( - "No GitHub preparation was assigned", - ); - expect(() => - resolver.resolve(source, { - cwd: workingDirectory, - preparation: { - preparerId: "write-once-github", - workingDirectory, - result: { resources: [{ root: workingDirectory }] }, - }, - }), - ).toThrow("was not included in the assigned preparation batch"); - expect(() => - resolver.resolve(source, { - cwd: workingDirectory, - preparation: { - preparerId: "write-once-github", - workingDirectory, - result: { - resources: [{ root: workingDirectory }], - metadata: { files: { [source]: { path: 1 } } }, - }, - }, - }), - ).toThrow("was not included in the assigned preparation batch"); - } finally { - await rm(workingDirectory, { recursive: true, force: true }); - } + it("uses HTTPS by default and supports configured SSH", () => { + expect(getRepositoryUrl({ owner: "owner", repository: "repo", path: "README.md" })).toBe( + "https://github.com/owner/repo.git", + ); + vi.stubEnv("GITHUB_GIT_PROTOCOL", "ssh"); + expect(getRepositoryUrl({ owner: "owner", repository: "repo", path: "README.md" })).toBe( + "git@github.com:owner/repo.git", + ); + }); + + it("allows a custom Git remote base URL for testing and GitHub Enterprise", () => { + vi.stubEnv("GITHUB_GIT_BASE_URL", "https://github.example.test/"); + expect(getRepositoryUrl({ owner: "owner", repository: "repo", path: "README.md" })).toBe( + "https://github.example.test/owner/repo.git", + ); }); }); describe("prepareGitHubFile", () => { - it("discovers default branch and uses tree/blob APIs", async () => { - const { calls } = mockApi(); - const result = await prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")); + it("materializes the default branch and returns a path inside the checkout", async () => { + const setup = await createRepository(); + const result = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo/src/index.ts"), + setup.root, + ); + expect(result.root).toBe(join(setup.root, "owner", "repo", "branch-main")); expect(result.resolved).toMatchObject({ ref: "main", - commit, + commit: setup.mainCommit, path: "src/index.ts", - blob: { sha: blob }, }); - expect(result.text).toBe("hello world\n"); - expect(calls.some(({ url }) => url.includes("/git/trees/"))).toBe(true); - }); - it("supports refs containing slashes", async () => { - mockApi(); - expect( - (await prepareGitHubFile(parseGitHubReference("repo:owner/repo@feature/nested/src/index.ts"))) - .resolved, - ).toMatchObject({ ref: "feature/nested", path: "src/index.ts" }); - }); - it("resolves tags", async () => { - const { calls } = mockApi(); - expect( - (await prepareGitHubFile(parseGitHubReference("repo:owner/repo@v1.0.0/src/index.ts"))) - .resolved.ref, - ).toBe("v1.0.0"); - expect(calls.some(({ url }) => url.includes("/git/ref/tags/v1.0.0"))).toBe(true); + expect(await readFile(join(result.root, result.path), "utf8")).toBe( + "export const value = 'main';\n", + ); }); - it("follows nested annotated tags to their commit", async () => { - const { calls } = mockApi({ annotatedTag: true }); - const result = await prepareGitHubFile( + + it("resolves branch names containing slashes and keeps states in separate checkouts", async () => { + const setup = await createRepository(); + const main = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo/src/index.ts"), + setup.root, + ); + const develop = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo@develop/src/index.ts"), + setup.root, + ); + const nested = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo@feature/nested/src/index.ts"), + setup.root, + ); + const tag = await prepareGitHubFile( parseGitHubReference("repo:owner/repo@v1.0.0/src/index.ts"), + setup.root, + ); + const annotatedTag = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo@v2.0.0/src/index.ts"), + setup.root, + ); + const commit = await prepareGitHubFile( + parseGitHubReference(`repo:owner/repo@${setup.mainCommit}/src/index.ts`), + setup.root, ); - expect(result.resolved.commit).toBe(commit); + + expect(develop.root).toContain("branch-develop"); + expect(nested.resolved.ref).toBe("feature/nested"); + expect(nested.path).toBe("src/index.ts"); + expect(tag.root).toContain("tag-v1.0.0"); + expect(commit.root).toContain(`commit-${setup.mainCommit}`); expect( - calls.filter(({ url }) => url.includes("/git/tags/")).map(({ url }) => url.split("/").at(-1)), - ).toEqual(["tag-sha", "nested-tag-sha"]); + new Set([main.root, develop.root, nested.root, tag.root, annotatedTag.root, commit.root]) + .size, + ).toBe(6); + expect(await readFile(join(develop.root, develop.path), "utf8")).toContain("develop"); + expect(await readFile(join(tag.root, tag.path), "utf8")).toContain("main"); + expect(annotatedTag.resolved.commit).toBe(setup.mainCommit); + expect(await readFile(join(annotatedTag.root, annotatedTag.path), "utf8")).toContain("main"); }); - it("paginates branch and tag discovery beyond 100 refs", async () => { - const branches = Array.from({ length: 100 }, (_, index) => `branch-${index}`); - branches.push("feature/nested"); - const { calls } = mockApi({ branches }); - const result = await prepareGitHubFile( - parseGitHubReference("repo:owner/repo@feature/nested/src/index.ts"), + + it("shares a checkout for multiple files in the same repository state", async () => { + const setup = await createRepository(); + const first = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo/src/index.ts"), + setup.root, ); - expect(result.resolved.ref).toBe("feature/nested"); - expect( - calls.some(({ url }) => url.includes("/git/matching-refs/heads/?per_page=100&page=2")), - ).toBe(true); + clearGitHubCaches(); + const second = await prepareGitHubFile( + parseGitHubReference("repo:owner/repo/docs/guide.md"), + setup.root, + ); + expect(second.root).toBe(first.root); + expect(second.path).toBe("docs/guide.md"); + expect(await readFile(join(second.root, second.path), "utf8")).toBe("main guide\n"); }); - it("prefers branches on branch/tag name collision", async () => { - const { calls } = mockApi({ branches: ["release"], tags: ["release"] }); - await prepareGitHubFile(parseGitHubReference("repo:owner/repo@release/src/index.ts")); - expect(calls.some(({ url }) => url.includes("/git/ref/heads/release"))).toBe(true); + + it("refreshes a branch checkout when the remote commit changes", async () => { + const setup = await createRepository(); + const reference = parseGitHubReference("repo:owner/repo/src/index.ts"); + const first = await prepareGitHubFile(reference, setup.root); + git(["checkout", "main"], setup.working); + await writeFile(join(setup.working, "src", "index.ts"), "export const value = 'updated';\n"); + git(["commit", "-am", "updated"], setup.working); + git(["push", setup.remote, "main"], setup.working); + clearGitHubCaches(); + const second = await prepareGitHubFile(reference, setup.root); + expect(second.root).toBe(first.root); + expect(second.resolved.commit).not.toBe(first.resolved.commit); + expect(await readFile(join(second.root, second.path), "utf8")).toContain("updated"); }); - it("caches tree metadata and duplicate blob downloads", async () => { - const { calls } = mockApi(); - await prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")); - await prepareGitHubFile(parseGitHubReference("repo:owner/repo/docs/guide.md")); - expect(calls.filter(({ url }) => url.endsWith(`/git/blobs/${blob}`))).toHaveLength(1); - expect(calls.filter(({ url }) => url.includes("/git/trees/"))).toHaveLength(1); + + it("integrates with Write Once by resolving files inside shared repository checkouts", async () => { + const setup = await createRepository(); + const plugin = githubPlugin(); + const preparer = plugin.preparers![0]!; + const resolver = plugin.resolvers![0]!; + expect(plugin.extractors).toBeUndefined(); + expect( + preparer.canPrepare(sourceReference("./local.ts", "README.md"), { cwd: setup.root }), + ).toBe(false); + const firstSource = "repo:owner/repo/src/index.ts"; + const secondSource = "repo:owner/repo/docs/guide.md"; + const references = [ + sourceReference(firstSource, "README.md"), + sourceReference(secondSource, "docs.md"), + ]; + const result = await preparer.prepare(references, { + cwd: setup.root, + workingDirectory: setup.root, + }); + expect(result.resources).toHaveLength(1); + const preparation = { preparerId: "write-once-github", workingDirectory: setup.root, result }; + expect(resolver.canResolve("./local.ts", { cwd: setup.root })).toBe(false); + expect( + resolver.canResolve(firstSource, { + cwd: setup.root, + preparation: { ...preparation, preparerId: "other" }, + }), + ).toBe(false); + expect(() => resolver.resolve(firstSource, { cwd: setup.root })).toThrow( + "No GitHub preparation was assigned", + ); + expect(() => + resolver.resolve(firstSource, { + cwd: setup.root, + preparation: { + ...preparation, + result: { resources: [], metadata: { files: { [firstSource]: { root: 42 } } } }, + }, + }), + ).toThrow("was not included in the assigned preparation batch"); + expect(() => + resolver.resolve(firstSource, { + cwd: setup.root, + preparation: { ...preparation, result: { resources: [] } }, + }), + ).toThrow("was not included in the assigned preparation batch"); + const first = await resolver.resolve(firstSource, { cwd: setup.root, preparation }); + const second = await resolver.resolve(secondSource, { cwd: setup.root, preparation }); + expect(first.root).toBe(second.root); + expect(first.path).toBe("src/index.ts"); + expect(second.path).toBe("docs/guide.md"); + expect(await readFile(join(first.root, first.path), "utf8")).toContain("main"); }); - it("reuses blob downloads across refs that point to the same content", async () => { - const { calls } = mockApi(); - await prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")); - await prepareGitHubFile(parseGitHubReference("repo:owner/repo@v1.0.0/docs/guide.md")); - expect(calls.filter(({ url }) => url.endsWith(`/git/blobs/${blob}`))).toHaveLength(1); + + it("reuses a clean checkout and accepts an HTTPS token", async () => { + const setup = await createRepository(); + const reference = parseGitHubReference("repo:owner/repo/src/index.ts"); + const first = await prepareGitHubFile(reference, setup.root, "test-token"); + clearGitHubCaches(); + const second = await prepareGitHubFile(reference, setup.root, "test-token"); + expect(second.root).toBe(first.root); + expect(second.resolved.commit).toBe(first.resolved.commit); }); - it("reports missing refs", async () => { - mockApi({ branches: ["main"], tags: ["v1.0.0"] }); + + it("reports when a repository has no default branch", async () => { + temporary = await mkdtemp(join(tmpdir(), "write-once-github-empty-")); + const remoteRoot = join(temporary, "remotes"); + const remote = join(remoteRoot, "owner", "repo.git"); + await mkdir(join(remoteRoot, "owner"), { recursive: true }); + git(["init", "--bare", "--initial-branch=main", remote]); + vi.stubEnv("GITHUB_GIT_BASE_URL", `file://${remoteRoot}`); await expect( - prepareGitHubFile(parseGitHubReference("repo:owner/repo@missing/src/index.ts")), - ).rejects.toThrow('GitHub ref "missing" was not found in owner/repo'); + prepareGitHubFile( + parseGitHubReference("repo:owner/repo/README.md"), + join(temporary, "plugin-work"), + ), + ).rejects.toThrow("Could not determine the default branch"); }); - it("explains permission failures", async () => { - mockApi({ status: 403, rateLimited: false }); + + it("reports Git failures when the repository cannot be reached", async () => { + const setup = await createRepository(); + vi.stubEnv("GITHUB_GIT_BASE_URL", join(temporary!, "missing-remote")); await expect( - prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")), - ).rejects.toThrow( - "Check token repository access and Contents: Read-only / Metadata: Read-only permissions.", - ); + prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts"), setup.root), + ).rejects.toThrow("Git command failed"); }); - it("sends tokens for private repository access", async () => { - const { calls } = mockApi(); - await prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts"), "secret"); - expect(calls.every(({ headers }) => headers.get("authorization") === "Bearer secret")).toBe( - true, - ); + + it("replaces a checkout whose Git metadata is corrupted", async () => { + const setup = await createRepository(); + const reference = parseGitHubReference("repo:owner/repo/src/index.ts"); + const prepared = await prepareGitHubFile(reference, setup.root); + await rm(join(prepared.root, ".git"), { recursive: true, force: true }); + await mkdir(join(prepared.root, ".git"), { recursive: true }); + clearGitHubCaches(); + const refreshed = await prepareGitHubFile(reference, setup.root); + expect(await readFile(join(refreshed.root, refreshed.path), "utf8")).toContain("main"); }); - it("rejects truncated repository trees", async () => { - mockApi({ truncated: true }); - await expect( - prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")), - ).rejects.toThrow("truncated file tree"); + + it("replaces a dirty checkout before resolving content", async () => { + const setup = await createRepository(); + const reference = parseGitHubReference("repo:owner/repo/src/index.ts"); + const prepared = await prepareGitHubFile(reference, setup.root); + await writeFile(join(prepared.root, prepared.path), "tampered\n"); + clearGitHubCaches(); + const refreshed = await prepareGitHubFile(reference, setup.root); + expect(await readFile(join(refreshed.root, refreshed.path), "utf8")).toContain("main"); }); - it("rejects unsafe paths even when called with a manually constructed reference", async () => { - mockApi(); + + it("rejects missing refs and symbolic-link paths", async () => { + const setup = await createRepository(); await expect( - prepareGitHubFile({ owner: "owner", repository: "repo", path: "../secret" }), - ).rejects.toThrow('Unsafe GitHub file path "../secret"'); - }); - it("reports unsupported blob encodings", async () => { - mockApi({ blobEncoding: "utf-8" }); + prepareGitHubFile(parseGitHubReference("repo:owner/repo@missing/src/index.ts"), setup.root), + ).rejects.toThrow("was not found"); await expect( - prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")), - ).rejects.toThrow('unsupported blob encoding "utf-8"'); + prepareGitHubFile(parseGitHubReference("repo:owner/repo/linked.ts"), setup.root), + ).rejects.toThrow("symbolic link"); }); - it("reports missing files", async () => { - mockApi(); - await expect( - prepareGitHubFile(parseGitHubReference("repo:owner/repo/missing.ts")), - ).rejects.toThrow("was not found in owner/repo@main"); + + it("reuses a clean checkout and accepts an HTTPS token", async () => { + const setup = await createRepository(); + const reference = parseGitHubReference("repo:owner/repo/src/index.ts"); + const first = await prepareGitHubFile(reference, setup.root, "test-token"); + clearGitHubCaches(); + const second = await prepareGitHubFile(reference, setup.root, "test-token"); + expect(second.root).toBe(first.root); + expect(second.resolved.commit).toBe(first.resolved.commit); }); - it("reports binary content", async () => { - mockApi(); + + it("reports when a repository has no default branch", async () => { + temporary = await mkdtemp(join(tmpdir(), "write-once-github-empty-")); + const remoteRoot = join(temporary, "remotes"); + const remote = join(remoteRoot, "owner", "repo.git"); + await mkdir(join(remoteRoot, "owner"), { recursive: true }); + git(["init", "--bare", "--initial-branch=main", remote]); + vi.stubEnv("GITHUB_GIT_BASE_URL", `file://${remoteRoot}`); await expect( - prepareGitHubFile(parseGitHubReference("repo:owner/repo/image.png")), - ).rejects.toThrow("is binary"); + prepareGitHubFile( + parseGitHubReference("repo:owner/repo/README.md"), + join(temporary, "plugin-work"), + ), + ).rejects.toThrow("Could not determine the default branch"); }); - it("reports invalid UTF-8", async () => { - mockApi({ blobContent: btoa(String.fromCharCode(255, 254)) }); + + it("reports Git failures when the repository cannot be reached", async () => { + const setup = await createRepository(); + vi.stubEnv("GITHUB_GIT_BASE_URL", join(temporary!, "missing-remote")); await expect( - prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")), - ).rejects.toThrow("not valid UTF-8"); + prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts"), setup.root), + ).rejects.toThrow("Git command failed"); }); - it("explains authentication failures", async () => { - const { fetchMock } = mockApi(); - fetchMock.mockImplementation(async () => json({}, 401)); - await expect( - prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")), - ).rejects.toThrow("Check GITHUB_TOKEN"); + + it("replaces a checkout whose Git metadata is corrupted", async () => { + const setup = await createRepository(); + const reference = parseGitHubReference("repo:owner/repo/src/index.ts"); + const prepared = await prepareGitHubFile(reference, setup.root); + await rm(join(prepared.root, ".git"), { recursive: true, force: true }); + await mkdir(join(prepared.root, ".git"), { recursive: true }); + clearGitHubCaches(); + const refreshed = await prepareGitHubFile(reference, setup.root); + expect(await readFile(join(refreshed.root, refreshed.path), "utf8")).toContain("main"); }); - it("includes Retry-After guidance", async () => { - mockApi({ status: 429 }); + + it("replaces a dirty checkout before resolving content", async () => { + const setup = await createRepository(); + const reference = parseGitHubReference("repo:owner/repo/src/index.ts"); + const prepared = await prepareGitHubFile(reference, setup.root); + await writeFile(join(prepared.root, prepared.path), "tampered\n"); + clearGitHubCaches(); + const refreshed = await prepareGitHubFile(reference, setup.root); + expect(await readFile(join(refreshed.root, refreshed.path), "utf8")).toContain("main"); + }); + + it("rejects missing refs and symbolic-link paths", async () => { + const setup = await createRepository(); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo@missing/src/index.ts"), setup.root), + ).rejects.toThrow("was not found"); await expect( - prepareGitHubFile(parseGitHubReference("repo:owner/repo/src/index.ts")), - ).rejects.toThrow("Retry after 60 seconds"); + prepareGitHubFile(parseGitHubReference("repo:owner/repo/linked.ts"), setup.root), + ).rejects.toThrow("symbolic link"); }); + + it("rejects missing files and unsafe paths", async () => { + const setup = await createRepository(); + await expect( + prepareGitHubFile(parseGitHubReference("repo:owner/repo/missing.ts"), setup.root), + ).rejects.toThrow("was not found"); + await expect( + prepareGitHubFile({ owner: "owner", repository: "repo", path: "../secret" }, setup.root), + ).rejects.toThrow("Unsafe GitHub file path"); + await Promise.all( + ["/absolute", "src\\secret", "src//secret", "src/./secret", ""].map((path) => + expect( + prepareGitHubFile({ owner: "owner", repository: "repo", path }, setup.root), + ).rejects.toThrow("Unsafe GitHub file path"), + ), + ); + }, 20_000); }); diff --git a/tests/reference.test.ts b/tests/reference.test.ts index 7ebe668..6164593 100644 --- a/tests/reference.test.ts +++ b/tests/reference.test.ts @@ -9,13 +9,15 @@ describe("GitHub references", () => { repository: "repo", path: "src/index.ts", })); - it("parses explicit refs", () => - expect(parseGitHubReference("repo:owner/repo@develop/src/index.ts")).toEqual({ + + it("preserves the remaining path for remote resolution of refs containing slashes", () => + expect(parseGitHubReference("repo:owner/repo@feature/nested/src/index.ts")).toEqual({ owner: "owner", repository: "repo", - ref: "develop", - path: "src/index.ts", + ref: "feature", + path: "nested/src/index.ts", })); + it.each([ "repo:owner/repo", "repo:owner/repo/../secret", @@ -26,11 +28,13 @@ describe("GitHub references", () => { ])("rejects invalid reference %s", (source) => expect(() => parseGitHubReference(source)).toThrow(), ); + it("rejects sources owned by another resolver", () => { expect(() => parseGitHubReference("./src/index.ts")).toThrow( "Unsupported GitHub source reference", ); }); + it("recognizes provider references", () => { expect(isGitHubReference("repo:owner/repo/a.ts")).toBe(true); expect(isGitHubReference("./a.ts")).toBe(false); diff --git a/vitest.config.ts b/vitest.config.ts index bb4811d..5124945 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ tsconfigPaths: true, }, test: { + testTimeout: 20_000, coverage: { provider: "istanbul", reporter: ["text", "html", "lcov"], From c9895506709fa06bc29c5b2e6499f053caa49f06 Mon Sep 17 00:00:00 2001 From: DanhezCode Date: Mon, 19 Oct 2026 08:15:37 -0400 Subject: [PATCH 10/10] chore(metadata): align package metadata and security policy --- LICENSE | 21 +++++++++++++++++++++ SECURITY.md | 27 +++++++++++++++++++++++++++ package.json | 19 ++++++++++++------- 3 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 LICENSE create mode 100644 SECURITY.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f587bc9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Opinionated TS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dee5fc6 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Supported Versions + +Security fixes are provided for the latest released version of `write-once`. Upgrade to the latest version before reporting an issue when possible. + +| Version | Supported | +| -------------- | --------- | +| Latest release | Yes | +| Older releases | No | + +## Reporting a Vulnerability + +Please report suspected security vulnerabilities privately by emailing **[danhezcode@gmail.com](mailto:danhezcode@gmail.com)**. Do not disclose the issue publicly in a GitHub issue or discussion until it has been reviewed and a fix or mitigation can be coordinated. + +Include as much of the following information as possible: + +- The affected version and environment. +- A description of the vulnerability and its potential impact. +- Steps to reproduce the issue or a proof of concept. +- Any suggested mitigation, if known. + +You should receive an acknowledgment within 7 days. We will investigate the report, assess its impact, and coordinate next steps with you where possible. Please allow time for a fix or mitigation before sharing details publicly. + +## Scope + +Reports are especially relevant to unintended file access or modification, unsafe handling of fragment markers or configuration, and other behavior that could compromise a user's project or data. Do not include real secrets or sensitive data in reports; use sanitized examples where possible. diff --git a/package.json b/package.json index bb28700..46ad3de 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,13 @@ { "name": "write-once-github", - "description": "GitHub REST API provider for resolving repository files in Write Once.", + "description": "Resolve GitHub repository files from a single source of truth in Write Once.", "keywords": [ + "content-synchronization", + "documentation-sync", + "fragment-reuse", "github", - "github-api", - "integration", - "opinionated-ts", - "plugin", - "remote-files", - "repository", + "repository-files", + "text-reuse", "write-once" ], "homepage": "https://github.com/opinionated-ts/write-once-github#readme", @@ -20,6 +19,12 @@ "name": "Opinionated TS", "url": "https://github.com/opinionated-ts" }, + "contributors": [ + { + "name": "Daniel A. Hernández Ochoa", + "url": "https://github.com/DanhezCode" + } + ], "repository": { "type": "git", "url": "git+https://github.com/opinionated-ts/write-once-github.git"