Skip to content

About

A tool to manage GitHub

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

gh-manager

A globally installable CLI for the GitHub operations the REST and GraphQL APIs do not expose: flipping container package visibility, deleting packages in bulk, managing package-level permissions, and switching a repository's code security settings, the dependency graph among them.

GitHub has no endpoint for any of those. The only place they exist is the web UI, so gh-manager drives a real browser for the writes, and uses the API for the reads and for verifying that a write actually landed. Actions secrets and branch protections use supported API writes.

npm i -g @link-foundation/gh-manager
gh-manager auth login
gh-manager package public box box-dind --org link-foundation

Features

  • Organization, repository and environment Actions secrets: encrypted storage, organization fallback and Actions-log health checks.

  • Protect every branch of an organization, user or repository against deletion and force pushes, with safe API fallbacks.

  • One login, reused: a dedicated Chrome profile in ~/.gh-manager/ survives SSO and 2FA, which a personal access token cannot carry

  • Hybrid strategy: API reads, browser writes, API re-read to verify

  • Whatever GitHub left out of its API: the dependency graph toggle has no REST or GraphQL endpoint at all, so the settings page is the only way to set it — and the SBOM endpoint is the only way to prove it is on

  • No false "no packages found": an empty API listing falls back to enumerating the packages the browser can see

  • Safe bulk operations: patterns are resolved against the packages that exist, the resolved list is always printed, and nothing destructive runs without a confirmation or an explicit --yes

  • Meaningful exit codes: a pipeline can branch on the reason, not the text

  • Loud failures: an unexpected page writes a screenshot and the HTML to ~/.gh-manager/logs/ and exits non-zero

Install

npm i -g @link-foundation/gh-manager
gh-manager --version

Node.js 20 or newer, plus a browser you already have. The session is an installed Chrome started by browser-commander with a dedicated --user-data-dir; --channel msedge|brave|chromium picks another one, and --engine picks the automation engine (playwright by default).

Quick start

# Sign in once. A browser window opens; finish the login (SSO, 2FA, whatever
# your organization requires) and the session is stored in the managed profile.
gh-manager auth login

# Remember the organization so later commands do not repeat --org.
gh-manager config set org link-foundation

# What is there?
gh-manager package list

# Make two packages public, verified through the API afterwards.
gh-manager package public box box-dind

# Delete everything matching a glob, showing the plan first.
gh-manager package delete --pattern 'box-test-*' --dry-run
gh-manager package delete --pattern 'box-test-*'

# Turn on the dependency graph a dependency-review workflow needs.
gh-manager security dependency-graph link-foundation/gh-manager --enable

How it works

Operation Read Write Verify
List packages API → browser — —
Change visibility API Browser API, then page
Delete a package API Browser API, then page
List or change permissions Browser Browser Page re-read
Read code security settings API → browser — —
Set the dependency graph SBOM probe Browser SBOM, then page
Set another security setting API API → browser API, then page
Protect branches API API Ruleset and effective branch rules

GET /orgs/{org}/packages?package_type=container answers with an empty array for a token without the package scopes. gh-manager treats an empty listing as "the API cannot see them", not as "there are none", and enumerates the packages page in the browser instead. The output says which source was used.

Verification is equally explicit. After a write, the API is re-read until it agrees, for up to 15 seconds. If the API cannot see the package at all — no token, or a token without scopes — that is reported as an absence of evidence and the page itself is read instead. A change that cannot be confirmed either way fails with VERIFICATION_FAILED (exit code 6); it is never reported as a success.

The dependency graph is the one setting with no endpoint on either side. Its state is probed with GET /repos/{owner}/{repo}/dependency-graph/sbom, which answers with a document while the graph is on and 404 while it is off. GitHub builds that document in the background, so a 404 right after the toggle was flipped is not evidence that the flip failed; when the SBOM has not appeared within the verification window, the settings page is read again and the command says which of the two confirmed the change.

Commands

The grammar is gh-manager <domain> <verb> [targets...] [flags]. Run gh-manager --help for the domain list, or gh-manager <domain> --help for a domain.

protect

gh-manager protect --org link-foundation
gh-manager protect --user konard
gh-manager protect link-foundation/gh-manager
gh-manager protect --org link-foundation --dry-run
gh-manager protect link-foundation/gh-manager --name guard --rule pull_request --yes
gh-manager protect link-foundation/gh-manager --from rules.json

Creates an active branch ruleset named protection, covering ~ALL branches with no exclusions or bypass actors. Its two default rules restrict deletions and block force pushes. Normal pushes, branch creation, pull requests, and release bots remain allowed. Stricter rules are additive and opt-in: repeat --rule for pull_request (one approval), required_signatures, required_linear_history, creation or update. Rules needing custom parameters use --from with a JSON file:

{
  "name": "protection",
  "rules": [
    {
      "type": "required_status_checks",
      "parameters": {
        "required_status_checks": [{ "context": "build" }],
        "strict_required_status_checks_policy": true
      }
    }
  ]
}

