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.

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