Skip to content

feat: keep one session per environment instead of a shared session.json (#179) - #186

Merged
garretpremo merged 2 commits into
devfrom
issues/179-per-env-sessions
Oct 19, 2026
Merged

garretpremo merged 2 commits into
devfrom
issues/179-per-env-sessions

Conversation

@garretpremo

@garretpremo garretpremo commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Closes #179

Summary

Every environment in config.json now keeps its own cached session at <configDir>/sessions/<env>.json, replacing the single shared session.json. Switching environments no longer logs you out, a session is never sent to another environment's host, and a script driving one environment no longer clobbers the session of someone working in another.

This also fixes the project-mode bug found in triage. config switch and CLI routine run built new SessionManager(cliName), which always points at ~/.<cli>/session.json. In project mode they deleted the global-mode session and left the project's session in place. After config switch b, environment a's Basic credentials went to b's host.

It implements the triage decisions on the issue:

  • Q1=C: CLI routine run reuses the session; programmatic runRoutine still starts fresh.
  • Q2=A: config clear-session [name] [--all].
  • Q3=A: the legacy session.json is ignored.

It also includes the maintainer's later rulings: sessions/.gitignore (from #182's triage), and clearing an environment's session when its credentials are saved plus 0700/0600 permissions (from the self-review).

Release note: log in once per environment after upgrading. The old session.json is no longer read and is safe to delete. The new .apijack/sessions/ (or ~/.<cli>/sessions/) directory ignores itself in git (.gitignore containing *), so session tokens stay out of git even in projects that only ignore .apijack/session.json.

Behavior changes

  • config switch no longer deletes the cached session. Each environment keeps its own.
  • CLI routine run (default, --json, -s/--quiet) no longer clears the session before running; it reuses the cached one. cli.runRoutine() / runRoutine() still clear first, but only the resolved environment's file.
  • Saving an environment's credentials clears that environment's session. This covers setup / login, config import, config update-password and the MCP setup tool, which all go through saveEnvironment(). Other environments' sessions are untouched. Re-running setup with unchanged credentials costs one extra login.
  • New config clear-session [name] [--all].
  • Session location and format: <configDir>/sessions/<env>.json, wrapped as { env, baseUrl, username, session }. sessions/ is created 0700 and session files are written 0600.
  • The legacy <configDir>/session.json is never read, migrated or deleted.

What changes

src/session.ts

  • New SessionManager.forEnvironment(configDir, { env, baseUrl, username }) stores <configDir>/sessions/<encoded-env>.json as { env, baseUrl, username, session }.
    • resolve() ignores a cached file recorded for a different env, baseUrl or username, and logs in again.
    • Strategies still receive a plain AuthSession.
  • Environment names are percent-encoded, with * and Windows device names (con, nul, com1, ...) escaped too. The encoding is reversible, so distinct names get distinct filenames and none can escape sessions/. On a case-insensitive filesystem Dev and dev share a file, but the recorded env keeps either from using the other's session.
  • env: null (credentials from <CLI>_URL/_USER/_PASS) maps to @env-vars.json. Encoded names always escape @, so no config environment can claim that file.
  • sessions/ is created 0700 with a .gitignore containing *, and session files are written 0600.
  • New SessionManager.clear(configDir, env) and SessionManager.clearAll(configDir), each reporting whether anything was removed.
  • new SessionManager(cliName, path?) is unchanged (single raw file, no guard, default permissions), for consumers that deep-import it.

src/config.ts

  • New resolveEnvironment(cliName, opts) returns { name, auth }, where name is null for env-var credentials. resolveAuth() delegates to it, with unchanged behavior.
  • saveEnvironment() clears the saved environment's session after writing config.json.

src/cli-builder.ts

  • createCli has a single currentEnvironment(), which selects the credentials and the session file, plus sessionManagerFor(). Both run() and _buildRoutineRuntime() use them.
  • cli.runRoutine() / runRoutine() keep clearing before a run, but only the resolved environment's file.
  • The config registrar receives { configDir, currentEnvironment } for config clear-session.