The default rules are always retained. JSON accepts name, rules, and optional target: "branch" and enforcement: "active"; coverage and bypass actors stay fixed to protect all branches. --name overrides the JSON name.

For an organization, the command first tries one organization ruleset covering all current and future repositories. GitHub's actual API response determines availability; a permissions or plan denial is explained before falling back to repository rulesets. Organization rulesets require org admin access and admin:org for classic tokens (gh auth refresh -s admin:org). Repository rulesets require repository admin access or Administration write permission; private repositories require appropriate token access. The command uses an API token, resolved in the same way as other commands.

For a user, it lists owned public repositories and, when the token belongs to that user, also enumerates owned private repositories through the authenticated API. To include private repositories, use that user's token. Archived repositories are skipped with a reason; forks are identified separately and protected when writable. Repository enumeration follows all pagination pages.

An existing active ruleset covering all branches with the requested rules and no bypass actors reports already protected. A local ruleset with the selected name is updated only after showing its before/after diff and confirming. Extra rules, check requirements and review requirements are preserved. Conflicting parameters fail with guidance to choose a separate name. Inherited rulesets are never edited. A concurrent edit detected before writing stops that update.

If repository rulesets are unavailable, the command plans classic protection for every current branch, preserving its checks, reviews and restrictions. Classic protection cannot cover future branches; rerun to include new ones. If classic protection is also unavailable, the repository is reported as a failure and the bulk run continues. Extra rules that cannot be represented safely by this fallback are reported without writing.

Bulk changes and updates require confirmation, or --yes in noninteractive runs. The resolved list and actions appear before consent; a fallback plan is shown and confirmed separately. --dry-run only reads and prints the plan, including each repository's create, update, already protected or skipped status. A dry run cannot predict an error GitHub returns only on creation. --json returns the plan/results as one JSON document. Writes are verified by re-reading the ruleset and one branch's effective rules in each repository. An empty repository verifies the ruleset and reports that no branch is available to probe. Classic protection is re-read for every branch. --timeout <seconds> sets the verification window (default 15); failed verification exits 6.

Library consumers can use createProtectionManager({ rest }) with plan(target, options) or protect(target, options):

const manager = createProtectionManager({ rest: createRestClient({ token }) });
const plan = await manager.plan({ org: 'link-foundation' });
const result = await manager.protect(
  { repo: { owner: 'link-foundation', name: 'gh-manager' } },
  { dryRun: true }
);

For bulk writes or updates, pass a confirm(plan) callback that returns consent and an optional onPlan(plan) callback to display the reviewed plan. Inspect repositories for per-repository failures and their exitCode, as one failure does not cancel the rest. See the read-only organization preview.

auth

gh-manager auth login    # open a browser window and wait for the sign-in
gh-manager auth status   # show the signed-in account and the API token in use
gh-manager auth logout   # delete the managed browser profile

auth login needs a visible window, so it refuses --headless. It waits up to 300 seconds by default; raise that with --timeout <seconds>.

package

gh-manager package list --org link-foundation
gh-manager package public box box-dind --org link-foundation
gh-manager package private --pattern 'box*' --org link-foundation
gh-manager package internal box --org link-foundation
gh-manager package delete box-test --org link-foundation --dry-run

--account <login> targets a user account instead of an organization. --package-type <type> selects the ecosystem (container by default).

delete always asks for a confirmation, even for a single explicitly named package. The visibility verbs ask only when the list came from a pattern, because a typed name is already a decision. --yes answers in advance, which is what a non-interactive run needs; without a TTY and without --yes, the command aborts rather than assuming consent.

permissions

gh-manager permissions list --pattern 'box*' --org link-foundation
gh-manager permissions grant box --team maintainers --role write --org link-foundation
gh-manager permissions revoke --pattern 'box*' --user someone --org link-foundation
gh-manager permissions sync --from policy.json --org link-foundation --dry-run

Roles are read, write, and admin. --team and --user may be repeated. revoke always asks; grant asks for pattern runs.

Because access is usually spoken about as a property of a package, the same verbs are also reachable under package, and the two spellings are the same command:

gh-manager package permissions list --pattern 'box*' --org link-foundation

security

gh-manager security status link-foundation/gh-manager
gh-manager security dependency-graph link-foundation/gh-manager --enable
gh-manager security vulnerability-alerts link-foundation/gh-manager --enable --yes
gh-manager security automated-security-fixes link-foundation/gh-manager --enable
gh-manager security secret-scanning link-foundation/gh-manager --enable
gh-manager security push-protection link-foundation/gh-manager --disable

These are the toggles of /settings/security_analysis. Targets are repositories, written as <owner>/<repo> or as <repo> together with --org or --account; a pattern is refused, so every repository is named.

The dependency graph is why the domain exists. A repository whose graph is off fails actions/dependency-review with "Dependency review is not supported on this repository", and GitHub publishes no REST endpoint, no GraphQL mutation, and no gh command for the toggle — only the page. gh-manager clicks it and then asks for the SBOM the graph makes possible, because that document is the only evidence the graph is on.

