| 1 | // node --test scripts/plugins-release.test.mjs |
| 2 | import assert from "node:assert/strict"; |
| 3 | import { spawnSync } from "node:child_process"; |
| 4 | import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 5 | import { tmpdir } from "node:os"; |
| 6 | import { dirname, join } from "node:path"; |
| 7 | import { after, test } from "node:test"; |
| 8 | import { fileURLToPath } from "node:url"; |
| 9 | |
| 10 | import { INSTALL, PUBLISHED, SOURCE, authEnv, tagName, build, expectedFiles, manifests, publish, scan, walk } from "./plugins-release.mjs"; |
| 11 | |
| 12 | const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); |
| 13 | const temps = []; |
| 14 | const temp = (prefix) => { |
| 15 | const dir = mkdtempSync(join(tmpdir(), prefix)); |
| 16 | temps.push(dir); |
| 17 | return dir; |
| 18 | }; |
| 19 | after(() => temps.forEach((d) => rmSync(d, { recursive: true, force: true }))); |
| 20 | |
| 21 | /** A copy of what the build reads from this repository, to change freely. */ |
| 22 | function source() { |
| 23 | const root = temp("plugins-src-"); |
| 24 | for (const path of [".claude-plugin", SOURCE, "LICENSE"]) cpSync(join(ROOT, path), join(root, path), { recursive: true }); |
| 25 | return root; |
| 26 | } |
| 27 | const editJson = (root, file, fn) => { |
| 28 | const path = join(root, file); |
| 29 | writeFileSync(path, JSON.stringify(fn(JSON.parse(readFileSync(path, "utf8"))), null, 2)); |
| 30 | }; |
| 31 | const bump = (root, version) => { |
| 32 | editJson(root, ".claude-plugin/marketplace.json", (m) => ({ ...m, plugins: [{ ...m.plugins[0], version }] })); |
| 33 | editJson(root, `${SOURCE}/.claude-plugin/plugin.json`, (p) => ({ ...p, version })); |
| 34 | }; |
| 35 | const git = (cwd, ...args) => spawnSync("git", args, { cwd, encoding: "utf8" }); |
| 36 | |
| 37 | test("this repository's manifests agree: g1t@g1t, one version", () => { |
| 38 | const { marketplace, plugin, version } = manifests(); |
| 39 | assert.equal(marketplace.plugins[0].version, version); |
| 40 | assert.equal(plugin.version, version); |
| 41 | }); |
| 42 | |
| 43 | test("the build is the marketplace, the plugin without its tests, a README and the license", () => { |
| 44 | const out = join(temp("plugins-out-"), "tree"); |
| 45 | const { version } = build(out); |
| 46 | const files = walk(out); |
| 47 | assert.deepEqual(files, expectedFiles()); |
| 48 | assert.ok(files.includes(`${PUBLISHED}/hooks/g1t.mjs`)); |
| 49 | assert.ok(!files.some((f) => f.includes("test/") || f.endsWith(".test.mjs")), files.join(" ")); |
| 50 | const marketplace = JSON.parse(readFileSync(join(out, ".claude-plugin/marketplace.json"), "utf8")); |
| 51 | assert.equal(marketplace.name, "g1t"); |
| 52 | assert.deepEqual(marketplace.plugins.map((p) => [p.name, p.source, p.version]), [["g1t", "./plugins/g1t", version]]); |
| 53 | // The plugin's files are the source's, byte for byte. |
| 54 | for (const f of files.filter((f) => f.startsWith(`${PUBLISHED}/`))) { |
| 55 | assert.equal(readFileSync(join(out, f), "utf8"), readFileSync(join(ROOT, SOURCE, f.slice(PUBLISHED.length + 1)), "utf8"), f); |
| 56 | } |
| 57 | const readme = readFileSync(join(out, "README.md"), "utf8"); |
| 58 | for (const line of INSTALL) assert.ok(readme.includes(line), line); |
| 59 | }); |
| 60 | |
| 61 | test("manifests that disagree stop the build", () => { |
| 62 | const root = source(); |
| 63 | editJson(root, ".claude-plugin/marketplace.json", (m) => ({ ...m, plugins: [{ ...m.plugins[0], version: "9.9.9" }] })); |
| 64 | assert.throws(() => build(join(root, "out"), root), /marketplace.json says version 9.9.9/); |
| 65 | const renamed = source(); |
| 66 | editJson(renamed, ".claude-plugin/marketplace.json", (m) => ({ ...m, name: "g1t-plugins" })); |
| 67 | assert.throws(() => build(join(renamed, "out"), renamed), /installs are g1t@g1t/); |
| 68 | }); |
| 69 | |
| 70 | test("a file the script does not know about stops the build", () => { |
| 71 | const root = source(); |
| 72 | writeFileSync(join(root, SOURCE, "notes.md"), "draft"); |
| 73 | assert.throws(() => build(join(root, "out"), root), /has notes\.md, which .* neither publishes nor keeps back/); |
| 74 | }); |
| 75 | |
| 76 | test("anything that looks like a credential, or a credentials file, is never published", () => { |
| 77 | const root = source(); |
| 78 | writeFileSync(join(root, SOURCE, "commands", "status.md"), `token: g1t_${"a1".repeat(20)}\n`); |
| 79 | assert.throws(() => build(join(root, "out"), root), /commands\/status\.md: looks like a g1t token/); |
| 80 | const dir = temp("plugins-scan-"); |
| 81 | writeFileSync(join(dir, ".env"), "X=1"); |
| 82 | writeFileSync(join(dir, "k.md"), "-----BEGIN OPENSSH PRIVATE KEY-----"); |
| 83 | assert.deepEqual(scan(dir), [".env: a file that is never published", "k.md: looks like a private key"]); |
| 84 | // The plugin as it is holds none. |
| 85 | assert.deepEqual(scan(join(ROOT, SOURCE, "hooks")), []); |
| 86 | }); |
| 87 | |
| 88 | test("releases are tagged as claude plugin tag tags them", () => { |
| 89 | assert.equal(tagName("0.2.2"), "g1t--v0.2.2"); |
| 90 | }); |
| 91 | |
| 92 | test("the token goes to git through its environment, never its arguments", () => { |
| 93 | const env = authEnv("g1t_secret"); |
| 94 | assert.equal(env.GIT_CONFIG_KEY_0, "http.extraHeader"); |
| 95 | assert.equal(env.GIT_CONFIG_VALUE_0, `Authorization: Basic ${Buffer.from("g1t:g1t_secret").toString("base64")}`); |
| 96 | assert.deepEqual(authEnv(undefined), {}); |
| 97 | }); |
| 98 | |
| 99 | test("publishing: the first commit and tag, nothing when unchanged, a new version for a changed plugin", () => { |
| 100 | const root = source(); |
| 101 | const remote = join(temp("plugins-remote-"), "g1t-plugins.git"); |
| 102 | git(dirname(remote), "init", "--quiet", "--bare", "--initial-branch=main", remote); |
| 103 | const run = (sourceCommit) => publish({ remote, token: "", source: sourceCommit, root, work: temp("plugins-work-") }); |
| 104 | const { version } = manifests(root); |
| 105 | |
| 106 | const first = run("a".repeat(40)); |
| 107 | assert.equal(first.changed, true); |
| 108 | assert.equal(first.tagged, true); |
| 109 | assert.equal(git(remote, "rev-parse", "main").stdout.trim(), first.commit); |
| 110 | assert.equal(git(remote, "rev-parse", `g1t--v${version}^{commit}`).stdout.trim(), first.commit); |
| 111 | assert.match(git(remote, "log", "-1", "--format=%an <%ae>%n%B", "main").stdout, /^g1t <g1t@users\.noreply\.g1t\.sh>\ng1t .*, from flagon-io\/g1t@aaaaaaaaaaaa/); |
| 112 | const listed = git(remote, "ls-tree", "-r", "--name-only", "main").stdout.trim().split("\n").sort(); |
| 113 | assert.deepEqual(listed, expectedFiles(root)); |
| 114 | |
| 115 | const again = run("b".repeat(40)); |
| 116 | assert.deepEqual([again.changed, again.tagged, again.commit], [false, false, first.commit]); |
| 117 | |
| 118 | // The plugin changed with its version already out: refused. |
| 119 | writeFileSync(join(root, SOURCE, "commands", "status.md"), "changed\n"); |
| 120 | assert.throws(() => run("c".repeat(40)), /already published.*Bump "version"/s); |
| 121 | assert.equal(git(remote, "rev-parse", "main").stdout.trim(), first.commit); |
| 122 | |
| 123 | bump(root, "99.0.0"); |
| 124 | const next = run("d".repeat(40)); |
| 125 | assert.equal(next.changed, true); |
| 126 | assert.equal(next.tagged, true); |
| 127 | assert.equal(git(remote, "rev-parse", "g1t--v99.0.0^{commit}").stdout.trim(), next.commit); |
| 128 | assert.equal(git(remote, "rev-parse", `g1t--v${version}^{commit}`).stdout.trim(), first.commit); |
| 129 | assert.equal(git(remote, "rev-parse", "main~1").stdout.trim(), first.commit); |
| 130 | |
| 131 | // The marketplace's own text may change without a new version. |
| 132 | editJson(root, ".claude-plugin/marketplace.json", (m) => ({ ...m, description: "g1t's plugins for Claude Code, and more." })); |
| 133 | const text = run("e".repeat(40)); |
| 134 | assert.deepEqual([text.changed, text.tagged], [true, false]); |
| 135 | }); |