# npm and Homebrew

> Publish every official release to npm and Homebrew under your own names, with no tokens to manage.

Okibi can publish each official release of your CLI to npm and Homebrew. Your users install it the way they install other tools, and they get the same build as the Okibi installer:

```sh
npm install -g @acme/my-cli
brew install acme/tap/my-cli
```

Everything is published from your own GitHub account under names you own. npm trusts a workflow in your CLI's repository, and the Homebrew formula lives in your own tap, so there is no npm token for you to store or rotate.

## Before you start

- **Your code is in your GitHub account.** Okibi can't publish from an Okibi-hosted repository, because npm ties each package to the repository that publishes it. To move the code, open **Settings** > **Where it lives**, click **Move to GitHub** next to **Code**, and pick **Your GitHub**.
- **The official release is public.** npm and Homebrew are public, so they only get a release whose **Access** is **Anyone with the install line** (Pro plan). See [Releases and install](https://okibi.ai/docs/guides/releases.md).
- **For npm:** an npm account, an npm organization if you want a scoped name like `@acme/my-cli` (organizations are free for public packages), and npm 11.15 or later on your machine.

Only workspace owners and admins can change these settings.

## Turn it on

1. Open the project's **Settings**. In **Where it lives**, click **Manage** next to **npm and Homebrew**.
2. Check the **Description** and **Homepage**. They show on npm and in `brew info`. An empty homepage uses your repository.
3. Turn on **Publish to npm** and enter the **Package name**, for example `@acme/my-cli` or `my-cli`.
4. Turn on **Publish to Homebrew**.
5. Click **Save**.

Homebrew needs nothing else. npm needs the one-time setup below.

## Set up npm (once)

1. **Merge the setup pull request.** Okibi opens it in your CLI's repository. It adds three files and nothing else, so merging it doesn't release a new version:
   - `.github/workflows/release-npm.yml`: the release workflow
   - `.github/npm/package.mjs`: turns a release's binaries into npm packages
   - `.github/npm/config.json`: your package name and the key that verifies each release
2. **Publish the first version from your terminal.** npm can only trust a workflow for a package that already exists, so the first publish uses your own npm login:

   ```sh
   npm login
   okibi clis npm setup my-cli
   ```

   This publishes the current official version, then tells npm to trust the release workflow for each package. npm may ask you to confirm with your second factor. New packages can take a few minutes to appear on npm, so the command waits for them. If it stops waiting first, run `okibi clis npm setup my-cli --skip-publish` a little later to finish; it doesn't publish or ask for your second factor again.

3. **Allow rollbacks.** On npmjs.com, open `@acme/my-cli` (the package without a platform suffix), go to **Settings**, and turn on **Allow npm dist-tag** for its trusted publisher. Rolling back moves npm's `latest` tag, which npm only lets the workflow do with this setting, and npm's command line can't turn it on yet.

The **npm and Homebrew** window shows **On npm** once it's done. From then on, each official release reaches npm within a few minutes, with no step from you.

Each release is seven packages: `@acme/my-cli`, which provides the `my-cli` command, plus one binary package per platform (`@acme/my-cli-darwin-arm64`, `@acme/my-cli-linux-x64`, and so on). npm installs only the one that matches the user's machine, so installing with `--omit=optional` leaves the CLI without its binary.

> **Note** `okibi clis npm setup` uses your npm login on your machine only, and Okibi never sees your npm credentials.

Trusted publishing adds the workflow as a way to publish; it doesn't remove the others. Your own npm login and tokens can still publish, and so can anyone who can run a workflow named `release-npm.yml` in your repository, which includes people with write access and the Okibi publisher app. To make the workflow the only way, open each package's **Settings** on npmjs.com and choose **Require two-factor authentication and disallow tokens**. Trusted publishing keeps working with that setting.

## Homebrew

The first time it publishes, Okibi creates a public `homebrew-tap` repository in your GitHub account. For each release it uploads the macOS and Linux archives to that repository and updates `Formula/my-cli.rb`. Your users run:

```sh
brew install acme/tap/my-cli
```

Share the full name. Homebrew 7 only loads formulae from taps you trust, and installing by full name trusts that one formula.

If your organization doesn't let apps create public repositories, create `homebrew-tap` yourself as a public repository, add it to the Okibi publisher app's repositories, and click **Check again**.

## What happens on each release

Okibi follows the official release. Whenever it changes (a merged release pull request, a version you choose, a skill or endpoint update), npm and Homebrew get it within a few minutes. The window shows each channel's status.

- **Rollbacks.** Choosing an older official version moves npm's `latest` back to it and rewrites the formula, so `npm update` and `brew upgrade` move users back too. A version npm or the tap already has is reused as it is. One they never got (for example, a release from before you turned publishing on) is published first.
- **When a CLI stops qualifying.** If its audience stops being public, or its code moves to an Okibi-hosted repository, Okibi stops publishing. npm and Homebrew keep serving the last version they got. The window still shows each channel and lets you turn it off.
- **Repairs.** When Okibi repairs a release, it releases the fix as the next patch version, which npm and Homebrew get like any other release. If a release is rebuilt under the same version, they keep the build they first got, since a published version is never replaced, and the window says so.
- **Commits to main.** A commit you push to your CLI's default branch ships with the next release.

## When something fails

The window says what failed and what fixes it, with a link to the workflow run. The person who turned publishing on also gets an email. Okibi retries on its own unless the fix is a setting on npmjs.com; then click **Check again** once it's changed.

| What you see | What to do |
| --- | --- |
| npm turned down the release workflow | Check that the package's trusted publisher is your repository with `release-npm.yml`, or run `okibi clis npm setup` again. |
| npm still serves the newer version after a rollback | Turn on **Allow npm dist-tag** for the package's trusted publisher on npmjs.com (step 3 of the setup). |
| The package name belongs to someone else | Choose another name and save. |
| Approve the new Actions permission | Click **Approve on GitHub**. Okibi needs to read workflow runs to tell you why a release failed. |
| The tap has a formula Okibi didn't write | Rename or remove that formula, then click **Check again**. |

## Run your own pipeline instead

You can publish with your own workflow instead. This needs the code in your own GitHub account; an Okibi-hosted repository can't hold your secrets.

Okibi pushes a `vX.Y.Z` tag to your repository when it releases a merged release pull request or an endpoint change. Save this workflow as `.github/workflows/package-managers.yml`. It waits until that version is the public official release, then downloads its archives and checks every checksum. Set the repository variable `OKIBI_CLI_ID` to the `cli.id` that `okibi clis hosting status my-cli --json` prints.

```yaml
name: Package managers
on:
  push:
    tags: ['v*']
  workflow_dispatch:
    inputs:
      version:
        description: Official version, X.Y.Z
        required: true

permissions:
  contents: read
  id-token: write

jobs:
  publish:
    runs-on: ubuntu-latest
    env:
      TAG: ${{ inputs.version || github.ref_name }}
      UPDATE_URL: https://okibi.ai/i/cli/${{ vars.OKIBI_CLI_ID }}
    steps:
      - uses: actions/checkout@v5
      - name: Wait for the official release
        id: official
        run: |
          version=${TAG#v}
          for _ in $(seq 1 6); do
            url=$(curl -fsSL -o latest.json -w '%{url_effective}' \
              "$UPDATE_URL" || true)
            current=$(jq -r .releaseVersion latest.json 2>/dev/null || true)
            if [ "$current" = "$version" ]; then
              echo "version=$version" >> "$GITHUB_OUTPUT"
              echo "base=${url%/latest.json}" >> "$GITHUB_OUTPUT"
              exit 0
            fi
            sleep 20
          done
          echo "::notice::$version isn't the official release; skipping."
      - name: Download the release archives
        if: steps.official.outputs.version != ''
        env:
          BASE: ${{ steps.official.outputs.base }}
        run: |
          mkdir -p release
          mv latest.json release/
          jq -r '.artifacts[] | "\(.url) \(.sha256)"' release/latest.json |
            while read -r path sum; do
              mkdir -p "release/$(dirname "$path")"
              curl -fsSL -o "release/$path" "$BASE/$path"
              echo "$sum  release/$path" | sha256sum -c -
            done
      # Your publishing steps go here, using release/ and
      # steps.official.outputs.version.
```

Skill-only updates don't push a tag. Run the workflow by hand for those, with the version from `okibi clis hosting status my-cli`.

### Publish to npm yourself

Save this script as `scripts/npm-packages.mjs`. It turns a release into one package per platform, each limited to its `os` and `cpu` so npm installs only the matching one, plus a launcher that depends on all six at the exact version and runs the right binary:

```js
// Turn an Okibi release into npm packages: one per platform, plus a launcher.
// node scripts/npm-packages.mjs <release folder or update URL> <out> <package>
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'

const [source, out, name] = process.argv.slice(2)
const remote = /^https?:/.test(source)
const base = remote
  ? (await fetch(source)).url.replace(/\/latest\.json$/, '')
  : source
const read = async (path) =>
  remote
    ? Buffer.from(await (await fetch(`${base}/${path}`)).arrayBuffer())
    : readFileSync(join(source, path))
const manifest = JSON.parse(await read('latest.json'))
const { binaryName: cli, releaseVersion: version } = manifest
const repo = process.env.GITHUB_REPOSITORY
const repository = repo && {
  type: 'git',
  url: `git+https://github.com/${repo}.git`,
}
const write = (dir, pkg) => {
  mkdirSync(join(out, dir, 'bin'), { recursive: true })
  const json = JSON.stringify({ ...pkg, version, repository }, null, 2)
  writeFileSync(join(out, dir, 'package.json'), json)
}

