| 1 | #!/usr/bin/env node |
| 2 | // Publishes g1t's Claude Code plugin marketplace to its own repository, |
| 3 | // flagon-io/g1t-plugins on g1t.sh, so adding the marketplace clones a few |
| 4 | // files rather than this monorepo: |
| 5 | // |
| 6 | // claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git |
| 7 | // claude plugin install g1t@g1t |
| 8 | // |
| 9 | // This repository stays the source: .claude-plugin/marketplace.json and |
| 10 | // plugins/claude-code/ are where the plugin is changed and tested, and |
| 11 | // .g1t/workflows/plugins-release.yml runs this on every push to main that |
| 12 | // touches them. Nobody commits to g1t-plugins by hand. |
| 13 | // |
| 14 | // node scripts/plugins-release.mjs check # the manifests agree; nothing is written |
| 15 | // node scripts/plugins-release.mjs build [DIR] # the published tree (default target/g1t-plugins) |
| 16 | // node scripts/plugins-release.mjs publish [--dry-run] |
| 17 | // |
| 18 | // What is published, and nothing else: |
| 19 | // |
| 20 | // .claude-plugin/marketplace.json this repository's, its plugin's source rewritten to ./plugins/g1t |
| 21 | // plugins/g1t/ plugins/claude-code/ without its tests (PLUGIN_FILES) |
| 22 | // README.md written here (readme()) |
| 23 | // LICENSE this repository's |
| 24 | // |
| 25 | // A file added to plugins/claude-code/ that PLUGIN_FILES does not name |
| 26 | // stops the build, so nothing is published or left out without a change |
| 27 | // here. Every published file is scanned for anything that looks like a |
| 28 | // credential. |
| 29 | // |
| 30 | // `publish` clones g1t-plugins, replaces its tree with the build, and |
| 31 | // commits on main only when something changed, naming this repository's |
| 32 | // commit; then tags g1t--v<version> once, the tag Claude Code's own |
| 33 | // `claude plugin tag` makes. Claude Code updates an installed |
| 34 | // plugin when its version changes, so a change to the plugin's files with |
| 35 | // the version already published stops the run: bump the version in |
| 36 | // plugins/claude-code/.claude-plugin/plugin.json and in marketplace.json. |
| 37 | // |
| 38 | // G1T_PLUGINS_REMOTE where to publish (default https://g1t.sh/flagon-io/g1t-plugins.git) |
| 39 | // PLUGINS_PUBLISH_TOKEN a g1t access token that can push there; sent as a |
| 40 | // git header through the environment, never on a |
| 41 | // command line |
| 42 | // G1T_SOURCE_COMMIT this repository's commit, for the commit message |
| 43 | // (default: git rev-parse HEAD) |
| 44 | |
| 45 | import { spawnSync } from "node:child_process"; |
| 46 | import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; |
| 47 | import { tmpdir } from "node:os"; |
| 48 | import { dirname, join, relative } from "node:path"; |
| 49 | import { fileURLToPath } from "node:url"; |
| 50 | |
| 51 | const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); |
| 52 | export const SOURCE = "plugins/claude-code"; |
| 53 | export const PUBLISHED = "plugins/g1t"; |
| 54 | export const REMOTE = "https://g1t.sh/flagon-io/g1t-plugins.git"; |
| 55 | export const INSTALL = ["claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git", "claude plugin install g1t@g1t"]; |
| 56 | |
| 57 | // What of plugins/claude-code/ is the plugin: entries published whole. |
| 58 | // DEV_FILES are this repository's only (the hooks' tests and their stand-in |
| 59 | // CLI). Anything else in the folder stops the build. |
| 60 | export const PLUGIN_FILES = [".claude-plugin", ".mcp.json", "commands", "hooks", "skills"]; |
| 61 | export const DEV_FILES = ["test"]; |
| 62 | |
| 63 | // Names no published file may have, and text no published file may hold. |
| 64 | const FORBIDDEN_NAMES = [/^\.env/, /^\.credentials/, /\.(pem|key|p12|pfx)$/i, /^id_(rsa|ed25519|ecdsa)/, /^\.npmrc$/, /^node_modules$/]; |
| 65 | const SECRETS = [ |
| 66 | ["a g1t token", /\bg1t[a-z]*_[0-9a-f]{32,}/], |
| 67 | ["an Anthropic key", /\bsk-ant-[A-Za-z0-9_-]{20,}/], |
| 68 | ["an OpenAI key", /\bsk-(proj-)?[A-Za-z0-9]{32,}/], |
| 69 | ["a GitHub token", /\b(gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/], |
| 70 | ["an AWS key", /\bAKIA[0-9A-Z]{16}\b/], |
| 71 | ["a Slack token", /\bxox[abpr]-[A-Za-z0-9-]{10,}/], |
| 72 | ["a Stripe key", /\b[rs]k_(live|test)_[A-Za-z0-9]{16,}/], |
| 73 | ["a private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/], |
| 74 | ]; |
| 75 | |
| 76 | const readJson = (root, file) => JSON.parse(readFileSync(join(root, file), "utf8")); |
| 77 | |
| 78 | /** Every file under `dir` but git's own, as paths relative to it with forward slashes, sorted. */ |
| 79 | export function walk(dir, base = dir) { |
| 80 | const out = []; |
| 81 | for (const name of readdirSync(dir)) { |
| 82 | if (name === ".git") continue; |
| 83 | const path = join(dir, name); |
| 84 | if (statSync(path).isDirectory()) out.push(...walk(path, base)); |
| 85 | else out.push(relative(base, path).replaceAll("\\", "/")); |
| 86 | } |
| 87 | return out.sort(); |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * The marketplace and plugin manifests, checked against each other: one |
| 92 | * plugin, g1t, in a marketplace g1t (so it installs as g1t@g1t wherever it |
| 93 | * is published from), whose source is plugins/claude-code and whose version |
| 94 | * is the plugin's own. Returns them, and the version. |
| 95 | */ |
| 96 | export function manifests(root = ROOT) { |
| 97 | const marketplace = readJson(root, ".claude-plugin/marketplace.json"); |
| 98 | const plugin = readJson(root, `${SOURCE}/.claude-plugin/plugin.json`); |
| 99 | const problems = []; |
| 100 | if (marketplace.name !== "g1t") problems.push(`the marketplace is named ${JSON.stringify(marketplace.name)}, not "g1t": installs are g1t@g1t`); |
| 101 | if (!Array.isArray(marketplace.plugins) || marketplace.plugins.length !== 1) problems.push("the marketplace lists one plugin, g1t"); |
| 102 | const entry = marketplace.plugins?.[0] ?? {}; |
| 103 | if (entry.name !== "g1t" || plugin.name !== "g1t") problems.push(`the plugin is named ${JSON.stringify(entry.name)} in marketplace.json and ${JSON.stringify(plugin.name)} in plugin.json, not "g1t"`); |
| 104 | if (entry.source !== `./${SOURCE}`) problems.push(`marketplace.json's source is ${JSON.stringify(entry.source)}, not "./${SOURCE}"`); |
| 105 | if (!/^\d+\.\d+\.\d+$/.test(plugin.version ?? "")) problems.push(`plugin.json's version ${JSON.stringify(plugin.version)} is not x.y.z`); |
| 106 | if (entry.version !== plugin.version) problems.push(`marketplace.json says version ${entry.version} and plugin.json ${plugin.version}: make them the same`); |
| 107 | if (entry.description !== plugin.description) problems.push("marketplace.json's description is not plugin.json's"); |
| 108 | if (problems.length) throw new Error(`The plugin's manifests disagree:\n- ${problems.join("\n- ")}`); |
| 109 | return { marketplace, plugin, version: plugin.version }; |
| 110 | } |
| 111 | |
| 112 | /** The marketplace manifest as published: its plugin's source is ./plugins/g1t. */ |
| 113 | export function publishedMarketplace(marketplace) { |
| 114 | return { ...marketplace, plugins: marketplace.plugins.map((p) => ({ ...p, source: `./${PUBLISHED}` })) }; |
| 115 | } |
| 116 | |
| 117 | export function readme(version) { |
| 118 | return `# g1t plugins |
| 119 | |
| 120 | g1t's plugin marketplace for Claude Code. Its one plugin, \`g1t\` (${version}), |
| 121 | records your Claude Code sessions on g1t, gives Claude the issue it is |
| 122 | working on, claims and hands off work, and adds g1t's MCP server, skills |
| 123 | and commands. |
| 124 | |
| 125 | \`\`\`sh |
| 126 | ${INSTALL.join("\n")} |
| 127 | \`\`\` |
| 128 | |
| 129 | Then start Claude Code again, run \`/mcp\` and sign **plugin:g1t:g1t** in. |
| 130 | The plugin's hooks need the [g1t CLI](https://docs.g1t.sh/guides/cli/) |
| 131 | 0.3.2 or later and Node 18 or later. |
| 132 | |
| 133 | The guide, with what each hook records and never records and how to opt |
| 134 | out: https://docs.g1t.sh/guides/claude-code-plugin/ |
| 135 | |
| 136 | This repository is published automatically from |
| 137 | [flagon-io/g1t](https://g1t.sh/flagon-io/g1t) (\`.claude-plugin/\` and |
| 138 | \`${SOURCE}/\`). Changes and issues go there. |
| 139 | `; |
| 140 | } |
| 141 | |
| 142 | /** A release's tag: `claude plugin tag`'s {name}--v{version}. */ |
| 143 | export const tagName = (version) => `g1t--v${version}`; |
| 144 | |
| 145 | /** Why the folder cannot be published as it is: entries PLUGIN_FILES and DEV_FILES do not name. */ |
| 146 | function unlisted(root) { |
| 147 | return readdirSync(join(root, SOURCE)).filter((name) => !PLUGIN_FILES.includes(name) && !DEV_FILES.includes(name)); |
| 148 | } |
| 149 | |
| 150 | /** Credentials and forbidden files in a built tree, as "path: what". */ |
| 151 | export function scan(dir) { |
| 152 | const found = []; |
| 153 | for (const file of walk(dir)) { |
| 154 | for (const part of file.split("/")) if (FORBIDDEN_NAMES.some((re) => re.test(part))) found.push(`${file}: a file that is never published`); |
| 155 | const text = readFileSync(join(dir, file), "utf8"); |
| 156 | for (const [what, re] of SECRETS) if (re.test(text)) found.push(`${file}: looks like ${what}`); |
| 157 | } |
| 158 | return found; |
| 159 | } |
| 160 | |
| 161 | /** The files the build writes, relative to its folder: the only ones it may contain. */ |
| 162 | export function expectedFiles(root = ROOT) { |
| 163 | const plugin = PLUGIN_FILES.flatMap((name) => { |
| 164 | const path = join(root, SOURCE, name); |
| 165 | return statSync(path).isDirectory() ? walk(path).map((f) => `${PUBLISHED}/${name}/${f}`) : [`${PUBLISHED}/${name}`]; |
| 166 | }); |
| 167 | return [".claude-plugin/marketplace.json", "LICENSE", "README.md", ...plugin].sort(); |
| 168 | } |
| 169 | |
| 170 | /** Writes the published tree to `out`, replacing what was there, and checks it. */ |
| 171 | export function build(out = join(ROOT, "target", "g1t-plugins"), root = ROOT) { |
| 172 | const { marketplace, version } = manifests(root); |
| 173 | const extra = unlisted(root); |
| 174 | if (extra.length) throw new Error(`${SOURCE}/ has ${extra.join(", ")}, which scripts/plugins-release.mjs neither publishes nor keeps back: add each to PLUGIN_FILES or DEV_FILES.`); |
| 175 | for (const name of PLUGIN_FILES) if (!existsSync(join(root, SOURCE, name))) throw new Error(`${SOURCE}/${name} is missing`); |
| 176 | |
| 177 | rmSync(out, { recursive: true, force: true }); |
| 178 | mkdirSync(join(out, ".claude-plugin"), { recursive: true }); |
| 179 | writeFileSync(join(out, ".claude-plugin", "marketplace.json"), `${JSON.stringify(publishedMarketplace(marketplace), null, 2)}\n`); |
| 180 | for (const name of PLUGIN_FILES) cpSync(join(root, SOURCE, name), join(out, PUBLISHED, name), { recursive: true }); |
| 181 | writeFileSync(join(out, "README.md"), readme(version)); |
| 182 | cpSync(join(root, "LICENSE"), join(out, "LICENSE")); |
| 183 | verify(out, root); |
| 184 | return { out, version }; |
| 185 | } |
| 186 | |
| 187 | /** Checks a built tree: exactly the expected files, manifests that agree, nothing secret. */ |
| 188 | export function verify(dir, root = ROOT) { |
| 189 | const { version } = manifests(root); |
| 190 | const problems = []; |
| 191 | const files = walk(dir); |
| 192 | const expected = expectedFiles(root); |
| 193 | const more = files.filter((f) => !expected.includes(f)); |
| 194 | const fewer = expected.filter((f) => !files.includes(f)); |
| 195 | if (more.length) problems.push(`files that are not the plugin's: ${more.join(", ")}`); |
| 196 | if (fewer.length) problems.push(`missing: ${fewer.join(", ")}`); |
| 197 | const marketplace = readJson(dir, ".claude-plugin/marketplace.json"); |
| 198 | const entry = marketplace.plugins?.[0] ?? {}; |
| 199 | if (entry.source !== `./${PUBLISHED}`) problems.push(`the published source is ${JSON.stringify(entry.source)}`); |
| 200 | const plugin = readJson(dir, `${PUBLISHED}/.claude-plugin/plugin.json`); |
| 201 | if (marketplace.name !== "g1t" || entry.name !== "g1t" || plugin.name !== "g1t") problems.push("the names are not g1t@g1t"); |
| 202 | if (entry.version !== version || plugin.version !== version) problems.push(`versions: marketplace ${entry.version}, plugin ${plugin.version}, source ${version}`); |
| 203 | for (const file of [`${PUBLISHED}/.mcp.json`, `${PUBLISHED}/hooks/hooks.json`]) readJson(dir, file); |
| 204 | problems.push(...scan(dir)); |
| 205 | if (problems.length) throw new Error(`The published tree is wrong:\n- ${problems.join("\n- ")}`); |
| 206 | } |
| 207 | |
| 208 | function git(cwd, args, { env = {}, allowFail = false } = {}) { |
| 209 | const r = spawnSync("git", args, { cwd, encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env } }); |
| 210 | if (r.status !== 0 && !allowFail) throw new Error(`git ${args[0]} failed (${r.status}): ${(r.stderr || r.stdout).trim()}`); |
| 211 | return r; |
| 212 | } |
| 213 | |
| 214 | /** |
| 215 | * The token as a git header, through git's environment config |
| 216 | * (GIT_CONFIG_COUNT), so it is on no command line and in no file. |
| 217 | */ |
| 218 | export function authEnv(token) { |
| 219 | if (!token) return {}; |
| 220 | const basic = Buffer.from(`g1t:${token}`).toString("base64"); |
| 221 | return { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "http.extraHeader", GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}` }; |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Publishes the build to `remote`'s main and tags v<version>. Returns |
| 226 | * { changed, commit, tagged }. An empty repository gets its first commit. |
| 227 | */ |
| 228 | export function publish({ remote = process.env.G1T_PLUGINS_REMOTE || REMOTE, token = process.env.PLUGINS_PUBLISH_TOKEN, source, dryRun = false, root = ROOT, work } = {}) { |
| 229 | source ??= process.env.G1T_SOURCE_COMMIT || git(root, ["rev-parse", "HEAD"]).stdout.trim(); |
| 230 | const env = authEnv(token); |
| 231 | work ??= mkdtempSync(join(tmpdir(), "g1t-plugins-")); |
| 232 | const built = join(work, "build"); |
| 233 | const clone = join(work, "clone"); |
| 234 | const { version } = build(built, root); |
| 235 | |
| 236 | git(work, ["clone", "--quiet", remote, clone], { env }); |
| 237 | const hasMain = git(clone, ["rev-parse", "--verify", "--quiet", "refs/remotes/origin/main"], { allowFail: true }).status === 0; |
| 238 | if (hasMain) git(clone, ["checkout", "--quiet", "-B", "main", "origin/main"]); |
| 239 | else git(clone, ["checkout", "--quiet", "--orphan", "main"]); |
| 240 | |
| 241 | const before = hasMain && existsSync(join(clone, PUBLISHED, ".claude-plugin", "plugin.json")) ? readJson(clone, `${PUBLISHED}/.claude-plugin/plugin.json`).version : null; |
| 242 | for (const name of readdirSync(clone)) if (name !== ".git") rmSync(join(clone, name), { recursive: true, force: true }); |
| 243 | cpSync(built, clone, { recursive: true }); |
| 244 | verify(clone, root); |
| 245 | |
| 246 | git(clone, ["add", "--all"]); |
| 247 | const changed = git(clone, ["status", "--porcelain"]).stdout.trim() !== ""; |
| 248 | const pluginChanged = hasMain ? git(clone, ["diff", "--cached", "--quiet", "--", PUBLISHED], { allowFail: true }).status !== 0 : true; |
| 249 | if (pluginChanged && before === version) { |
| 250 | throw new Error(`The plugin's files changed, but ${version} is already published, and Claude Code updates installed plugins only when the version changes. Bump "version" in ${SOURCE}/.claude-plugin/plugin.json and .claude-plugin/marketplace.json.`); |
| 251 | } |
| 252 | |
| 253 | const tag = tagName(version); |
| 254 | const remoteTag = git(clone, ["ls-remote", "--tags", "origin", `refs/tags/${tag}`], { env }).stdout.trim(); |
| 255 | let commit = hasMain ? git(clone, ["rev-parse", "HEAD"]).stdout.trim() : null; |
| 256 | if (changed) { |
| 257 | const ident = ["-c", "user.name=g1t", "-c", "user.email=g1t@users.noreply.g1t.sh"]; |
| 258 | git(clone, [...ident, "commit", "--quiet", "-m", `g1t ${version}, from flagon-io/g1t@${source.slice(0, 12)}\n\nPublished by scripts/plugins-release.mjs from https://g1t.sh/flagon-io/g1t/commit/${source}.\nChange the plugin there; this repository is overwritten.`]); |
| 259 | commit = git(clone, ["rev-parse", "HEAD"]).stdout.trim(); |
| 260 | } |
| 261 | const tagged = !remoteTag && commit != null; |
| 262 | if (dryRun) return { changed, commit, tagged, version, dir: clone }; |
| 263 | if (changed) git(clone, ["push", "--quiet", "origin", "main:refs/heads/main"], { env }); |
| 264 | if (tagged) git(clone, ["push", "--quiet", "origin", `${commit}:refs/tags/${tag}`], { env }); |
| 265 | return { changed, commit, tagged, version, dir: clone }; |
| 266 | } |
| 267 | |
| 268 | if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/plugins-release.mjs")) { |
| 269 | const [command, ...rest] = process.argv.slice(2); |
| 270 | try { |
| 271 | if (command === "check") { |
| 272 | const { version } = manifests(); |
| 273 | const dir = mkdtempSync(join(tmpdir(), "g1t-plugins-check-")); |
| 274 | build(dir); |
| 275 | rmSync(dir, { recursive: true, force: true }); |
| 276 | console.log(`g1t@g1t ${version}: the manifests agree and the published tree is only the plugin`); |
| 277 | } else if (command === "build") { |
| 278 | const { out, version } = build(rest[0] ? join(process.cwd(), rest[0]) : undefined); |
| 279 | console.log(`Built g1t@g1t ${version} in ${out}:\n${walk(out).map((f) => ` ${f}`).join("\n")}`); |
| 280 | } else if (command === "publish") { |
| 281 | const dryRun = rest.includes("--dry-run"); |
| 282 | const r = publish({ dryRun }); |
| 283 | const where = process.env.G1T_PLUGINS_REMOTE || REMOTE; |
| 284 | console.log(r.changed ? `${dryRun ? "Would publish" : "Published"} g1t ${r.version} to ${where} as ${r.commit}` : `${where} already has g1t ${r.version}; nothing to publish`); |
| 285 | if (r.tagged) console.log(`${dryRun ? "Would tag" : "Tagged"} ${tagName(r.version)}`); |
| 286 | if (process.env.GITHUB_STEP_SUMMARY) writeFileSync(process.env.GITHUB_STEP_SUMMARY, `- g1t@g1t ${r.version}: ${r.changed ? `published as \`${r.commit}\`` : "unchanged"}${r.tagged ? `, tagged \`${tagName(r.version)}\`` : ""}\n`, { flag: "a" }); |
| 287 | } else { |
| 288 | console.error("usage: node scripts/plugins-release.mjs check | build [DIR] | publish [--dry-run]"); |
| 289 | process.exit(2); |
| 290 | } |
| 291 | } catch (e) { |
| 292 | console.error(e.message); |
| 293 | process.exit(1); |
| 294 | } |
| 295 | } |