diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bfee380 --- /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@v7 + + - uses: actions/setup-node@v7 + 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..4937b63 --- /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@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v7 + 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..f6142b0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# 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 (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 + +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..e47b27b 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,15 +27,15 @@ "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": { - "test": "node --test test/*.test.js" + "test": "node --test" }, "dependencies": {} } 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); +});