Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
104 changes: 104 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <name> --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/<name>/profile.json` holds host/login/git identity/token; `profiles/<name>/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.
45 changes: 33 additions & 12 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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
```
Expand All @@ -22,21 +22,21 @@ 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

**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 <name> --dry-run` prints everything a switch would change without writing, which is usually enough to verify behaviour.
`gswap use <name> --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.

Expand All @@ -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
Expand All @@ -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

Expand Down
Loading