const platforms = {}
for (const { platform, url, sha256 } of manifest.artifacts) {
  const archive = await read(url)
  const sum = createHash('sha256').update(archive).digest('hex')
  if (sum !== sha256) throw new Error(`${url}: checksum mismatch`)
  // Okibi says windows where npm says win32.
  const [os, cpu] = platform.replace(/^windows/, 'win32').split('-')
  const dir = `${os}-${cpu}`
  platforms[`${name}-${dir}`] = version
  write(dir, { name: `${name}-${dir}`, os: [os], cpu: [cpu], files: ['bin'] })
  const bin = join(out, dir, 'bin')
  execFileSync('tar', ['-xz', '--exclude', '.okibi-*', '-C', bin], {
    input: archive,
  })
}

const LAUNCHER = `#!/usr/bin/env node
const { dirname, join } = require('node:path')
const { spawnSync } = require('node:child_process')
const { name, bin } = require('../package.json')
const cli = Object.keys(bin)[0]
const pkg = name + '-' + process.platform + '-' + process.arch
let dir
try {
  dir = dirname(require.resolve(pkg + '/package.json'))
} catch {
  console.error(cli + ': ' + pkg + ' is missing; reinstall it with optional'
    + ' dependencies')
  process.exit(1)
}
const exe = join(dir, 'bin', process.platform === 'win32' ? cli + '.exe' : cli)
const off = cli.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_NO_AUTO_UPDATE'
const run = spawnSync(exe, process.argv.slice(2), {
  stdio: 'inherit',
  env: { ...process.env, [off]: '1' },
})
process.exit(run.status ?? 1)
`