Every change asks for a confirmation, however the repository was named: turning a protection off removes it, and turning one on can start alerting a whole organization. --yes answers in advance, and --dry-run prints the plan and stops.

A setting that already has the wanted state is reported as such and exits 0, so re-running a command is never an error. --json reports which of the two happened, along with what performed the change and what proved it:

$ gh-manager security dependency-graph link-foundation/gh-manager --enable --yes --json
[
  {
    "target": "link-foundation/gh-manager",
    "ok": true,
    "feature": "dependency-graph",
    "state": "enabled",
    "changed": true,
    "changedBy": "browser",
    "verifiedBy": "api"
  }
]

Two settings depend on another one, and GitHub refuses them with a bare 422 when the dependency is missing, so gh-manager names the missing one before anything is touched: Dependabot security updates need Dependabot alerts, and push protection needs secret scanning.

A toggle an organization or enterprise policy owns cannot be flipped from a repository at all. gh-manager refuses it with the screenshot and the page HTML in ~/.gh-manager/logs/ and a non-zero exit, rather than reporting a change it did not make. To set these switches across an organization, GitHub does offer an API: code security configurations.

secret

Actions secrets use the authenticated GitHub REST API and libsodium sealed-box encryption. Values enter through stdin or caller callbacks and never appear in results, dry runs or debug logs. Secret names, reasons, validation and failure patterns belong to the caller.

# Try selected organization scope first; fall back to each repository on refusal.
gh-manager secret ensure CUSTOM_TOKEN --org link-foundation --repos gh-manager,project < token.txt
gh-manager secret list --org link-foundation --repo gh-manager --json
gh-manager secret get-metadata CUSTOM_TOKEN --org link-foundation

# Explicit repository or environment storage.
gh-manager secret set CUSTOM_TOKEN --repo link-foundation/project < token.txt
gh-manager secret set CUSTOM_TOKEN --repo link-foundation/project --env production < token.txt

# Inspect the latest default-branch runs of workflows referencing this name.
gh-manager secret health CUSTOM_TOKEN --org link-foundation --failure-pattern 'CUSTOM_AUTH_ERROR' --json
gh-manager secret health CUSTOM_TOKEN --repo link-foundation/project --json
# Record a replacement's registry expiry when storing it.
gh-manager secret set CUSTOM_TOKEN --org link-foundation --repos project --expires-at '<ISO-date>' < token.txt
# Dispatch a workflow, or rerun its latest run, wait, then classify the evidence.
gh-manager secret test CUSTOM_TOKEN --repo link-foundation/project --timeout 300
# A caller can explicitly report a broken secret after inspecting its health.
gh-manager secret ensure CUSTOM_TOKEN --repo link-foundation/project --broken --reason 'Actions authentication failure' < token.txt

gh-manager secret audit --org link-foundation --json
gh-manager secret delete CUSTOM_TOKEN --repo link-foundation/project --dry-run
# Cleanup takes explicit names and a reason; execution requires a verification callback.
gh-manager secret cleanup OLD_TOKEN --repo link-foundation/project --reason 'Caller migration' --validator ./validator.mjs --dry-run

ensure leaves existing secrets unchanged when health is ok or unknown, unless a caller validator or recorded expiry establishes that replacement is needed. Organization ensure uses selected visibility and --repos; on organization permission, plan or personal-account refusal it reports path: "repository", a fallback reason, and one result per repository. Explicit set overwrites the chosen scope. Repository secrets override organization secrets with the same name.

Health returns ok, auth-failing, expiring, expired or unknown, plus workflow/job/step evidence. It reads the companion NAME_EXPIRES_AT Actions variable and includes expiresAt and a separate expiry assessment with its source scope. A date within seven days reports expiring even when the last publish passed; a date at or before the current time reports expired. --rotate-before (seconds) or library option rotateBeforeMs changes the window. Expired metadata takes precedence over authentication failures; authentication failures take precedence over an upcoming expiry. Missing organization metadata emits an "Expiry unknown; record it with secret set ... --expires-at" warning to stderr and the JSON warnings array, including when repository health finds a visible organization secret. Malformed and unreadable metadata have distinct reasons; workflow evidence remains available. Repository health reads repository metadata first, then accessible organization variables when there is no repository secret override.

Only a failed step that uses the secret and has a matching log line is auth-failing; generic patterns include 401, 403, unauthorized, bad credentials, expired and invalid token. Successful secret steps can be ok even when unrelated steps fail. No runs, skipped steps, unresolved names, unreadable evidence and non-authentication failures are unknown. Static dot and bracket references and environment aliases are supported. Health follows local and remote reusable workflows, explicit secret aliases, secrets: inherit and nested calls. Local calls use the caller's run commit; remote calls use the SHA in the run's referenced_workflows or an explicit commit SHA. Missing pins, unreadable calls, cycles and ambiguous names remain unknown. Called jobs with environments are resolved when readable environment metadata confirms no same-name override; overrides, dynamic names and unreadable environment metadata remain unknown. Skipped reusable invocations report workflow-call-not-executed. Resolution is bounded to ten workflow levels and 100 visits. Dynamic references, secret values forwarded as inputs, and composite action internals require separate inspection. Logs are GitHub-masked and additionally redact common credential forms.

