npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

octoops

v1.27.0

Published

Declarative GitHub repo configuration using the gh CLI

Readme

octoops

Declarative GitHub repo configuration using the gh CLI.

Maintain a JSON config describing desired state for your org's repos. Running octoops reconciles actual state with desired state using gh api calls. Idempotent and safe to run repeatedly.

npm install -g octoops

Usage

octoops apply config.json
octoops apply --dry-run config.json
octoops apply --audit config.json

Import an existing org into a config file:

octoops import my-org > config.json
octoops import my-org -o config.json
octoops import my-org --only members
octoops import my-org --only members,teams

Seed state from an existing config (skips GitHub API calls):

octoops seed config.json

Resync state from live GitHub (use this if your state file got out of sync):

octoops resync config.json

Respects GitHub API rate limits automatically.

Configuration

{
  "org": "my-org",
  "presets": {
    "standard-teams": [
      { "name": "backend", "permission": "write" },
      { "name": "devops", "permission": "admin" }
    ]
  },
  "repos": [
    {
      "name": "my-service",
      "description": "Does the thing",
      "private": true,
      "merging": { "squashOnly": true, "deleteBranchOnMerge": true },
      "topics": ["nodejs"],
      "teams": "standard-teams",
      "branchProtection": [
        { "branch": "main", "enforceAdmins": true, "requiredReviews": { "approvals": 1 } }
      ],
      "environments": [{ "name": "production", "reviewers": [{ "team": "devops" }] }],
      "rulesets": [
        {
          "name": "protect-workflows",
          "include": ["~ALL"],
          "filePathRestrictions": [".github/workflows/**"],
          "bypassActors": [{ "team": "devops" }]
        }
      ],
      "npm": {
        "trustedPublishing": { "workflow": "release.yml", "environment": "production" }
      }
    }
  ]
}

Any repo field that accepts an object or array can be a string instead, referencing a key in presets. Supported fields: merging, teams, topics, branchProtection, environments, rulesets, npm. This lets you define a config once and reuse it across repos.

For array fields you can also mix preset references with inline objects element-by-element:

"presets": {
  "integrity": { "name": "integrity", "preventForcePush": true },
  "tags": { "name": "tags", "target": "tag", "preventCreation": true }
},
"repos": [
  { "name": "api", "rulesets": ["integrity", "tags", { "name": "ad-hoc", "preventDeletion": true }] }
]

Each string element is looked up in presets (it can resolve to a single object or an array — arrays are spread). Inline objects pass through unchanged.

Repo settings

Top-level repo fields for basic settings:

  • description — repo description
  • homepage — repo homepage url
  • private: true|false — visibility
  • internal: true — internal visibility (Enterprise only, overrides private)
  • defaultBranch — default branch name (e.g. "main")
  • wiki: true|false — enable/disable repo wiki
  • projects: true|false — enable/disable repo projects
  • archived: true — archive the repo (skips further reconcile). Removing this from the config (when state has it) unarchives the repo
  • init: true — initialize the repo with a README so the default branch exists. On create, passes --add-readme to gh repo create. On an existing empty repo (no branches), creates README.md retroactively. Once initialized, recorded in state and not re-checked
  • template: "owner/repo" — create the repo from a template repo. Only used at create time. Mutually exclusive with init (templates already have content)
  • merging{ squashOnly, deleteBranchOnMerge }

Omitting a field leaves the current GitHub value untouched. Setting it makes octoops reconcile it.

Sharing config across files

A config file can extends one or more other files. This lets multiple teams share a common base of defaults, presets, etc:

// shared.json
{
  "defaults": {
    "base": { "private": true, "wiki": false }
  },
  "presets": {
    "default-rules": [
      { "name": "main", "preventForcePush": true, "requirePR": { "approvals": 1 } }
    ]
  }
}
// team-a.json
{
  "extends": "../shared.json",
  "org": "my-org",
  "presets": {
    "team-a-rules": [...]
  },
  "repos": [
    { "name": "api", "defaults": "base" }
  ]
}

Resolution rules:

  • extends is a string or array of paths. Paths are relative to the file declaring them.
  • Files are loaded recursively (extended files can extend further). Cycles error.
  • Deep-merge: objects merge per key, arrays/scalars replace. So adding a preset in the leaf adds it to the inherited set; redefining a preset key replaces just that one.
  • The leaf's own fields override the merged base.
  • State files belong to the leaf config (team-a.state.json), not the shared file.

Defaults

Define named defaults at the top level and let repos opt in via defaults: "name". Defaults can chain via extends:

