Skip to content

Commit 88d78d3

Browse files
fix(mod,deploy): reject private repos with a clear error (#119)
* fix(mod,deploy): reject private repos with a clear error message - dot deploy --modable now calls assertPublicGitHubRepo during preflight and throws ModablePreflightError for private or missing GitHub repos, preventing the obscure downstream failure - resolveDefaultBranch in dot mod detects 401/404 API responses and surfaces 'private or does not exist' instead of the misleading 'pin one in metadata.branch' hint - Covers both paths with unit tests * chore: fix biome formatting
1 parent ad40181 commit 88d78d3

5 files changed

Lines changed: 153 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"playground-cli": patch
3+
---
4+
5+
`dot deploy --modable` now rejects private GitHub repositories at preflight with a clear error message instead of silently failing later. `dot mod` also surfaces a more actionable error when it encounters a private or non-existent repository instead of the misleading "pin one in metadata.branch" hint.

src/utils/deploy/modable.test.ts

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import { execFileSync } from "node:child_process";
33
import { mkdtempSync, rmSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
6-
import { decideRepositoryAction, resolveRepositoryUrl } from "./modable.js";
6+
import {
7+
decideRepositoryAction,
8+
resolveRepositoryUrl,
9+
assertPublicGitHubRepo,
10+
ModablePreflightError,
11+
} from "./modable.js";
712

813
describe("decideRepositoryAction", () => {
914
it("uses the existing origin when present", () => {
@@ -35,6 +40,63 @@ describe("decideRepositoryAction", () => {
3540
});
3641
});
3742

43+
describe("assertPublicGitHubRepo", () => {
44+
it("does nothing for a public repo", async () => {
45+
const mockFetch: typeof fetch = async () =>
46+
new Response(JSON.stringify({ private: false }), { status: 200 });
47+
await expect(
48+
assertPublicGitHubRepo("https://github.com/foo/bar", mockFetch),
49+
).resolves.toBeUndefined();
50+
});
51+
52+
it("throws for a private repo (API reports private: true)", async () => {
53+
const mockFetch: typeof fetch = async () =>
54+
new Response(JSON.stringify({ private: true }), { status: 200 });
55+
await expect(
56+
assertPublicGitHubRepo("https://github.com/org/secret", mockFetch),
57+
).rejects.toThrow(ModablePreflightError);
58+
});
59+
60+
it("throws for a 404 response (private or missing)", async () => {
61+
const mockFetch: typeof fetch = async () => new Response("Not Found", { status: 404 });
62+
await expect(
63+
assertPublicGitHubRepo("https://github.com/org/ghost", mockFetch),
64+
).rejects.toThrow(/private or does not exist/i);
65+
});
66+
67+
it("throws for a 401 response", async () => {
68+
const mockFetch: typeof fetch = async () => new Response("Unauthorized", { status: 401 });
69+
await expect(
70+
assertPublicGitHubRepo("https://github.com/org/ghost", mockFetch),
71+
).rejects.toThrow(ModablePreflightError);
72+
});
73+
74+
it("does nothing for a non-GitHub URL", async () => {
75+
const mockFetch: typeof fetch = async () => {
76+
throw new Error("should not be called");
77+
};
78+
await expect(
79+
assertPublicGitHubRepo("https://gitlab.com/foo/bar", mockFetch),
80+
).resolves.toBeUndefined();
81+
});
82+
83+
it("does nothing on network error (fail open)", async () => {
84+
const mockFetch: typeof fetch = async () => {
85+
throw new Error("ECONNREFUSED");
86+
};
87+
await expect(
88+
assertPublicGitHubRepo("https://github.com/foo/bar", mockFetch),
89+
).resolves.toBeUndefined();
90+
});
91+
92+
it("skips check for rate-limit (403) responses", async () => {
93+
const mockFetch: typeof fetch = async () => new Response("rate limited", { status: 403 });
94+
await expect(
95+
assertPublicGitHubRepo("https://github.com/foo/bar", mockFetch),
96+
).resolves.toBeUndefined();
97+
});
98+
});
99+
38100
describe("resolveRepositoryUrl", () => {
39101
let tmp: string | null = null;
40102

@@ -43,6 +105,11 @@ describe("resolveRepositoryUrl", () => {
43105
tmp = null;
44106
});
45107

108+
const publicFetch: typeof fetch = async () =>
109+
new Response(JSON.stringify({ private: false }), { status: 200 });
110+
const privateFetch: typeof fetch = async () =>
111+
new Response(JSON.stringify({ private: true }), { status: 200 });
112+
46113
it("uses an existing origin without pushing", async () => {
47114
tmp = mkdtempSync(join(tmpdir(), "pg-modable-origin-"));
48115
execFileSync("git", ["init"], { cwd: tmp, stdio: "ignore" });
@@ -51,8 +118,21 @@ describe("resolveRepositoryUrl", () => {
51118
stdio: "ignore",
52119
});
53120

54-
await expect(resolveRepositoryUrl({ cwd: tmp, repoName: null })).resolves.toBe(
55-
"git@github.com:foo/bar",
56-
);
121+
await expect(
122+
resolveRepositoryUrl({ cwd: tmp, repoName: null, fetch: publicFetch }),
123+
).resolves.toBe("git@github.com:foo/bar");
124+
});
125+
126+
it("throws when the existing origin is a private GitHub repo", async () => {
127+
tmp = mkdtempSync(join(tmpdir(), "pg-modable-private-"));
128+
execFileSync("git", ["init"], { cwd: tmp, stdio: "ignore" });
129+
execFileSync("git", ["remote", "add", "origin", "https://github.com/org/secret.git"], {
130+
cwd: tmp,
131+
stdio: "ignore",
132+
});
133+
134+
await expect(
135+
resolveRepositoryUrl({ cwd: tmp, repoName: null, fetch: privateFetch }),
136+
).rejects.toThrow(ModablePreflightError);
57137
});
58138
});

src/utils/deploy/modable.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import { execFile, execFileSync } from "node:child_process";
1111
import { promisify } from "node:util";
1212
import { commandExists, TOOL_STEPS } from "../toolchain.js";
13+
import { parseGitHubRepoUrl } from "../mod/source.js";
1314

1415
const execFileAsync = promisify(execFile);
1516

@@ -85,9 +86,45 @@ export interface ResolveRepoOptions {
8586
cwd: string;
8687
repoName: string | null;
8788
onLog?: (line: string) => void;
89+
fetch?: typeof fetch;
90+
}
91+
92+
/**
93+
* Verifies that a GitHub repository URL is publicly accessible.
94+
* Throws ModablePreflightError for private or missing repos.
95+
* Skips silently for non-GitHub URLs or when the API is unreachable.
96+
*/
97+
export async function assertPublicGitHubRepo(url: string, f: typeof fetch = fetch): Promise<void> {
98+
const ref = parseGitHubRepoUrl(url);
99+
if (!ref) return;
100+
101+
let res: Response;
102+
try {
103+
res = await f(`https://api.github.com/repos/${ref.owner}/${ref.repo}`);
104+
} catch {
105+
return; // network error — can't verify, let downstream fail
106+
}
107+
108+
if (res.ok) {
109+
const body = (await res.json()) as { private?: boolean };
110+
if (body.private) {
111+
throw new ModablePreflightError(
112+
`${ref.owner}/${ref.repo} is a private repository — modable apps must use a public repository so users can clone the source`,
113+
);
114+
}
115+
return;
116+
}
117+
118+
if (res.status === 404 || res.status === 401) {
119+
throw new ModablePreflightError(
120+
`${ref.owner}/${ref.repo} is private or does not exist — modable apps must use a public repository`,
121+
);
122+
}
123+
// other non-ok status (rate limit, server error) — skip check
88124
}
89125

90126
export async function resolveRepositoryUrl(opts: ResolveRepoOptions): Promise<string> {
127+
const f = opts.fetch ?? fetch;
91128
const action = decideRepositoryAction({
92129
originUrl: readOrigin(opts.cwd),
93130
repoName: opts.repoName,
@@ -99,6 +136,7 @@ export async function resolveRepositoryUrl(opts: ResolveRepoOptions): Promise<st
99136
}
100137
if (action.kind === "use-origin") {
101138
opts.onLog?.(`using existing origin (${action.url})…`);
139+
await assertPublicGitHubRepo(action.url, f);
102140
return action.url;
103141
}
104142

src/utils/mod/source.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,24 @@ describe("resolveDefaultBranch", () => {
9999
resolveDefaultBranch({ owner: "foo", repo: "bar" }, { fetch: fetchImpl }),
100100
).rejects.toThrow(/could not resolve a default branch/i);
101101
});
102+
103+
it("throws a private-or-missing error when the API returns 404 and probes fail", async () => {
104+
const fetchImpl: typeof fetch = async () => new Response("Not Found", { status: 404 });
105+
await expect(
106+
resolveDefaultBranch({ owner: "foo", repo: "bar" }, { fetch: fetchImpl }),
107+
).rejects.toThrow(/private or does not exist/i);
108+
});
109+
110+
it("throws a private-or-missing error when the API returns 401 and probes fail", async () => {
111+
const fetchImpl: typeof fetch = async (url) => {
112+
if (String(url).startsWith("https://api.github.com"))
113+
return new Response("Unauthorized", { status: 401 });
114+
return new Response("Not Found", { status: 404 });
115+
};
116+
await expect(
117+
resolveDefaultBranch({ owner: "foo", repo: "bar" }, { fetch: fetchImpl }),
118+
).rejects.toThrow(/private or does not exist/i);
119+
});
102120
});
103121

104122
describe("downloadGitHubTarball", () => {

src/utils/mod/source.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,16 @@ export async function resolveDefaultBranch(
3737
opts: FetchOpts = {},
3838
): Promise<string> {
3939
const f = opts.fetch ?? fetch;
40+
let apiStatus: number | undefined;
4041
try {
4142
const res = await f(`https://api.github.com/repos/${ref.owner}/${ref.repo}`);
43+
apiStatus = res.status;
4244
if (res.ok) {
4345
const body = (await res.json()) as { default_branch?: string };
4446
if (body.default_branch) return body.default_branch;
4547
}
4648
} catch {
47-
// fall through to the heuristic probes
49+
// network error — fall through to the heuristic probes
4850
}
4951
for (const candidate of ["main", "master"]) {
5052
try {
@@ -54,6 +56,11 @@ export async function resolveDefaultBranch(
5456
// try next
5557
}
5658
}
59+
if (apiStatus === 404 || apiStatus === 401) {
60+
throw new Error(
61+
`Repository ${ref.owner}/${ref.repo} is private or does not exist — dot mod only supports public repositories`,
62+
);
63+
}
5764
throw new Error(
5865
`Could not resolve a default branch for ${ref.owner}/${ref.repo} — pin one in metadata.branch`,
5966
);

0 commit comments

Comments
 (0)