Health selects the latest run of each referencing workflow on the repository's default branch; --branch overrides that selection. Expired logs (HTTP 410) produce unknown evidence with reason: "logs-expired"; absent/deleted logs (HTTP 404) use reason: "logs-unavailable". Runs older than 90 days are reported as expired without fetching jobs or logs. Set --retention-days (or library option retentionDays) to your repository/organization retention period. A recent rerun uses its updated timestamp. Missing timestamps leave availability to the API. GitHub's log retention settings can differ between repositories.

Organization health and failure scans continue after repository errors. JSON results include each affected repository's error and unknown status, alongside available evidence. Progress (n/total repositories) and a repository-error count go to stderr, keeping stdout parseable under --json. An organization inventory failure still raises an error because there is no repository list to scan.

secret test uses workflow_dispatch when supported, otherwise reruns the last run. --input name=value supplies dispatch inputs. Polling is bounded by --timeout (seconds); a timeout returns unknown evidence. A rerun must have a newer attempt before its result is accepted. Health/testing operate on organization/repository scope, not environment-specific secret attribution.

All writes support --dry-run. Set/delete dry runs make no requests; ensure/cleanup read metadata but never read stdin or invoke callbacks. Delete/cleanup request confirmation unless --yes is supplied. --org plus --repo filters list/get-metadata/audit; storage uses one scope. --env requires --repo.

--validator ./validator.mjs is a generic local ES module with optional validate, acquire, revokePrevious, and cleanup verify exports. gh-manager runs these hooks without deciding what they check. Callbacks receive secret name, scope and metadata; candidate validation receives the value only in memory. Callback errors are sanitized. Optional caller expiry is recorded in NAME_EXPIRES_AT at matching scope/access; --rotate-before changes the seven-day rotation window. Secret and expiry writes are separate requests, so a metadata failure can leave the new ciphertext stored; revocation does not run after failed verification.

Audit parses default-branch workflow YAML, ignoring comments. Dynamic references, unreadable workflows and partial organization inventories make unmatched names only possibly unused; audit never deletes them. RELEASE_PR_TOKEN users can request a GitHub App plan:

gh-manager secret setup-app --org link-foundation --dry-run
gh-manager secret setup-app --org link-foundation --app-id 123 --repos gh-manager < app-key.pem

Organization secrets need admin:org (and repo for private repositories), or fine-grained Organization secrets permissions. Repository secrets need Secrets permissions; environment secrets need Environments permissions. Companion metadata needs Variables permissions. Private workflow data needs Actions read permission; dispatch/rerun needs Actions write.

For secret set, ensure and setup-app, an organization HTTP 403 with a token discovered from gh auth token starts gh auth refresh -h github.com -s admin:org once in an interactive terminal, reads the refreshed token and retries the denied request. Authentication prompts go to stderr so --json stays parseable. The user completes GitHub's authentication flow; no manual command restart is needed. Explicit tokens, environment tokens, unattended processes and dry runs retain permission recovery guidance. This route requests the required scope; it does not use the organization browser form.

repo and runs

Discover repositories, selected files, Actions runs and log evidence without cloning:

gh-manager repo list --org link-foundation --json
gh-manager repo list --user konard --include-archived --include-forks --json
gh-manager repo files link-foundation/project --match '**/manifest.json' --match '.github/workflows/*.yml' --content
gh-manager runs list link-foundation/project --workflow release.yml --branch main --status failure
gh-manager runs logs https://github.com/link-foundation/project/actions/runs/123 --grep 'CALLER_ERROR'
gh-manager runs logs 123 --repo link-foundation/project --grep 'CALLER_ERROR'
gh-manager runs failures --org link-foundation --grep 'CALLER_ERROR' --json

Repository listing omits archived repositories and forks by default. File globs support *, ? and **; content is read by immutable blob SHA, with subtree traversal when GitHub truncates a recursive tree. Run listing follows pagination. runs failures inspects the latest default-branch run of each workflow before checking failure logs, so a recovered workflow is not reported from an older failure. It shares health's --retention-days cutoff.

runs failures outputs an object with matches (workflow failures with matching lines), unavailableLogs (counts grouped by repository and unavailable-log reason), errors (repository/workflow errors), and repositories (matching-line, unavailable-log and error counts for every scanned repository). Matching evidence is retained when another job's log is unavailable. Missing logs contribute counts without placeholder jobs or text. An unavailable/expired whole run counts as one; otherwise each missing job log counts as one. The library's runs.failures() keeps its detailed array of failure/unknown findings; runs.failureReport() returns the compact CLI report.

runs logs keeps its array of matched lines and includes typed { status: "logs-expired" | "logs-unavailable", runId, url, ... } entries for unavailable logs. Library rest.text and runs.jobLog return a string on success or { status, httpStatus, path } for HTTP 404/410; callers should narrow the result before parsing text. Authentication, rate-limit and server errors remain errors and are collected by bulk scans. Signed download URLs and API tokens never appear in unavailable-log results.

Stable library API

The package entry exports typed secrets, health, repos, and runs factory aliases, plus createSecretManager, createSecretHealth, createRepoManager, and createRunManager. These services use an injected REST client and are supported for programmatic use:

import {
  createRestClient,
  secrets,
  health,
  repos,
  runs,
} from '@link-foundation/gh-manager';
const options = { rest: createRestClient({ token: process.env.GH_TOKEN }) };
const repositories = await repos(options).list({ org: 'link-foundation' });
const files = await repos(options).files('link-foundation/project', {
  match: ['**/manifest.json', '.github/workflows/*.yml'],
  content: true,
});
const state = await health(options).health('CUSTOM_TOKEN', {
  scope: { repo: { owner: 'link-foundation', name: 'project' } },
  failurePatterns: ['CALLER_AUTH_ERROR'],
});

See the programmatic secret cycle, API types, and the requirement/research report. Factory options accept rest and optional debug logging; health additionally accepts injectable clock/sleep. CLI timeouts use seconds; library polling durations use milliseconds. API permission errors carry HTTP status; unreadable health evidence is unknown.

Migration: built-in publishing-policy maps/functions and --registry were removed. Replace policy queries in the caller, pass explicit cleanup names/reason and a generic verify hook, and use SecretCallbackContext instead of RegistrySecretContext. Existing GitHub Packages and browser APIs remain available. gh-manager never invokes package-registry-manager; callers own their publishing integrations.

config

gh-manager config list
gh-manager config set org link-foundation
gh-manager config get org
gh-manager config unset org

config list also prints the path of the file it read.

Stored keys: org, account, engine, channel, headless, packageType. Flags always win over what is stored.

Targets, patterns, and safety

Targets are package names, or a --pattern. The two cannot be combined, and a command that gets neither is a usage error.

  • --pattern 'box*' is a glob by default: * matches any run of characters, ? matches exactly one, and every other character is literal. A glob is anchored, so box* matches box-dind but dind matches nothing.
  • --regex switches the same flag to a JavaScript regular expression, which is not anchored: --pattern 'box' --regex matches sandbox too. Anchor it yourself when that matters: --pattern '^box(-dind)?$' --regex.
  • Both forms match case-insensitively.
  • A pattern is always expanded against the packages that actually exist, and the resolved list is printed before anything is touched.
  • A pattern that matches nothing exits with NO_MATCHES (4) and names the pattern and how many packages were considered. It never silently succeeds.
  • An over-broad pattern (*, **, .*, .+, ^.*$) is refused unless --all is passed, so a typo cannot expand to "everything".
  • --dry-run prints the plan and stops before acting.

Policy files

permissions sync --from policy.json converges package access onto a file:

{
  "packages": [
    {
      "pattern": "box*",
      "teams": { "maintainers": "admin", "reviewers": "read" },
      "users": { "konard": "write" }
    },
    {
      "names": ["gh-manager"],
      "exclusive": false,
      "teams": { "maintainers": "write" }
    }
  ]
}

Each entry selects packages by name, names, or pattern. Grantees the entry omits are revoked, which is what makes the file a policy and not a wish list; set "exclusive": false when an entry should only add. Roles are case-insensitive. Combine with --dry-run to see the grants and revokes a file implies before applying it.

Exit codes

Code Name Meaning
0 SUCCESS Every requested operation completed and was verified
1 FAILURE An operation failed (browser, GitHub, unexpected UI)
2 USAGE The command line was wrong
3 AUTH No usable browser session or token
4 NO_MATCHES The targets or pattern resolved to nothing
5 ABORTED A confirmation was declined
6 VERIFICATION_FAILED The action ran but could not be confirmed

In a bulk run one failure does not cancel the rest: the remaining packages are still processed, the failures are listed, and the exit code reports the first one.

Application directory

~/.gh-manager/
├── config.json      # stored defaults
├── chrome-profile/  # the persistent browser profile
└── logs/            # failure screenshots and page dumps

Override the location with --app-dir <path> or GH_MANAGER_HOME, which is what a CI runner or a second account needs.

Authentication

Two independent credentials, used for different things.

The browser session performs every write. It is created once by gh-manager auth login and stored in the managed Chrome profile. gh-manager never sees a password.

The API token is only used for reads and verification, and is optional — without one, gh-manager falls back to reading pages. It is resolved in this order:

  1. --token <value>
  2. GH_MANAGER_TOKEN, GH_TOKEN, GITHUB_TOKEN
  3. gh auth token, so an existing GitHub CLI login just works