config switch / config clear-session

  • config switch deletes nothing; the invalidateSession dep is gone from configSwitchAction.
  • New config clear-session [name] [--all] (src/commands/config/clear-session/):
    • With no name, it clears the session the next command would use (the env-var session when <CLI>_URL/_USER/_PASS are set).
    • With a name, it clears that environment. An unknown name exits 1 with the list of configured environments, like config switch.
    • --all removes sessions/. --all with a name is rejected (exit 1).
    • It lives under config because Commander 14 throws on a duplicate top-level name, and downstream CLIs may register their own clear-session.
$ mycli config clear-session
Cleared the session for 'dev'.
$ mycli config clear-session staging
No cached session for 'staging'.
$ mycli config clear-session --all
Cleared every environment's session.

routine run (CLI)

  • routine/register.ts no longer constructs a SessionManager, and the invalidateSession dep is gone from routineRunAction.

Docs

  • CLAUDE.md: new "Sessions (one per environment)" subsection, plus config clear-session in the built-in commands table (also in AGENTS.md / GEMINI.md).
  • skills/setup-api/SKILL.md: mentions config clear-session.
  • test-next-release teardown: removes sessions/petstore.json.

Downstream consumers

Consumers that deep-import apijack internals (e.g. new SessionManager(cliName, path)) or read session.json directly need a coordinated update when they move to this release. This isn't semver-covered, because these are src/ deep imports rather than @apijack/core exports:

  • The legacy new SessionManager(cliName, path?) constructor keeps working unchanged, but it logs in separately from core until moved to SessionManager.forEnvironment.
  • Custom commands that unlink or read session.json should switch to the built-in config clear-session and to requiresAuth + ctx.session.

Acceptance criteria

  • Sessions are stored at <configDir>/sessions/<encoded-env>.json. A name containing /, \ or .. can't escape sessions/, and filenames are valid on Windows.
  • Every core path uses the same per-environment file: generated commands, custom commands and dispatchers (ctx.resolveSession / saveSession / refreshSession), generate, cli.runRoutine() / runRoutine().
  • src/commands/config/register.ts and src/commands/routine/register.ts no longer construct a SessionManager.
  • In project mode, no command reads, writes or deletes ~/.<cli>/session.json.
  • config switch clears no session. Switching a → b → a reuses a's cached session.
  • A cached session whose baseUrl or username doesn't match is not sent. Environment a's session is never sent to b's host.
  • CLI routine run (default, --json, -s/--quiet) no longer clears the session.
  • cli.runRoutine() / runRoutine() still clear before running, only the resolved environment's file.
  • config clear-session clears the active environment, config clear-session <name> clears that one (unknown name exits 1 with the list), and --all removes sessions/. --all plus a name is rejected.
  • Env-var credential sessions can't collide with a config environment's file.
  • Saving an environment's credentials clears that environment's session only. After config update-password, the old cached Basic session is not reused.
  • Creating sessions/ writes sessions/.gitignore containing *, so no session file shows in git status. sessions/ is 0700 and session files are 0600 (not asserted on win32).
  • The legacy <configDir>/session.json is neither read nor deleted.
  • new SessionManager(cliName, path?) behaves as before. CliContext, AuthSession, AuthStrategy are unchanged.