{
  "org": "my-org",
  "defaults": {
    "base": {
      "private": true,
      "merging": { "squashOnly": true, "deleteBranchOnMerge": true },
      "wiki": false,
      "projects": false
    },
    "service": {
      "extends": "base",
      "teams": [{ "name": "backend", "permission": "write" }],
      "rulesets": "default-rules"
    },
    "oss-module": {
      "extends": "base",
      "private": false,
      "rulesets": "oss-rules"
    }
  },
  "repos": [
    { "name": "api", "defaults": "service" },
    { "name": "web", "defaults": "service", "topics": ["frontend"] },
    { "name": "lib-foo", "defaults": "oss-module" },
    { "name": "internal-thing" }
  ]
}

Resolution rules:

  • extends and defaults accept a string or an array. Array form is left-to-right, rightmost wins: extends: ["a", "b"] starts from a and layers b on top.
  • Each name is fully resolved (its own extends walked) before being used as a layer.
  • Objects deep-merge (so a repo can override merging.squashOnly without clobbering merging.deleteBranchOnMerge).
  • Arrays and scalars replace.
  • Repo fields override the resolved defaults.
  • Cycles or unknown names are an error.
  • After defaults are applied, preset resolution runs as usual (so rulesets: "default-rules" still resolves through presets).

Example with mixin composition:

{
  "defaults": {
    "private-base": { "private": true, "wiki": false },
    "squash-merging": { "merging": { "squashOnly": true, "deleteBranchOnMerge": true } },
    "service": {
      "extends": ["private-base", "squash-merging"],
      "teams": [{ "name": "backend", "permission": "write" }]
    }
  },
  "repos": [
    { "name": "api", "defaults": "service" },
    { "name": "frontend", "defaults": ["service", "frontend-mixin"] }
  ]
}

GitHub Advanced Security

Per-repo, under security:

{
  "name": "my-repo",
  "security": {
    "advancedSecurity": true,
    "secretScanning": true,
    "secretScanningPushProtection": true,
    "secretScanningValidityChecks": true,
    "dependabotSecurityUpdates": true,
    "codeScanningDefaultSetup": true
  }
}

Maps to the repo's security_and_analysis settings; codeScanningDefaultSetup toggles the code-scanning default setup via its own endpoint. If GHAS isn't available on the plan/repo (private without GHAS, forks, etc.) the relevant calls log skip-code-scanning and continue.

Org-level defaults for newly-created repos can be set at the top level:

{
  "org": "my-org",
  "security": {
    "advancedSecurity": true,
    "secretScanning": true,
    "secretScanningPushProtection": true,
    "dependabotAlerts": true,
    "dependabotSecurityUpdates": true
  }
}

These map to GitHub's *_enabled_for_new_repositories fields on the org settings — they only affect freshly-created repos; existing repos need the per-repo block.

GitHub Packages visibility

Set the visibility of an org's GitHub Packages by package name:

{
  "org": "my-org",
  "githubPackages": [
    { "name": "@my-org/foo", "visibility": "private" },
    { "name": "my-tool", "visibility": "public" }
  ]
}

Fields:

  • name — required, the package name (with @scope/ prefix if scoped).
  • visibility — required, one of "public" | "private" | "internal".
  • type — optional, defaults to "npm". Other types (e.g. "container") work the same way via the same API.

Behavior:

  • Apply errors if the package isn't published yet on GitHub Packages — publish it first (e.g. via your trusted-publishing workflow), then octoops can set visibility.
  • No-op when current visibility already matches.
  • Tracked in state; removing an entry from config leaves the package's visibility untouched (octoops won't flip it back).

Runners

Manage self-hosted runner groups and GitHub-hosted larger runners at the org level. Octoops doesn't provision the underlying machines for self-hosted runners — those still register with GitHub the usual way — but it manages how they're grouped and which repos can use them.

{
  "org": "my-org",
  "runnerGroups": {
    "ci": {
      "visibility": "selected",
      "repos": ["api", "web"],
      "allowsPublicRepositories": false,
      "restrictedToWorkflows": ["my-org/ci/.github/workflows/*"]
    },
    "release": {
      "visibility": "private"
    }
  },
  "hostedRunners": {
    "build-8core": {
      "size": "8-core",
      "image": "ubuntu-22.04",
      "runnerGroup": "ci",
      "maximumRunners": 3
    }
  },
  "pruneOfflineRunners": true
}

Runner group fields:

  • visibility"all" | "selected" | "private". Default "all".
  • repos — when visibility is "selected", the list of repo names that can use this group.
  • allowsPublicRepositories: true|false — whether public repos in the org can use this group.
  • restrictedToWorkflows: ["my-org/repo/.github/workflows/*"] — restrict to specific workflow refs.

Hosted runner fields:

  • size — required, e.g. "4-core", "8-core", "16-core". Match what your plan exposes.
  • image — required, image id or display name (e.g. "ubuntu-22.04", "windows-2022").
  • runnerGroup — required, name of a runner group (octoops resolves to id).
  • maximumRunners — optional cap on parallel runners.
  • enableStaticIp: true — optional static IP allocation.