gh-manager auth status prints which source was used.

Headless and CI

--headless runs the browser without a window, and every verb except auth login supports it. A CI run needs three things:

  1. An application directory holding a profile that is already signed in. Point GH_MANAGER_HOME at it, or restore it from a secret; the profile is the credential, so treat it as one.
  2. --yes, because a destructive verb refuses to assume consent when there is no TTY.
  3. A token in the environment, so changes are verified through the API rather than through a page read.
GH_MANAGER_HOME=/secure/gh-manager \
  gh-manager package public box --org link-foundation --headless --yes

If the profile has expired, the run fails with AUTH (3) and a screenshot, which is the signal to refresh it with an interactive gh-manager auth login.

When something goes wrong

Every page interaction waits for a condition and gives up with a clear message instead of sleeping and hoping. When a page does not look the way the driver expects, the failure writes <label>.png and <label>.html to ~/.gh-manager/logs/ and names them in the error.

  • --verbose prints the navigation and page-state trace.
  • --json prints machine-readable output for package list, permissions list, config list, security status, and every security change verb.
  • All the GitHub selectors live in src/browser/selectors.js, so a UI change is a one-file fix.

Known limitations

  • Package visibility is a container-registry concept; npm and other ecosystems expose a different settings page, so container is both the default and the tested path.
  • The browser drivers target the current GitHub UI. A redesign needs src/browser/selectors.js updated; the tests in tests/browser-*.test.js run against fixture pages and will fail loudly when a driver stops matching.
  • auth login cannot run headless, by design.
  • The dependency graph can only be confirmed through the SBOM endpoint, which documents no ref parameter: it answers for the default branch, and only once GitHub has built the document. A change the SBOM has not caught up with is confirmed by re-reading the settings page, and the output says so.
  • Repository-level security toggles are refused when an organization or enterprise policy owns them. That is GitHub's rule, not a gh-manager limitation; the organization-wide equivalent is the code security configurations API.

Library use

The package is also importable, which is how the tests and examples/basic-usage.js exercise the pure parts (pattern resolution, policy parsing, access diffing) with no browser and no token:

import {
  matchPackageNames,
  parsePolicy,
  runCli,
} from '@link-foundation/gh-manager';

runCli(argv, options) returns an exit code and never calls process.exit, so a whole command line can be run in process. examples/enable-dependency-graph.js does exactly that, turning the dependency graph on for a list of repositories and branching on the code each one returns.

Development

# Install dependencies
bun install

# Run tests
bun test --timeout 30000

# Or with other runtimes:
npm test
deno test --allow-read --allow-write --allow-env

# Lint code
bun run lint

# Format code
bun run format

# Check all (lint + format + zero new duplication relative to origin/main)
bun run check

# Try the pure parts without a browser
node examples/basic-usage.js

# Turn the dependency graph on for one or more repositories
node examples/enable-dependency-graph.js owner/repo

# See what the API can say about a repository's security settings
GITHUB_TOKEN=$(gh auth token) node experiments/security-settings-endpoints.mjs owner/repo

# Build the universal React example app
npm install --prefix examples/universal-app
npm run example:web:build
npm run example:desktop:package

The duplication check compares code with origin/main and rejects new clones of at least 50 tokens. Fetch origin/main before running it. Existing duplication is reported without blocking changes; an empty scan fails.

Project Structure

.
├── .changeset/           # Changeset configuration
├── .github/workflows/    # GitHub Actions CI/CD
├── .husky/               # Git hooks (pre-commit)
├── bin/gh-manager.js     # CLI entry point
├── examples/             # Usage examples
│   └── universal-app/    # React + GitHub Pages + Electron + Capacitor app
├── scripts/              # Build and release scripts
├── src/
│   ├── browser/          # Page drivers and every GitHub selector
│   ├── cli/              # Argument parsing, prompts, entry point
│   ├── domains/          # Command domains: auth, package, permissions, security, config
│   ├── github/           # REST client and token discovery
│   ├── packages/         # The hybrid API/browser gateway
│   ├── permissions/      # Policy parsing and access diffing
│   ├── security/         # Code security settings: features, API, gateway
│   ├── index.js          # Library entry point
│   └── index.d.ts        # TypeScript definitions
├── tests/                # Test files
├── eslint.config.js      # ESLint configuration
├── .prettierrc           # Prettier configuration
├── bunfig.toml           # Bun configuration
├── deno.json             # Deno configuration
└── package.json          # Node.js package manifest

Design Choices

Multi-Runtime Support

gh-manager runs on all major JavaScript runtimes:

  • Bun: Primary runtime with highest performance, uses native test support (bun test)
  • Node.js: Alternative runtime, uses built-in test runner (node --test)
  • Deno: Secure runtime with built-in TypeScript support (deno test)

The test-anywhere framework provides a unified testing API that works identically across all runtimes.

Package Manager Agnostic

