g1t's Claude Code plugin installs from its own repository, published by a workspace token, and a token for chosen repositories no longer creates others by pushing. `claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git` then `claude plugin install g1t@g1t` clones only the plugin, not this monorepo, which stays its source (.claude-plugin/marketplace.json, plugins/claude-code/). scripts/plugins-release.mjs builds the published tree (the plugin and a marketplace that points at it), checks that both manifests agree and that nothing else is in it, and pushes it with a tag `g1t--v<version>`; it refuses changed plugin files under a version already published, and says "nothing to publish" when that version is there. .g1t/workflows/plugins-release.yml runs it on pushes to main with the flagon-io workspace token PLUGINS_PUBLISH_TOKEN, sent as Basic auth, never a person's token; CI runs its tests. The README, the plugin guide, the docs' agent list, the MCP reference and its instructions, bring-your-own-agent, why-blame, the site's agent setup and the Live now empty state, llms.txt, install/claude.sh and bench/ask all give the new address, and the earlier ~/.g1t/hook.mjs install path is gone. Automation is pointed at a workspace's own tokens: the API reference's Authentication section, Actions' note on changing workflows from a job (it said a personal token) and llms.txt (never ask your person for a personal token for that) now say so, as Settings → Access tokens, the authentication and workspaces guides and git's deploy keys already did. Pushing to an address that does not exist made a repository for any token holding code:write and a member's or the workspace's identity, so a workspace token for only g1t-plugins could create flagon-io/<anything> and push to it; repos' git_token_refusal now refuses push-to-create for a token whose reach is chosen repositories, public ones only, or another workspace, and the git guide says so. Checked: the plugin's hook tests (49), plugins-release tests (8) and its check (g1t@g1t 0.2.2), cargo test -p g1t-repos --lib (314, new: chosen repositories never create one, all of a workspace creates there only, a token without a reach as before) and its wasm32 check, the site's agent-setup and llms.txt tests (20), the docs build (513 pages).
24 files+619−1910/24 viewed
| 128 | 128 | run: node scripts/deploy.mjs manifest --check | |
| 129 | 129 | - name: The Claude Code plugin's tests | |
| 130 | 130 | run: node --test plugins/claude-code/test/hooks.test.mjs | |
| 131 | + | # What plugins-release.yml publishes to flagon-io/g1t-plugins: the | |
| 132 | + | # manifests agree, and the tree is only the plugin. | |
| 133 | + | - name: The plugin's published marketplace | |
| 134 | + | run: | | |
| 135 | + | node scripts/plugins-release.mjs check | |
| 136 | + | node --test scripts/plugins-release.test.mjs | |
| 131 | 137 | ||
| 132 | 138 | build: | |
| 133 | 139 | name: Build |
| 1 | + | # Publishes g1t's Claude Code plugin marketplace to flagon-io/g1t-plugins | |
| 2 | + | # on g1t.sh, so people add it without cloning this repository: | |
| 3 | + | # | |
| 4 | + | # claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git | |
| 5 | + | # claude plugin install g1t@g1t | |
| 6 | + | # | |
| 7 | + | # This repository is the plugin's source: .claude-plugin/marketplace.json | |
| 8 | + | # and plugins/claude-code/. scripts/plugins-release.mjs builds the published | |
| 9 | + | # tree from them (the manifest, the plugin without its tests, a README and | |
| 10 | + | # the license, the plugin's source rewritten to ./plugins/g1t), checks it | |
| 11 | + | # holds nothing else and nothing secret, and commits it to g1t-plugins' | |
| 12 | + | # main only when it changed; each new version is tagged g1t--v<version>, | |
| 13 | + | # as `claude plugin tag` would. A change to the plugin's files without a | |
| 14 | + | # new version stops the run, as Claude Code updates installed plugins only | |
| 15 | + | # when the version changes. | |
| 16 | + | # | |
| 17 | + | # Runs on a push to main that changes the plugin, its manifest or the | |
| 18 | + | # script, and by hand. | |
| 19 | + | # | |
| 20 | + | # Needs the repository secret PLUGINS_PUBLISH_TOKEN: a workspace token of | |
| 21 | + | # flagon-io (g1t.sh/flagon-io/-/tokens, made by an owner; it acts as the | |
| 22 | + | # workspace, not a person), select repositories: g1t-plugins only, with | |
| 23 | + | # Code: read and write (code:write) and nothing else. g1t-plugins must | |
| 24 | + | # exist, public; this never creates it. The published tree has no workflow | |
| 25 | + | # files, so the token needs no workflow_files:write. The job's own token | |
| 26 | + | # reaches this repository only, and only to read it. | |
| 27 | + | name: Plugins release | |
| 28 | + | ||
| 29 | + | on: | |
| 30 | + | push: | |
| 31 | + | branches: [main] | |
| 32 | + | paths: | |
| 33 | + | - .claude-plugin/** | |
| 34 | + | - plugins/claude-code/** | |
| 35 | + | - scripts/plugins-release.mjs | |
| 36 | + | - LICENSE | |
| 37 | + | workflow_dispatch: | |
| 38 | + | ||
| 39 | + | permissions: | |
| 40 | + | contents: read | |
| 41 | + | ||
| 42 | + | concurrency: | |
| 43 | + | group: plugins-release | |
| 44 | + | cancel-in-progress: false | |
| 45 | + | ||
| 46 | + | jobs: | |
| 47 | + | publish: | |
| 48 | + | name: Publish flagon-io/g1t-plugins | |
| 49 | + | runs-on: ubuntu-latest | |
| 50 | + | timeout-minutes: 10 | |
| 51 | + | steps: | |
| 52 | + | - uses: actions/checkout@v5 | |
| 53 | + | with: | |
| 54 | + | # Without the job's own token in git's config: it would go with | |
| 55 | + | # the push to g1t-plugins beside the publish token, and be refused. | |
| 56 | + | persist-credentials: false | |
| 57 | + | - name: The plugin's tests | |
| 58 | + | run: | | |
| 59 | + | node --test plugins/claude-code/test/hooks.test.mjs | |
| 60 | + | node --test scripts/plugins-release.test.mjs | |
| 61 | + | - name: Publish | |
| 62 | + | env: | |
| 63 | + | PLUGINS_PUBLISH_TOKEN: ${{ secrets.PLUGINS_PUBLISH_TOKEN }} | |
| 64 | + | G1T_PLUGINS_REMOTE: ${{ github.server_url }}/flagon-io/g1t-plugins.git | |
| 65 | + | G1T_SOURCE_COMMIT: ${{ github.sha }} | |
| 66 | + | run: | | |
| 67 | + | if [ -z "${PLUGINS_PUBLISH_TOKEN}" ]; then | |
| 68 | + | echo "::error::Set the repository secret PLUGINS_PUBLISH_TOKEN (this workflow's header says what it needs)." | |
| 69 | + | exit 1 | |
| 70 | + | fi | |
| 71 | + | echo "::add-mask::${PLUGINS_PUBLISH_TOKEN}" | |
| 72 | + | node scripts/plugins-release.mjs publish |
| 15 | 15 | | Something a person does on g1t.sh | The guide for it in `apps/docs/src/content/docs/guides/` | | |
| 16 | 16 | | An API route, a field, or an MCP tool | `apps/api/src/operations.rs` descriptions (they feed the OpenAPI document and the API reference), the example and notes for it in `apps/api/src/reference.json`, `reference/mcp.md`, and any guide that shows the call. Then refresh the docs' copy of the OpenAPI document with `G1T_WRITE_OPENAPI=1 cargo test -p g1t-api openapi`; `cargo test` fails until you do. | | |
| 17 | 17 | | What coding agents are told about g1t | `guides/bring-your-own-agent.mdx`, `guides/claude-code-plugin.md`, the plugin's skills in `plugins/claude-code/`, and `apps/web/public/llms.txt` | | |
| 18 | + | | The Claude Code plugin's files (`plugins/claude-code/`, except `test/`) | Its version, the same in `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json`: Claude Code updates an installed plugin only when it changes. On main, `plugins-release.yml` publishes it to `flagon-io/g1t-plugins`, and refuses a changed plugin with a version already published. Never commit to g1t-plugins itself. | | |
| 18 | 19 | | Settings, limits or prices | The page that names them, and the table it is in | | |
| 19 | 20 | | A new feature | A section in the guide that owns it, linked from the docs home if it is a new task | | |
| 20 | 21 | | How g1t is put together: a service, a binding, a store, an event route, the path of a request | Its page in the docs' Architecture section (`apps/docs/src/content/docs/architecture/`), and its diagram in `apps/docs/src/diagrams/architecture.ts`. [How g1t ships](https://docs.g1t.sh/architecture/shipping/#keeping-this-section-true) says which page covers what. | |
| 30 | 30 | ||
| 31 | 31 | # 3. Install g1t's Claude Code plugin. It adds g1t's MCP server and records | |
| 32 | 32 | # your sessions; run /mcp in Claude Code and sign plugin:g1t:g1t in. | |
| 33 | − | claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git | |
| 33 | + | claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git | |
| 34 | 34 | claude plugin install g1t@g1t | |
| 35 | 35 | ``` | |
| 36 | 36 | ||
| ⋯ | |||
| 143 | 143 | | `crates/sshd` | Git over SSH, bridged to Cloudflare Artifacts. Not deployed. | Rust | | |
| 144 | 144 | | `packages/contracts` | The same interfaces for TypeScript callers. | TypeScript | | |
| 145 | 145 | | `packages/theme` | Design tokens and the logo, shared by the site and the docs. | CSS | | |
| 146 | − | | `plugins/claude-code` | g1t's Claude Code plugin: hooks, skills and commands. | JavaScript | | |
| 146 | + | | `plugins/claude-code` | g1t's Claude Code plugin: hooks, skills and commands, published to `flagon-io/g1t-plugins` on every change. | JavaScript | | |
| 147 | 147 | | `plugins/other-harnesses` | The AGENTS.md snippet for Codex, Cursor and other harnesses. | Markdown | | |
| 148 | 148 | | `deploy` | `stack.jsonc`, every deployable part and its resources; `self-host`, the Docker Compose version. | JSON, Docker Compose | | |
| 149 | 149 | ||
| 148 | 148 | "connect": format!("claude mcp add --transport http g1t {}", addresses.mcp), | |
| 149 | 149 | "claude_code_plugin": { | |
| 150 | 150 | "install": [ | |
| 151 | − | "claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git", | |
| 151 | + | "claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git", | |
| 152 | 152 | "claude plugin install g1t@g1t", | |
| 153 | 153 | ], | |
| 154 | 154 | "note": "The plugin adds this server (listed in /mcp as plugin:g1t:g1t) and records your sessions on g1t. With it installed, skip `connect`, or each tool is listed twice.", |
| 1808 | 1808 | even with `contents: write`. A push that does is declined, naming the file, | |
| 1809 | 1809 | so a workflow cannot rewrite the workflows that run with its repository's | |
| 1810 | 1810 | secrets. To change workflows from a job, push with a | |
| 1811 | − | [personal access token](/guides/authentication/#workflow-files) that has | |
| 1812 | − | Workflow files: write, kept as a secret. | |
| 1811 | + | [workspace access token](/guides/workspaces/#workspace-access-tokens) that | |
| 1812 | + | has [Workflow files: write](/guides/authentication/#workflow-files), kept | |
| 1813 | + | as a secret. | |
| 1813 | 1814 | ||
| 1814 | 1815 | The job's token is the repository's workspace acting with the Write role | |
| 1815 | 1816 | at most, never Admin: it cannot manage webhooks, secrets, deploy keys or who |
| 181 | 181 | repository is recorded without the agent having to remember: | |
| 182 | 182 | ||
| 183 | 183 | ```sh | |
| 184 | − | claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git | |
| 184 | + | claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git | |
| 185 | 185 | claude plugin install g1t@g1t | |
| 186 | 186 | ``` | |
| 187 | 187 |
| 39 | 39 | 2. Add g1t's plugin marketplace and install the plugin: | |
| 40 | 40 | ||
| 41 | 41 | ```sh | |
| 42 | − | claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git | |
| 42 | + | claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git | |
| 43 | 43 | claude plugin install g1t@g1t | |
| 44 | 44 | ``` | |
| 45 | 45 | ||
| 46 | 46 | Or run `/plugin` inside Claude Code and choose the marketplace there. | |
| 47 | 47 | ||
| 48 | + | The marketplace, `flagon-io/g1t-plugins`, holds the plugin and nothing | |
| 49 | + | else, so adding it downloads about 100 KB. On Windows, use Claude Code | |
| 50 | + | 2.1.296 or later for PowerShell session support. | |
| 51 | + | ||
| 48 | 52 | 3. Start Claude Code again, run `/mcp` and choose **plugin:g1t:g1t** to | |
| 49 | 53 | sign g1t's MCP server in through your browser. | |
| 50 | 54 | ||
| 51 | − | The plugin adds g1t's MCP server itself. If you added it before with | |
| 52 | − | `claude mcp add … g1t https://mcp.g1t.sh`, remove that one | |
| 53 | − | (`claude mcp remove g1t`), or Claude sees every g1t tool twice. | |
| 55 | + | The plugin adds g1t's MCP server itself, so do not also add it with | |
| 56 | + | `claude mcp add`, or Claude sees every g1t tool twice. | |
| 54 | 57 | ||
| 55 | 58 | `curl -fsSL https://g1t.sh/install/claude.sh | sh` prints the same | |
| 56 | 59 | commands; add `-s -- --cli` to have it install the CLI too. | |
| ⋯ | |||
| 59 | 62 | the host in `G1T_HOST` for a self-hosted g1t. Everywhere else its hooks do | |
| 60 | 63 | nothing. | |
| 61 | 64 | ||
| 65 | + | ### Update | |
| 66 | + | ||
| 67 | + | Each release of the plugin has a new version, tagged `g1t--v<version>` in | |
| 68 | + | `flagon-io/g1t-plugins`. To get the newest: | |
| 69 | + | ||
| 70 | + | ```sh | |
| 71 | + | claude plugin marketplace update g1t | |
| 72 | + | claude plugin update g1t@g1t | |
| 73 | + | ``` | |
| 74 | + | ||
| 75 | + | Then start Claude Code again. `claude plugin list` says which version you | |
| 76 | + | have. | |
| 77 | + | ||
| 78 | + | ### Where the plugin is made | |
| 79 | + | ||
| 80 | + | `flagon-io/g1t-plugins` is published from g1t's repository, where the | |
| 81 | + | plugin is developed and tested (`plugins/claude-code/`). Every change there | |
| 82 | + | is published automatically, so the two never differ; issues and changes | |
| 83 | + | go to [flagon-io/g1t](https://g1t.sh/flagon-io/g1t). | |
| 84 | + | ||
| 62 | 85 | ## What it records | |
| 63 | 86 | ||
| 64 | 87 | The plugin's hooks call the `g1t` CLI, signed in as you, so a session is | |
| ⋯ | |||
| 263 | 286 | pull request's **Session** tab and the repository's **Work** page: who ran | |
| 264 | 287 | it, its subagents as a tree, the skills used, what it touched, and the | |
| 265 | 288 | issues and pull requests it worked on. | |
| 266 | − | ||
| 267 | − | ## The earlier hook installer | |
| 268 | − | ||
| 269 | − | An earlier `curl -fsSL https://g1t.sh/install/claude.sh | sh` added a hook | |
| 270 | − | of its own, `node ~/.g1t/hook.mjs`, to `~/.claude/settings.json`. The | |
| 271 | − | plugin replaces it. Once the plugin is installed, remove those entries from | |
| 272 | − | `~/.claude/settings.json` so a session is not recorded twice. Running the | |
| 273 | − | installer again brings the old hook up to date and prints the plugin's | |
| 274 | − | commands. | |
| 275 | 289 | ||
| 276 | 290 | ## Other coding agents | |
| 277 | 291 | ||
| 45 | 45 | ||
| 46 | 46 | Pushing to a repository that does not exist, in a workspace you belong to, | |
| 47 | 47 | creates it as a private repository, so nothing pushed by mistake is | |
| 48 | − | published. To make it public, see | |
| 48 | + | published. An access token can create one only where it reaches all of the | |
| 49 | + | workspace's repositories; a token for chosen repositories is refused. To | |
| 50 | + | make it public, see | |
| 49 | 51 | [change who can see a repository](/guides/managing-repositories/#change-who-can-see-a-repository). | |
| 50 | 52 | ||
| 51 | 53 | ```sh |
| 38 | 38 | agent having to remember: | |
| 39 | 39 | ||
| 40 | 40 | ```sh | |
| 41 | − | claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git | |
| 41 | + | claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git | |
| 42 | 42 | claude plugin install g1t@g1t | |
| 43 | 43 | ``` | |
| 44 | 44 |
| 51 | 51 | same, so Octokit and the actions built on it, such as `actions/checkout` | |
| 52 | 52 | and `actions/github-script`, are signed in with the token they are given. | |
| 53 | 53 | ||
| 54 | + | For CI and other automation that works for a team rather than for you, use | |
| 55 | + | one of the workspace's own | |
| 56 | + | [workspace access tokens](/guides/workspaces/#workspace-access-tokens): | |
| 57 | + | it acts as the workspace and keeps working when you leave. | |
| 58 | + | ||
| 54 | 59 | Public data can be read without a token. A token that is not valid is | |
| 55 | 60 | rejected with `401` rather than treated as anonymous. Each endpoint's page | |
| 56 | 61 | says whether it needs a token. |
| 20 | 20 | (`plugin:g1t:g1t` in `/mcp`) and records your sessions: | |
| 21 | 21 | ||
| 22 | 22 | ```sh | |
| 23 | − | claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git | |
| 23 | + | claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git | |
| 24 | 24 | claude plugin install g1t@g1t | |
| 25 | 25 | ``` | |
| 26 | 26 |
| 7 | 7 | export const MCP_URL = 'https://mcp.g1t.sh'; | |
| 8 | 8 | ||
| 9 | 9 | /** Installs g1t's Claude Code plugin, which adds the MCP server (as `plugin:g1t:g1t`) and records sessions. */ | |
| 10 | − | export const PLUGIN_INSTALL = 'claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git\nclaude plugin install g1t@g1t'; | |
| 10 | + | export const PLUGIN_INSTALL = 'claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git\nclaude plugin install g1t@g1t'; | |
| 11 | 11 | ||
| 12 | 12 | export type AgentSetup = { | |
| 13 | 13 | label: string; |
| 63 | 63 | sessionTitle, | |
| 64 | 64 | sessionTree, | |
| 65 | 65 | } from "../lib/coordination"; | |
| 66 | + | import { PLUGIN_INSTALL } from "../lib/agent-setup"; | |
| 66 | 67 | import { CopyLine, EmptyState, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 67 | 68 | import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; | |
| 68 | 69 | import { Avatar } from "./ui/avatar"; | |
| ⋯ | |||
| 895 | 896 | <EmptyState title={title}> | |
| 896 | 897 | <div className="mx-auto mt-3 max-w-lg space-y-3 text-left"> | |
| 897 | 898 | <p>Install the g1t plugin in Claude Code, and each session shows here as it works:</p> | |
| 898 | − | <CopyLine text="claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git" prompt /> | |
| 899 | − | <CopyLine text="claude plugin install g1t@g1t" prompt /> | |
| 899 | + | {PLUGIN_INSTALL.split("\n").map((line) => ( | |
| 900 | + | <CopyLine key={line} text={line} prompt /> | |
| 901 | + | ))} | |
| 900 | 902 | <p>From any other harness, or by hand, claim an issue with the g1t CLI:</p> | |
| 901 | 903 | <CopyLine text="g1t work claim 12" prompt /> | |
| 902 | 904 | <p> | |
| 85 | 85 | test("every snippet points at the MCP server", () => { | |
| 86 | 86 | // Claude Code's is g1t's plugin, which adds the server itself. | |
| 87 | 87 | for (const id of AGENT_IDS) if (id !== "claude-code") assert.ok(AGENTS[id].code.includes(MCP_URL), id); | |
| 88 | − | assert.match(AGENTS["claude-code"].code, /^claude plugin marketplace add https:\/\/g1t\.sh\/flagon-io\/g1t\.git\nclaude plugin install g1t@g1t$/); | |
| 88 | + | assert.match(AGENTS["claude-code"].code, /^claude plugin marketplace add https:\/\/g1t\.sh\/flagon-io\/g1t-plugins\.git\nclaude plugin install g1t@g1t$/); | |
| 89 | 89 | assert.match(AGENTS["claude-code"].then, /plugin:g1t:g1t/); | |
| 90 | 90 | assert.deepEqual(JSON.parse(AGENTS.cursor.code), { mcpServers: { g1t: { url: MCP_URL } } }); | |
| 91 | 91 | assert.equal(JSON.parse(AGENTS.opencode.code).mcp.g1t.type, "remote"); |
| 15 | 15 | export const MCP_URL = "https://mcp.g1t.sh"; | |
| 16 | 16 | ||
| 17 | 17 | /** Installs g1t's Claude Code plugin, which adds g1t.sh's MCP server and records sessions. */ | |
| 18 | − | export const PLUGIN_INSTALL = "claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git\nclaude plugin install g1t@g1t"; | |
| 18 | + | export const PLUGIN_INSTALL = "claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git\nclaude plugin install g1t@g1t"; | |
| 19 | 19 | ||
| 20 | 20 | export const AGENT_IDS = ["claude-code", "codex", "opencode", "cursor"] as const; | |
| 21 | 21 | export type AgentId = (typeof AGENT_IDS)[number]; |
| 7 | 7 | # curl -fsSL https://g1t.sh/install/claude.sh | sh | |
| 8 | 8 | # curl -fsSL https://g1t.sh/install/claude.sh | sh -s -- --cli | |
| 9 | 9 | # | |
| 10 | − | # It changes nothing else. An earlier version of this script installed a | |
| 11 | − | # hook in ~/.g1t; if that is there, it is brought up to date, and you can | |
| 12 | − | # remove it once the plugin is installed (see below). | |
| 10 | + | # It changes nothing else. | |
| 13 | 11 | set -eu | |
| 14 | 12 | ||
| 15 | 13 | BASE="${G1T_SITE:-https://g1t.sh}" | |
| ⋯ | |||
| 18 | 16 | curl -fsSL "$BASE/install" | sh | |
| 19 | 17 | fi | |
| 20 | 18 | ||
| 21 | − | if [ -f "$HOME/.g1t/hook.mjs" ]; then | |
| 22 | − | curl -fsSL "$BASE/install/hook.mjs" -o "$HOME/.g1t/hook.mjs" | |
| 23 | − | echo "Updated the earlier g1t hook in ~/.g1t/hook.mjs. The plugin replaces it:" | |
| 24 | − | echo "once the plugin is installed, remove the \`node \"$HOME/.g1t/hook.mjs\"\`" | |
| 25 | − | echo "entries from ~/.claude/settings.json, so a session is not recorded twice." | |
| 26 | − | echo | |
| 27 | − | fi | |
| 28 | − | ||
| 19 | + | # The plugin's marketplace is published only on g1t.sh, whichever g1t it | |
| 20 | + | # works with (a self-hosted one is G1T_HOST to its hooks). | |
| 29 | 21 | cat <<EOF | |
| 30 | 22 | Install g1t's plugin for Claude Code: | |
| 31 | 23 | ||
| 32 | − | claude plugin marketplace add $BASE/flagon-io/g1t.git | |
| 24 | + | claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git | |
| 33 | 25 | claude plugin install g1t@g1t | |
| 34 | 26 | ||
| 35 | 27 | The plugin's hooks use the g1t CLI (0.3.2 or later) and Node 18 or later. | |
| 1 | − | #!/usr/bin/env node | |
| 2 | − | // g1t's Claude Code hook: records what your agent does onto the pull | |
| 3 | − | // request you are working on, so its session sits beside the code. | |
| 4 | − | // | |
| 5 | − | // Claude Code runs this for UserPromptSubmit, PostToolUse and Stop, with | |
| 6 | − | // the event as JSON on stdin. It works out the pull request from the | |
| 7 | − | // working copy's git remote: a g1t fork (g1t.sh/pulls/<id>) or a branch | |
| 8 | − | // of a g1t repository with an open pull request. Anywhere else it does | |
| 9 | − | // nothing. It never fails the agent's turn. | |
| 10 | − | // | |
| 11 | − | // It records what each tool was called with, never what it returned. | |
| 12 | − | // | |
| 13 | − | // Installed by an earlier `curl -fsSL https://g1t.sh/install/claude.sh | sh`. | |
| 14 | − | // g1t's Claude Code plugin replaces it: | |
| 15 | − | // https://docs.g1t.sh/guides/claude-code-plugin/ | |
| 16 | − | ||
| 17 | − | import { execFileSync } from "node:child_process"; | |
| 18 | − | import { existsSync, readFileSync, writeFileSync } from "node:fs"; | |
| 19 | − | import { homedir } from "node:os"; | |
| 20 | − | import { join } from "node:path"; | |
| 21 | − | ||
| 22 | − | const HOME = join(homedir(), ".g1t"); | |
| 23 | − | const CONFIG = join(HOME, "config.json"); | |
| 24 | − | const CACHE = join(HOME, "pulls.json"); | |
| 25 | − | /** The longest any one entry is sent. */ | |
| 26 | − | const MAX_TEXT = 4000; | |
| 27 | − | ||
| 28 | − | function read(path, fallback) { | |
| 29 | − | try { | |
| 30 | − | return JSON.parse(readFileSync(path, "utf8")); | |
| 31 | − | } catch { | |
| 32 | − | return fallback; | |
| 33 | − | } | |
| 34 | − | } | |
| 35 | − | ||
| 36 | − | function git(cwd, ...args) { | |
| 37 | − | try { | |
| 38 | − | return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); | |
| 39 | − | } catch { | |
| 40 | − | return ""; | |
| 41 | − | } | |
| 42 | − | } | |
| 43 | − | ||
| 44 | − | function clip(text) { | |
| 45 | − | const value = typeof text === "string" ? text : JSON.stringify(text ?? ""); | |
| 46 | − | return value.length > MAX_TEXT ? `${value.slice(0, MAX_TEXT)}…` : value; | |
| 47 | − | } | |
| 48 | − | ||
| 49 | − | /** What a tool call did, in one line where possible. */ | |
| 50 | − | function describeCall(name, input = {}) { | |
| 51 | − | if (input.command) return input.command; | |
| 52 | − | if (input.file_path) return input.file_path; | |
| 53 | − | if (input.pattern) return `${input.pattern}${input.path ? ` in ${input.path}` : ""}`; | |
| 54 | − | if (input.url) return input.url; | |
| 55 | − | return clip(input); | |
| 56 | − | } | |
| 57 | − | ||
| 58 | − | /** | |
| 59 | − | * The pull request this working copy belongs to, if any. Only a pull | |
| 60 | − | * request found is cached: a branch whose pull request is opened later in | |
| 61 | − | * the session is looked up again, and recorded from then on. | |
| 62 | − | */ | |
| 63 | − | async function pullFor(cwd, config) { | |
| 64 | − | const remote = git(cwd, "remote", "get-url", "origin"); | |
| 65 | − | const branch = git(cwd, "branch", "--show-current"); | |
| 66 | − | if (!remote.includes("g1t.sh/")) return null; | |
| 67 | − | const key = `${remote}#${branch}`; | |
| 68 | − | const cache = read(CACHE, {}); | |
| 69 | − | if (cache[key]) return cache[key]; | |
| 70 | − | const headers = { authorization: `Bearer ${config.token}` }; | |
| 71 | − | let found = null; | |
| 72 | − | const fork = /g1t\.sh\/pulls\/([a-z0-9_]+?)(?:\.git)?$/.exec(remote); | |
| 73 | − | const repo = /g1t\.sh\/([a-z0-9-]+)\/([a-z0-9._-]+?)(?:\.git)?$/i.exec(remote); | |
| 74 | − | if (fork) { | |
| 75 | − | const response = await fetch(`${config.api}/pulls/${fork[1]}`, { headers }); | |
| 76 | − | if (response.ok) { | |
| 77 | − | const pull = await response.json(); | |
| 78 | − | found = { repo: `${pull.repo.namespace}/${pull.repo.name}`, number: pull.number }; | |
| 79 | − | } | |
| 80 | − | } else if (repo && branch) { | |
| 81 | − | const response = await fetch(`${config.api}/repos/${repo[1]}/${repo[2]}/pulls?state=open`, { headers }); | |
| 82 | − | if (response.ok) { | |
| 83 | − | const pulls = await response.json(); | |
| 84 | − | const pull = (Array.isArray(pulls) ? pulls : []).find((p) => p.branch === branch); | |
| 85 | − | if (pull) found = { repo: `${repo[1]}/${repo[2]}`, number: pull.number }; | |
| 86 | − | } | |
| 87 | − | } | |
| 88 | − | if (found) { | |
| 89 | − | cache[key] = found; | |
| 90 | − | try { | |
| 91 | − | writeFileSync(CACHE, JSON.stringify(cache)); | |
| 92 | − | } catch {} | |
| 93 | − | } | |
| 94 | − | return found; | |
| 95 | − | } | |
| 96 | − | ||
| 97 | − | /** The agent's last words this turn, from its transcript. */ | |
| 98 | − | function lastMessage(transcriptPath) { | |
| 99 | − | if (!transcriptPath || !existsSync(transcriptPath)) return null; | |
| 100 | − | const lines = readFileSync(transcriptPath, "utf8").trim().split("\n").reverse(); | |
| 101 | − | for (const line of lines) { | |
| 102 | − | try { | |
| 103 | − | const entry = JSON.parse(line); | |
| 104 | − | if (entry.type !== "assistant") continue; | |
| 105 | − | const content = entry.message?.content; | |
| 106 | − | const text = Array.isArray(content) | |
| 107 | − | ? content.filter((part) => part.type === "text").map((part) => part.text).join("\n") | |
| 108 | − | : typeof content === "string" | |
| 109 | − | ? content | |
| 110 | − | : ""; | |
| 111 | − | if (text.trim()) return text.trim(); | |
| 112 | − | } catch {} | |
| 113 | − | } | |
| 114 | − | return null; | |
| 115 | − | } | |
| 116 | − | ||
| 117 | − | async function main() { | |
| 118 | − | const config = read(CONFIG, null); | |
| 119 | − | if (!config?.token) return; | |
| 120 | − | let raw = ""; | |
| 121 | − | for await (const chunk of process.stdin) raw += chunk; | |
| 122 | − | const event = JSON.parse(raw || "{}"); | |
| 123 | − | const pull = await pullFor(event.cwd ?? process.cwd(), config); | |
| 124 | − | if (!pull) return; | |
| 125 | − | ||
| 126 | − | const entries = []; | |
| 127 | − | switch (event.hook_event_name) { | |
| 128 | − | case "UserPromptSubmit": | |
| 129 | − | if (event.prompt && process.env.G1T_RECORD_PROMPTS !== "0") entries.push({ kind: "prompt", text: clip(event.prompt) }); | |
| 130 | − | break; | |
| 131 | − | case "PostToolUse": | |
| 132 | − | entries.push({ kind: "tool_call", tool: event.tool_name, text: clip(describeCall(event.tool_name, event.tool_input)) }); | |
| 133 | − | break; | |
| 134 | − | case "Stop": { | |
| 135 | − | const message = lastMessage(event.transcript_path); | |
| 136 | − | if (message) entries.push({ kind: "message", text: clip(message) }); | |
| 137 | − | break; | |
| 138 | − | } | |
| 139 | − | } | |
| 140 | − | if (entries.length === 0) return; | |
| 141 | − | await fetch(`${config.api}/repos/${pull.repo}/pulls/${pull.number}/session`, { | |
| 142 | − | method: "POST", | |
| 143 | − | headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" }, | |
| 144 | − | body: JSON.stringify({ entries }), | |
| 145 | − | }); | |
| 146 | − | } | |
| 147 | − | ||
| 148 | − | main().catch(() => {}).finally(() => process.exit(0)); |
| 138 | 138 | person installs it: | |
| 139 | 139 | ||
| 140 | 140 | ```sh | |
| 141 | − | claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git | |
| 141 | + | claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git | |
| 142 | 142 | claude plugin install g1t@g1t | |
| 143 | 143 | ``` | |
| 144 | 144 | ||
| ⋯ | |||
| 353 | 353 | `issues:write`, `pull_requests:write`, `notifications:write`). For CI, the CI preset (`repo:read`, `code:read`, | |
| 354 | 354 | `code:write`, `packages:read`, `packages:write`, `workflows:read`, | |
| 355 | 355 | `workflows:write`, `checks:read`, `checks:write`, `deployments:read`, | |
| 356 | − | `deployments:write`). | |
| 356 | + | `deployments:write`). Automation that works for a team rather than a | |
| 357 | + | person (CI, publishing, integrations) takes a workspace token, made by an | |
| 358 | + | owner at g1t.sh/<workspace>/-/tokens: it acts as the workspace, reaches | |
| 359 | + | only that workspace's chosen repositories, and keeps working when its | |
| 360 | + | creator leaves; never ask your person for a personal token for that. | |
| 357 | 361 | `chat:read` and `chat:write` are in no preset either: with them a person's | |
| 358 | 362 | token, OAuth or personal, uses Chat's JSON routes (`/{workspace}/-/chat/api`) | |
| 359 | 363 | and live sockets on the website without "Use the website as you", and | |
| 75 | 75 | const pluginDir = join(ROOT, "plugins", "claude-code"); | |
| 76 | 76 | if (flags.installed) { | |
| 77 | 77 | const list = spawnSync("claude", ["plugin", "list"], { encoding: "utf8" }); | |
| 78 | − | add("g1t plugin installed", /\bg1t\b/.test(list.stdout ?? ""), "claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git && claude plugin install g1t@g1t"); | |
| 78 | + | add("g1t plugin installed", /\bg1t\b/.test(list.stdout ?? ""), "claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git && claude plugin install g1t@g1t"); | |
| 79 | 79 | } else add("g1t plugin (--plugin-dir)", existsSync(join(pluginDir, ".claude-plugin", "plugin.json")), pluginDir); | |
| 80 | 80 | ||
| 81 | 81 | const hooks = existsSync(join(pluginDir, "hooks", "hooks.json")) ? JSON.parse(readFileSync(join(pluginDir, "hooks", "hooks.json"), "utf8")) : {}; |
| 16 | 16 | "test:deploy": "node --test \"scripts/deploy/*.test.mjs\"", | |
| 17 | 17 | "test:ops": "node --test \"scripts/ops/*.test.mjs\"", | |
| 18 | 18 | "test:conformance": "node --test \"conformance/*.test.mjs\"", | |
| 19 | − | "test:release": "node --test \"scripts/desktop-release.test.mjs\" \"scripts/mobile-release.test.mjs\" \"scripts/cli-release.test.mjs\"" | |
| 19 | + | "test:release": "node --test \"scripts/desktop-release.test.mjs\" \"scripts/mobile-release.test.mjs\" \"scripts/cli-release.test.mjs\" \"scripts/plugins-release.test.mjs\"" | |
| 20 | 20 | }, | |
| 21 | 21 | "devDependencies": { | |
| 22 | 22 | "typescript": "^5.9.3", |
| 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 | + | ||
| 45 | + | import { spawnSync } from "node:child_process"; | |
| 46 | + | import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; | |
| 47 | + | import { tmpdir } from "node:os"; | |
| 48 | + | import { dirname, join, relative } from "node:path"; | |
| 49 | + | import { fileURLToPath } from "node:url"; | |
| 50 | + | ||
| 51 | + | const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); | |
| 52 | + | export const SOURCE = "plugins/claude-code"; | |
| 53 | + | export const PUBLISHED = "plugins/g1t"; | |
| 54 | + | export const REMOTE = "https://g1t.sh/flagon-io/g1t-plugins.git"; | |
| 55 | + | export 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. | |
| 60 | + | export const PLUGIN_FILES = [".claude-plugin", ".mcp.json", "commands", "hooks", "skills"]; | |
| 61 | + | export const DEV_FILES = ["test"]; | |
| 62 | + | ||
| 63 | + | // Names no published file may have, and text no published file may hold. | |
| 64 | + | const FORBIDDEN_NAMES = [/^\.env/, /^\.credentials/, /\.(pem|key|p12|pfx)$/i, /^id_(rsa|ed25519|ecdsa)/, /^\.npmrc$/, /^node_modules$/]; | |
| 65 | + | const 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 | + | ||
| 76 | + | const 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. */ | |
| 79 | + | export 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 | + | */ | |
| 96 | + | export 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. */ | |
| 113 | + | export function publishedMarketplace(marketplace) { | |
| 114 | + | return { ...marketplace, plugins: marketplace.plugins.map((p) => ({ ...p, source: `./${PUBLISHED}` })) }; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | export function readme(version) { | |
| 118 | + | return `# g1t plugins | |
| 119 | + | ||
| 120 | + | g1t's plugin marketplace for Claude Code. Its one plugin, \`g1t\` (${version}), | |
| 121 | + | records your Claude Code sessions on g1t, gives Claude the issue it is | |
| 122 | + | working on, claims and hands off work, and adds g1t's MCP server, skills | |
| 123 | + | and commands. | |
| 124 | + | ||
| 125 | + | \`\`\`sh | |
| 126 | + | ${INSTALL.join("\n")} | |
| 127 | + | \`\`\` | |
| 128 | + | ||
| 129 | + | Then start Claude Code again, run \`/mcp\` and sign **plugin:g1t:g1t** in. | |
| 130 | + | The plugin's hooks need the [g1t CLI](https://docs.g1t.sh/guides/cli/) | |
| 131 | + | 0.3.2 or later and Node 18 or later. | |
| 132 | + | ||
| 133 | + | The guide, with what each hook records and never records and how to opt | |
| 134 | + | out: https://docs.g1t.sh/guides/claude-code-plugin/ | |
| 135 | + | ||
| 136 | + | This 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}. */ | |
| 143 | + | export 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. */ | |
| 146 | + | function 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". */ | |
| 151 | + | export 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. */ | |
| 162 | + | export 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. */ | |
| 171 | + | export 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. */ | |
| 188 | + | export 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 | + | ||
| 208 | + | function 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 | + | */ | |
| 218 | + | export 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 | + | */ | |
| 228 | + | export 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 | + | ||
| 268 | + | if (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 | + | } |
| 1 | + | // node --test scripts/plugins-release.test.mjs | |
| 2 | + | import assert from "node:assert/strict"; | |
| 3 | + | import { spawnSync } from "node:child_process"; | |
| 4 | + | import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 5 | + | import { tmpdir } from "node:os"; | |
| 6 | + | import { dirname, join } from "node:path"; | |
| 7 | + | import { after, test } from "node:test"; | |
| 8 | + | import { fileURLToPath } from "node:url"; | |
| 9 | + | ||
| 10 | + | import { INSTALL, PUBLISHED, SOURCE, authEnv, tagName, build, expectedFiles, manifests, publish, scan, walk } from "./plugins-release.mjs"; | |
| 11 | + | ||
| 12 | + | const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); | |
| 13 | + | const temps = []; | |
| 14 | + | const temp = (prefix) => { | |
| 15 | + | const dir = mkdtempSync(join(tmpdir(), prefix)); | |
| 16 | + | temps.push(dir); | |
| 17 | + | return dir; | |
| 18 | + | }; | |
| 19 | + | after(() => temps.forEach((d) => rmSync(d, { recursive: true, force: true }))); | |
| 20 | + | ||
| 21 | + | /** A copy of what the build reads from this repository, to change freely. */ | |
| 22 | + | function source() { | |
| 23 | + | const root = temp("plugins-src-"); | |
| 24 | + | for (const path of [".claude-plugin", SOURCE, "LICENSE"]) cpSync(join(ROOT, path), join(root, path), { recursive: true }); | |
| 25 | + | return root; | |
| 26 | + | } | |
| 27 | + | const editJson = (root, file, fn) => { | |
| 28 | + | const path = join(root, file); | |
| 29 | + | writeFileSync(path, JSON.stringify(fn(JSON.parse(readFileSync(path, "utf8"))), null, 2)); | |
| 30 | + | }; | |
| 31 | + | const bump = (root, version) => { | |
| 32 | + | editJson(root, ".claude-plugin/marketplace.json", (m) => ({ ...m, plugins: [{ ...m.plugins[0], version }] })); | |
| 33 | + | editJson(root, `${SOURCE}/.claude-plugin/plugin.json`, (p) => ({ ...p, version })); | |
| 34 | + | }; | |
| 35 | + | const git = (cwd, ...args) => spawnSync("git", args, { cwd, encoding: "utf8" }); | |
| 36 | + | ||
| 37 | + | test("this repository's manifests agree: g1t@g1t, one version", () => { | |
| 38 | + | const { marketplace, plugin, version } = manifests(); | |
| 39 | + | assert.equal(marketplace.plugins[0].version, version); | |
| 40 | + | assert.equal(plugin.version, version); | |
| 41 | + | }); | |
| 42 | + | ||
| 43 | + | test("the build is the marketplace, the plugin without its tests, a README and the license", () => { | |
| 44 | + | const out = join(temp("plugins-out-"), "tree"); | |
| 45 | + | const { version } = build(out); | |
| 46 | + | const files = walk(out); | |
| 47 | + | assert.deepEqual(files, expectedFiles()); | |
| 48 | + | assert.ok(files.includes(`${PUBLISHED}/hooks/g1t.mjs`)); | |
| 49 | + | assert.ok(!files.some((f) => f.includes("test/") || f.endsWith(".test.mjs")), files.join(" ")); | |
| 50 | + | const marketplace = JSON.parse(readFileSync(join(out, ".claude-plugin/marketplace.json"), "utf8")); | |
| 51 | + | assert.equal(marketplace.name, "g1t"); | |
| 52 | + | assert.deepEqual(marketplace.plugins.map((p) => [p.name, p.source, p.version]), [["g1t", "./plugins/g1t", version]]); | |
| 53 | + | // The plugin's files are the source's, byte for byte. | |
| 54 | + | for (const f of files.filter((f) => f.startsWith(`${PUBLISHED}/`))) { | |
| 55 | + | assert.equal(readFileSync(join(out, f), "utf8"), readFileSync(join(ROOT, SOURCE, f.slice(PUBLISHED.length + 1)), "utf8"), f); | |
| 56 | + | } | |
| 57 | + | const readme = readFileSync(join(out, "README.md"), "utf8"); | |
| 58 | + | for (const line of INSTALL) assert.ok(readme.includes(line), line); | |
| 59 | + | }); | |
| 60 | + | ||
| 61 | + | test("manifests that disagree stop the build", () => { | |
| 62 | + | const root = source(); | |
| 63 | + | editJson(root, ".claude-plugin/marketplace.json", (m) => ({ ...m, plugins: [{ ...m.plugins[0], version: "9.9.9" }] })); | |
| 64 | + | assert.throws(() => build(join(root, "out"), root), /marketplace.json says version 9.9.9/); | |
| 65 | + | const renamed = source(); | |
| 66 | + | editJson(renamed, ".claude-plugin/marketplace.json", (m) => ({ ...m, name: "g1t-plugins" })); | |
| 67 | + | assert.throws(() => build(join(renamed, "out"), renamed), /installs are g1t@g1t/); | |
| 68 | + | }); | |
| 69 | + | ||
| 70 | + | test("a file the script does not know about stops the build", () => { | |
| 71 | + | const root = source(); | |
| 72 | + | writeFileSync(join(root, SOURCE, "notes.md"), "draft"); | |
| 73 | + | assert.throws(() => build(join(root, "out"), root), /has notes\.md, which .* neither publishes nor keeps back/); | |
| 74 | + | }); | |
| 75 | + | ||
| 76 | + | test("anything that looks like a credential, or a credentials file, is never published", () => { | |
| 77 | + | const root = source(); | |
| 78 | + | writeFileSync(join(root, SOURCE, "commands", "status.md"), `token: g1t_${"a1".repeat(20)}\n`); | |
| 79 | + | assert.throws(() => build(join(root, "out"), root), /commands\/status\.md: looks like a g1t token/); | |
| 80 | + | const dir = temp("plugins-scan-"); | |
| 81 | + | writeFileSync(join(dir, ".env"), "X=1"); | |
| 82 | + | writeFileSync(join(dir, "k.md"), "-----BEGIN OPENSSH PRIVATE KEY-----"); | |
| 83 | + | assert.deepEqual(scan(dir), [".env: a file that is never published", "k.md: looks like a private key"]); | |
| 84 | + | // The plugin as it is holds none. | |
| 85 | + | assert.deepEqual(scan(join(ROOT, SOURCE, "hooks")), []); | |
| 86 | + | }); | |
| 87 | + | ||
| 88 | + | test("releases are tagged as claude plugin tag tags them", () => { | |
| 89 | + | assert.equal(tagName("0.2.2"), "g1t--v0.2.2"); | |
| 90 | + | }); | |
| 91 | + | ||
| 92 | + | test("the token goes to git through its environment, never its arguments", () => { | |
| 93 | + | const env = authEnv("g1t_secret"); | |
| 94 | + | assert.equal(env.GIT_CONFIG_KEY_0, "http.extraHeader"); | |
| 95 | + | assert.equal(env.GIT_CONFIG_VALUE_0, `Authorization: Basic ${Buffer.from("g1t:g1t_secret").toString("base64")}`); | |
| 96 | + | assert.deepEqual(authEnv(undefined), {}); | |
| 97 | + | }); | |
| 98 | + | ||
| 99 | + | test("publishing: the first commit and tag, nothing when unchanged, a new version for a changed plugin", () => { | |
| 100 | + | const root = source(); | |
| 101 | + | const remote = join(temp("plugins-remote-"), "g1t-plugins.git"); | |
| 102 | + | git(dirname(remote), "init", "--quiet", "--bare", "--initial-branch=main", remote); | |
| 103 | + | const run = (sourceCommit) => publish({ remote, token: "", source: sourceCommit, root, work: temp("plugins-work-") }); | |
| 104 | + | const { version } = manifests(root); | |
| 105 | + | ||
| 106 | + | const first = run("a".repeat(40)); | |
| 107 | + | assert.equal(first.changed, true); | |
| 108 | + | assert.equal(first.tagged, true); | |
| 109 | + | assert.equal(git(remote, "rev-parse", "main").stdout.trim(), first.commit); | |
| 110 | + | assert.equal(git(remote, "rev-parse", `g1t--v${version}^{commit}`).stdout.trim(), first.commit); | |
| 111 | + | assert.match(git(remote, "log", "-1", "--format=%an <%ae>%n%B", "main").stdout, /^g1t <g1t@users\.noreply\.g1t\.sh>\ng1t .*, from flagon-io\/g1t@aaaaaaaaaaaa/); | |
| 112 | + | const listed = git(remote, "ls-tree", "-r", "--name-only", "main").stdout.trim().split("\n").sort(); | |
| 113 | + | assert.deepEqual(listed, expectedFiles(root)); | |
| 114 | + | ||
| 115 | + | const again = run("b".repeat(40)); | |
| 116 | + | assert.deepEqual([again.changed, again.tagged, again.commit], [false, false, first.commit]); | |
| 117 | + | ||
| 118 | + | // The plugin changed with its version already out: refused. | |
| 119 | + | writeFileSync(join(root, SOURCE, "commands", "status.md"), "changed\n"); | |
| 120 | + | assert.throws(() => run("c".repeat(40)), /already published.*Bump "version"/s); | |
| 121 | + | assert.equal(git(remote, "rev-parse", "main").stdout.trim(), first.commit); | |
| 122 | + | ||
| 123 | + | bump(root, "99.0.0"); | |
| 124 | + | const next = run("d".repeat(40)); | |
| 125 | + | assert.equal(next.changed, true); | |
| 126 | + | assert.equal(next.tagged, true); | |
| 127 | + | assert.equal(git(remote, "rev-parse", "g1t--v99.0.0^{commit}").stdout.trim(), next.commit); | |
| 128 | + | assert.equal(git(remote, "rev-parse", `g1t--v${version}^{commit}`).stdout.trim(), first.commit); | |
| 129 | + | assert.equal(git(remote, "rev-parse", "main~1").stdout.trim(), first.commit); | |
| 130 | + | ||
| 131 | + | // The marketplace's own text may change without a new version. | |
| 132 | + | editJson(root, ".claude-plugin/marketplace.json", (m) => ({ ...m, description: "g1t's plugins for Claude Code, and more." })); | |
| 133 | + | const text = run("e".repeat(40)); | |
| 134 | + | assert.deepEqual([text.changed, text.tagged], [true, false]); | |
| 135 | + | }); |
| 3059 | 3059 | if access.deploy_key.is_some() && !exists { | |
| 3060 | 3060 | return Some(format!("This deploy key is for {repo}, which is not there any more.")); | |
| 3061 | 3061 | } | |
| 3062 | + | // A token narrowed to chosen repositories, or to another workspace, never | |
| 3063 | + | // makes one outside them by pushing to an empty address. | |
| 3064 | + | if write | |
| 3065 | + | && !exists | |
| 3066 | + | && let Some(reach) = &access.reach | |
| 3067 | + | { | |
| 3068 | + | let namespace = repo.split('/').next().unwrap_or_default(); | |
| 3069 | + | let all = reach.repositories == g1t_contracts::scopes::RepositorySelection::All; | |
| 3070 | + | if !all || !reach.workspace.as_deref().is_some_and(|w| w.eq_ignore_ascii_case(namespace)) { | |
| 3071 | + | return Some(format!( | |
| 3072 | + | "This access token cannot create {repo}: it reaches only the repositories chosen for it. Create the repository on g1t.sh, then add it to the token." | |
| 3073 | + | )); | |
| 3074 | + | } | |
| 3075 | + | } | |
| 3062 | 3076 | None | |
| 3063 | 3077 | } | |
| 3064 | 3078 | ||
| ⋯ | |||
| 3142 | 3156 | assert!(args.is_private); | |
| 3143 | 3157 | assert_eq!((args.namespace.as_str(), args.name.as_str()), ("acme", "site")); | |
| 3144 | 3158 | } | |
| 3159 | + | ||
| 3160 | + | fn token(repositories: &str, workspace: Option<&str>) -> g1t_contracts::scopes::TokenAccess { | |
| 3161 | + | serde_json::from_value(serde_json::json!({ | |
| 3162 | + | "token_id": "tok_1", | |
| 3163 | + | "scopes": ["code:read", "code:write"], | |
| 3164 | + | "fine_grained": { "workspace": workspace, "repositories": repositories, "repo_ids": ["rep_plugins"] }, | |
| 3165 | + | })) | |
| 3166 | + | .unwrap() | |
| 3167 | + | } | |
| 3168 | + | ||
| 3169 | + | #[test] | |
| 3170 | + | fn a_token_for_chosen_repositories_never_creates_one_by_pushing() { | |
| 3171 | + | let why = git_token_refusal(&token("selected", Some("acme")), "acme/new", None, true, false, false).unwrap(); | |
| 3172 | + | assert!(why.contains("cannot create acme/new"), "{why}"); | |
| 3173 | + | // It still pushes to a repository that is there; its selection is | |
| 3174 | + | // the role check's. | |
| 3175 | + | assert_eq!(git_token_refusal(&token("selected", Some("acme")), "acme/plugins", None, true, false, true), None); | |
| 3176 | + | } | |
| 3177 | + | ||
| 3178 | + | #[test] | |
| 3179 | + | fn a_token_for_all_of_a_workspace_creates_there_and_nowhere_else() { | |
| 3180 | + | assert_eq!(git_token_refusal(&token("all", Some("acme")), "Acme/new", None, true, false, false), None); | |
| 3181 | + | assert!(git_token_refusal(&token("all", Some("acme")), "other/new", None, true, false, false).is_some()); | |
| 3182 | + | assert!(git_token_refusal(&token("public", Some("acme")), "acme/new", None, true, false, false).is_some()); | |
| 3183 | + | assert!(git_token_refusal(&token("all", None), "acme/new", None, true, false, false).is_some()); | |
| 3184 | + | } | |
| 3185 | + | ||
| 3186 | + | #[test] | |
| 3187 | + | fn a_token_without_a_reach_creates_as_its_owner_would() { | |
| 3188 | + | let wide: g1t_contracts::scopes::TokenAccess = | |
| 3189 | + | serde_json::from_value(serde_json::json!({ "token_id": "tok_1", "scopes": ["code:write"] })).unwrap(); | |
| 3190 | + | assert_eq!(git_token_refusal(&wide, "acme/new", None, true, false, false), None); | |
| 3191 | + | } | |
| 3145 | 3192 | } | |
| 3146 | 3193 | ||
| 3147 | 3194 | #[cfg(test)] | |
Comments
Sign in to comment.