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.

358 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
123/**
124 * The oldest g1t CLI the plugin's hooks work with: the CLI contract's
125 * minimum_version, which the hooks' MIN_CLI keeps to.
126 */
127export function minimumCli(root = ROOT) {
128 return readJson(root, `${SOURCE}/test/cli-contract.json`).minimum_version;
129}
130
131export function readme(version, cli = minimumCli()) {
132 return `# g1t plugins
133
134g1t's plugin marketplace for Claude Code and Codex. Choose your client below.
135
136## Claude Code
137
138The Claude Code plugin, \`g1t\` (${version}),
139records your Claude Code sessions on g1t, gives Claude the work item it is
140working on, claims and hands off work, and adds g1t's MCP server, skills
141and commands.
142
143\`\`\`sh
144${INSTALL.join("\n")}
145\`\`\`
146
147Then start Claude Code again, run \`/mcp\` and sign **plugin:g1t:g1t** in.
148The plugin's hooks need the [g1t CLI](https://docs.g1t.sh/guides/cli/)
149${cli} or later and Node 18 or later.
150
151The guide, with what each hook records and never records and how to opt
152out: https://docs.g1t.sh/guides/claude-code-plugin/
153
154This repository is published automatically from
155[flagon-io/g1t](https://g1t.sh/flagon-io/g1t) (\`.claude-plugin/\` and
156\`${SOURCE}/\`). Changes and issues go there.
157`;
158}
159
160/** Codex's catalog uses paths relative to the marketplace root. */
161export function codexMarketplace() {
162 return { name: "g1t", interface: { displayName: "g1t" }, plugins: [{
163 name: "g1t", source: { source: "local", path: `./${CODEX_PUBLISHED}` },
164 policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: "Developer Tools",
165 }] };
166}
167
168function codexManifest(root) {
169 const plugin = readJson(root, `${CODEX_SOURCE}/plugin.json`);
170 if (plugin.name !== "g1t" || !/^\d+\.\d+\.\d+$/.test(plugin.version ?? "")) throw new Error("Invalid Codex plugin identity or version");
171 const extra = readdirSync(join(root, CODEX_SOURCE)).filter(name => !CODEX_FILES.includes(name));
172 if (extra.length) throw new Error(`Unlisted Codex plugin files: ${extra.join(", ")}`);
173 return plugin;
174}
175
176/** A release's tag: `claude plugin tag`'s {name}--v{version}. */
177export const tagName = (version) => `g1t--v${version}`;
178
179/** Why the folder cannot be published as it is: entries PLUGIN_FILES and DEV_FILES do not name. */
180function unlisted(root) {
181 return readdirSync(join(root, SOURCE)).filter((name) => !PLUGIN_FILES.includes(name) && !DEV_FILES.includes(name));
182}
183
184/** Credentials and forbidden files in a built tree, as "path: what". */
185export function scan(dir) {
186 const found = [];
187 for (const file of walk(dir)) {
188 for (const part of file.split("/")) if (FORBIDDEN_NAMES.some((re) => re.test(part))) found.push(`${file}: a file that is never published`);
189 const text = readFileSync(join(dir, file), "utf8");
190 for (const [what, re] of SECRETS) if (re.test(text)) found.push(`${file}: looks like ${what}`);
191 }
192 return found;
193}
194
195/** The files the build writes, relative to its folder: the only ones it may contain. */
196export function expectedFiles(root = ROOT) {
197 const plugin = PLUGIN_FILES.flatMap((name) => {
198 const path = join(root, SOURCE, name);
199 return statSync(path).isDirectory() ? walk(path).map((f) => `${PUBLISHED}/${name}/${f}`) : [`${PUBLISHED}/${name}`];
200 });
201 const codex = walk(join(root, CODEX_SOURCE)).map(f => `${CODEX_PUBLISHED}/${f}`);
202 return [".claude-plugin/marketplace.json", ".agents/plugins/marketplace.json", "LICENSE", "README.md", ...plugin, ...codex].sort();
203}
204
205/** Writes the published tree to `out`, replacing what was there, and checks it. */
206export function build(out = join(ROOT, "target", "g1t-plugins"), root = ROOT) {
207 const { marketplace, version } = manifests(root);
208 codexManifest(root);
209 const extra = unlisted(root);
210 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.`);
211 for (const name of PLUGIN_FILES) if (!existsSync(join(root, SOURCE, name))) throw new Error(`${SOURCE}/${name} is missing`);
212
213 rmSync(out, { recursive: true, force: true });
214 mkdirSync(join(out, ".claude-plugin"), { recursive: true });
215 writeFileSync(join(out, ".claude-plugin", "marketplace.json"), `${JSON.stringify(publishedMarketplace(marketplace), null, 2)}\n`);
216 for (const name of PLUGIN_FILES) cpSync(join(root, SOURCE, name), join(out, PUBLISHED, name), { recursive: true });
217 mkdirSync(join(out, ".agents/plugins"), { recursive: true });
218 writeFileSync(join(out, ".agents/plugins/marketplace.json"), JSON.stringify(codexMarketplace(), null, 2) + "\n");
219 for (const name of CODEX_FILES) cpSync(join(root, CODEX_SOURCE, name), join(out, CODEX_PUBLISHED, name), { recursive: true });
220 writeFileSync(join(out, "README.md"), readme(version, minimumCli(root)) + `
221## Codex
222
223Add this marketplace with:
224
225\`\`\`sh
226${CODEX_INSTALL}
227codex plugin add g1t@g1t
228\`\`\`
229
230In the ChatGPT desktop app's Plugins directory, choose the g1t marketplace,
231install g1t and authorize its MCP connection. Restart the app if needed.
232The Codex plugin (${codexManifest(root).version}) bundles the remote MCP server and
233a work item/session workflow skill. It records sessions through MCP calls, not
234Claude Code hooks. Disable a duplicate standalone g1t MCP connection if installed.
235Direct MCP setup and ChatGPT instructions: https://docs.g1t.sh/guides/bring-your-own-agent/
236`);
237 cpSync(join(root, "LICENSE"), join(out, "LICENSE"));
238 verify(out, root);
239 return { out, version };
240}
241
242/** Checks a built tree: exactly the expected files, manifests that agree, nothing secret. */
243export function verify(dir, root = ROOT) {
244 const { version } = manifests(root);
245 const problems = [];
246 const files = walk(dir);
247 const expected = expectedFiles(root);
248 const more = files.filter((f) => !expected.includes(f));
249 const fewer = expected.filter((f) => !files.includes(f));
250 if (more.length) problems.push(`files that are not the plugin's: ${more.join(", ")}`);
251 if (fewer.length) problems.push(`missing: ${fewer.join(", ")}`);
252 const marketplace = readJson(dir, ".claude-plugin/marketplace.json");
253 const entry = marketplace.plugins?.[0] ?? {};
254 if (entry.source !== `./${PUBLISHED}`) problems.push(`the published source is ${JSON.stringify(entry.source)}`);
255 const plugin = readJson(dir, `${PUBLISHED}/.claude-plugin/plugin.json`);
256 if (marketplace.name !== "g1t" || entry.name !== "g1t" || plugin.name !== "g1t") problems.push("the names are not g1t@g1t");
257 if (entry.version !== version || plugin.version !== version) problems.push(`versions: marketplace ${entry.version}, plugin ${plugin.version}, source ${version}`);
258 for (const file of [`${PUBLISHED}/.mcp.json`, `${PUBLISHED}/hooks/hooks.json`]) readJson(dir, file);
259 const codex = readJson(dir, `${CODEX_PUBLISHED}/plugin.json`);
260 if (codex.name !== "g1t" || codex.version !== codexManifest(root).version) problems.push("Codex plugin identity or version differs from source");
261 if (JSON.stringify(readJson(dir, ".agents/plugins/marketplace.json")) !== JSON.stringify(codexMarketplace())) problems.push("Codex marketplace differs from expected catalog");
262 const mcp = readJson(dir, `${CODEX_PUBLISHED}/mcp.json`);
263 if (mcp.mcpServers?.g1t?.url !== "https://mcp.g1t.sh" || mcp.mcpServers?.g1t?.type !== "streamable-http") problems.push("Invalid Codex MCP server");
264 problems.push(...scan(dir));
265 if (problems.length) throw new Error(`The published tree is wrong:\n- ${problems.join("\n- ")}`);
266}
267
268function git(cwd, args, { env = {}, allowFail = false } = {}) {
269 const r = spawnSync("git", args, { cwd, encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env } });
270 if (r.status !== 0 && !allowFail) throw new Error(`git ${args[0]} failed (${r.status}): ${(r.stderr || r.stdout).trim()}`);
271 return r;
272}
273
274/**
275 * The token as a git header, through git's environment config
276 * (GIT_CONFIG_COUNT), so it is on no command line and in no file.
277 */
278export function authEnv(token) {
279 if (!token) return {};
280 const basic = Buffer.from(`g1t:${token}`).toString("base64");
281 return { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "http.extraHeader", GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}` };
282}
283
284/**
285 * Publishes the build to `remote`'s main and tags v<version>. Returns
286 * { changed, commit, tagged }. An empty repository gets its first commit.
287 */
288export function publish({ remote = process.env.G1T_PLUGINS_REMOTE || REMOTE, token = process.env.PLUGINS_PUBLISH_TOKEN, source, dryRun = false, root = ROOT, work } = {}) {
289 source ??= process.env.G1T_SOURCE_COMMIT || git(root, ["rev-parse", "HEAD"]).stdout.trim();
290 const env = authEnv(token);
291 work ??= mkdtempSync(join(tmpdir(), "g1t-plugins-"));
292 const built = join(work, "build");
293 const clone = join(work, "clone");
294 const { version } = build(built, root);
295
296 git(work, ["clone", "--quiet", remote, clone], { env });
297 const hasMain = git(clone, ["rev-parse", "--verify", "--quiet", "refs/remotes/origin/main"], { allowFail: true }).status === 0;
298 if (hasMain) git(clone, ["checkout", "--quiet", "-B", "main", "origin/main"]);
299 else git(clone, ["checkout", "--quiet", "--orphan", "main"]);
300
301 const codexBefore = hasMain && existsSync(join(clone, CODEX_PUBLISHED, "plugin.json")) ? readJson(clone, `${CODEX_PUBLISHED}/plugin.json`).version : null;
302 const before = hasMain && existsSync(join(clone, PUBLISHED, ".claude-plugin", "plugin.json")) ? readJson(clone, `${PUBLISHED}/.claude-plugin/plugin.json`).version : null;
303 for (const name of readdirSync(clone)) if (name !== ".git") rmSync(join(clone, name), { recursive: true, force: true });
304 cpSync(built, clone, { recursive: true });
305 verify(clone, root);
306
307 git(clone, ["add", "--all"]);
308 const changed = git(clone, ["status", "--porcelain"]).stdout.trim() !== "";
309 const pluginChanged = hasMain ? git(clone, ["diff", "--cached", "--quiet", "--", PUBLISHED], { allowFail: true }).status !== 0 : true;
310 if (pluginChanged && before === version) {
311 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.`);
312 }
313
314 const codexChanged = hasMain ? git(clone, ["diff", "--cached", "--quiet", "--", CODEX_PUBLISHED], { allowFail: true }).status !== 0 : true;
315 if (codexChanged && codexBefore === codexManifest(root).version) throw new Error("Codex plugin files changed without a version bump in plugins/codex/plugin.json");
316 const tag = tagName(version);
317 const remoteTag = git(clone, ["ls-remote", "--tags", "origin", `refs/tags/${tag}`], { env }).stdout.trim();
318 let commit = hasMain ? git(clone, ["rev-parse", "HEAD"]).stdout.trim() : null;
319 if (changed) {
320 const ident = ["-c", "user.name=g1t", "-c", "user.email=g1t@users.noreply.g1t.sh"];
321 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.`]);
322 commit = git(clone, ["rev-parse", "HEAD"]).stdout.trim();
323 }
324 const tagged = !remoteTag && commit != null;
325 if (dryRun) return { changed, commit, tagged, version, dir: clone };
326 if (changed) git(clone, ["push", "--quiet", "origin", "main:refs/heads/main"], { env });
327 if (tagged) git(clone, ["push", "--quiet", "origin", `${commit}:refs/tags/${tag}`], { env });
328 return { changed, commit, tagged, version, dir: clone };
329}
330
331if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/plugins-release.mjs")) {
332 const [command, ...rest] = process.argv.slice(2);
333 try {
334 if (command === "check") {
335 const { version } = manifests();
336 const dir = mkdtempSync(join(tmpdir(), "g1t-plugins-check-"));
337 build(dir);
338 rmSync(dir, { recursive: true, force: true });
339 console.log(`g1t@g1t ${version}: the manifests agree and the published tree is only the plugin`);
340 } else if (command === "build") {
341 const { out, version } = build(rest[0] ? join(process.cwd(), rest[0]) : undefined);
342 console.log(`Built g1t@g1t ${version} in ${out}:\n${walk(out).map((f) => ` ${f}`).join("\n")}`);
343 } else if (command === "publish") {
344 const dryRun = rest.includes("--dry-run");
345 const r = publish({ dryRun });
346 const where = process.env.G1T_PLUGINS_REMOTE || REMOTE;
347 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`);
348 if (r.tagged) console.log(`${dryRun ? "Would tag" : "Tagged"} ${tagName(r.version)}`);
349 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" });
350 } else {
351 console.error("usage: node scripts/plugins-release.mjs check | build [DIR] | publish [--dry-run]");
352 process.exit(2);
353 }
354 } catch (e) {
355 console.error(e.message);
356 process.exit(1);
357 }
358}