While package.json is the source of truth for dependencies, any of these work:

  • bun: Primary choice, uses bun.lockb
  • npm: Uses package-lock.json
  • yarn: Uses yarn.lock
  • pnpm: Uses pnpm-lock.yaml
  • deno: Uses deno.json for configuration

Note: package-lock.json is not committed by default to allow any package manager.

Universal App Example

examples/universal-app is a Vite React app that imports matchPackageNames and isOverBroadPattern from src/patterns.js and lets you try a pattern against a list of package names, including the over-broad check that protects bulk operations. The same static build is used by:

  • GitHub Pages (npm run example:web:build)
  • Electron desktop packaging (npm run example:desktop:package)
  • Capacitor Android/iOS sync (npm run example:mobile:sync)

The example app has its own package.json and lockfile, so React, Electron, and Capacitor stay out of the published CLI package.

See examples/universal-app/README.md for local web, desktop, Android, and iOS testing instructions.

Code Quality

  • ESLint: Configured with recommended rules + Prettier integration
  • Prettier: Consistent code formatting
  • Husky + lint-staged: Pre-commit hooks ensure code quality
  • File size limit: Files must stay under 1500 lines for maintainability (enforced via ESLint and CI)

Release Workflow

The release workflow uses Changesets for version management:

  1. Creating a changeset: Run bun run changeset to document changes
  2. PR validation: CI checks for valid changeset in each PR
  3. Automated versioning: Merging to main triggers version bump
  4. npm publishing: Automated via OIDC trusted publishing (no tokens needed)
  5. Optional Docker Hub publishing: When configured, waits for the exact npm version and tags the Docker image with that version
  6. GitHub releases: Auto-created with formatted release notes

First publish: Use package-registry-manager setup --registry npm --package @link-foundation/gh-manager --execute to sign in through the browser, publish the first version and attach trusted publishing. Release jobs use OIDC and require no stored npm token.

Manual Releases

Two manual release modes are available via GitHub Actions:

  • Instant release: Immediately bump version and publish
  • Changeset PR: Create a PR with changeset for review

CI/CD Pipeline

The GitHub Actions workflow (.github/workflows/release.yml) implements a fast-fail pipeline:

Fast checks (~7-30s each, run first for fastest feedback):

  1. Test compilation: Syntax-checks all .mjs files with node --check
  2. Lint, format & secrets scan: ESLint, Prettier, jscpd, and secretlint for credential leak detection
  3. File line limits: Enforces the 1500-line limit on JavaScript (.js, .mjs, .cjs) and Markdown (.md) files plus release.yml
  4. Changeset check: Validates PR has exactly one changeset (added by that PR)
  5. Version check: Blocks manual version changes in package.json
  6. Documentation validation: Checks required doc files (doc line limits are enforced by the file line limits check)

Slow checks (only run after all fast checks pass):

  1. Test matrix: 3 runtimes × 3 OS = 9 test combinations
  2. Broken link checks: Validates all links in Markdown/HTML files (separate workflow)

Release (on merge to main):

  1. Changeset merge: Combines multiple pending changesets at release time
  2. Release: Automated versioning and npm publishing
  3. Optional Docker publish: Publishes Docker Hub latest and npm-version tags after the npm package is visible

Reasonable Timeouts

Every CI job declares an explicit timeout-minutes so hung steps fail in minutes instead of reaching the GitHub Actions default of six hours. Fast checks use 5-10 minute caps, release jobs use 30 minutes, and the link checker uses 10 minutes for external network variance.

That cap is a backstop, never the deadline: GitHub reports a job it kills as cancelled, not failed. Long steps therefore own an explicit budget via scripts/run-with-budget-warning.sh, which warns at 70% of the budget and fails the step with exit code 124 when it expires. See CI-TIMEOUT-BUDGETS.md.

Individual tests are also capped inside supported runners: npm test runs node --test --test-timeout=30000, and the CI Bun runner uses bun test --timeout 30000. Both bound a single test, not the suite, which is why the suite budget above exists. Deno does not provide a single global per-test timeout flag, so Deno tests are protected by their step budget and the matrix job backstop.

See BEST-PRACTICES.md for detailed explanations of each practice.

Robust Changeset Handling

The CI/CD pipeline is designed to handle concurrent PRs gracefully:

  • PR Validation: Only validates changesets added by the current PR, not pre-existing ones from other merged PRs. This prevents false failures when multiple PRs merge before a release cycle completes.

  • Release-time Merging: If multiple changesets exist when releasing, they are automatically merged into a single changeset with:

    • The highest version bump type (major > minor > patch)
    • All descriptions preserved in chronological order

This design decouples PR validation from the need to pull changes from the default branch, reducing conflicts and ensuring that even if CI/CD fails, all unpublished changesets will still get published when the error is resolved.

Deploying the example app

