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.

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