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 work item 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
225a work item/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}