The example-app.yml workflow deploys the universal example app to GitHub Pages on every push to main. Before the first run on main in a new repository created from this template, open Settings → Pages and set Source = GitHub Actions. This is a one-time manual step and cannot be configured from a workflow because the Pages source defaults to Deploy from a branch. Without it, the pages-deploy job fails on actions/deploy-pages with Get Pages site failed / Failed to create deployment. After flipping the source, the workflow provisions the Pages site on its first run.

Auto-regenerated preview screenshots

The same example-app.yml workflow contains a preview-regen job that boots the built example app in a headless Chromium via browser-commander + Playwright and writes fresh screenshots to docs/screenshots/example-app/example-app-{locale}-{theme}.png on every push to main (and on workflow_dispatch). Any drift is committed back to main with [skip ci] so README/site images never go stale between releases. The job runs in the official Playwright container with the browser already installed, avoiding CI stalls from live Chromium downloads.

The same script is available locally:

npm install --prefix examples/universal-app
npm run example:web:preview-images
# Verbose probe of <html data-theme>, <html lang>, and PNG signatures:
PREVIEW_VERBOSE=1 npm run example:web:preview-images

The matrix defaults to {en, ru} × {light, dark}. The shipped example app has no localization or theme toggle yet, so every cell currently renders the same UI — when a fork adds either, the matrix produces real per-cell variants without script edits.

Broken Link Checker

The link checker workflow (.github/workflows/links.yml) validates all links in Markdown and HTML files:

  1. Detection: Uses lychee to scan all *.md and *.html files
  2. Web Archive fallback: For any broken links found, automatically checks the Wayback Machine for archived versions
  3. Actionable suggestions: Reports one of three outcomes for each broken link:
    • Archived: Suggests the Web Archive URL as a replacement
    • Not archived: Clearly reports the link is unrecoverable
  4. Scheduled checks: Runs weekly to catch links that break over time (even if no files changed)
  5. Issue creation: On scheduled runs, creates a GitHub Issue with the full broken links report

Add regex patterns to .lycheeignore to exclude URLs from checks (e.g., local dev URLs, example.com, known rate-limited sites).

Configuration

Package Name

The published name lives in package.json (@link-foundation/gh-manager) and in .changeset/config.json. Release scripts read it from package.json at runtime, so there are no package-name constants to keep in sync.

Optional Docker Hub Publishing

Docker publishing is disabled by default. To enable it for a project that ships a Docker image, add a Dockerfile and configure these GitHub Actions settings:

Setting Type Description
DOCKERHUB_IMAGE Variable Docker Hub image name, for example namespace/image. This enables Docker publishing.
DOCKERHUB_USERNAME Variable Docker Hub username used by docker/login-action.
DOCKERHUB_TOKEN Secret Docker Hub access token used for registry authentication.
DOCKER_CONTEXT Variable, optional Docker build context. Defaults to ..
DOCKERFILE Variable, optional Dockerfile path. Defaults to ./Dockerfile.

When enabled, the release workflow waits until the exact published npm version is visible in the npm registry, then publishes Docker Hub tags for latest and that same version. The Docker build also receives NPM_PACKAGE_VERSION as a build argument so Dockerfiles can install the matching published package.

ESLint Rules

Customize ESLint in eslint.config.js. Current configuration:

  • ES Modules support
  • Prettier integration
  • No console restrictions (common in CLI tools)
  • Strict equality enforcement
  • Async/await best practices
  • Strict unused variables rule: No exceptions - all unused variables, arguments, and caught errors must be removed (no _ prefix exceptions)

Prettier Options

Configured in .prettierrc:

  • Single quotes
  • Semicolons
  • 2-space indentation
  • 80-character line width
  • ES5 trailing commas
  • LF line endings

Scripts Reference

Script Description
bun test --timeout 30000 Run tests with Bun and a 30s per-test cap
npm test Run tests with Node.js and a 30s per-test cap
bun run lint Check code with ESLint
bun run lint:fix Fix ESLint issues automatically
bun run format Format code with Prettier
bun run format:check Check formatting without changing files
bun run check Run all checks (lint + format)
npm run example:web:dev Start the universal app Vite dev server
npm run example:web:build Build the universal app static web bundle
npm run example:web:preview-images Regenerate preview screenshots via browser-commander
npm run example:desktop:package Package the Electron desktop app locally
npm run example:mobile:sync Build and sync the app bundle into Capacitor projects
bun run changeset Create a new changeset

Contributing

See CONTRIBUTING.md for detailed contribution guidelines.

Quick steps:

  1. Fork the repository
  2. Create a feature branch: git checkout -b feature/my-feature
  3. Make your changes
  4. Create a changeset: bun run changeset
  5. Commit your changes (pre-commit hooks will run automatically)
  6. Push and create a Pull Request

Best Practices

This template implements CI/CD best practices for AI-driven development. See BEST-PRACTICES.md for details on:

  • File size limits for AI readability
  • Automated formatting and linting
  • Multi-runtime and cross-platform testing
  • Changeset-based versioning
  • Concurrency control for CI/CD pipelines

License

Unlicense - Public Domain

About

A tool to manage GitHub

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages