Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
295 linesCodeBlameRaw
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
45import { spawnSync } from "node:child_process";
46import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
47import { tmpdir } from "node:os";
48import { dirname, join, relative } from "node:path";
49import { fileURLToPath } from "node:url";
50
51const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
52export const SOURCE = "plugins/claude-code";
53export const PUBLISHED = "plugins/g1t";
54export const REMOTE = "https://g1t.sh/flagon-io/g1t-plugins.git";
55export 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.
60export const PLUGIN_FILES = [".claude-plugin", ".mcp.json", "commands", "hooks", "skills"];
61export const DEV_FILES = ["test"];
62
63// Names no published file may have, and text no published file may hold.
64const FORBIDDEN_NAMES = [/^\.env/, /^\.credentials/, /\.(pem|key|p12|pfx)$/i, /^id_(rsa|ed25519|ecdsa)/, /^\.npmrc$/, /^node_modules$/];
65const 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
76const 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. */
79export 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 */
96export 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. */
113export function publishedMarketplace(marketplace) {
114 return { ...marketplace, plugins: marketplace.plugins.map((p) => ({ ...p, source: `./${PUBLISHED}` })) };
115}
116
117export function readme(version) {
118 return `# g1t plugins
119
120g1t's plugin marketplace for Claude Code. Its one plugin, \`g1t\` (${version}),
121records your Claude Code sessions on g1t, gives Claude the issue it is
122working on, claims and hands off work, and adds g1t's MCP server, skills
123and commands.
124
125\`\`\`sh
126${INSTALL.join("\n")}
127\`\`\`
128
129Then start Claude Code again, run \`/mcp\` and sign **plugin:g1t:g1t** in.
130The plugin's hooks need the [g1t CLI](https://docs.g1t.sh/guides/cli/)
1310.3.2 or later and Node 18 or later.
132
133The guide, with what each hook records and never records and how to opt
134out: https://docs.g1t.sh/guides/claude-code-plugin/
135
136This 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}. */
143export 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. */
146function 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". */
151export 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. */
162export 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. */
171export 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. */
188export 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
208function 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 */
218export 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 */
228export 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
268if (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}