diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38c38e9..58c4210 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: push: branches: - main + # Lets a maintainer force a run from the Actions tab. Needed because this + # repository is a fork: forks start with their workflows suppressed, so + # having a manual trigger avoids pushing empty commits to get a run. + workflow_dispatch: permissions: contents: read @@ -12,14 +16,18 @@ permissions: jobs: test: name: test - runs-on: ubuntu-latest - timeout-minutes: 10 + # Blacksmith runners are registered at the org level (runner group + # "Blacksmith runners", visibility: all). They also restore the Go build + # cache, which matters here: a cold cross-compile of this module costs + # ~8 min per target, a warm one ~3 s. + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.1 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6.0.1 with: go-version-file: go.mod @@ -39,14 +47,14 @@ jobs: lint: name: lint - runs-on: ubuntu-latest - timeout-minutes: 10 + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.1 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6.0.1 with: go-version-file: go.mod @@ -56,3 +64,31 @@ jobs: # Built with Go >= 1.26 so it can lint a module targeting go 1.26.x # (golangci-lint refuses a target newer than its own build toolchain). version: v2.11.4 + + package: + name: package + # Guards the npm distribution on every pull request rather than at tag + # time: builds a host binary, stamps + packs the wrapper packages, installs + # the tarballs, and runs `olk version` through the JS launcher. Covers both + # the unscoped upstream layout and the scoped fork layout. + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 + steps: + - name: Checkout + uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.1 + + - name: Set up Go + uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6.0.1 + with: + go-version-file: go.mod + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - name: Test npm packaging (pack, install, run) + run: bash scripts/test-npm-package.sh + + - name: Verify the working tree is unchanged + run: git diff --exit-code diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 0000000..65fbade --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,265 @@ +name: Publish (npm, @planmonster) + +# Publishes the fork's npm distribution: @planmonster/olkcli plus the six +# @planmonster/olk-- binary packages. +# +# Separate from release.yml on purpose. release.yml is the upstream pipeline +# (Homebrew cask + unscoped npmjs names + MCP Registry) and cannot run here: +# the unscoped names belong to upstream, TAP_GITHUB_TOKEN does not exist on +# this fork, and io.github.rlrghb/outlook is not our MCP namespace. +# +# Trigger with a tag (npm-v0.9.5-pm.1) or from the Actions tab. The tag prefix +# is "npm-v" so it does not also fire release.yml, which listens on "v*". + +on: + push: + tags: + - "npm-v*" + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g. 0.9.5-pm.1)" + required: true + type: string + dry_run: + description: "Pack and validate only — do not publish" + required: false + default: true + type: boolean + +permissions: + contents: read + +env: + NPM_SCOPE: "@planmonster" + NPM_REGISTRY: "https://registry.npmjs.org" + +jobs: + version: + name: resolve version + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 5 + outputs: + version: ${{ steps.v.outputs.version }} + dry_run: ${{ steps.v.outputs.dry_run }} + steps: + - name: Resolve version and mode + id: v + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + version="${{ inputs.version }}" + dry_run="${{ inputs.dry_run }}" + else + version="${GITHUB_REF_NAME#npm-v}" + dry_run="false" + fi + version="${version#v}" + # Refuse a bare upstream version. The fork must stay distinguishable + # from upstream olkcli releases even though the scope differs. + if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.-]+$'; then + echo "version '$version' must carry a fork suffix, e.g. 0.9.5-pm.1" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "dry_run=$dry_run" >> "$GITHUB_OUTPUT" + echo "publishing $version (dry_run=$dry_run)" >> "$GITHUB_STEP_SUMMARY" + + build-linux-windows: + name: build linux + windows + needs: version + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 30 + steps: + - name: Checkout + uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.1 + + - name: Set up Go + uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6.0.1 + with: + go-version-file: go.mod + + - name: Cross-compile (CGO disabled) + env: + VERSION: ${{ needs.version.outputs.version }} + CGO_ENABLED: "0" + run: | + set -euo pipefail + build() { # goos goarch outdir exe + echo "==> $1/$2" + mkdir -p "npm/$3/bin" + GOOS="$1" GOARCH="$2" go build \ + -ldflags "-s -w \ + -X github.com/rlrghb/olkcli/internal/cmd.Version=${VERSION} \ + -X github.com/rlrghb/olkcli/internal/cmd.Commit=${GITHUB_SHA} \ + -X github.com/rlrghb/olkcli/internal/cmd.Date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -o "npm/$3/bin/$4" ./cmd/olk + } + build linux amd64 olk-linux-x64 olk + build linux arm64 olk-linux-arm64 olk + build windows amd64 olk-win32-x64 olk.exe + build windows arm64 olk-win32-arm64 olk.exe + + - name: Upload binaries + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bin-linux-windows + path: npm/olk-*/bin/ + retention-days: 1 + + build-darwin: + name: build darwin + needs: version + # Must stay on a GitHub-hosted macOS runner: the keyring backend needs + # CGO against the macOS Keychain, and Blacksmith supplies no macOS runners. + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + - name: Build (CGO enabled) + env: + VERSION: ${{ needs.version.outputs.version }} + CGO_ENABLED: "1" + run: | + set -euo pipefail + build() { # goarch outdir + echo "==> darwin/$1" + mkdir -p "npm/$2/bin" + GOOS=darwin GOARCH="$1" go build \ + -ldflags "-s -w \ + -X github.com/rlrghb/olkcli/internal/cmd.Version=${VERSION} \ + -X github.com/rlrghb/olkcli/internal/cmd.Commit=${GITHUB_SHA} \ + -X github.com/rlrghb/olkcli/internal/cmd.Date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -o "npm/$2/bin/olk" ./cmd/olk + } + build arm64 olk-darwin-arm64 + build amd64 olk-darwin-x64 + + - name: Upload binaries + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bin-darwin + path: npm/olk-*/bin/ + retention-days: 1 + + publish: + name: publish to npm + needs: [version, build-linux-windows, build-darwin] + # Must stay on a GitHub-hosted runner. npm Trusted Publishing accepts + # cloud-hosted runners only; a Blacksmith runner presents itself as + # self-hosted in the OIDC claims and npm rejects it. This job only runs + # npm, so it does not need Blacksmith's speed. + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + id-token: write # OIDC token for npm Trusted Publishing + contents: read + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + # Trusted Publishing needs Node >= 22.14 and npm >= 11.5.1. + node-version: "24" + registry-url: ${{ env.NPM_REGISTRY }} + always-auth: true + + - name: Upgrade npm + run: npm install -g npm@latest + + - name: Download linux + windows binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bin-linux-windows + path: npm/ + + - name: Download darwin binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bin-darwin + path: npm/ + + - name: Verify all six binaries are present and executable + run: | + set -euo pipefail + chmod +x npm/olk-*/bin/olk npm/olk-*/bin/olk.exe 2>/dev/null || true + missing=0 + for d in olk-linux-x64 olk-linux-arm64 olk-darwin-x64 olk-darwin-arm64; do + [ -x "npm/$d/bin/olk" ] || { echo "missing npm/$d/bin/olk" >&2; missing=1; } + done + for d in olk-win32-x64 olk-win32-arm64; do + [ -f "npm/$d/bin/olk.exe" ] || { echo "missing npm/$d/bin/olk.exe" >&2; missing=1; } + done + [ "$missing" -eq 0 ] + ls -l npm/olk-*/bin/* + + - name: Smoke-test the linux binary + run: npm/olk-linux-x64/bin/olk version --json + + - name: Stamp and pack (dry run) + if: ${{ needs.version.outputs.dry_run == 'true' }} + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + set -euo pipefail + node scripts/build-npm.mjs "$VERSION" \ + --scope "$NPM_SCOPE" --registry "$NPM_REGISTRY" \ + --repository "$GITHUB_REPOSITORY" + mkdir -p dist-npm + npm pack --pack-destination dist-npm ./npm/olk-* ./npm/olk + ls -lh dist-npm + { + echo "### Dry run — nothing published" + echo '```' + ( cd dist-npm && ls -lh ) + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload dry-run tarballs + if: ${{ needs.version.outputs.dry_run == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: npm-tarballs + path: dist-npm/*.tgz + retention-days: 7 + + - name: Stamp and publish + if: ${{ needs.version.outputs.dry_run != 'true' }} + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + set -euo pipefail + # build-npm.mjs publishes the six binary packages first and the + # launcher last, so a consumer never sees a launcher whose optional + # dependencies are absent. It skips versions that already exist, so + # a re-run after a partial failure is safe. + node scripts/build-npm.mjs "$VERSION" \ + --scope "$NPM_SCOPE" --registry "$NPM_REGISTRY" \ + --repository "$GITHUB_REPOSITORY" --publish + echo "### Published $NPM_SCOPE/olkcli@$VERSION" >> "$GITHUB_STEP_SUMMARY" + + - name: Verify the published package installs and runs + if: ${{ needs.version.outputs.dry_run != 'true' }} + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + set -euo pipefail + # The registry CDN caches for up to 5 minutes; retry before failing. + for i in 1 2 3 4 5 6; do + if npx -y "$NPM_SCOPE/olkcli@$VERSION" version --json; then + exit 0 + fi + echo "not installable yet — retry $i" >&2 + sleep 30 + done + echo "published version did not become installable" >&2 + exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 296161c..7a3763e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,16 +10,18 @@ permissions: jobs: pre-build: - runs-on: ubuntu-latest - timeout-minutes: 15 + # Blacksmith: linux/windows cross-compiles benefit from the restored Go + # build cache (cold ~8 min per target, warm ~3 s). + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.1 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6.0.1 with: go-version-file: go.mod @@ -77,6 +79,10 @@ jobs: # configured for this repo + workflow, and npm mints a short-lived # credential per run from the GitHub OIDC identity. Gated on the # PUBLISH_NPM repo variable. + # + # Keep this job on a GitHub-hosted runner: npm Trusted Publishing accepts + # cloud-hosted runners only, and a Blacksmith runner reports itself as + # self-hosted in the OIDC claims. npm-publish: runs-on: ubuntu-latest needs: release diff --git a/AGENTS.md b/AGENTS.md index b2ae0ef..185a26b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,16 +41,20 @@ ## CI -- `.github/workflows/ci.yml` runs on every pull request and push to `main`. -- The `test` job runs `go mod tidy` drift check → `go vet ./...` → `go build ./...` → `go test -race -count=1 ./...` on Ubuntu using the Go version pinned in `go.mod`. -- The `lint` job runs `golangci-lint` v2.5.0 against `.golangci.yml`. -- Both jobs use pinned action SHAs to match `release.yml` style. +- `.github/workflows/ci.yml` runs on every pull request, on push to `main`, and via `workflow_dispatch` (manual trigger — this repo is a fork, and forks start with workflows suppressed). +- The `test` job runs `go mod tidy` drift check → `go vet ./...` → `go build ./...` → `go test -race -count=1 ./...` using the Go version pinned in `go.mod`. +- The `lint` job runs `golangci-lint` **v2.11.4** against `.golangci.yml`. Do not downgrade: golangci-lint refuses a module whose target Go version is newer than its own build toolchain, so v2.5.0 (built with go1.25) cannot lint this module (`go 1.26.4`). +- The `package` job runs `scripts/test-npm-package.sh` — builds a host binary, stamps + packs the npm wrapper packages, installs the tarballs, and runs `olk version` through the JS launcher in both unscoped and scoped layouts. This makes npm packaging breakage a PR failure instead of a tag-time failure. +- **Runners:** all Linux jobs use `blacksmith-4vcpu-ubuntu-2404` with `useblacksmith/checkout` + `useblacksmith/setup-go` (restored Go build cache: a cold cross-compile of this module costs ~8 min per target, warm ~3 s). Two exceptions, both deliberate: macOS jobs stay on `macos-latest` (Blacksmith has no macOS runners, and the darwin build needs CGO for the Keychain), and any **npm publish** job stays on `ubuntu-latest` (npm Trusted Publishing accepts cloud-hosted runners only; a Blacksmith runner reports itself as self-hosted in the OIDC claims). +- All jobs use pinned action SHAs. ## Release & Distribution - A `vX.Y.Z` tag triggers `release.yml`, publishing to three channels: **Homebrew** (goreleaser → `rlrghb/tap` cask, with a quarantine-strip postflight), **npm** (the `olkcli` package + six `olk--` per-platform packages under `npm/`; `scripts/build-npm.mjs` stamps + publishes), and the **MCP Registry** (`server.json` → `io.github.rlrghb/outlook`, published via `mcp-publisher`). - **No publish secrets:** npm uses **Trusted Publishing (OIDC)** (`id-token: write`, npm ≥ 11.5.1, SLSA provenance); the registry uses GitHub OIDC. The npm package is `olkcli`; the binary is `olk`. - The official MCP registry has no in-place edit — `server.json` description/version changes apply on the **next release** (versions are CI-stamped from the tag). `npm-publish`/`registry-publish` are gated on the `PUBLISH_NPM` repo variable. +- **Fork npm distribution (`@planmonster`) — `publish-npm.yml`, this fork only.** An `npm-vX.Y.Z-` tag (or a `workflow_dispatch` with `dry_run`) builds all six binaries (linux/windows on Blacksmith with `CGO_ENABLED=0`; darwin on `macos-latest` with `CGO_ENABLED=1`) and publishes `@planmonster/olkcli` + six `@planmonster/olk--` packages to npmjs.org via Trusted Publishing. The tag prefix is `npm-v`, not `v`, so it does not also fire `release.yml`. Versions **must** carry a fork suffix (e.g. `0.9.5-pm.1`); the workflow rejects a bare `X.Y.Z` because upstream owns those numbers. `scripts/build-npm.mjs` gained `--scope`, `--registry`, `--repository`, `--access`, and `--skip-binary-check` (bootstrap only) — re-scoping is idempotent. See `docs/npm-publishing.md` for the one-time bootstrap (npm Trusted Publishing can only be configured on a package that already exists, so the seven names must be created by hand once). +- `release.yml` is the **upstream** pipeline and cannot run on this fork: the unscoped npm names belong to upstream, `TAP_GITHUB_TOKEN` does not exist here, and `io.github.rlrghb/outlook` is not our MCP namespace. Leave it inert rather than repurposing it. - **ClawHub (OpenClaw skill) — manual, separate from the tag pipeline.** olk is listed on ClawHub as a **skill** from `SKILL.md`. Publish with the `clawhub` CLI (publisher `rlrghb`; `clawhub whoami` / `clawhub login`) from a folder containing **only `SKILL.md`** — `mkdir -p /tmp/olk-skill && cp SKILL.md /tmp/olk-skill/`, then `clawhub skill publish /tmp/olk-skill --slug olk --name Outlook --version --tags calendar,contacts,drive,latest,mail,microsoft,onedrive,outlook,tasks --changelog '…'`. The skill version is **independent of the binary** — align it to the release. Display name is `Outlook` (pass `--name`; `SKILL.md`'s `name: olk` is only the slug). The summary comes from `SKILL.md` `description:`; **category** ("DATA & APIS") is web-UI only. **No `--dry-run`** — verify the live entry with `clawhub inspect olk` and confirm before publishing. ## Key Design Decisions diff --git a/docs/npm-publishing.md b/docs/npm-publishing.md new file mode 100644 index 0000000..6548e04 --- /dev/null +++ b/docs/npm-publishing.md @@ -0,0 +1,173 @@ +# Publishing the fork to npm (`@planmonster`) + +This fork publishes its own npm distribution. The upstream project owns the +unscoped names (`olkcli`, `olk-linux-x64`, …) on npmjs.org, so this fork uses +the `@planmonster` scope instead. + +The distribution exists so that a control plane (Convex) can start the CLI in an +ephemeral sandbox (Daytona) with `npx`, and so that an agent runner can start the +MCP server. + +## What gets published + +Seven packages per release: + +| Package | Contents | Size | +| --- | --- | --- | +| `@planmonster/olkcli` | JS launcher only | ~1.5 KB | +| `@planmonster/olk-linux-x64` | `bin/olk` | ~119 MB unpacked, ~23 MB compressed | +| `@planmonster/olk-linux-arm64` | `bin/olk` | same | +| `@planmonster/olk-darwin-x64` | `bin/olk` | same | +| `@planmonster/olk-darwin-arm64` | `bin/olk` | same | +| `@planmonster/olk-win32-x64` | `bin/olk.exe` | same | +| `@planmonster/olk-win32-arm64` | `bin/olk.exe` | same | + +The Microsoft Graph SDK makes the binary large. A consumer downloads one binary +package only, because each binary package declares `os` and `cpu`. npm skips the +five that do not match the host. + +`npm/olk/bin/olk.js` finds its binary through its own `optionalDependencies`, so +the launcher needs no scope-specific code. The same launcher works for the +unscoped upstream packages and for the scoped fork packages. + +## Versioning + +The fork must use a version that carries a suffix, for example `0.9.5-pm.1`. +`publish-npm.yml` rejects a bare `X.Y.Z` version. Two reasons: + +- Upstream `olkcli` is at 1.10.0. A shared number implies a parity that does not + exist. +- The suffix records which upstream release the fork tracks. + +npm registry data is immutable. You can never re-use a version number, even +after an unpublish. Publish a new version to correct a defect. + +## One-time bootstrap (do this before the first release) + +npm Trusted Publishing can only be configured on a package that already exists. +Therefore a person must create the seven names once by hand. CI publishes every +version after that. + +1. Create the `planmonster` organization on npmjs.org. Enable 2FA on your + account. +2. Create a granular access token with write access, or publish interactively + and answer the 2FA prompt. +3. From a clean checkout, stamp and publish placeholder packages: + + ```sh + node scripts/build-npm.mjs 0.0.1-bootstrap.0 \ + --scope @planmonster \ + --registry https://registry.npmjs.org \ + --repository PlanMonster/olkcli \ + --publish --skip-binary-check + git checkout -- npm/olk/package.json npm/olk-*/package.json + ``` + + `--skip-binary-check` permits a publish with no binary. Use it here only. +4. For each of the seven packages, open Settings on npmjs.com and add a Trusted + Publisher: + - Organization or user: `PlanMonster` + - Repository: `olkcli` + - Workflow: `publish-npm.yml` +5. Leave "Publishing access" at the default. Do **not** select "Require + two-factor authentication and disallow tokens": that turns on staged + publishing, which makes a person approve every CI publish before consumers + can install it. + +After step 5, CI holds no secret. Each publish mints a short-lived credential +from the GitHub OIDC identity. + +## Release procedure + +```sh +git tag npm-v0.9.5-pm.1 +git push origin npm-v0.9.5-pm.1 +``` + +The tag prefix is `npm-v`, not `v`, so it does not also fire `release.yml` +(the upstream Homebrew and MCP Registry pipeline, which cannot run on this fork). + +To validate without publishing, start the workflow from the Actions tab with +`dry_run: true`. The run packs the seven tarballs and uploads them as an +artifact. A dry run does not verify registry credentials. + +## Pipeline + +| Job | Runner | Why | +| --- | --- | --- | +| `version` | `blacksmith-4vcpu-ubuntu-2404` | validates the version string | +| `build-linux-windows` | `blacksmith-4vcpu-ubuntu-2404` | 4 targets, `CGO_ENABLED=0`, restored Go build cache | +| `build-darwin` | `macos-latest` | needs CGO against the Keychain; Blacksmith has no macOS runners | +| `publish` | `ubuntu-latest` | npm Trusted Publishing accepts cloud-hosted runners only | + +Keep the `publish` job on `ubuntu-latest`. A Blacksmith runner reports itself as +self-hosted in the OIDC claims, and npm rejects a self-hosted claim. + +Expect about 10–15 minutes for a release with a warm Go build cache. A cold +cache adds roughly 8 minutes per cross-compiled target. + +The publish step publishes the six binary packages first and the launcher last. +A consumer therefore never sees a launcher whose optional dependencies are +absent. The step skips a version that already exists, so a re-run after a +partial failure is safe. + +## Consuming the package + +### From a Daytona sandbox + +```sh +npx -y @planmonster/olkcli@0.9.5-pm.1 mail list --json +``` + +Pin the exact version. `@latest` makes an agent runner non-reproducible. + +Pass the credential and the guard rails through the environment, never through +the command line, because command lines are visible to other processes: + +``` +OLK_ACCESS_TOKEN= +OLK_ACCESS_TOKEN_EXPIRES_AT= +OLK_ACCOUNT_EMAIL= +OLK_JSON=1 +OLK_NO_INPUT=1 +OLK_NO_WRITE=1 +OLK_NO_SEND=1 +OLK_WRAP_UNTRUSTED=1 +OLK_CONCISE=1 +OLK_ENABLE_COMMANDS=mail,calendar +``` + +With an injected token the CLI reads no keyring and creates no configuration +directory. If the token is expired, the CLI stops before the first request and +returns exit code 77. + +Read `stdout` for the JSON envelope. Ignore `stderr`, which carries hints only. + +### As an MCP server + +```sh +npx -y @planmonster/olkcli@0.9.5-pm.1 mcp +``` + +The server is read-only by default and always wraps untrusted text. + +### Baked into a sandbox image + +```sh +npm i -g @planmonster/olkcli@0.9.5-pm.1 +``` + +This removes the registry round trip from a cold start. + +## Local testing + +```sh +bash scripts/test-npm-package.sh +``` + +The script builds a host binary, stamps the packages, packs them, installs the +tarballs into a scratch project, runs `olk version` through the launcher, and +checks that a failing command still returns a non-zero exit code. It tests the +unscoped layout and the scoped layout. It restores `npm/*/package.json` on exit. + +The `package` job in `ci.yml` runs this script on every pull request. diff --git a/npm/olk/bin/olk.js b/npm/olk/bin/olk.js index e860289..98adb82 100644 --- a/npm/olk/bin/olk.js +++ b/npm/olk/bin/olk.js @@ -5,21 +5,45 @@ // per-platform optional dependency (olk--); npm installs only // the one matching this host (via the package's "os"/"cpu" fields). We resolve // that binary and exec it, forwarding args, stdio, and exit code. +// +// The platform package name is read from our own optionalDependencies rather +// than hardcoded, so the same launcher works for the unscoped npmjs.org +// packages and for a re-scoped build (e.g. @planmonster/olk-linux-x64 on an +// internal registry). See scripts/build-npm.mjs --scope. const { execFileSync } = require("child_process"); +const path = require("path"); -function binaryPath() { - const pkg = `olk-${process.platform}-${process.arch}`; - const exe = process.platform === "win32" ? "olk.exe" : "olk"; +function candidates() { + const suffix = `olk-${process.platform}-${process.arch}`; + const names = []; try { - return require.resolve(`${pkg}/bin/${exe}`); + const self = require(path.join(__dirname, "..", "package.json")); + for (const dep of Object.keys(self.optionalDependencies || {})) { + if (dep === suffix || dep.endsWith(`/${suffix}`)) names.push(dep); + } } catch (_) { - throw new Error( - `olk: no prebuilt binary for ${process.platform}-${process.arch}. ` + - `The optional dependency "${pkg}" is missing — reinstall without ` + - `--no-optional, or build from source (https://github.com/rlrghb/olkcli).` - ); + // fall through to the unscoped default + } + if (!names.includes(suffix)) names.push(suffix); + return names; +} + +function binaryPath() { + const exe = process.platform === "win32" ? "olk.exe" : "olk"; + const tried = candidates(); + for (const pkg of tried) { + try { + return require.resolve(`${pkg}/bin/${exe}`); + } catch (_) { + // try the next candidate + } } + throw new Error( + `olk: no prebuilt binary for ${process.platform}-${process.arch}. ` + + `The optional dependency "${tried[0]}" is missing — reinstall without ` + + `--no-optional, or build from source (https://github.com/rlrghb/olkcli).` + ); } try { diff --git a/scripts/build-npm.mjs b/scripts/build-npm.mjs index 57f74ab..4792ffa 100644 --- a/scripts/build-npm.mjs +++ b/scripts/build-npm.mjs @@ -1,7 +1,16 @@ #!/usr/bin/env node // Stamp versions across the npm wrapper packages and (optionally) publish them. // -// node scripts/build-npm.mjs [--publish] +// node scripts/build-npm.mjs [options] +// +// --publish npm publish (default: stamp only) +// --scope @my-org rename every package under an npm scope +// --registry publish/lookup against a non-default registry +// --repository owner/name rewrite repository/homepage/bugs URLs +// --access public|restricted +// --skip-binary-check publish binary packages with no binary +// (one-time bootstrap only — see +// docs/npm-publishing.md) // // may be "v0.9.2" or "0.9.2" (a leading "v" is stripped). Without // --publish it only stamps versions (safe for local inspection / dry-run). @@ -9,6 +18,14 @@ // main package's optionalDependencies already resolve), then the main `olk` // package. The per-platform binaries must already be present in // npm/olk--/bin/ (the release workflow extracts them there). +// +// Scoped mode exists for forks and internal registries: npmjs.org names +// (`olkcli`, `olk-linux-x64`, …) belong to the upstream project, and GitHub +// Packages requires the scope to equal the owning org. `--scope @planmonster +// --registry https://npm.pkg.github.com --repository PlanMonster/olkcli` +// produces `@planmonster/olkcli` + `@planmonster/olk--`. +// The launcher (npm/olk/bin/olk.js) resolves the platform package from its own +// optionalDependencies, so it needs no stamping and works in either mode. import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs"; import { execFileSync } from "node:child_process"; @@ -18,31 +35,74 @@ import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const npmDir = path.join(root, "npm"); -const rawVersion = process.argv[2] || process.env.VERSION || process.env.GITHUB_REF_NAME; +const argv = process.argv.slice(2); +function flag(name) { + const i = argv.indexOf(name); + return i === -1 ? undefined : argv[i + 1]; +} + +const rawVersion = argv.find((a) => !a.startsWith("-")) || process.env.VERSION || process.env.GITHUB_REF_NAME; if (!rawVersion) { - console.error("usage: build-npm.mjs [--publish]"); + console.error("usage: build-npm.mjs [--publish] [--scope @org] [--registry url] [--repository owner/name]"); process.exit(2); } const version = rawVersion.replace(/^v/, ""); -const publish = process.argv.includes("--publish"); +const publish = argv.includes("--publish"); +const registry = flag("--registry") || process.env.NPM_REGISTRY; +const repository = flag("--repository") || process.env.NPM_REPOSITORY; +const access = flag("--access") || process.env.NPM_ACCESS || "public"; +// npm Trusted Publishing can only be configured on a package that already +// exists, so the very first publish of each name has to happen by hand and may +// carry no binary. Never set this in the release workflow. +const skipBinaryCheck = argv.includes("--skip-binary-check"); + +let scope = flag("--scope") || process.env.NPM_SCOPE || ""; +if (scope && !scope.startsWith("@")) scope = `@${scope}`; +if (scope.endsWith("/")) scope = scope.slice(0, -1); const platformPkgs = readdirSync(npmDir).filter((d) => d.startsWith("olk-")); +// Re-scoping must be idempotent: drop any existing scope before applying ours. +function rename(name) { + const bare = name.includes("/") ? name.slice(name.indexOf("/") + 1) : name; + return scope ? `${scope}/${bare}` : bare; +} + function stamp(pkgDir, mutate) { const p = path.join(npmDir, pkgDir, "package.json"); const json = JSON.parse(readFileSync(p, "utf8")); json.version = version; + json.name = rename(json.name); + if (repository) { + // GitHub Packages links a package to a repo via this field and rejects + // a publish whose repository does not match the pushing repo. + json.repository = { type: "git", url: `git+https://github.com/${repository}.git` }; + json.homepage = `https://github.com/${repository}`; + if (json.bugs) json.bugs = `https://github.com/${repository}/issues`; + } + if (registry) { + json.publishConfig = { ...(json.publishConfig || {}), registry, access }; + } if (mutate) mutate(json); writeFileSync(p, JSON.stringify(json, null, 2) + "\n"); + return json.name; } for (const pkg of platformPkgs) stamp(pkg); -stamp("olk", (json) => { +const mainName = stamp("olk", (json) => { + const deps = {}; for (const dep of Object.keys(json.optionalDependencies || {})) { - json.optionalDependencies[dep] = version; + deps[rename(dep)] = version; } + json.optionalDependencies = deps; + // `mcpName` claims an MCP Registry namespace derived from the upstream repo; + // it is meaningless (and misleading) for a re-scoped/internal build. + if (scope) delete json.mcpName; }); -console.log(`stamped version ${version} across olk + ${platformPkgs.length} platform packages`); +console.log( + `stamped ${mainName}@${version} + ${platformPkgs.length} platform packages` + + (registry ? ` for ${registry}` : "") +); if (!publish) { console.log("(stamp-only — pass --publish to npm publish)"); @@ -53,15 +113,19 @@ function pkgName(pkgDir) { return JSON.parse(readFileSync(path.join(npmDir, pkgDir, "package.json"), "utf8")).name; } -// Idempotent: skip a package@version that already exists on npm, so re-running -// a partially-failed release (e.g. the registry step failed) does not error on +// Idempotent: skip a package@version that already exists, so re-running a +// partially-failed release (e.g. the registry step failed) does not error on // "cannot publish over previously published version". function alreadyPublished(name) { + const args = ["view", `${name}@${version}`, "version"]; + if (registry) args.push("--registry", registry); try { - return execFileSync("npm", ["view", `${name}@${version}`, "version"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim() === version; + return ( + execFileSync("npm", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim() === version + ); } catch { return false; } @@ -74,7 +138,9 @@ function npmPublish(pkgDir) { return; } console.log(`publishing ${name}@${version} ...`); - execFileSync("npm", ["publish", "--access", "public"], { + const args = ["publish", "--access", access]; + if (registry) args.push("--registry", registry); + execFileSync("npm", args, { cwd: path.join(npmDir, pkgDir), stdio: "inherit", }); @@ -83,8 +149,11 @@ function npmPublish(pkgDir) { for (const pkg of platformPkgs) { const exe = pkg.includes("win32") ? "olk.exe" : "olk"; if (!existsSync(path.join(npmDir, pkg, "bin", exe))) { - console.error(`refusing to publish ${pkg}: missing bin/${exe}`); - process.exit(1); + if (!skipBinaryCheck) { + console.error(`refusing to publish ${pkg}: missing bin/${exe}`); + process.exit(1); + } + console.warn(`WARNING: ${pkg} has no bin/${exe} — publishing a placeholder`); } npmPublish(pkg); } diff --git a/scripts/test-npm-package.sh b/scripts/test-npm-package.sh new file mode 100755 index 0000000..0129ed0 --- /dev/null +++ b/scripts/test-npm-package.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# End-to-end test of the npm wrapper packages. +# +# bash scripts/test-npm-package.sh +# +# Builds a host binary, stamps the wrapper packages, packs them, installs the +# tarballs into a scratch project, and runs `olk version` through the JS +# launcher. Asserts the launcher resolved the per-platform optional dependency +# and returned the stamped version. +# +# Runs twice: unscoped (the upstream npmjs.org layout) and scoped (the fork +# layout, e.g. @planmonster/olkcli). This is what makes packaging breakage a +# pull-request failure instead of a tag-time failure. +# +# Restores npm/*/package.json on exit — stamping mutates them in place. +set -euo pipefail + +VERSION="${1:-0.0.0-test.1}" +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" + +goos="$(go env GOOS)" +goarch="$(go env GOARCH)" +case "$goos" in + darwin | linux) platform="$goos" ;; + windows) platform="win32" ;; + *) + echo "unsupported GOOS for this test: $goos" >&2 + exit 2 + ;; +esac +pkgdir="npm/olk-${platform}-${goarch/amd64/x64}" +exe="olk" +[ "$platform" = "win32" ] && exe="olk.exe" + +if [ ! -d "$pkgdir" ]; then + echo "no wrapper package for this host: $pkgdir" >&2 + exit 2 +fi + +tmp="$(mktemp -d)" +cleanup() { + git checkout -- npm/olk/package.json npm/olk-*/package.json 2>/dev/null || true + rm -rf "$tmp" "$pkgdir/bin/$exe" +} +trap cleanup EXIT + +echo "==> build $goos/$goarch -> $pkgdir/bin/$exe" +mkdir -p "$pkgdir/bin" +CGO_ENABLED=0 go build \ + -ldflags "-s -w -X github.com/rlrghb/olkcli/internal/cmd.Version=$VERSION" \ + -o "$pkgdir/bin/$exe" ./cmd/olk + +run_case() { + local label="$1" + shift + local dest="$tmp/$label" + mkdir -p "$dest" + + echo + echo "==> case: $label" + node scripts/build-npm.mjs "$VERSION" "$@" + + local main_name + main_name="$(node -p "require('./npm/olk/package.json').name")" + local plat_name + plat_name="$(node -p "require('./$pkgdir/package.json').name")" + echo " launcher=$main_name platform=$plat_name" + + # The launcher must not hardcode the platform package name; assert it is + # listed as an optional dependency at the stamped version. + node -e " + const p = require('./npm/olk/package.json'); + const want = '$plat_name', v = '$VERSION'; + if (p.optionalDependencies[want] !== v) { + console.error('optionalDependencies missing ' + want + '@' + v); + process.exit(1); + } + " + + npm pack --silent --pack-destination "$dest" "./$pkgdir" ./npm/olk >/dev/null + local plat_tgz main_tgz + plat_tgz="$(ls "$dest"/*olk-"$platform"-"${goarch/amd64/x64}"-"$VERSION".tgz)" + main_tgz="$(ls "$dest"/*olkcli-"$VERSION".tgz)" + echo " packed $(basename "$plat_tgz") ($(du -h "$plat_tgz" | cut -f1))" + + ( cd "$dest" && npm init -y >/dev/null 2>&1 ) + # --omit=optional: the other five platform packages do not exist at this + # version yet. The one we need is installed explicitly, as a direct + # dependency, so the launcher still resolves it. + ( cd "$dest" && npm install --omit=optional --no-audit --no-fund --silent \ + "$plat_tgz" "$main_tgz" >/dev/null ) + + local got + got="$( cd "$dest" && ./node_modules/.bin/olk version --json )" + echo " olk version --json -> $got" + node -e " + const got = JSON.parse(process.argv[1]); + if (got.version !== '$VERSION') { + console.error('want version $VERSION, got ' + got.version); + process.exit(1); + } + " "$got" + + # Exit-code propagation through the launcher matters for the control plane. + local code=0 + ( cd "$dest" && ./node_modules/.bin/olk --definitely-not-a-flag >/dev/null 2>&1 ) || code=$? + if [ "$code" -eq 0 ]; then + echo "launcher did not propagate a non-zero exit code" >&2 + exit 1 + fi + echo " exit-code propagation ok (got $code)" + + git checkout -- npm/olk/package.json npm/olk-*/package.json + echo " PASS: $label" +} + +run_case unscoped +run_case scoped --scope @planmonster --registry https://registry.npmjs.org \ + --repository PlanMonster/olkcli + +echo +echo "npm package test: PASS"