@rightkit/git 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/adopt.mjs +96 -0
- package/cli/right-git.mjs +207 -0
- package/cli.test.mjs +68 -0
- package/drift.mjs +133 -0
- package/drift.test.mjs +98 -0
- package/guard-coverage.test.mjs +220 -0
- package/install.mjs +72 -0
- package/manifest.mjs +278 -0
- package/manifest.schema.json +85 -0
- package/manifest.test.mjs +196 -0
- package/pack-smoke.test.mjs +55 -0
- package/package.json +30 -0
- package/policy/visibility-gate.mjs +168 -0
- package/render.mjs +35 -0
- package/render.test.mjs +117 -0
- package/sync.mjs +139 -0
- package/sync.test.mjs +84 -0
- package/templates/ci.yml +47 -0
- package/templates/package-smoke.yml +37 -0
- package/templates/publish-npm.yml +52 -0
- package/templates/publish-package-managers.yml +157 -0
- package/templates/qualification.yml +82 -0
- package/templates/release-candidate.yml +48 -0
- package/test-support.mjs +33 -0
- package/uninstall.mjs +36 -0
- package/uninstall.test.mjs +77 -0
- package/visibility-gate.test.mjs +141 -0
package/test-support.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Shared fixtures for sync/drift/uninstall tests. Not a *.test.mjs file, so
|
|
2
|
+
// `node --test *.test.mjs` never picks it up as a test suite on its own.
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
export function makeTempRepo(manifest, { packageJson } = {}) {
|
|
9
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "right-git-test-"));
|
|
10
|
+
if (manifest) fs.writeFileSync(path.join(dir, ".rightgit.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
11
|
+
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(packageJson || { name: "widgets" }), "utf8");
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ghDeps(visibility) {
|
|
16
|
+
return {
|
|
17
|
+
execFile: async (cmd) => {
|
|
18
|
+
if (cmd === "gh") return { stdout: JSON.stringify({ visibility }) };
|
|
19
|
+
throw new Error(`unexpected exec: ${cmd}`);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const PUBLIC_DEPS = ghDeps("PUBLIC");
|
|
25
|
+
export const PRIVATE_DEPS = ghDeps("PRIVATE");
|
|
26
|
+
|
|
27
|
+
export const SAMPLE_MANIFEST = {
|
|
28
|
+
schemaVersion: 1,
|
|
29
|
+
lanes: ["ci", "package-smoke"],
|
|
30
|
+
matrix: { os: ["ubuntu-latest"], node: ["22.13"] },
|
|
31
|
+
packageManager: "pnpm@11.18.0",
|
|
32
|
+
templateVersion: "1.0.0",
|
|
33
|
+
};
|
package/uninstall.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Remove every right-git-managed file from a repo, leaving zero managed
|
|
2
|
+
// files (invariant I5). No visibility check — uninstall must always be
|
|
3
|
+
// possible, including from a repo that flipped private after install.
|
|
4
|
+
//
|
|
5
|
+
// F6 fix: `.rightgit.json` is itself a file `install` writes, so it counts
|
|
6
|
+
// as a managed file. The old implementation removed only marker-bearing
|
|
7
|
+
// workflow files and never the manifest, so it leaked `.rightgit.json` —
|
|
8
|
+
// including after an install that failed partway (install writes the
|
|
9
|
+
// manifest *before* running sync, so a sync failure still leaves the
|
|
10
|
+
// manifest on disk with zero workflow files to key off of). Uninstall must
|
|
11
|
+
// remove the manifest unconditionally on presence, independent of whether
|
|
12
|
+
// any workflow files exist.
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { listManagedFiles } from "./sync.mjs";
|
|
17
|
+
import { MANIFEST_FILENAME } from "./manifest.mjs";
|
|
18
|
+
|
|
19
|
+
/** Returns { ok, removed: string[] }. Never throws. */
|
|
20
|
+
export function uninstallCommand(repoPath) {
|
|
21
|
+
const dir = path.join(repoPath, ".github", "workflows");
|
|
22
|
+
const managed = listManagedFiles(repoPath);
|
|
23
|
+
const removed = [];
|
|
24
|
+
for (const filename of managed) {
|
|
25
|
+
fs.rmSync(path.join(dir, filename), { force: true });
|
|
26
|
+
removed.push(filename);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const manifestPath = path.join(repoPath, MANIFEST_FILENAME);
|
|
30
|
+
if (fs.existsSync(manifestPath)) {
|
|
31
|
+
fs.rmSync(manifestPath, { force: true });
|
|
32
|
+
removed.push(MANIFEST_FILENAME);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { ok: true, removed };
|
|
36
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { syncCommand } from "./sync.mjs";
|
|
6
|
+
import { installCommand } from "./install.mjs";
|
|
7
|
+
import { uninstallCommand } from "./uninstall.mjs";
|
|
8
|
+
import { listManagedFiles } from "./sync.mjs";
|
|
9
|
+
import { MANIFEST_FILENAME } from "./manifest.mjs";
|
|
10
|
+
import { makeTempRepo, PUBLIC_DEPS, SAMPLE_MANIFEST } from "./test-support.mjs";
|
|
11
|
+
|
|
12
|
+
test("uninstall removes every managed file, leaving zero behind — including the manifest (F6/I5)", async () => {
|
|
13
|
+
const dir = makeTempRepo(SAMPLE_MANIFEST);
|
|
14
|
+
await syncCommand(dir, { deps: PUBLIC_DEPS });
|
|
15
|
+
assert.equal(listManagedFiles(dir).length, 2);
|
|
16
|
+
|
|
17
|
+
const result = uninstallCommand(dir);
|
|
18
|
+
assert.equal(result.ok, true);
|
|
19
|
+
// 2 workflow files + the manifest itself: I5 requires *zero* managed
|
|
20
|
+
// files left behind, and .rightgit.json is a file `install` wrote.
|
|
21
|
+
assert.equal(result.removed.length, 3);
|
|
22
|
+
assert.ok(result.removed.includes(MANIFEST_FILENAME));
|
|
23
|
+
assert.deepEqual(listManagedFiles(dir), []);
|
|
24
|
+
assert.equal(fs.existsSync(path.join(dir, ".github", "workflows", "ci.yml")), false);
|
|
25
|
+
assert.equal(fs.existsSync(path.join(dir, ".github", "workflows", "package-smoke.yml")), false);
|
|
26
|
+
assert.equal(fs.existsSync(path.join(dir, MANIFEST_FILENAME)), false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// F6 regression: install writes .rightgit.json *before* running sync (see
|
|
30
|
+
// install.mjs), so a repo whose visibility check flips or errors between
|
|
31
|
+
// that write and sync's own visibility re-check is left with an orphaned
|
|
32
|
+
// manifest and zero (or partial) workflow files. The old uninstallCommand
|
|
33
|
+
// only ever looked at marker-bearing workflow files, so it reported success
|
|
34
|
+
// while leaving the manifest behind. Reproduces the QA report's
|
|
35
|
+
// "interruptedInstall" probe.
|
|
36
|
+
test("F6: uninstall removes the manifest left behind by an install interrupted after the visibility check", async () => {
|
|
37
|
+
const dir = makeTempRepo(null);
|
|
38
|
+
let calls = 0;
|
|
39
|
+
const flakyDeps = {
|
|
40
|
+
execFile: async (cmd) => {
|
|
41
|
+
calls += 1;
|
|
42
|
+
if (cmd === "gh" && calls === 1) return { stdout: JSON.stringify({ visibility: "PUBLIC" }) };
|
|
43
|
+
if (cmd === "gh") throw new Error("gh: rate limited");
|
|
44
|
+
throw new Error(`unexpected exec: ${cmd}`);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const installResult = await installCommand(dir, { lanes: ["ci"] }, flakyDeps);
|
|
49
|
+
assert.equal(installResult.ok, false);
|
|
50
|
+
assert.equal(installResult.stage, "synced");
|
|
51
|
+
assert.ok(fs.existsSync(path.join(dir, MANIFEST_FILENAME)), "setup: interrupted install must still leave the manifest on disk");
|
|
52
|
+
|
|
53
|
+
const uninstallResult = uninstallCommand(dir);
|
|
54
|
+
assert.equal(uninstallResult.ok, true);
|
|
55
|
+
assert.ok(uninstallResult.removed.includes(MANIFEST_FILENAME));
|
|
56
|
+
assert.equal(fs.existsSync(path.join(dir, MANIFEST_FILENAME)), false, "F6: manifest must not survive uninstall after an interrupted install");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("uninstall never touches an unmanaged file in the same directory", async () => {
|
|
60
|
+
const dir = makeTempRepo(SAMPLE_MANIFEST);
|
|
61
|
+
await syncCommand(dir, { deps: PUBLIC_DEPS });
|
|
62
|
+
const handWritten = path.join(dir, ".github", "workflows", "hand-written.yml");
|
|
63
|
+
fs.writeFileSync(handWritten, "name: hand-written\n", "utf8");
|
|
64
|
+
|
|
65
|
+
uninstallCommand(dir);
|
|
66
|
+
assert.ok(fs.existsSync(handWritten), "uninstall deleted a file it does not manage");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("uninstall on a repo with no managed files is a no-op, not an error", () => {
|
|
70
|
+
// makeTempRepo(null): no manifest, no workflow files — genuinely zero
|
|
71
|
+
// managed state (makeTempRepo(SAMPLE_MANIFEST) would itself write
|
|
72
|
+
// .rightgit.json, which is now correctly treated as managed per F6).
|
|
73
|
+
const dir = makeTempRepo(null);
|
|
74
|
+
const result = uninstallCommand(dir);
|
|
75
|
+
assert.equal(result.ok, true);
|
|
76
|
+
assert.deepEqual(result.removed, []);
|
|
77
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { assertPublic, resolveVisibility, parseOwnerRepo } from "./policy/visibility-gate.mjs";
|
|
4
|
+
|
|
5
|
+
function ghExecFile(visibility) {
|
|
6
|
+
return async (cmd, cmdArgs) => {
|
|
7
|
+
if (cmd === "gh") return { stdout: JSON.stringify({ visibility }) };
|
|
8
|
+
throw new Error(`unexpected exec: ${cmd} ${cmdArgs.join(" ")}`);
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function failingGhExecFile(remoteUrl) {
|
|
13
|
+
return async (cmd, cmdArgs) => {
|
|
14
|
+
if (cmd === "gh") throw new Error("gh: command not found");
|
|
15
|
+
if (cmd === "git" && cmdArgs[0] === "remote") return { stdout: `${remoteUrl}\n` };
|
|
16
|
+
throw new Error(`unexpected exec: ${cmd} ${cmdArgs.join(" ")}`);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("assertPublic allows a repo gh reports as public", async () => {
|
|
21
|
+
const result = await assertPublic("/repo", { execFile: ghExecFile("PUBLIC") });
|
|
22
|
+
assert.equal(result.allowed, true);
|
|
23
|
+
assert.equal(result.visibility, "public");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("assertPublic refuses a repo gh reports as private — no override", async () => {
|
|
27
|
+
const result = await assertPublic("/repo", { execFile: ghExecFile("PRIVATE") });
|
|
28
|
+
assert.equal(result.allowed, false);
|
|
29
|
+
assert.equal(result.visibility, "private");
|
|
30
|
+
assert.match(result.reason, /public repos only/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("assertPublic refuses a repo gh reports as internal", async () => {
|
|
34
|
+
const result = await assertPublic("/repo", { execFile: ghExecFile("INTERNAL") });
|
|
35
|
+
assert.equal(result.allowed, false);
|
|
36
|
+
assert.equal(result.visibility, "internal");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("assertPublic falls back to git-remote + API when gh is unavailable, and allows a confirmed-public repo", async () => {
|
|
40
|
+
const execFile = failingGhExecFile("https://github.com/acme/widgets.git");
|
|
41
|
+
const fetchFn = async () => ({ ok: true, json: async () => ({ private: false }) });
|
|
42
|
+
const result = await assertPublic("/repo", { execFile, fetch: fetchFn });
|
|
43
|
+
assert.equal(result.allowed, true);
|
|
44
|
+
assert.equal(result.source, "git-remote-api");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("assertPublic fails closed to 'unknown' when neither gh nor the API can confirm public", async () => {
|
|
48
|
+
const execFile = failingGhExecFile("https://github.com/acme/widgets.git");
|
|
49
|
+
const fetchFn = async () => ({ ok: false, status: 404 });
|
|
50
|
+
const result = await assertPublic("/repo", { execFile, fetch: fetchFn });
|
|
51
|
+
assert.equal(result.allowed, false);
|
|
52
|
+
assert.equal(result.visibility, "unknown");
|
|
53
|
+
assert.match(result.reason, /fail closed/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("assertPublic never throws even when every network path errors", async () => {
|
|
57
|
+
const execFile = async () => {
|
|
58
|
+
throw new Error("no network");
|
|
59
|
+
};
|
|
60
|
+
const result = await assertPublic("/repo", { execFile, fetch: async () => { throw new Error("no network"); } });
|
|
61
|
+
assert.equal(result.allowed, false);
|
|
62
|
+
assert.equal(result.visibility, "unknown");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("resolveVisibility never throws and returns a typed result on total failure", async () => {
|
|
66
|
+
const execFile = async () => {
|
|
67
|
+
throw new Error("boom");
|
|
68
|
+
};
|
|
69
|
+
const result = await resolveVisibility("/repo", { execFile });
|
|
70
|
+
assert.equal(result.source, "error");
|
|
71
|
+
assert.equal(result.visibility, "unknown");
|
|
72
|
+
assert.ok(typeof result.error === "string");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("parseOwnerRepo handles https and ssh remote forms", () => {
|
|
76
|
+
assert.deepEqual(parseOwnerRepo("https://github.com/acme/widgets.git"), { owner: "acme", repo: "widgets" });
|
|
77
|
+
assert.deepEqual(parseOwnerRepo("git@github.com:acme/widgets.git"), { owner: "acme", repo: "widgets" });
|
|
78
|
+
assert.equal(parseOwnerRepo("not-a-github-url"), null);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// F16 regression: the old repo-name group excluded '.' from its character
|
|
82
|
+
// class, so it could never reach the end-of-string anchor for a dotted repo
|
|
83
|
+
// name — the whole match failed and a legitimate public repo could not be
|
|
84
|
+
// identified through the gh-unavailable fallback. Reproduces the QA report's
|
|
85
|
+
// exact probe input.
|
|
86
|
+
test("F16: parseOwnerRepo identifies a valid GitHub repo whose name contains dots", () => {
|
|
87
|
+
assert.deepEqual(parseOwnerRepo("https://github.com/owner/repo.with.dot.git"), {
|
|
88
|
+
owner: "owner",
|
|
89
|
+
repo: "repo.with.dot",
|
|
90
|
+
});
|
|
91
|
+
assert.deepEqual(parseOwnerRepo("git@github.com:owner/repo.with.dot.git"), {
|
|
92
|
+
owner: "owner",
|
|
93
|
+
repo: "repo.with.dot",
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// F16 non-regression: an unconfigured enterprise GitHub host must still fail
|
|
98
|
+
// closed to null (the unauthenticated-API fallback only ever targets
|
|
99
|
+
// api.github.com, so this parse never reaches an enterprise instance).
|
|
100
|
+
test("F16: parseOwnerRepo still fails closed for an unconfigured enterprise GitHub host", () => {
|
|
101
|
+
assert.equal(parseOwnerRepo("https://github.example.com/owner/repo.git"), null);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Canonicalization hygiene regression: uppercase host, embedded userinfo
|
|
105
|
+
// (credentials), and a non-standard port are all *genuinely* github.com —
|
|
106
|
+
// DNS is case-insensitive, userinfo is just embedded credentials, and the
|
|
107
|
+
// API query ignores the port. These are not lookalike hosts, so they must
|
|
108
|
+
// still resolve to the real owner/repo rather than fail closed. The WHATWG
|
|
109
|
+
// URL parser already lowercases hostnames and separates userinfo/port from
|
|
110
|
+
// host — parseOwnerRepo's `url.hostname` comparison relies on exactly that,
|
|
111
|
+
// so this locks the behavior in against any future regression that starts
|
|
112
|
+
// comparing against the raw authority string instead of `url.hostname`.
|
|
113
|
+
test("canonicalization: parseOwnerRepo accepts uppercase github.com host", () => {
|
|
114
|
+
assert.deepEqual(parseOwnerRepo("https://GITHUB.COM/owner/repo"), { owner: "owner", repo: "repo" });
|
|
115
|
+
assert.deepEqual(parseOwnerRepo("https://GitHub.com/owner/repo.git"), { owner: "owner", repo: "repo" });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("canonicalization: parseOwnerRepo accepts a github.com remote with embedded userinfo", () => {
|
|
119
|
+
assert.deepEqual(parseOwnerRepo("https://attacker@github.com/victim/repo"), {
|
|
120
|
+
owner: "victim",
|
|
121
|
+
repo: "repo",
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("canonicalization: parseOwnerRepo accepts a github.com remote on a non-standard port", () => {
|
|
126
|
+
assert.deepEqual(parseOwnerRepo("https://github.com:8443/owner/repo"), { owner: "owner", repo: "repo" });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Lookalike-family non-regression (N1): every one of these has a host that
|
|
130
|
+
// is NOT github.com — they must keep failing closed to null. Re-asserted
|
|
131
|
+
// here alongside the canonicalization-hygiene cases above so the two
|
|
132
|
+
// concerns (accept real github.com in any authority shape, reject anything
|
|
133
|
+
// that merely contains the substring "github.com") are tested side by side.
|
|
134
|
+
test("N1 lookalikes: parseOwnerRepo still rejects every non-github.com host", () => {
|
|
135
|
+
assert.equal(parseOwnerRepo("https://notgithub.com/owner/repo"), null);
|
|
136
|
+
assert.equal(parseOwnerRepo("https://github.com.evil.io/owner/repo"), null);
|
|
137
|
+
assert.equal(parseOwnerRepo("https://mygithub.com/owner/repo"), null);
|
|
138
|
+
assert.equal(parseOwnerRepo("https://github.com@evil.io/owner/repo"), null);
|
|
139
|
+
assert.equal(parseOwnerRepo("https://github.como/owner/repo"), null);
|
|
140
|
+
assert.equal(parseOwnerRepo("https://github.example.com/owner/repo"), null);
|
|
141
|
+
});
|