// The launcher runs the binary for this machine. npm owns the install, so
// the binary's own updater stays off.
write('launcher', {
  name,
  bin: { [cli]: 'bin/cli.js' },
  files: ['bin'],
  optionalDependencies: platforms,
})
writeFileSync(join(out, 'launcher', 'bin', 'cli.js'), LAUNCHER, { mode: 0o755 })
console.log(`Prepared ${name}@${version}`)
```

Replace the comment at the end of the workflow with these steps. They publish the platform packages first and the launcher last, so the launcher never points at a binary that isn't on npm yet, and skip versions npm already has, so a rerun is safe:

```yaml
      - uses: actions/setup-node@v5
        if: steps.official.outputs.version != ''
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org
      - name: Publish to npm
        if: steps.official.outputs.version != ''
        env:
          VERSION: ${{ steps.official.outputs.version }}
        run: |
          npm install --global npm@latest
          node scripts/npm-packages.mjs release npm @acme/my-cli
          for dir in npm/*-*/ npm/launcher/; do
            name=$(jq -r .name "$dir/package.json")
            # No token: setup-node's placeholder would fail the lookup.
            NODE_AUTH_TOKEN='' npm view "$name@$VERSION" version \
              >/dev/null 2>&1 && continue
            npm publish "$dir" --access public --tag latest
          done
```

npm can only trust a workflow for a package that already exists, so publish the first version from your machine, then trust the workflow for each of the seven packages:

```sh
npm login
export GITHUB_REPOSITORY=acme/my-cli
node scripts/npm-packages.mjs https://okibi.ai/i/cli/<cli.id> npm @acme/my-cli
for dir in npm/*-*/ npm/launcher/; do
  npm publish "$dir" --access public --tag latest
done
for pkg in @acme/my-cli @acme/my-cli-{darwin,linux,win32}-{arm64,x64}; do
  npm trust github "$pkg" --repo acme/my-cli --file package-managers.yml \
    --allow-publish --yes
done
```

npm asks you to approve each publish and each trust in your browser; on the first approval, tick "skip two-factor authentication for the next 5 minutes" to approve the rest. To use a token instead of trusted publishing, store an npm access token as the secret `NPM_TOKEN` and add `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` to the publish step's `env`.

`--tag latest` is explicit because npm won't move `latest` to a version below its highest on its own, which is what publishing an older official version does. Rolling back to a version npm already has needs `npm dist-tag add @acme/my-cli@1.2.0 latest`. From the workflow, that works only with **Allow npm dist-tag** turned on for its trusted publisher.

### Publish to Homebrew yourself

Your own tap needs a formula per release, built from the same `release/` folder: a `url` and `sha256` from `latest.json` for each macOS and Linux archive. Install the binaries into `libexec` and make `bin/my-cli` a wrapper that tells the CLI where its `okibi-auth` helper is, as Okibi's own formula does, so helpers from two CLIs never collide in Homebrew's `bin`:

```ruby
  def install
    libexec.install "my-cli"
    env = {}
    if File.exist?("okibi-auth")
      libexec.install "okibi-auth"
      env[:MY_CLI_AUTH_HELPER] = libexec/"okibi-auth"
    end
    (bin/"my-cli").write_env_script libexec/"my-cli", env
  end
```

The variable is the CLI's name in capitals with dashes as underscores, followed by `_AUTH_HELPER`.

- [Releases and install](https://okibi.ai/docs/guides/releases.md): Choose the official release and its audience.
- [API keys](https://okibi.ai/docs/guides/api-keys.md): Keys for okibi in CI jobs.