Other:

  • pruneOfflineRunners: true — remove offline/stale self-hosted runners at the org level on every apply.

Groups and hosted runners in state but not in config are deleted. Default runner groups (Default) are never deleted even if they're not in config.

Environments

Each entry in environments supports:

  • name — required
  • reviewers: [{ team }] — required-reviewer teams (deployments are gated until one approves). On private repos this requires GitHub Enterprise; otherwise octoops prints skip-environments unless enterprise: true is set
  • preventSelfReview: true — block the actor who triggered a deployment from approving it themselves
  • secrets — see "Secrets" below
"environments": [
  { "name": "npm", "reviewers": [{ "team": "release" }], "preventSelfReview": true }
]

Secrets

Reference a local dotenv-style file from a repo or environment:

{
  "name": "my-repo",
  "secrets": ".secrets",
  "environments": [
    { "name": "production", "secrets": ".secrets.prod" }
  ]
}

File format (KEY=value, # comments, optional quoting):

NPM_TOKEN=abc123
SLACK_WEBHOOK="https://hooks.slack.com/..."
# tokens
GH_TOKEN='ghp_...'

Behavior:

  • Paths resolve relative to the config file
  • Missing file → octoops prints skip-secrets and leaves existing secrets/state alone (so you can .gitignore the secrets file and only run apply where it's present)
  • Each secret is hashed with a per-secret HMAC-SHA256 salt ([salt, hmac]); state never holds plaintext, and hashes can't be correlated across secrets/repos/state files
  • Only secrets whose value changed are PUT to GitHub; secrets removed from the file are deleted from GitHub
  • Values are sent to gh secret set over stdin (never on the command line, never logged)

Ruleset fields

Each entry in rulesets supports:

  • name — required
  • target"branch" (default) or "tag"
  • enforcement"active" (default), "evaluate", or "disabled"
  • include / exclude — branch/tag patterns. Defaults to ["~DEFAULT_BRANCH"]. Use "~ALL" to match everything
  • preventCreation: true — block creating matching branches/tags
  • preventUpdate: true — block updating matching branches/tags
  • preventDeletion: true — block branch/tag deletion
  • preventForcePush: true — block force pushes
  • requireLinearHistory: true — require linear commit history (no merge commits)
  • requireSignedCommits: true — require signed commits
  • requirePR: { approvals, dismissStale, codeOwners, lastPushApproval, resolveThreads, requiredReviewers } — require pull requests
  • requirePR.requiredReviewers — see "Required reviewers" below
  • requiredStatusChecks: { strict, checks: [...] } — required CI checks; checks is strings or { context, integrationId }
  • filePathRestrictions: ["..."] — glob restrictions on which file paths can change
  • requiredWorkflows: [{ path, repositoryId, ref }] — required GitHub Actions workflows
  • bypassActors: [...] — entries: { team }, { username }, { app } (GitHub App slug, e.g. "dependabot"), or { type: "OrganizationAdmin" }, each with optional mode: "always"|"pull_request"

Required reviewers (beta)

requirePR.requiredReviewers lets you require specific teams to approve PRs that touch certain file paths. Each entry has:

  • team — name of an org team that must approve
  • filePatterns — array of fnmatch patterns; the team is required when a PR changes any matching file
  • minApprovals — minimum approvals from that team (default 1; 0 adds the team as a reviewer without requiring approval)

Example: infra team must approve any change to Terraform files or infra/, with two approvals; security team must approve any change under auth/:

{
  "name": "main-protection",
  "include": ["~DEFAULT_BRANCH"],
  "preventForcePush": true,
  "requirePR": {
    "approvals": 1,
    "requiredReviewers": [
      { "team": "infra", "filePatterns": ["**/*.tf", "infra/**"], "minApprovals": 2 },
      { "team": "security", "filePatterns": ["auth/**"] }
    ]
  }
}

GitHub flags this API as beta — the parameter shape may change on their side.

{
  "org": "my-org",
  "presets": {
    "default-rules": [
      {
        "name": "main",
        "preventDeletion": true,
        "preventForcePush": true,
        "requirePR": { "approvals": 1 },
        "bypassActors": [{ "type": "OrganizationAdmin" }]
      }
    ]
  },
  "repos": [
    { "name": "api", "private": true, "rulesets": "default-rules" },
    { "name": "web", "private": true, "rulesets": "default-rules" },
    { "name": "docs", "private": false, "rulesets": "default-rules" }
  ]
}

Lock down a set of public modules

{
  "org": "my-org",
  "presets": {
    "oss-merging": { "squashOnly": true, "deleteBranchOnMerge": true },
    "oss-protection": [
      {
        "name": "main-branch",
        "include": ["~DEFAULT_BRANCH"],
        "preventDeletion": true,
        "preventForcePush": true,
        "requirePR": { "approvals": 1, "dismissStale": true, "lastPushApproval": true }
      }
    ]
  },
  "repos": [
    {
      "name": "module-a",
      "private": false,
      "merging": "oss-merging",
      "rulesets": "oss-protection"
    },
    {
      "name": "module-b",
      "private": false,
      "merging": "oss-merging",
      "rulesets": "oss-protection"
    },
    { "name": "module-c", "private": false, "merging": "oss-merging", "rulesets": "oss-protection" }
  ]
}

Manage org members

{
  "org": "my-org",
  "admins": ["alice", "bob"],
  "members": ["charlie", "dave", "eve"]
}

admins and members are independent. If admins is present, only listed users will be admins. If members is present, only listed users will be members. Omit either to leave that role unmanaged.

Removing someone from the org also removes them from all teams. If that person is still listed in a team config, the next apply will re-add them. Make sure admins/members is the superset of everyone referenced in teams.

Manage org teams

{
  "org": "my-org",
  "teams": [
    {
      "name": "engineering",
      "description": "All engineers",
      "privacy": "closed",
      "members": [
        { "username": "alice", "role": "maintainer" },
        { "username": "bob", "role": "member" }
      ]
    },
    {
      "name": "backend",
      "parent": "engineering",
      "members": [
        { "username": "alice", "role": "maintainer" },
        { "username": "charlie", "role": "member" }
      ]
    },
    {
      "name": "devops",
      "parent": "engineering",
      "members": [{ "username": "dave", "role": "maintainer" }]
    }
  ],
  "repos": [{ "name": "api", "teams": [{ "name": "backend", "permission": "write" }] }]
}

Org teams are reconciled before repos. Parent teams should come before children in the array. Members not in the list are removed. Teams in state but not in config are deleted. Renaming a team will create the new team and delete the old one.

Add individual collaborators to a repo

{
  "org": "my-org",
  "repos": [
    {
      "name": "secret-project",
      "private": true,
      "collaborators": [
        { "username": "alice", "permission": "admin" },
        { "username": "bob", "permission": "write" }
      ]
    }
  ]
}

Only direct collaborators are managed. Org-level implicit access is ignored. Unlisted direct collaborators are removed.

Minimal repo with just teams and topics

{
  "org": "my-org",
  "repos": [
    {
      "name": "internal-tool",
      "private": true,
      "topics": ["internal", "tooling"],
      "teams": [
        { "name": "platform", "permission": "admin" },
        { "name": "everyone", "permission": "read" }
      ]
    }
  ]
}

npm trusted publishing

{
  "org": "my-org",
  "repos": [
    {
      "name": "my-module",
      "private": false,
      "npm": {
        "package": "my-module",
        "trustedPublishing": {
          "workflow": "publish.yml",
          "environment": "npm"
        }
      }
    }
  ]
}

Sets up npm trusted publishing so GitHub Actions can publish via OIDC without npm tokens. If the package doesn't exist on npm yet, a placeholder 0.0.0 is published first. package defaults to the repo name if omitted, or to <scope>/<repo-name> if scope is set. Requires interactive npm authentication on first run.

{
  "npm": { "scope": "@my-org", "trustedPublishing": { "workflow": "publish.yml", "environment": "npm" } }
}

…makes the package name default to @my-org/<repo-name>. The leading @ is optional; octoops adds it if missing. Defining package explicitly overrides scope.

For repos that publish multiple packages, use an array:

"npm": [
  { "package": "my-module", "trustedPublishing": { "workflow": "publish.yml", "environment": "npm" } },
  { "package": "my-module-cli", "trustedPublishing": { "workflow": "publish.yml", "environment": "npm" } }
]

You can also manage the package's npm maintainer list:

"npm": {
  "package": "my-module",
  "maintainers": ["alice", "bob", "charlie"],
  "trustedPublishing": { "workflow": "publish.yml", "environment": "npm" }
}

Octoops adds anyone in maintainers who isn't already an owner and removes anyone who is an owner but not in the list — except the caller (whoever ran octoops apply). The caller is never removed, even if they're not listed in maintainers; you'll see a npm-keep-self log line. This prevents you from locking yourself out by mistake.

Programmatic usage

const { apply, importOrg, seed } = require('octoops')

await apply(config, {
  dry: false,
  statePath: './config.state.json',
  audit: true
})

const config = await importOrg('my-org')
const membersOnly = await importOrg('my-org', { only: ['members'] })

seed(config, { statePath: './config.state.json' })

A state file is written next to the config to track what was last applied. On partial failure, completed steps are saved so the next run picks up where it left off.

Note

This was written by a silly robot so be aware of mistakes.

License

Apache-2.0