From 453e0b2ec2e774cddeddb34b9d5039819f1554f7 Mon Sep 17 00:00:00 2001 From: Imad Rashid Date: Mon, 24 Aug 2026 00:27:11 +0500 Subject: [PATCH 1/2] Rename to gswap, add gh bootstrap and update checking, release v1.0.0 The npm name "ghswap" is owned by an unrelated project, so the package, binary, data directory (~/.gswap) and environment variables (GSWAP_*) are all renamed while nothing is published yet and the change is still free. New: - `gswap install-gh` installs the GitHub CLI via whichever package manager the machine has. It only auto-runs installers that need no elevation and are real executables; anything requiring sudo/admin, or shipped as a .cmd/.ps1 shim, is printed for the user to run. Never a postinstall hook, so a global install can't escalate privileges or reach the network on its own. `add` offers it inline when it finds no gh. - A daily update check with `gswap upgrade`. It notifies rather than self-updating: this tool holds GitHub tokens, and new code shouldn't start running against them unannounced. GSWAP_AUTO_UPDATE=1 opts in. The check writes to stderr and is skipped under --json, CI and non-TTY use so it can't leak into piped output or slow a script down. The upgrade command detects npm/pnpm/bun/volta/yarn/brew and defers to the right one. Fixes: - credentialHelpers() read --global only, but helpers are usually configured at system scope: Git for Windows writes credential.helper=manager into C:\Program Files\Git\etc\gitconfig, and Xcode's gitconfig sets osxkeychain the same way. Reading only --global reported "no helper", so `use` silently skipped storing the credential and left the user pushing as the previous account. Now reads merged scope, which is what git itself consults. - addedVia recorded 'browser' for tokens pasted through the no-gh fallback. - Removed --login, which was documented and recommended but never read. - run() now survives Node refusing to spawn .cmd shims instead of throwing. - The version is read from package.json rather than duplicated in cli.js. - Corrected a README troubleshooting entry that predated reading tokens via `gh auth token`, and a message that blamed the network for a 404. CI runs the tests on ubuntu, macOS and Windows; this project's real bugs have all been platform-specific. Publishing is gated on the version not already being on npm, so ordinary merges to main are a no-op rather than a red build. --- .github/workflows/ci.yml | 42 +++++++ .github/workflows/publish.yml | 92 +++++++++++++++ CLAUDE.md | 99 ++++++++++++++++ CONTRIBUTING.md | 45 ++++++-- README.md | 132 ++++++++++++++++------ package.json | 12 +- src/cli.js | 73 ++++++++++-- src/commands/add.js | 72 +++++++++--- src/commands/doctor.js | 14 +-- src/commands/import.js | 6 +- src/commands/installGh.js | 108 ++++++++++++++++++ src/commands/list.js | 4 +- src/commands/remove.js | 8 +- src/commands/status.js | 6 +- src/commands/upgrade.js | 116 +++++++++++++++++++ src/commands/use.js | 10 +- src/lib/ghinstall.js | 119 ++++++++++++++++++++ src/lib/git.js | 11 +- src/lib/github.js | 2 +- src/lib/paths.js | 12 +- src/lib/ui.js | 2 +- src/lib/update.js | 205 ++++++++++++++++++++++++++++++++++ src/lib/util.js | 25 ++++- test/unit.test.js | 118 +++++++++++++++++++ 24 files changed, 1216 insertions(+), 117 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 CLAUDE.md create mode 100644 src/commands/installGh.js create mode 100644 src/commands/upgrade.js create mode 100644 src/lib/ghinstall.js create mode 100644 src/lib/update.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11b5aa7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: node ${{ matrix.node }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # gswap resolves paths, spawns git and picks credential helpers + # differently per platform, so all three are load-bearing — a + # Linux-only matrix would not have caught the Windows-only bugs + # this project has already hit. + os: [ubuntu-latest, macos-latest, windows-latest] + node: [18, 22] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + # No install step: the package has zero runtime dependencies and no + # lockfile, so `npm ci` would fail and there is nothing to fetch. + - name: Run tests + run: npm test + + - name: Smoke-test the CLI + shell: bash + run: | + node src/cli.js --version + node src/cli.js --help > /dev/null + # An unknown command must exit non-zero rather than silently succeed. + if node src/cli.js definitely-not-a-command > /dev/null 2>&1; then + echo "expected a non-zero exit for an unknown command"; exit 1 + fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..42bfae6 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,92 @@ +name: Publish + +on: + push: + branches: [main] + # Lets you re-run a publish without pushing an empty commit. + workflow_dispatch: + +# Never let two publishes race; the second would fail on a taken version. +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write # push the release tag + id-token: write # npm provenance attestation + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Run tests + run: npm test + + # Every merge to main runs this workflow, but most merges don't bump the + # version. Publishing unconditionally would fail on "version already + # exists" and paint the branch red for ordinary commits, so decide here + # and make the publish itself conditional. + - name: Decide whether to publish + id: check + run: | + NAME=$(node -p "require('./package.json').name") + VERSION=$(node -p "require('./package.json').version") + echo "name=$NAME" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "::notice::$NAME@$VERSION is already on npm — nothing to publish." + else + echo "publish=true" >> "$GITHUB_OUTPUT" + echo "::notice::Publishing $NAME@$VERSION" + fi + + - name: Publish to npm + if: steps.check.outputs.publish == 'true' + # --provenance records a signed, verifiable link from this tarball back + # to the exact commit and workflow that built it. It needs the + # id-token permission above and a public repo. + # + # NPM_TOKEN is a granular access token with publish rights. If you + # later configure npm Trusted Publishing for this package, you can + # delete the secret and the env block entirely — OIDC covers auth. + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Tag the release + if: steps.check.outputs.publish == 'true' + env: + TAG: v${{ steps.check.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "$TAG" + git push origin "$TAG" + + - name: Create the GitHub release + if: steps.check.outputs.publish == 'true' + env: + GH_TOKEN: ${{ github.token }} + TAG: v${{ steps.check.outputs.version }} + PKG: ${{ steps.check.outputs.name }} + VERSION: ${{ steps.check.outputs.version }} + run: | + # --generate-notes builds the changelog from merged PRs; --verify-tag + # makes this fail loudly if the tag push above didn't land, rather + # than silently creating a release pointing at nothing. + gh release create "$TAG" \ + --title "$PKG $TAG" \ + --verify-tag \ + --generate-notes diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d445eb3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```sh +npm test # node --test test/*.test.js +node --test test/unit.test.js # single file +node --test --test-name-pattern 'maskToken' # single test by name +node src/cli.js --help # run the local CLI +``` + +No build step, no lint config, no runtime dependencies — `src/` is what ships (`package.json` `files`). + +### Testing safely + +This tool writes to the global git config, gh's config, and the OS keychain. Never exercise write paths against real accounts. Redirect all four before experimenting: + +```sh +export GSWAP_HOME=/tmp/gswap-dev +export GH_CONFIG_DIR=/tmp/gswap-dev/gh +export GIT_CONFIG_GLOBAL=/tmp/gswap-dev/gitconfig +export HOME=/tmp/gswap-dev +``` + +`gswap use --dry-run` prints every write a switch would perform without doing any of them. + +`npm test` covers pure logic only (arg parsing, hosts.yml parsing, name validation, masking). Tests must not touch the filesystem, network, or credential stores — keep new tests to that standard. + +## Architecture + +A profile is the *triple* that decides who GitHub thinks you are. Every command exists to keep those three in sync: + +| Concern | Live location | Module | +| --- | --- | --- | +| gh CLI auth | `hosts.yml` in `ghConfigDir()` | `lib/ghconfig.js`, `lib/gh.js` | +| Commit identity | global `user.name` / `user.email` | `lib/git.js` | +| HTTPS push credential | OS keychain via git's credential helper | `lib/credentials.js` | + +`commands/use.js` applies all three in that order and is the reference for what a "switch" means. Any new command that changes identity must handle all three or explicitly say which it skips. + +Stored state lives under `gswapHome()` (`~/.gswap`, overridable via `GSWAP_HOME`): `state.json` holds the active profile name; `profiles//profile.json` holds host/login/git identity/token; `profiles//hosts.yml` is a verbatim copy of gh's file. + +### Layers + +- `src/cli.js` — hand-rolled arg parser (`parseArgs`, exported for tests) plus a command table. Value-taking flags are enumerated in `VALUE_FLAGS`; add there when introducing one. `--no-x` becomes `flags.x = false`. A value flag with nothing usable after it parses as `true`, which commands read as "prompt me securely" (this is why `--token` alone is the recommended form). +- `src/commands/*.js` — each exports `async (argv, flags) => exitCode` and owns its own `HELP` string, printed on `flags.help`. Errors are thrown; `cli.js` catches them, prints, and exits 1. Throwing `new Error('cancelled')` exits 130 quietly. +- `src/lib/*.js` — `ghinstall.js` (gh bootstrap), `update.js` (version check + install-method detection), `paths.js` (all path resolution, incl. gh's GH_CONFIG_DIR → XDG → platform-default order), `profiles.js` (profile/state CRUD), `util.js` (`run`, private-file writes, validation, masking), `ui.js` (colour, prompts, numbered picker, table), `github.js` (only ever `GET /user`, to verify a token and learn its login). + +### Invariants that must be preserved + +These are load-bearing, not style preferences (see CONTRIBUTING.md "Design rules"): + +1. **Zero runtime dependencies.** The tool handles tokens; a small tree is the point. Dev deps are fine. +2. **Never parse-and-rewrite `hosts.yml`.** `ghconfig.js`'s parser is read-only, for display. gh's file is copied byte-for-byte on save and restore so unknown keys and formatting survive. A lossy YAML round-trip is the one bug that could destroy someone's real gh auth. When a token exists but no file can be copied (keyring-backed gh, or `--token`), generate one with `lib/hosts.js` `buildHostsYml`. +3. **Never touch a keychain directly.** All credential access goes through `git credential fill/approve/reject`, delegating to whatever helper the user configured. `readCredential` must keep `GIT_TERMINAL_PROMPT=0` and the askpass overrides, or a cache miss hangs instead of failing. +4. **Switching only ever stores, never erases.** `git credential approve` overwrites the host's entry, so erasing first is unnecessary — and would let a routine switch destroy a keychain entry the user still needs. Deletion belongs solely to `gswap remove --logout`, behind a confirmation. +5. **Cross-platform by default.** Windows, macOS, Linux. No shelling out to platform-specific tools, no assumed POSIX paths, and **no arrow-key TUIs** — raw-mode cursor rendering breaks in cmd.exe, Git Bash and CI, so pickers are numbered (`ui.js` `select`). +6. **Read tokens via `gh auth token`, not by parsing `hosts.yml`.** gh can keep credentials in the OS keyring, in which case its `hosts.yml` contains no token at all and parsing reports a false "no token found". +7. **Never `shell: true`.** `util.run` spawns without a shell so profile names and tokens can't be interpreted as shell syntax. +8. **Files that may hold a token go through `util.writePrivate`**, which opens `wx` with mode `0600` and renames into place — the content is never briefly world-readable. + +`commands/add.js` browser sign-in is the single place that mutates live gh state as a side effect (`gh auth login` necessarily switches the active account). It snapshots the previous token/login/hosts.yml, warns the user first, and restores afterwards via `gh auth switch` or by writing the snapshot back. Keep that snapshot-and-restore intact on every early-return path. + +## Conventions + +- Token values must never be printed unmasked — use `util.maskToken`. +- `list` and `status` support `--json`; keep that shape stable, it's a scripting surface. +- `status` reads *live* system state rather than trusting `state.json`, so it can report drift (someone ran `gh auth logout`, edited `.gitconfig` by hand). Preserve that: don't shortcut it into reading the saved profile. +- `src/cli.js` reads its version from `package.json` at runtime — don't reintroduce a hardcoded constant, the update check compares against it. + +## gh bootstrap and self-update + +Both features are deliberately opt-in, and the constraints are the point: + +- **`install-gh` is the one sanctioned exception to "no platform-specific tools"** (`lib/ghinstall.js`). It stays sanctioned only while it (a) never runs from an npm lifecycle hook — a global install must not escalate privileges or hit the network on its own, and (b) only auto-runs installers needing no elevation and no shell. Anything requiring `sudo`/admin, or shipped as a `.cmd`/`.ps1` shim, is `runnable: false` and gets printed for the user to run. Every command is a fixed literal with no interpolation. +- **Updates notify; they do not self-install.** gswap holds tokens, so replacing its own code without the user asking is off the table. `GSWAP_AUTO_UPDATE=1` opts in. The check must stay invisible to scripts: `shouldCheck` gates on `--json`, `CI`, `NO_UPDATE_NOTIFIER`/`GSWAP_NO_UPDATE_CHECK` and TTY, the notice goes to **stderr**, and the fetch is capped at 2s with a 24h cache. A failed check is never an error the user hears about. +- `detectInstallMethod` matches `/cellar/` for Homebrew, **not** `/homebrew/` — npm globals under a brew-installed node live in `/opt/homebrew/lib/node_modules` and are still npm. There's a test pinning that distinction. +- `util.run` swallows spawn *throws* (not just ENOENT) and reports them as `missing`, because Node refuses to spawn `.bat`/`.cmd` without a shell. + +## Release process + +`main` auto-publishes. `.github/workflows/publish.yml` fires on every push to +`main` but gates on whether `package.json`'s version already exists on npm, so +only a version bump actually publishes — don't "fix" that gate by making the +publish unconditional, it exists to keep ordinary merges green. + +- Cutting a release = bump `package.json` `version`, merge to `main`. CI tests, + publishes with `--provenance`, tags `vX.Y.Z`, opens a GitHub release. +- `.github/workflows/ci.yml` runs the matrix on **ubuntu + macOS + windows**. + Keep all three: this project's real bugs have been platform-specific (path + resolution, credential-helper scope, spawning `.cmd` shims), and a + Linux-only matrix would have caught none of them. +- Auth is `secrets.NPM_TOKEN`, or npm Trusted Publishing (OIDC) if configured — + in which case drop the secret and the `NODE_AUTH_TOKEN` env block. +- The npm package is **`gswap`**; `ghswap` on npm belongs to an unrelated + author. `lib/update.js` derives the name from `package.json` for exactly this + reason — never hardcode it, or the updater points users at their package. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd5e28c..f99378f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,12 @@ -# Contributing to ghswap +# Contributing to gswap Contributions are welcome — bug reports, fixes, docs, and new features alike. ## Getting set up ```sh -git clone https://github.com/ImadRashid/ghswap.git -cd ghswap +git clone https://github.com/ImadRashid/gswap.git +cd gswap npm install # no runtime deps; this just sets up the workspace npm test ``` @@ -22,7 +22,7 @@ node src/cli.js --help To test it as an installed command without touching your real setup, point it at a throwaway directory: ```sh -GHSWAP_HOME=/tmp/ghswap-dev node src/cli.js list +GSWAP_HOME=/tmp/gswap-dev node src/cli.js list ``` ## Testing safely @@ -30,13 +30,13 @@ GHSWAP_HOME=/tmp/ghswap-dev node src/cli.js list **This tool writes to your git config, your gh config, and your OS keychain.** Please don't test destructive paths against your real accounts. Redirect all four of these when experimenting: ```sh -export GHSWAP_HOME=/tmp/ghswap-dev -export GH_CONFIG_DIR=/tmp/ghswap-dev/gh -export GIT_CONFIG_GLOBAL=/tmp/ghswap-dev/gitconfig -export HOME=/tmp/ghswap-dev # so credential helpers resolve here too +export GSWAP_HOME=/tmp/gswap-dev +export GH_CONFIG_DIR=/tmp/gswap-dev/gh +export GIT_CONFIG_GLOBAL=/tmp/gswap-dev/gitconfig +export HOME=/tmp/gswap-dev # so credential helpers resolve here too ``` -`ghswap use --dry-run` prints everything a switch would change without writing, which is usually enough to verify behaviour. +`gswap use --dry-run` prints everything a switch would change without writing, which is usually enough to verify behaviour. Unit tests (`npm test`) cover pure logic only — parsing, validation, masking. They never touch the filesystem or credential stores, and new tests should keep that property. @@ -47,7 +47,7 @@ A few constraints keep this tool safe and portable. Please preserve them: 1. **Zero runtime dependencies.** It handles tokens; a small dependency tree is a feature. Dev dependencies are fine. 2. **Never parse-and-rewrite `hosts.yml`.** gh's config is copied verbatim. The included parser is read-only, for display. A lossy YAML round-trip could corrupt someone's real gh auth. 3. **Never write to a keychain directly.** All credential access goes through `git credential`, so the user's configured helper does the work on every platform. -4. **Switching must not delete anything.** `ghswap use` only stores. Credential deletion belongs solely in `ghswap remove --logout`, behind a confirmation. +4. **Switching must not delete anything.** `gswap use` only stores. Credential deletion belongs solely in `gswap remove --logout`, behind a confirmation. 5. **Cross-platform by default.** Code must work on Windows, macOS and Linux. No shelling out to platform-specific tools, no assuming POSIX paths, no arrow-key TUIs (they break in cmd.exe and Git Bash). ## Pull requests @@ -58,11 +58,32 @@ A few constraints keep this tool safe and portable. Please preserve them: - Run `npm test` before opening the PR. - Describe what you changed and why. If it touches credential handling, say which platforms you tested on. +## Releasing + +Releases are automated. Merging to `main` runs `.github/workflows/publish.yml`, +which publishes to npm **only when `package.json` has a version that isn't on +npm yet** — ordinary merges are a no-op, so nothing turns red when you haven't +bumped anything. + +To cut a release: + +1. Bump `version` in `package.json` (that field is the single source of truth — + `gswap --version` and the update check both read it at runtime). +2. Merge to `main`. + +CI then runs the tests, publishes with [npm provenance](https://docs.npmjs.com/generating-provenance-statements), +pushes a `vX.Y.Z` tag and opens a GitHub release with generated notes. + +Publishing needs an `NPM_TOKEN` repository secret — a granular npm access token +with publish rights for `gswap`. If you configure npm Trusted Publishing for the +package instead, delete the secret and the `NODE_AUTH_TOKEN` env block; OIDC +handles auth on its own. + ## Reporting bugs -Open an issue with your OS, Node version (`node --version`), git version, and the output of `ghswap doctor`. +Open an issue with your OS, Node version (`node --version`), git version, and the output of `gswap doctor`. -**Never paste a token into an issue.** `ghswap status` masks tokens, but double-check any terminal output before posting it. +**Never paste a token into an issue.** `gswap status` masks tokens, but double-check any terminal output before posting it. ## Security issues diff --git a/README.md b/README.md index 260ac91..8df9547 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# ghswap +# gswap Switch between multiple GitHub accounts from one command line — on Windows, macOS and Linux. -If you have a work account and a personal account (or a client account, or a bot account), `ghswap` saves each one as a named profile and swaps all three things that actually decide *who GitHub thinks you are*: +If you have a work account and a personal account (or a client account, or a bot account), `gswap` saves each one as a named profile and swaps all three things that actually decide *who GitHub thinks you are*: | What | Where it lives | | --- | --- | @@ -10,30 +10,33 @@ If you have a work account and a personal account (or a client account, or a bot | Commit identity | global `user.name` / `user.email` | | HTTPS push credentials | your OS keychain, via git's credential helper | -Swapping only one of those is the usual source of "why did I just commit as the wrong person" — so `ghswap` does all three together. +Swapping only one of those is the usual source of "why did I just commit as the wrong person" — so `gswap` does all three together. ## Install ```sh -npm install -g ghswap +npm install -g gswap ``` -Requires Node 18+ and git. The `gh` CLI is optional — without it, `ghswap` still manages your git identity and HTTPS credentials. +Requires Node 18+ and git. The `gh` CLI is optional — without it, `gswap` still manages your git identity and HTTPS credentials. ## Quick start ```sh # 1. Capture the account you're already signed into -ghswap import work +gswap import work # 2. Add your other account (prompts for a token, hidden input) -ghswap add personal +gswap add personal # 3. Switch whenever you need to -ghswap use personal +gswap use personal ``` -Run `ghswap use` with no name for an interactive picker: +Step 1 needs the `gh` CLI, since it reads the login gh already holds. Without +gh, skip it and start at step 2 — or run `gswap install-gh` first. + +Run `gswap use` with no name for an interactive picker: ``` Switch to which account? @@ -47,10 +50,10 @@ Select a number (or q to cancel): You sign in through your browser, the same flow as `gh auth login` — there's no access token to create by hand. -- `ghswap import` reuses the credential you're **already** signed in with. Nothing to enter at all. -- `ghswap add` opens your browser to authorise the new account, then saves it. +- `gswap import` reuses the credential you're **already** signed in with. Nothing to enter at all. +- `gswap add` opens your browser to authorise the new account, then saves it. -Because signing in a second account necessarily changes which account `gh` is active as, `ghswap add` tells you before it starts and restores your previous login once the new credential is captured. +Because signing in a second account necessarily changes which account `gh` is active as, `gswap add` tells you before it starts and restores your previous login once the new credential is captured.
Using a personal access token instead @@ -58,7 +61,7 @@ Because signing in a second account necessarily changes which account `gh` is ac Browser sign-in needs the [`gh` CLI](https://cli.github.com). Without it — or for a bot / CI account — pass a token directly: ```sh -ghswap add ci --token ghp_xxxxxxxxxxxx +gswap add ci --token ghp_xxxxxxxxxxxx ``` Create one at **Settings → Developer settings → Personal access tokens**: @@ -66,74 +69,129 @@ Create one at **Settings → Developer settings → Personal access tokens**: - **Classic token:** tick `repo`, `read:org`, `gist`, and `workflow` for full functionality. - **Fine-grained token:** works for git push and most API calls; some `gh` subcommands still expect a classic token. -Omit the value (`ghswap add ci --token`) and you'll be prompted with hidden input, so the token stays out of your shell history. +Omit the value (`gswap add ci --token`) and you'll be prompted with hidden input, so the token stays out of your shell history.
-Either way, `ghswap` verifies the credential against the GitHub API before saving, so a bad token fails immediately rather than at your next push. Pass `--no-verify` to skip that when offline. +Either way, `gswap` verifies the credential against the GitHub API before saving, so a bad token fails immediately rather than at your next push. Pass `--no-verify` to skip that when offline. ## Commands | Command | What it does | | --- | --- | -| `ghswap import [name]` | Save the account you're currently logged into | -| `ghswap add [name]` | Save another account (prompts for a token) | -| `ghswap use [name]` | Switch to a profile; interactive if no name given | -| `ghswap list` | List saved profiles, marking the active one | -| `ghswap status` | Show what gh, git and your keychain actually report right now | -| `ghswap remove [name]` | Delete a profile | -| `ghswap doctor` | Check this machine's setup and suggest fixes | +| `gswap import [name]` | Save the account you're currently logged into | +| `gswap add [name]` | Save another account (prompts for a token) | +| `gswap use [name]` | Switch to a profile; interactive if no name given | +| `gswap list` | List saved profiles, marking the active one | +| `gswap status` | Show what gh, git and your keychain actually report right now | +| `gswap remove [name]` | Delete a profile | +| `gswap doctor` | Check this machine's setup and suggest fixes | +| `gswap install-gh` | Install the GitHub CLI, if you want browser sign-in | +| `gswap upgrade` | Update gswap to the latest published version | `list` and `status` accept `--json` for scripting. ### See before you switch ```sh -ghswap use personal --dry-run +gswap use personal --dry-run ``` Prints every file and credential entry it *would* touch and writes nothing. Good for the first run on a new machine. ### Checking for drift -`ghswap status` reads live state rather than trusting its own record, so it catches the case where something changed underneath it — someone ran `gh auth logout`, or edited `.gitconfig` by hand: +`gswap status` reads live state rather than trusting its own record, so it catches the case where something changed underneath it — someone ran `gh auth logout`, or edited `.gitconfig` by hand: ``` ! gh is authenticated as "alice" but the active profile is "alice-corp". - Re-apply with: ghswap use work + Re-apply with: gswap use work +``` + +## Installing the GitHub CLI + +`gh` is optional, but it's what makes browser sign-in and `gswap import` +possible. If you don't have it: + +```sh +gswap install-gh ``` +This picks the right package manager for your machine — winget on Windows, +Homebrew on macOS, apt/dnf/pacman and friends on Linux — and shows you the +exact command before running anything. + +It is a command you run, never something that happens during +`npm install`. Two reasons: installing software shouldn't be a side effect of +adding a dependency, and anything needing `sudo` or admin rights should be +your decision, not a package's. Where elevation *is* required, `gswap` prints +the command and steps back rather than invoking `sudo` on your behalf. + +## Staying up to date + +`gswap` checks npm for a newer version once a day and prints a one-line +notice when there is one: + +``` +gswap 0.1.0 -> 0.2.0 is available. Run gswap upgrade +``` + +```sh +gswap upgrade # update now +gswap upgrade --check # just tell me, don't install +``` + +`upgrade` works out how gswap was installed — npm, pnpm, bun, volta, +Homebrew — and uses the matching command, so it won't fight your package +manager. Your profiles and active selection survive an upgrade untouched. + +**It notifies; it doesn't silently self-update.** This tool holds your GitHub +tokens, and new code arriving unannounced would run against those credentials +before you'd had a chance to look at it. If you'd rather it just handle +itself, opt in: + +| Variable | Effect | +| --- | --- | +| `GSWAP_AUTO_UPDATE=1` | Run the upgrade automatically when one is found | +| `GSWAP_NO_UPDATE_CHECK=1` | Never check at all | +| `NO_UPDATE_NOTIFIER=1` | Same, following the common convention | + +The check is skipped automatically under `CI`, with `--json`, and whenever +output isn't a terminal — so it can't slow a script down or leak a banner into +piped output. Results are cached for 24 hours in `~/.gswap/update-check.json`. + ## Where things are stored -Profiles live in `~/.ghswap/profiles//`: +Profiles live in `~/.gswap/profiles//`: ``` -~/.ghswap/ +~/.gswap/ state.json # which profile is active + update-check.json # cached result of the daily version check profiles/ work/ profile.json # host, login, git identity, token hosts.yml # verbatim copy of gh's config ``` -Set `GHSWAP_HOME` to move that directory elsewhere. +Set `GSWAP_HOME` to move that directory elsewhere. ## Security notes Worth being straight about, since this tool holds tokens: -- **Tokens are stored on disk in plain text** inside `~/.ghswap`, the same way `gh` itself stores them in `hosts.yml`. Files are created with `0600` (owner read/write only) on macOS and Linux; on Windows they inherit your user profile's ACLs. Anyone with read access to your user account can read them — this is a convenience tool, not a secrets vault. -- **Switching never deletes credentials.** `ghswap use` only ever *stores*; it doesn't erase the entry it's replacing. A routine switch cannot destroy a keychain entry you still need. -- **Deletion is always explicit.** `ghswap remove` deletes ghswap's own copy of a token and leaves your OS keychain alone unless you pass `--logout`, which asks for confirmation first. +- **Tokens are stored on disk in plain text** inside `~/.gswap`, the same way `gh` itself stores them in `hosts.yml`. Files are created with `0600` (owner read/write only) on macOS and Linux; on Windows they inherit your user profile's ACLs. Anyone with read access to your user account can read them — this is a convenience tool, not a secrets vault. +- **Switching never deletes credentials.** `gswap use` only ever *stores*; it doesn't erase the entry it's replacing. A routine switch cannot destroy a keychain entry you still need. +- **Deletion is always explicit.** `gswap remove` deletes gswap's own copy of a token and leaves your OS keychain alone unless you pass `--logout`, which asks for confirmation first. - **Removing a profile does not revoke the token.** Revoke it at github.com/settings/tokens if it's no longer needed. -- **Don't commit `~/.ghswap`** to a dotfiles repo. +- **Don't commit `~/.gswap`** to a dotfiles repo. ## How it works No magic and no reimplemented credential stores: - **gh auth** — your `hosts.yml` is copied verbatim per profile and copied back on switch. Nothing is parsed and re-serialised, so unknown keys and formatting survive untouched. -- **HTTPS credentials** — driven through `git credential approve`, which delegates to whatever helper you already use: Credential Manager on Windows, Keychain on macOS, libsecret or `store` on Linux. `ghswap` never writes to a keychain directly. +- **HTTPS credentials** — driven through `git credential approve`, which delegates to whatever helper you already use: Credential Manager on Windows, Keychain on macOS, libsecret or `store` on Linux. `gswap` never writes to a keychain directly. - **git identity** — plain `git config --global`. Zero runtime dependencies. @@ -141,7 +199,7 @@ Zero runtime dependencies. ## Troubleshooting **`git push` still asks for a password** -No credential helper is configured. `ghswap doctor` will tell you, and the fix is one line: +No credential helper is configured. `gswap doctor` will tell you, and the fix is one line: ```sh # Windows @@ -152,11 +210,11 @@ git config --global credential.helper osxkeychain git config --global credential.helper store ``` -**`ghswap import` says it found no token** -`gh` can be configured to keep tokens in your OS keyring rather than `hosts.yml`. Use `ghswap add` with a personal access token instead. +**`gswap import` says it found no token** +It means `gh` isn't signed in to that host — run `gh auth login` and try again. (A keyring-backed `gh` is fine: `gswap` asks `gh auth token` for the credential rather than parsing `hosts.yml`, so tokens kept in your OS keyring are found normally.) **Pushes still use the wrong account over SSH** -`ghswap` manages HTTPS, not SSH keys. Either switch your remotes to HTTPS, or configure per-account SSH keys with `Host` aliases in `~/.ssh/config`. +`gswap` manages HTTPS, not SSH keys. Either switch your remotes to HTTPS, or configure per-account SSH keys with `Host` aliases in `~/.ssh/config`. ## Contributing diff --git a/package.json b/package.json index 8a48eb7..2f270fa 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "ghswap", - "version": "0.1.0", + "name": "gswap", + "version": "1.0.0", "description": "Switch between multiple GitHub accounts — gh CLI auth, git identity, and HTTPS credentials — with one command.", "type": "module", "bin": { - "ghswap": "./src/cli.js" + "gswap": "./src/cli.js" }, "engines": { "node": ">=18.0.0" @@ -27,11 +27,11 @@ "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/ImadRashid/ghswap.git" + "url": "git+https://github.com/ImadRashid/gswap.git" }, - "homepage": "https://github.com/ImadRashid/ghswap#readme", + "homepage": "https://github.com/ImadRashid/gswap#readme", "bugs": { - "url": "https://github.com/ImadRashid/ghswap/issues" + "url": "https://github.com/ImadRashid/gswap/issues" }, "author": "Imad Rashid", "scripts": { diff --git a/src/cli.js b/src/cli.js index 4801f0e..99020cb 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1,16 +1,32 @@ #!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import path from 'node:path'; import process from 'node:process'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { add } from './commands/add.js'; import { doctor } from './commands/doctor.js'; import { importCmd } from './commands/import.js'; +import { installGh } from './commands/installGh.js'; import { list } from './commands/list.js'; import { remove } from './commands/remove.js'; import { status } from './commands/status.js'; +import { upgrade } from './commands/upgrade.js'; import { use } from './commands/use.js'; +import { checkForUpdate, shouldCheck } from './lib/update.js'; import { bold, cyan, dim, error, info } from './lib/ui.js'; -const VERSION = '0.1.0'; +// Read from the manifest rather than duplicating it here, so --version, the +// update check and the published package can never disagree. +function readVersion() { + try { + const here = path.dirname(fileURLToPath(import.meta.url)); + return JSON.parse(readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version; + } catch { + return '0.0.0'; + } +} + +const VERSION = readVersion(); const COMMANDS = { add, @@ -23,6 +39,9 @@ const COMMANDS = { remove, rm: remove, doctor, + 'install-gh': installGh, + upgrade, + update: upgrade, }; // Flags that take a value; everything else is boolean. @@ -99,10 +118,10 @@ function camel(s) { function usage() { info(` -${bold('ghswap')} ${dim(`v${VERSION}`)} — switch between multiple GitHub accounts +${bold('gswap')} ${dim(`v${VERSION}`)} — switch between multiple GitHub accounts ${bold('Usage')} - ghswap [options] + gswap [options] ${bold('Commands')} ${cyan('import')} [name] Save the account you're already signed into @@ -112,6 +131,8 @@ ${bold('Commands')} ${cyan('status')} Show what gh, git and your keychain report now ${cyan('remove')} [name] Delete a profile ${cyan('doctor')} Check this machine's setup + ${cyan('install-gh')} Install the GitHub CLI (enables browser sign-in) + ${cyan('upgrade')} Update gswap to the latest version ${bold('Options')} -h, --help Show help (works per command too) @@ -121,9 +142,9 @@ ${bold('Options')} --token On ${cyan('add')}: use a token instead of browser sign-in ${bold('Getting started')} - ${dim('$')} ghswap import work ${dim('# save the account you already use')} - ${dim('$')} ghswap add personal ${dim('# sign in to a second one')} - ${dim('$')} ghswap use personal ${dim('# switch')} + ${dim('$')} gswap import work ${dim('# save the account you already use')} + ${dim('$')} gswap add personal ${dim('# sign in to a second one')} + ${dim('$')} gswap use personal ${dim('# switch')} `); } @@ -151,11 +172,43 @@ async function main() { const command = COMMANDS[commandName]; if (!command) { error(`Unknown command "${commandName}".`); - info(`Run ${bold('ghswap --help')} to see available commands.`); + info(`Run ${bold('gswap --help')} to see available commands.`); return 1; } - return (await command(argv, flags)) ?? 0; + const code = (await command(argv, flags, { currentVersion: VERSION })) ?? 0; + await maybeNotifyUpdate(flags, commandName); + return code; +} + +/** + * One-line "update available" notice, at most once a day. + * + * Written to stderr so it can never contaminate piped stdout, and skipped + * entirely for --json, CI and non-interactive use (see shouldCheck). We + * notify rather than self-update because gswap holds GitHub tokens: new code + * should not start running against those without the user saying so. + * GSWAP_AUTO_UPDATE=1 opts in to the other behaviour. + */ +async function maybeNotifyUpdate(flags, commandName) { + // `upgrade` runs its own forced check; don't do it twice. + if (commandName === 'upgrade' || commandName === 'update') return; + if (!shouldCheck(flags)) return; + + const update = await checkForUpdate(VERSION); + if (!update) return; + + if (process.env.GSWAP_AUTO_UPDATE) { + info(''); + info(dim(`Auto-updating gswap ${update.current} -> ${update.latest} (GSWAP_AUTO_UPDATE=1)`)); + await upgrade([], { yes: true }, { currentVersion: VERSION }); + return; + } + + process.stderr.write( + `\n${dim('gswap')} ${dim(update.current)} ${dim('->')} ${cyan(bold(update.latest))}` + + ` ${dim('is available. Run')} ${bold('gswap upgrade')}\n`, + ); } // Only run when invoked as the executable — importing this module (for @@ -175,7 +228,7 @@ if (invokedDirectly) { return; } error(err?.message || String(err)); - if (process.env.GHSWAP_DEBUG) console.error(err); + if (process.env.GSWAP_DEBUG) console.error(err); process.exitCode = 1; }); } diff --git a/src/commands/add.js b/src/commands/add.js index 234a2a4..55843b1 100644 --- a/src/commands/add.js +++ b/src/commands/add.js @@ -11,10 +11,11 @@ import { readIdentity } from '../lib/git.js'; import { verifyToken } from '../lib/github.js'; import { buildHostsYml } from '../lib/hosts.js'; import { getActive, profileExists, readProfile, readProfileHosts, writeProfile, writeProfileHosts } from '../lib/profiles.js'; -import { isValidProfileName, maskToken } from '../lib/util.js'; +import { pickInstaller, runInstaller } from '../lib/ghinstall.js'; +import { hasCommand, isValidProfileName, maskToken } from '../lib/util.js'; import { ask, askSecret, bold, confirm, dim, info, success, warn, yellow } from '../lib/ui.js'; -const HELP = `Usage: ghswap add [name] [options] +const HELP = `Usage: gswap add [name] [options] Save a GitHub account as a profile. @@ -22,7 +23,6 @@ By default this signs you in through your browser, the same way "gh auth login" does — no personal access token to create by hand. Options: - --login Sign in via browser (default when gh is installed) --token Use a personal access token instead of the browser --host GitHub host (default: github.com) --name git user.name for this profile @@ -32,7 +32,7 @@ Options: Browser sign-in temporarily changes which account gh is signed into, in order to capture the new credential. Your previously active profile is -restored afterwards, and ghswap tells you before it starts.`; +restored afterwards, and gswap tells you before it starts.`; export async function add(argv, flags) { if (flags.help) { @@ -67,20 +67,31 @@ export async function add(argv, flags) { // `--token` with no value parses as `true`, meaning "prompt me" — that is // the recommended form, since a token passed inline lands in shell history. const wantsToken = Boolean(flags.token); - const canBrowser = ghInstalled(); let token = typeof flags.token === 'string' ? flags.token : null; let capturedLogin = null; + // How the credential was *actually* obtained, which is a different question + // from which flag was passed: the no-gh fallback lands on the token path + // without --token ever being given. + let tokenSource = wantsToken ? 'token' : 'browser'; if (wantsToken && !token) { token = await promptForToken(host); } else if (!wantsToken) { + let canBrowser = ghInstalled(); + if (!canBrowser) { warn('gh is not installed, so browser sign-in is unavailable.'); - info(dim(' Install it from https://cli.github.com for the easiest flow,')); - info(dim(' or paste a personal access token below.')); info(''); - token = await promptForToken(host); - } else { + canBrowser = await offerGhInstall(flags); + if (!canBrowser) { + info(dim(' Falling back to a personal access token.')); + info(''); + token = await promptForToken(host); + tokenSource = 'token'; + } + } + + if (canBrowser) { const captured = await captureViaBrowser(host, flags); if (!captured) return 1; token = captured.token; @@ -132,7 +143,7 @@ export async function add(argv, flags) { git: { name: gitName || null, email: gitEmail || null }, token, createdAt: new Date().toISOString(), - addedVia: wantsToken ? 'token' : 'browser', + addedVia: tokenSource, }); writeProfileHosts(name, buildHostsYml(host, login, token)); @@ -144,10 +155,43 @@ export async function add(argv, flags) { if (gitEmail) info(` email ${gitEmail}`); info(` token ${maskToken(token)}`); info(''); - info(`Switch to it with: ${bold(`ghswap use ${name}`)}`); + info(`Switch to it with: ${bold(`gswap use ${name}`)}`); return 0; } +/** + * Offer to install gh in the middle of `add`, since that is exactly where the + * absence is felt. Returns true only once gh is genuinely on PATH, so the + * caller can fall straight through to the token prompt otherwise. + */ +async function offerGhInstall(flags) { + if (flags.dryRun) return false; + + const installer = pickInstaller(); + if (!installer || !installer.runnable) { + info(dim(' Run "gswap install-gh" to set it up for next time.')); + return false; + } + + const go = await confirm(`Install gh now with ${installer.manager}?`, { default: true }); + if (!go) return false; + + info(''); + const res = runInstaller(installer); + if (!res.ok) { + warn('gh could not be installed; continuing without it.'); + return false; + } + if (!hasCommand('gh')) { + warn('gh was installed but is not on PATH yet.'); + info(dim(' Open a new terminal to pick it up.')); + return false; + } + + success('Installed gh.'); + return true; +} + async function promptForToken(host) { const settingsHost = host === 'github.com' ? 'github.com' : host; info(bold('Create a personal access token')); @@ -161,7 +205,7 @@ async function promptForToken(host) { * Sign in through the browser and capture the resulting credential. * * `gh auth login` necessarily changes gh's active account, so this is the one - * place ghswap mutates live gh state as a side effect. We say so up front, + * place gswap mutates live gh state as a side effect. We say so up front, * snapshot what was there, and put it back afterwards. */ async function captureViaBrowser(host, flags) { @@ -175,7 +219,7 @@ async function captureViaBrowser(host, flags) { info(` gh will open ${dim(`https://${host}`)} for you to authorise the new account.`); if (previousLogin) { info(` You are currently signed in as ${bold(previousLogin)}.`); - info(` ghswap will restore that login when the new one is captured.`); + info(` gswap will restore that login when the new one is captured.`); } if (flags.dryRun) { @@ -251,6 +295,6 @@ function restorePrevious({ host, previousLogin, previousHosts, activeProfile }) warn('Could not automatically restore your previous gh login.'); if (activeProfile) { - info(dim(` Run "ghswap use ${activeProfile}" to restore it.`)); + info(dim(` Run "gswap use ${activeProfile}" to restore it.`)); } } diff --git a/src/commands/doctor.js b/src/commands/doctor.js index 1baccc2..d5aee84 100644 --- a/src/commands/doctor.js +++ b/src/commands/doctor.js @@ -1,13 +1,13 @@ import process from 'node:process'; import fs from 'node:fs'; import { credentialHelpers } from '../lib/git.js'; -import { ghConfigDir, ghswapHome, isWindows } from '../lib/paths.js'; +import { ghConfigDir, gswapHome, isWindows } from '../lib/paths.js'; import { exists, hasCommand } from '../lib/util.js'; import { bold, dim, green, info, red, yellow } from '../lib/ui.js'; -const HELP = `Usage: ghswap doctor +const HELP = `Usage: gswap doctor -Check that this machine has what ghswap needs, and suggest fixes.`; +Check that this machine has what gswap needs, and suggest fixes.`; export async function doctor(argv, flags) { if (flags.help) { @@ -28,7 +28,7 @@ export async function doctor(argv, flags) { label: 'gh CLI installed', ok: gh, warnOnly: true, - fix: 'Optional. Install from https://cli.github.com to manage gh auth too.', + fix: 'Optional. Run "gswap install-gh" to enable browser sign-in and import.', }); const helpers = credentialHelpers(); @@ -46,8 +46,8 @@ export async function doctor(argv, flags) { }); checks.push({ - label: `ghswap home (${ghswapHome()})`, - ok: canWrite(ghswapHome()), + label: `gswap home (${gswapHome()})`, + ok: canWrite(gswapHome()), fix: 'Check permissions on your home directory.', }); @@ -107,7 +107,7 @@ async function canReachGitHub() { const timer = setTimeout(() => controller.abort(), 5000); const res = await fetch('https://api.github.com/zen', { signal: controller.signal, - headers: { 'User-Agent': 'ghswap' }, + headers: { 'User-Agent': 'gswap' }, }); clearTimeout(timer); return res.ok; diff --git a/src/commands/import.js b/src/commands/import.js index daeb3ee..d7df075 100644 --- a/src/commands/import.js +++ b/src/commands/import.js @@ -7,7 +7,7 @@ import { profileExists, writeProfile, writeProfileHosts } from '../lib/profiles. import { isValidProfileName } from '../lib/util.js'; import { ask, bold, confirm, dim, info, success, warn } from '../lib/ui.js'; -const HELP = `Usage: ghswap import [name] [--host ] +const HELP = `Usage: gswap import [name] [--host ] Capture the account you are ALREADY signed into as a profile, reading your existing gh CLI login and git identity. No token to create — it reuses the @@ -26,7 +26,7 @@ export async function importCmd(argv, flags) { if (!ghInstalled()) { throw new Error( 'gh is not installed, so there is no existing login to import.\n' + - 'Install it from https://cli.github.com, or run "ghswap add " to save an account manually.', + 'Install it with "gswap install-gh", or run "gswap add " to save an account manually.', ); } @@ -106,6 +106,6 @@ export async function importCmd(argv, flags) { if (identity.name) info(` name ${identity.name}`); if (identity.email) info(` email ${identity.email}`); info(''); - info(`Now add your other account with: ${bold('ghswap add --login')}`); + info(`Now add your other account with: ${bold('gswap add ')}`); return 0; } diff --git a/src/commands/installGh.js b/src/commands/installGh.js new file mode 100644 index 0000000..e3b4261 --- /dev/null +++ b/src/commands/installGh.js @@ -0,0 +1,108 @@ +import { + expectedManager, + formatCommand, + manualInstallUrl, + pickInstaller, + runInstaller, +} from '../lib/ghinstall.js'; +import { ghVersion } from '../lib/gh.js'; +import { hasCommand } from '../lib/util.js'; +import { bold, confirm, dim, info, success, warn } from '../lib/ui.js'; + +const HELP = `Usage: gswap install-gh [--yes] [--dry-run] + +Install the GitHub CLI (gh), which gswap uses for browser sign-in and for +importing an existing login. + +gh is optional: without it gswap still manages your git identity and HTTPS +credentials, but you have to create personal access tokens by hand. + +This is never run automatically. Installing another program is an explicit +act, so it is an explicit command. + +Options: + --yes Skip the confirmation prompt + --dry-run Print the command that would run, without running it`; + +export async function installGh(argv, flags) { + if (flags.help) { + info(HELP); + return 0; + } + + if (hasCommand('gh')) { + const v = ghVersion(); + success(`gh is already installed${v ? ` ${dim(`(v${v})`)}` : ''}`); + info(dim(' Nothing to do. Run "gswap import " to capture your current login.')); + return 0; + } + + const installer = pickInstaller(); + + if (!installer) { + warn(`No supported package manager was found (looked for ${expectedManager()}).`); + info(''); + info(`Install gh manually from ${bold(manualInstallUrl())}`); + info(dim(' Then run "gswap install-gh" again to confirm it worked.')); + return 1; + } + + const command = formatCommand(installer); + + // Anything needing elevation, or shipped as a shim we would have to spawn + // through a shell, is printed rather than run. Handing the user an exact + // command is honest; silently invoking sudo on their behalf is not. + if (!installer.runnable) { + info(''); + info(`Install gh with ${bold(installer.manager)}:`); + info(''); + info(` ${bold(command)}`); + info(''); + info(dim(' gswap does not run this for you because it needs elevated')); + info(dim(' privileges. Run it yourself, then re-run "gswap install-gh".')); + return 1; + } + + if (flags.dryRun) { + info(''); + info(dim(`Dry run — would run: ${command}`)); + return 0; + } + + info(''); + info(`gh can be installed with ${bold(installer.manager)}:`); + info(` ${dim(command)}`); + info(''); + + if (!flags.yes) { + const go = await confirm('Run that now?', { default: true }); + if (!go) { + info('Cancelled.'); + info(dim(` Run it yourself any time: ${command}`)); + return 1; + } + } + + info(''); + const res = runInstaller(installer); + if (res.missing) { + throw new Error(`${installer.manager} disappeared from PATH; nothing was installed.`); + } + if (!res.ok) { + // The installer printed its own diagnostics; don't paper over them. + throw new Error(`${installer.manager} exited with code ${res.code}; gh was not installed.`); + } + + info(''); + if (!hasCommand('gh')) { + warn('The installer finished, but gh is still not on PATH.'); + info(dim(' Open a new terminal so PATH is re-read, then run "gswap doctor".')); + return 1; + } + + const v = ghVersion(); + success(`Installed gh${v ? ` ${dim(`v${v}`)}` : ''}`); + info(''); + info(`Now capture your current login with: ${bold('gswap import ')}`); + return 0; +} diff --git a/src/commands/list.js b/src/commands/list.js index 9b01981..c33d74f 100644 --- a/src/commands/list.js +++ b/src/commands/list.js @@ -1,7 +1,7 @@ import { getActive, listProfiles, readProfile } from '../lib/profiles.js'; import { bold, dim, green, info, table, warn } from '../lib/ui.js'; -const HELP = `Usage: ghswap list [--json] +const HELP = `Usage: gswap list [--json] List saved profiles. The active one is marked with *.`; @@ -32,7 +32,7 @@ export async function list(argv, flags) { if (!profiles.length) { warn('No profiles saved yet.'); - info(`Add one with: ${bold('ghswap add work')}`); + info(`Add one with: ${bold('gswap add work')}`); return 0; } diff --git a/src/commands/remove.js b/src/commands/remove.js index c8f0f72..9121f52 100644 --- a/src/commands/remove.js +++ b/src/commands/remove.js @@ -2,9 +2,9 @@ import { eraseCredential } from '../lib/credentials.js'; import { clearActive, deleteProfile, getActive, listProfiles, readProfile } from '../lib/profiles.js'; import { bold, confirm, dim, info, select, success, warn } from '../lib/ui.js'; -const HELP = `Usage: ghswap remove [name] [--force] [--logout] +const HELP = `Usage: gswap remove [name] [--force] [--logout] -Delete a saved profile. This removes ghswap's copy of the token from disk. +Delete a saved profile. This removes gswap's copy of the token from disk. By default your OS credential store is left untouched, so removing a profile cannot break a login you still rely on. Pass --logout to also erase the @@ -54,7 +54,7 @@ export async function remove(argv, flags) { const host = profile.host || 'github.com'; // Erasing touches the real OS keychain, so it only happens when explicitly - // requested — never as a side effect of deleting ghswap's own copy. + // requested — never as a side effect of deleting gswap's own copy. let erased = false; if (flags.logout) { if (!flags.force) { @@ -76,7 +76,7 @@ export async function remove(argv, flags) { info(` ${dim(`Erased the stored HTTPS credential for ${host}.`)}`); } else if (wasActive) { warn('That was the active profile, but your keychain was left as-is.'); - info(dim(' Switch to another profile with "ghswap use " to replace it.')); + info(dim(' Switch to another profile with "gswap use " to replace it.')); } info(dim('Revoke the token at https://github.com/settings/tokens if it is no longer needed.')); return 0; diff --git a/src/commands/status.js b/src/commands/status.js index 09e6853..1367907 100644 --- a/src/commands/status.js +++ b/src/commands/status.js @@ -6,9 +6,9 @@ import { ghConfigDir } from '../lib/paths.js'; import { hasCommand, maskToken } from '../lib/util.js'; import { bold, dim, green, info, warn, yellow } from '../lib/ui.js'; -const HELP = `Usage: ghswap status [--json] +const HELP = `Usage: gswap status [--json] -Show the live state of the machine: which profile ghswap last applied, and +Show the live state of the machine: which profile gswap last applied, and what gh, git and your credential helper actually report right now.`; export async function status(argv, flags) { @@ -97,7 +97,7 @@ export async function status(argv, flags) { if (warnings.length) { info(''); for (const w of warnings) warn(w); - info(dim(` Re-apply with: ghswap use ${active}`)); + info(dim(` Re-apply with: gswap use ${active}`)); } info(''); diff --git a/src/commands/upgrade.js b/src/commands/upgrade.js new file mode 100644 index 0000000..bd6b1f1 --- /dev/null +++ b/src/commands/upgrade.js @@ -0,0 +1,116 @@ +import { spawnSync } from 'node:child_process'; +import process from 'node:process'; +import { + checkForUpdate, + clearCache, + detectInstallMethod, + isSourceCheckout, + latestVersion, +} from '../lib/update.js'; +import { bold, confirm, cyan, dim, info, success, warn } from '../lib/ui.js'; + +const HELP = `Usage: gswap upgrade [--check] [--yes] + +Update gswap to the latest published version. + +gswap checks for updates once a day and prints a one-line notice when one is +available. It does not update itself silently: this tool holds your GitHub +tokens, so new code runs against your credentials only when you say so. + +Set GSWAP_AUTO_UPDATE=1 to have that notice run the upgrade for you instead. +Set GSWAP_NO_UPDATE_CHECK=1 to turn the check off entirely. + +Options: + --check Report whether an update exists, without installing it + --yes Skip the confirmation prompt`; + +export async function upgrade(argv, flags, { currentVersion } = {}) { + if (flags.help) { + info(HELP); + return 0; + } + + const current = currentVersion || '0.0.0'; + + // An explicit upgrade should never be answered from a day-old cache. + info(dim('Checking for updates...')); + const latest = await latestVersion({ force: true }); + + if (!latest) { + // A 404 and a dead network are indistinguishable by the time we get here, + // so say what we actually know rather than blaming the connection. + warn('Could not determine the latest published version of gswap.'); + info(dim(' The registry may be unreachable, or gswap may not be published yet.')); + return 1; + } + + const update = await checkForUpdate(current); + if (!update) { + success(`gswap ${bold(current)} is up to date ${dim(`(latest: ${latest})`)}`); + return 0; + } + + info(''); + info(`Update available: ${dim(update.current)} -> ${cyan(bold(update.latest))}`); + + if (flags.check) { + info(''); + info(`Install it with: ${bold('gswap upgrade')}`); + return 0; + } + + // Running from a checkout means `npm install -g` would shadow the source + // being worked on. That is never what a contributor wants. + if (isSourceCheckout()) { + info(''); + warn('gswap is running from a git checkout, not an installed copy.'); + info(dim(' Update it with "git pull" instead.')); + return 1; + } + + const method = detectInstallMethod(process.argv[1]); + const command = method.command.join(' '); + + info(''); + info(`Detected install method: ${bold(method.manager)}`); + info(` ${dim(command)}`); + + if (!method.runnable) { + info(''); + info(dim(' gswap cannot run this for you on this platform, because npm')); + info(dim(' ships as a .cmd shim that needs a shell. Run it yourself:')); + info(''); + info(` ${bold(command)}`); + return 1; + } + + if (!flags.yes) { + info(''); + const go = await confirm('Run that now?', { default: true }); + if (!go) { + info('Cancelled.'); + info(dim(` Run it yourself any time: ${command}`)); + return 1; + } + } + + info(''); + const [cmd, ...args] = method.command; + const res = spawnSync(cmd, args, { stdio: 'inherit', shell: false }); + + if (res.error?.code === 'ENOENT') { + throw new Error(`${method.manager} is not on PATH; run "${command}" yourself.`); + } + if (res.status !== 0) { + throw new Error(`${method.manager} exited with code ${res.status}; gswap was not updated.`); + } + + // The cached "latest" is now stale in the other direction — drop it so the + // next run doesn't re-announce an update we just applied. + clearCache(); + + info(''); + success(`Updated gswap to ${bold(update.latest)}`); + info(dim(' Your profiles and active selection are untouched.')); + return 0; +} diff --git a/src/commands/use.js b/src/commands/use.js index 82c092a..40d83e7 100644 --- a/src/commands/use.js +++ b/src/commands/use.js @@ -12,7 +12,7 @@ import { } from '../lib/profiles.js'; import { bold, dim, info, select, success, warn, yellow } from '../lib/ui.js'; -const HELP = `Usage: ghswap use [name] [--dry-run] +const HELP = `Usage: gswap use [name] [--dry-run] Switch to a saved profile. With no name, shows an interactive picker. @@ -26,7 +26,7 @@ Options: Switching never deletes credentials: storing a credential overwrites the entry for that host, and profiles you are not switching to are left alone. -Use "ghswap remove" if you actually want a credential erased.`; +Use "gswap remove" if you actually want a credential erased.`; export async function use(argv, flags) { if (flags.help) { @@ -38,7 +38,7 @@ export async function use(argv, flags) { const profiles = listProfiles(); if (!profiles.length) { warn('No profiles saved yet.'); - info(`Add one with: ${bold('ghswap add work')}`); + info(`Add one with: ${bold('gswap add work')}`); return 1; } @@ -66,7 +66,7 @@ export async function use(argv, flags) { const profile = readProfile(name); if (!profile) { - throw new Error(`No profile named "${name}". Run "ghswap list" to see saved profiles.`); + throw new Error(`No profile named "${name}". Run "gswap list" to see saved profiles.`); } const host = profile.host || 'github.com'; @@ -109,7 +109,7 @@ export async function use(argv, flags) { // entry for this host, so there is no need to erase first — and erasing // would mean a routine switch could destroy a keychain entry (osxkeychain // on macOS, Credential Manager on Windows) that the user still needs. - // Deletion is reserved for `ghswap remove`, which asks first. + // Deletion is reserved for `gswap remove`, which asks first. if (profile.token) { const helpers = credentialHelpers(); if (!helpers.length) { diff --git a/src/lib/ghinstall.js b/src/lib/ghinstall.js new file mode 100644 index 0000000..ca4bff2 --- /dev/null +++ b/src/lib/ghinstall.js @@ -0,0 +1,119 @@ +import { spawnSync } from 'node:child_process'; +import process from 'node:process'; +import { hasCommand } from './util.js'; +import { isMac, isWindows } from './paths.js'; + +/** + * Bootstrapping the gh CLI. + * + * This is the one place gswap knowingly reaches for platform-specific + * tooling (design rule 5 keeps the *core* switch portable; installing another + * program cannot be). Two constraints keep it honest: + * + * 1. It only ever runs from an explicit `gswap install-gh`, never from a + * postinstall hook. A global npm install must not escalate privileges or + * reach the network on its own. + * 2. We only auto-run installers that need no elevation and are real + * executables. Anything requiring sudo/admin, or shipped as a .cmd/.ps1 + * shim we would have to spawn through a shell, is printed for the user + * to run themselves. + * + * Every command below is a fixed literal — no user input is interpolated, so + * there is nothing here that could be turned into shell syntax. + */ + +const MANUAL_URL = 'https://github.com/cli/cli#installation'; + +/** + * Candidate installers for this platform, best first. `runnable: false` means + * we print the command rather than executing it. + */ +function candidates(platform = process.platform) { + if (platform === 'win32') { + return [ + // winget ships with Windows 10 1809+ as a real .exe and needs no admin + // for a user-scope install. + { + manager: 'winget', + probe: 'winget', + runnable: true, + command: [ + 'winget', + 'install', + '--id', + 'GitHub.cli', + '-e', + '--source', + 'winget', + '--accept-source-agreements', + '--accept-package-agreements', + ], + }, + // scoop is a PowerShell function / .cmd shim; spawning it without a + // shell fails, and we will not enable one. Print it instead. + { manager: 'scoop', probe: 'scoop', runnable: false, command: ['scoop', 'install', 'gh'] }, + { manager: 'choco', probe: 'choco', runnable: false, command: ['choco', 'install', 'gh', '-y'] }, + ]; + } + + if (platform === 'darwin') { + return [ + { manager: 'brew', probe: 'brew', runnable: true, command: ['brew', 'install', 'gh'] }, + { manager: 'port', probe: 'port', runnable: false, command: ['sudo', 'port', 'install', 'gh'] }, + ]; + } + + return [ + // Linuxbrew, when present, is the only one here that needs no root. + { manager: 'brew', probe: 'brew', runnable: true, command: ['brew', 'install', 'gh'] }, + { manager: 'apt', probe: 'apt-get', runnable: false, command: ['sudo', 'apt', 'install', 'gh'] }, + { manager: 'dnf', probe: 'dnf', runnable: false, command: ['sudo', 'dnf', 'install', 'gh'] }, + { manager: 'pacman', probe: 'pacman', runnable: false, command: ['sudo', 'pacman', '-S', 'github-cli'] }, + { manager: 'zypper', probe: 'zypper', runnable: false, command: ['sudo', 'zypper', 'install', 'gh'] }, + { manager: 'apk', probe: 'apk', runnable: false, command: ['sudo', 'apk', 'add', 'github-cli'] }, + { manager: 'snap', probe: 'snap', runnable: false, command: ['sudo', 'snap', 'install', 'gh'] }, + ]; +} + +/** + * Pick the installer to use. Exported with an injectable probe so the + * selection logic is testable without touching the real PATH. + */ +export function pickInstaller(platform = process.platform, probe = hasCommand) { + for (const c of candidates(platform)) { + if (probe(c.probe)) return c; + } + return null; +} + +/** The command string we show the user, ready to paste. */ +export function formatCommand(installer) { + return installer.command.join(' '); +} + +export function manualInstallUrl() { + return MANUAL_URL; +} + +/** Human name for the platform's expected package manager, for empty-case help. */ +export function expectedManager(platform = process.platform) { + if (platform === 'win32') return 'winget'; + if (platform === 'darwin') return 'Homebrew'; + return 'your distribution package manager'; +} + +/** + * Run an installer with inherited stdio so the user sees its real progress and + * can answer any prompts it raises. Never invoked for `runnable: false`. + */ +export function runInstaller(installer) { + if (!installer.runnable) { + throw new Error(`${installer.manager} must be run manually: ${formatCommand(installer)}`); + } + const [cmd, ...args] = installer.command; + const res = spawnSync(cmd, args, { stdio: 'inherit', shell: false }); + if (res.error?.code === 'ENOENT') return { ok: false, missing: true }; + return { ok: res.status === 0, code: res.status }; +} + +export { isMac, isWindows }; diff --git a/src/lib/git.js b/src/lib/git.js index 253e275..1fc358a 100644 --- a/src/lib/git.js +++ b/src/lib/git.js @@ -33,9 +33,18 @@ export function applyIdentity(identity) { /** * Which credential helper git will use for HTTPS. Multiple helpers can be * configured; git tries them in order, so we return the whole list. + * + * Deliberately *merged* scope — no --global. The question here is "what will + * git actually do at push time", and helpers are very often configured at + * system scope: Git for Windows' installer writes credential.helper=manager + * into C:\Program Files\Git\etc\gitconfig, and Xcode's bundled gitconfig sets + * osxkeychain the same way. Reading only --global misses those, so `use` + * decides there is no helper and silently skips storing the credential — + * leaving the user pushing as the previous account. Writing identity stays + * --global; only this read is merged. */ export function credentialHelpers() { - const res = run('git', ['config', '--global', '--get-all', 'credential.helper']); + const res = run('git', ['config', '--get-all', 'credential.helper']); if (!res.ok || !res.stdout) return []; return res.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); } diff --git a/src/lib/github.js b/src/lib/github.js index 095778e..b251b5b 100644 --- a/src/lib/github.js +++ b/src/lib/github.js @@ -18,7 +18,7 @@ export async function verifyToken(token, host = 'github.com', { timeoutMs = 1000 headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', - 'User-Agent': 'ghswap', + 'User-Agent': 'gswap', }, signal: controller.signal, }); diff --git a/src/lib/paths.js b/src/lib/paths.js index cec2714..147be6d 100644 --- a/src/lib/paths.js +++ b/src/lib/paths.js @@ -6,16 +6,16 @@ export const isWindows = process.platform === 'win32'; export const isMac = process.platform === 'darwin'; /** - * Root ghswap data directory. Overridable via GHSWAP_HOME for testing and + * Root gswap data directory. Overridable via GSWAP_HOME for testing and * for users who keep dotfiles in a non-standard location. */ -export function ghswapHome() { - if (process.env.GHSWAP_HOME) return path.resolve(process.env.GHSWAP_HOME); - return path.join(os.homedir(), '.ghswap'); +export function gswapHome() { + if (process.env.GSWAP_HOME) return path.resolve(process.env.GSWAP_HOME); + return path.join(os.homedir(), '.gswap'); } export function profilesDir() { - return path.join(ghswapHome(), 'profiles'); + return path.join(gswapHome(), 'profiles'); } export function profileDir(name) { @@ -23,7 +23,7 @@ export function profileDir(name) { } export function stateFile() { - return path.join(ghswapHome(), 'state.json'); + return path.join(gswapHome(), 'state.json'); } /** diff --git a/src/lib/ui.js b/src/lib/ui.js index 74cf11a..9023fcb 100644 --- a/src/lib/ui.js +++ b/src/lib/ui.js @@ -54,7 +54,7 @@ export async function ask(question, { default: def } = {}) { /** Read a secret without echoing it to the terminal. */ export async function askSecret(question) { if (!process.stdin.isTTY) { - // Piped input: read one line so `echo TOKEN | ghswap add ...` works in CI. + // Piped input: read one line so `echo TOKEN | gswap add ...` works in CI. const rl = createInterface(); try { return (await rl.question('')).trim(); diff --git a/src/lib/update.js b/src/lib/update.js new file mode 100644 index 0000000..58ea191 --- /dev/null +++ b/src/lib/update.js @@ -0,0 +1,205 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { gswapHome } from './paths.js'; +import { exists, readJson, writeJson } from './util.js'; + +/** + * Update checking. + * + * Deliberately a *notifier*, not a silent self-updater. gswap holds GitHub + * tokens; quietly replacing its own code on a user's machine without them + * asking is not a trade we should make for them. `gswap upgrade` performs the + * update, and GSWAP_AUTO_UPDATE=1 opts in to running it automatically. + * + * The check must never be the reason a command feels slow or fails, so it is + * cached for a day, hard-capped at a couple of seconds, and skipped entirely + * for scripted use. + */ + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 2000; + +/** + * The published package name, read from our own manifest. + * + * Never hardcode this. The update check and the upgrade command both resolve + * a package by name, so a name that drifts from what is actually published + * means telling users to install *somebody else's* package — the npm name + * "ghswap" is already owned by an unrelated author. Deriving it from + * package.json makes that class of mistake impossible. + */ +export function packageName() { + try { + const here = path.dirname(fileURLToPath(import.meta.url)); + const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', '..', 'package.json'), 'utf8')); + return typeof pkg?.name === 'string' && pkg.name ? pkg.name : null; + } catch { + return null; + } +} + +function registryUrl(name) { + // Scoped names are addressed as @scope%2Fname; the @ itself stays literal. + return `https://registry.npmjs.org/${name.replace('/', '%2F')}/latest`; +} + +function cacheFile() { + return path.join(gswapHome(), 'update-check.json'); +} + +/** Parse a semver-ish string. Returns null for anything we can't compare. */ +function parseVersion(v) { + const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(v || '').trim()); + if (!m) return null; + return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null }; +} + +/** -1 / 0 / 1, with a prerelease sorting below its own release. */ +export function compareVersions(a, b) { + const pa = parseVersion(a); + const pb = parseVersion(b); + if (!pa || !pb) return 0; + for (let i = 0; i < 3; i++) { + if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] > pb.nums[i] ? 1 : -1; + } + if (pa.pre && !pb.pre) return -1; + if (!pa.pre && pb.pre) return 1; + if (pa.pre && pb.pre) return pa.pre === pb.pre ? 0 : pa.pre > pb.pre ? 1 : -1; + return 0; +} + +export function isNewer(candidate, current) { + return compareVersions(candidate, current) > 0; +} + +/** + * Where this copy of gswap was installed from, which decides the upgrade + * command. Path-based because there is no reliable API for it. + * + * The subtle case: a Homebrew-installed *node* puts npm globals under + * /opt/homebrew/lib/node_modules, which is still an npm install. Only a real + * brew formula lives under a Cellar directory, so that is what we match. + */ +export function detectInstallMethod(modulePath, platform = process.platform, name = packageName() || 'gswap') { + const raw = modulePath || fileURLToPath(import.meta.url); + const p = raw.replace(/\\/g, '/').toLowerCase(); + const spec = `${name}@latest`; + + if (p.includes('/cellar/')) { + return { manager: 'brew', command: ['brew', 'upgrade', name], runnable: true }; + } + if (p.includes('/.volta/')) { + return { manager: 'volta', command: ['volta', 'install', spec], runnable: true }; + } + if (p.includes('/.bun/')) { + return { manager: 'bun', command: ['bun', 'add', '-g', spec], runnable: true }; + } + if (p.includes('/pnpm/')) { + return { manager: 'pnpm', command: ['pnpm', 'add', '-g', spec], runnable: true }; + } + if (p.includes('/.yarn/') || p.includes('/yarn/global/')) { + return { manager: 'yarn', command: ['yarn', 'global', 'upgrade', name], runnable: true }; + } + // npm's own shim is npm.cmd on Windows, which we cannot spawn without a + // shell — so there we print the command instead of running it. + return { + manager: 'npm', + command: ['npm', 'install', '-g', spec], + runnable: platform !== 'win32', + }; +} + +/** True when gswap is running from a git checkout rather than an install. */ +export function isSourceCheckout(packageRoot) { + const root = packageRoot || path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + return exists(path.join(root, '.git')); +} + +/** + * Whether to run a background check at all. Scripted and non-interactive use + * gets no network call and no notice — a version banner in piped output is a + * bug, and CI should never wait on the npm registry. + */ +export function shouldCheck(flags = {}, env = process.env, stream = process.stderr) { + if (env.GSWAP_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER) return false; + if (env.CI) return false; + if (flags.json) return false; + return Boolean(stream.isTTY); +} + +function readCache() { + const cached = readJson(cacheFile()); + if (!cached || typeof cached.latest !== 'string' || typeof cached.checkedAt !== 'number') { + return null; + } + return cached; +} + +function writeCache(latest) { + try { + writeJson(cacheFile(), { latest, checkedAt: Date.now() }); + } catch { + // A cache we cannot persist just means we check again next time. + } +} + +/** Ask the npm registry for the published version, or null on any failure. */ +async function fetchLatest() { + const name = packageName(); + if (!name) return null; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(registryUrl(name), { + // The abbreviated metadata document is a fraction of the full one. + headers: { Accept: 'application/vnd.npm.install-v1+json', 'User-Agent': 'gswap' }, + signal: controller.signal, + }); + if (!res.ok) return null; + const body = await res.json(); + return typeof body?.version === 'string' ? body.version : null; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +/** + * Latest published version, from cache when fresh. `force` bypasses the cache + * for an explicit `gswap upgrade --check`. + */ +export async function latestVersion({ force = false } = {}) { + if (!force) { + const cached = readCache(); + if (cached && Date.now() - cached.checkedAt < CACHE_TTL_MS) return cached.latest; + } + const latest = await fetchLatest(); + if (latest) writeCache(latest); + return latest; +} + +/** + * Resolve an available update, or null. Never throws — a failed check is not + * an error the user needs to hear about. + */ +export async function checkForUpdate(currentVersion, opts = {}) { + try { + const latest = await latestVersion(opts); + if (!latest || !isNewer(latest, currentVersion)) return null; + return { current: currentVersion, latest }; + } catch { + return null; + } +} + +/** Discard the cached result so the next check hits the registry. */ +export function clearCache() { + try { + if (exists(cacheFile())) fs.rmSync(cacheFile()); + } catch { + // Nothing depends on this succeeding. + } +} diff --git a/src/lib/util.js b/src/lib/util.js index d743e00..0a623ea 100644 --- a/src/lib/util.js +++ b/src/lib/util.js @@ -61,11 +61,26 @@ export function exists(p) { * tokens can't be interpreted as shell syntax. */ export function run(cmd, args, opts = {}) { - const res = spawnSync(cmd, args, { - encoding: 'utf8', - shell: false, - ...opts, - }); + let res; + try { + res = spawnSync(cmd, args, { + encoding: 'utf8', + shell: false, + ...opts, + }); + } catch (err) { + // Node refuses to spawn .bat/.cmd shims without a shell, and we will not + // enable one (see the comment above). Several Windows package-manager + // front-ends ship as exactly that, so treat the refusal as "not usable" + // rather than letting it crash the CLI. + return { + ok: false, + code: null, + stdout: '', + stderr: String(err?.message || err), + missing: true, + }; + } return { ok: res.status === 0, code: res.status, diff --git a/test/unit.test.js b/test/unit.test.js index c24a0f2..319b051 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -4,6 +4,11 @@ import { parseHosts, summariseHosts } from '../src/lib/ghconfig.js'; import { parseArgs } from '../src/cli.js'; import { isValidProfileName, maskToken } from '../src/lib/util.js'; import { buildHostsYml } from '../src/lib/hosts.js'; +import { readFileSync } from 'node:fs'; +import { compareVersions, detectInstallMethod, isNewer, packageName, shouldCheck } from '../src/lib/update.js'; +import { formatCommand, pickInstaller } from '../src/lib/ghinstall.js'; + +const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); const SAMPLE = `github.com: users: @@ -129,3 +134,116 @@ test('maskToken handles short and missing values', () => { assert.equal(maskToken(null), '(none)'); assert.equal(maskToken('abc'), '***'); }); + +// --- update checking ------------------------------------------------------- + +test('compareVersions orders releases numerically, not lexically', () => { + assert.equal(compareVersions('0.2.0', '0.1.0'), 1); + assert.equal(compareVersions('0.1.0', '0.2.0'), -1); + assert.equal(compareVersions('0.1.0', '0.1.0'), 0); + // The classic bug: "10" sorts before "9" as a string. + assert.equal(compareVersions('0.10.0', '0.9.0'), 1); + assert.equal(compareVersions('1.0.0', '0.99.99'), 1); +}); + +test('compareVersions sorts a prerelease below its own release', () => { + assert.equal(compareVersions('1.0.0-beta.1', '1.0.0'), -1); + assert.equal(compareVersions('1.0.0', '1.0.0-beta.1'), 1); + assert.equal(compareVersions('1.0.0-beta.2', '1.0.0-beta.1'), 1); +}); + +test('compareVersions treats unparseable input as equal rather than newer', () => { + assert.equal(compareVersions('not-a-version', '1.0.0'), 0); + assert.equal(compareVersions('1.0.0', ''), 0); + assert.equal(compareVersions(null, undefined), 0); +}); + +test('isNewer only fires on a genuine upgrade', () => { + assert.equal(isNewer('0.2.0', '0.1.0'), true); + assert.equal(isNewer('0.1.0', '0.1.0'), false); + assert.equal(isNewer('0.0.9', '0.1.0'), false); + assert.equal(isNewer('garbage', '0.1.0'), false); +}); + +test('detectInstallMethod distinguishes a brew formula from npm under brew', () => { + // A real brew formula lives in a Cellar directory... + assert.equal( + detectInstallMethod('/opt/homebrew/Cellar/gswap/0.1.0/bin/gswap').manager, + 'brew', + ); + // ...whereas this is npm's global dir that merely sits inside brew's prefix. + assert.equal( + detectInstallMethod('/opt/homebrew/lib/node_modules/gswap/src/cli.js').manager, + 'npm', + ); +}); + +test('detectInstallMethod recognises the other global installers', () => { + assert.equal(detectInstallMethod('/home/u/.volta/tools/image/packages/gswap/x.js').manager, 'volta'); + assert.equal(detectInstallMethod('/home/u/.bun/install/global/node_modules/gswap/x.js').manager, 'bun'); + assert.equal(detectInstallMethod('/home/u/Library/pnpm/global/5/node_modules/gswap/x.js').manager, 'pnpm'); + assert.equal(detectInstallMethod('/usr/local/lib/node_modules/gswap/src/cli.js').manager, 'npm'); +}); + +test('detectInstallMethod handles Windows paths and marks npm unrunnable there', () => { + const m = detectInstallMethod('C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\gswap\\src\\cli.js', 'win32'); + assert.equal(m.manager, 'npm'); + // npm ships as npm.cmd on Windows, which we refuse to spawn through a shell. + assert.equal(m.runnable, false); + assert.equal(detectInstallMethod('/usr/local/lib/node_modules/gswap/x.js', 'linux').runnable, true); +}); + +test('shouldCheck stays quiet for scripted and non-interactive use', () => { + const tty = { isTTY: true }; + assert.equal(shouldCheck({}, {}, tty), true); + assert.equal(shouldCheck({ json: true }, {}, tty), false, 'must not pollute --json output'); + assert.equal(shouldCheck({}, { CI: '1' }, tty), false); + assert.equal(shouldCheck({}, { GSWAP_NO_UPDATE_CHECK: '1' }, tty), false); + assert.equal(shouldCheck({}, { NO_UPDATE_NOTIFIER: '1' }, tty), false); + assert.equal(shouldCheck({}, {}, { isTTY: false }), false, 'must not block a piped run'); +}); + +// --- gh installer selection ------------------------------------------------ + +test('pickInstaller prefers winget on Windows and only auto-runs safe ones', () => { + const winget = pickInstaller('win32', (c) => c === 'winget'); + assert.equal(winget.manager, 'winget'); + assert.equal(winget.runnable, true); + + // scoop and choco are shims or need elevation, so we print rather than run. + assert.equal(pickInstaller('win32', (c) => c === 'scoop').runnable, false); + assert.equal(pickInstaller('win32', (c) => c === 'choco').runnable, false); +}); + +test('pickInstaller picks brew on macOS and sudo-free options elsewhere', () => { + assert.equal(pickInstaller('darwin', (c) => c === 'brew').manager, 'brew'); + assert.equal(pickInstaller('darwin', (c) => c === 'brew').runnable, true); + + const apt = pickInstaller('linux', (c) => c === 'apt-get'); + assert.equal(apt.manager, 'apt'); + assert.equal(apt.runnable, false, 'anything needing sudo must be printed, not run'); +}); + +test('pickInstaller returns null when nothing is available', () => { + assert.equal(pickInstaller('linux', () => false), null); + assert.equal(pickInstaller('win32', () => false), null); +}); + +test('formatCommand never interpolates anything user-supplied', () => { + const cmd = formatCommand(pickInstaller('darwin', (c) => c === 'brew')); + assert.equal(cmd, 'brew install gh'); +}); + +test('detectInstallMethod uses the real package name, never a hardcoded one', () => { + // Guards a supply-chain footgun: "gswap" on npm belongs to someone else, so + // an upgrade command built from a literal name would install their package. + const scoped = detectInstallMethod('/usr/local/lib/node_modules/x/cli.js', 'linux', '@me/gswap'); + assert.deepEqual(scoped.command, ['npm', 'install', '-g', '@me/gswap@latest']); + const brew = detectInstallMethod('/opt/homebrew/Cellar/x/1.0.0/bin/x', 'darwin', 'gh-swap'); + assert.deepEqual(brew.command, ['brew', 'upgrade', 'gh-swap']); +}); + +test('packageName matches the manifest the CLI reports its version from', () => { + // If these two ever disagree, the update check points at the wrong package. + assert.equal(packageName(), pkg.name); +}); From 9d0c8f3f7c98ffd168b944021c3acf2ecccd7759 Mon Sep 17 00:00:00 2001 From: Imad Rashid Date: Mon, 24 Aug 2026 00:30:23 +0500 Subject: [PATCH 2/2] Fix the test script on Windows and bump deprecated actions `node --test test/*.test.js` relies on the shell expanding the glob. PowerShell doesn't do that for external commands, so on Windows Node received the literal string and exited 1. Node 22 hid the problem by globbing internally; Node 18 did not, which is what CI caught. Bare `node --test` discovers test/ on its own and behaves the same on every platform and both Node versions. Also bumps actions/checkout and actions/setup-node to v7; v4 targets the now-deprecated Node 20 runtime and the runners were force-upgrading them. --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 4 ++-- CLAUDE.md | 7 ++++++- package.json | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11b5aa7..bfee380 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,9 @@ jobs: node: [18, 22] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 42bfae6..4937b63 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,11 +20,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 22 registry-url: https://registry.npmjs.org diff --git a/CLAUDE.md b/CLAUDE.md index d445eb3..f6142b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,12 +5,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Commands ```sh -npm test # node --test test/*.test.js +npm test # node --test (auto-discovers test/) node --test test/unit.test.js # single file node --test --test-name-pattern 'maskToken' # single test by name node src/cli.js --help # run the local CLI ``` +The test script takes **no path argument on purpose**: `node --test test/*.test.js` relies on +shell glob expansion, which PowerShell does not do for external commands, so it fails on +Windows with Node 18 (Node 22 masks it by globbing internally). Bare `node --test` discovers +`test/` itself and behaves identically everywhere. + No build step, no lint config, no runtime dependencies — `src/` is what ships (`package.json` `files`). ### Testing safely diff --git a/package.json b/package.json index 2f270fa..e47b27b 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ }, "author": "Imad Rashid", "scripts": { - "test": "node --test test/*.test.js" + "test": "node --test" }, "dependencies": {} }