Test plan

  • bun test: 1124 pass / 0 fail
  • bun run lint: 0 errors (118 warnings, same as dev)
  • bunx tsc --noEmit -p .: 16 errors, same set as dev. The one in src/commands/config/register.ts is dev's existing config import save: saveEnvironment typing error (line 118 on dev, 163 here), untouched.
  • New tests/per-env-sessions.test.ts drives the real createCli().run():
    • With a configPath:
      • switch a → b → a: each environment keeps its own session, with no cross-host leak.
      • config clear-session: active, named, unknown, --all, and --all + name.
      • Env-var credentials keep a session apart from a config environment named default, and config clear-session clears it.
      • config update-password with BasicAuthStrategy: alice:old before, alice:NEW after.
      • cli.runRoutine() clears only the active environment.
    • In a child process with an isolated HOME (os.homedir() ignores runtime HOME changes):
      • A default-mode switch doesn't log you out.
      • routine run (default, --json, --quiet) reuses the session.
      • With a configPath, whoami / config switch / routine run / config clear-session [--all] leave a sentinel ~/.<cli>/session.json byte-identical.
  • tests/session.test.ts covers:
    • per-environment file location and metadata, and isolation between environments
    • baseUrl / username / env guards, and the legacy file being ignored
    • name encoding: traversal, injectivity, Windows-invalid characters, device names
    • the env-var file not colliding with config environments
    • .gitignore, checked with real git status in a temp repo (exit codes asserted)
    • 0700 / 0600 permissions
    • invalidate / clear / clearAll
  • tests/config.test.ts: resolveEnvironment() (active name, env-var null name, nothing configured), and saveEnvironment() clearing only the saved environment's session.
  • src/commands/config/clear-session/clear-session.test.ts: env-var target, not-configured, nothing-to-clear.
  • Every new behavior test was watched failing against dev (or against a targeted mutation) before the code went in. Mutations that fail at least one test:
    • dropping the env check from the guard
    • writing the env-var session as default.json
    • dropping the * or device-name escapes
    • removing the .gitignore write
    • removing the clear in saveEnvironment()
  • Self-review: an independent reviewer subagent (superpowers:requesting-code-review) returned "With fixes":
    • Fixed, Important: a credential change left the old session cached; saveEnvironment() now clears it, per the maintainer's ruling.
    • Fixed, Minor: a misleading currentEnvironment comment, the case-insensitive-filesystem comment, the env-var cli.run test gap, a .gitignore test that could pass vacuously, CLAUDE.md location wording, and session-file permissions.
    • Skipped per the maintainer: a per-credential env-var session file. No leak, and the spec is satisfied.

…on (#179)

Each environment in config.json now caches its session at
<configDir>/sessions/<env>.json, recording the env, baseUrl and username
it was minted for; a cached session recorded for anything else is
ignored. Credentials from <CLI>_URL/_USER/_PASS get their own file.

- config switch no longer deletes a session, so switching away and back
  doesn't log you out, and one environment's session is never sent to
  another's host (project mode used to delete the global-mode file and
  keep the project one).
- New config clear-session [name] [--all].
- CLI routine run reuses the cached session; cli.runRoutine() and
  runRoutine() still clear first, scoped to the resolved environment.
- saveEnvironment() clears the saved environment's session, so setup,
  config import, config update-password and MCP setup never leave the
  old credentials cached.
- sessions/ is created 0700 with a .gitignore containing '*'; session
  files are written 0600.
- The legacy session.json is no longer read or written.
- new SessionManager(cliName, path?) is unchanged for deep importers.
@github-actions github-actions Bot added the needs review Open PR awaiting review label Oct 19, 2026
@garretpremo garretpremo added review in progress Review is actively underway and removed needs review Open PR awaiting review labels Oct 19, 2026
…sts (#179)

os.homedir() reads USERPROFILE on Windows, so the default-mode fixture
read the runner's real profile and found no config.

@garretpremo garretpremo left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review by claude — generated by the review-issue skill. Treat as advisory; a human still owns the merge decision.

Looks good. Every acceptance criterion from #179's triage is met, and the maintainer's settled decisions are implemented as described.

  • Every core path goes through sessionManagerFor(currentEnvironment()):
    • generated commands
    • custom commands and dispatchers (resolveSession / saveSession / refreshSession)
    • generate
    • the refresh-on-401 retry
    • cli.runRoutine()
  • saveEnvironment() clears the same directory that createCli writes to. That holds for setup, config import, config update-password, the MCP setup tool and the shared binary in both modes.
  • The env-name encoding is injective and stays inside sessions/.
  • new SessionManager(cliName, path?) and src/index.ts are unchanged.

What was checked:

  • CI is green at d3569d7.
  • bun test passes, apart from the known /tmp-location paths.test.ts failure.
  • tsc reports the same error set as dev, and lint has 0 errors.
  • config clear-session (active, named, unknown, --all, env-var credentials) was run against a real project-mode .apijack/: output and exit codes match the PR body, and sessions/ is 0700 with 0600 files.
Non-blocking observations
  • A password edited by hand in config.json keeps the old Basic session until config clear-session.
    • The guard in src/session.ts checks baseUrl and username, not the password, and BasicAuthStrategy.restore returns the cached Authorization header as-is.
    • This predates the PR. Before it, though, default-mode config switch and routine run reset the session as a side effect, and both are gone now.
    • CLIs without knownSites have no config update-password, so editing the file is a plausible way to change a password. The public updateEnvironmentField(cli, 'password', …) doesn't clear the session either.
    • Suggest one line in the CLAUDE.md "Sessions" section (and maybe the setup-api troubleshooting list): run config clear-session after editing credentials by hand.
  • On case-insensitive filesystems, Dev and dev share one file.
    • The recorded-env guard prevents a leak, as the PR notes.
    • But switching between the two re-authenticates every time, and saveEnvironment('dev') / config clear-session dev also clear Dev's session.
    • It's rare and acknowledged. If it ever matters, escaping uppercase letters or appending a short hash of the raw name would separate them.
  • Test gaps:
    • The --all case in tests/per-env-sessions.test.ts checks that sessions are minted again, but not that sessions/ itself is removed, which the acceptance criterion states. I confirmed by probe that it is removed; an existsSync(...sessions) === false assertion would pin it.
    • Generated API commands and generate are covered only by reading the code. They share step 5's sessionMgr.
  • Branch is behind dev: #185 (882fa22) has landed since the branch point. It merges cleanly, and bun test and lint pass on the merge result.
Nitpicks
  • mkdirSync(dir, { mode: 0o700 }) and writeFileSync(..., { mode: 0o600 }) only apply when the file or directory is created. A sessions/ directory that already exists with looser permissions isn't tightened.
  • Speculative: percent-encoding expands non-ASCII names about 9×, so a name of roughly 28 or more CJK characters exceeds the 255-byte filename limit, and the first save would throw ENAMETOOLONG. A hashed fallback above a length cap would cover it.

@garretpremo garretpremo added first pass reviewed Review passed with no blocking issues and removed review in progress Review is actively underway labels Oct 19, 2026

@garretpremo garretpremo left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Final review by claude — generated by the final-review skill. CI is green, no blockers, soak window elapsed. Marking approved and merging.

Approved.

Outstanding observations from first-pass review

  • A password edited by hand in config.json keeps the old Basic session until config clear-session.
    • The guard in src/session.ts checks baseUrl and username, not the password, and BasicAuthStrategy.restore returns the cached Authorization header as-is.
    • This predates the PR. Before it, though, default-mode config switch and routine run reset the session as a side effect, and both are gone now.
    • CLIs without knownSites have no config update-password, so editing the file is a plausible way to change a password. The public updateEnvironmentField(cli, 'password', …) doesn't clear the session either.
    • Suggest one line in the CLAUDE.md "Sessions" section (and maybe the setup-api troubleshooting list): run config clear-session after editing credentials by hand.
  • On case-insensitive filesystems, Dev and dev share one file.
    • The recorded-env guard prevents a leak, as the PR notes.
    • But switching between the two re-authenticates every time, and saveEnvironment('dev') / config clear-session dev also clear Dev's session.
    • It's rare and acknowledged. If it ever matters, escaping uppercase letters or appending a short hash of the raw name would separate them.
  • Test gaps:
    • The --all case in tests/per-env-sessions.test.ts checks that sessions are minted again, but not that sessions/ itself is removed, which the acceptance criterion states. I confirmed by probe that it is removed; an existsSync(...sessions) === false assertion would pin it.
    • Generated API commands and generate are covered only by reading the code. They share step 5's sessionMgr.
  • Branch is behind dev: #185 (882fa22) has landed since the branch point. It merges cleanly, and bun test and lint pass on the merge result.

@garretpremo garretpremo added approved PR has been fully approved and is ready to merge and removed first pass reviewed Review passed with no blocking issues labels Oct 19, 2026
@garretpremo
garretpremo merged commit 8d327c9 into dev Oct 19, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved PR has been fully approved and is ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant