Skip to content

Commit 233fa65

Browse files
committed
feat(profiles): multi-account model — key creds by profile, inherit from default
Profiles now serve the real use case: multiple accounts on prod, not envs. - Credentials are keyed by PROFILE NAME (not backend host), so two accounts on the same prod backend (e.g. alice@reply.io, bob@reply.io) no longer collide. Store file renamed hosts.json -> credentials.json; interface get/set/remove take a `key`, and hosts() -> keys(). - Profiles inherit every field from the embedded default (prod) unless overridden — an account profile needs no URLs at all. Partial overrides (e.g. only api_base) inherit the rest. - `profile add <name> [--authority] [--api-base]` creates a profile (URLs optional). Unknown profile is still a usage error (typo-safe). - Profiles are fully optional: no flag/env/current/config -> embedded prod. Human output hides "profile: default", so the common no-profile user never sees profile noise; it appears only for a non-default profile. - README refocused: leads with the zero-config path; multi-account is an optional section; non-prod backend is an advanced note. 112 offline tests green; multi-account isolation + no-profile path verified on the built binary.
1 parent ce32545 commit 233fa65

13 files changed

Lines changed: 273 additions & 164 deletions

File tree

‎README.md‎

Lines changed: 67 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ authentication and identity only — `auth login` (OAuth or API key), `auth
55
logout`, `auth status`, and `auth whoami`. Resource commands (contacts,
66
sequences, inbox, …) come later.
77

8-
The CLI is one binary, `reply`. It targets **prod** by default; other backends
9-
are opt-in via user-defined profiles (see [Profiles](#profiles)). Credentials
10-
live in a per-user config dir with strict permissions.
8+
One binary, `reply`. It talks to Reply.io **prod** out of the box — no setup,
9+
no flags. (Profiles, below, are an optional extra for people who need more than
10+
one account.)
1111

1212
## Install
1313

@@ -21,19 +21,21 @@ npm install -g @reply-team/reply-cli@next # newest build off main (for teste
2121
npm install -g @reply-team/reply-cli@0.1.42 # a specific build
2222
```
2323

24-
Every commit on `main` is published under the `next` tag, so testers can install
25-
**any** build. Released builds are promoted to `latest`.
24+
Every commit on `main` is published under `next`; released builds are promoted
25+
to `latest`.
2626

2727
## Authentication
2828

29+
For most people this is the whole story — no profiles, no config:
30+
2931
```bash
3032
# OAuth (authorization code + PKCE, loopback redirect — opens your browser)
3133
reply auth login
3234

3335
# API key from stdin (kept out of shell history / argv)
3436
echo "$MY_API_KEY" | reply auth login --with-token
3537

36-
# Active credential source, profile, user, and OAuth expiry (no secrets)
38+
# Active credential source, user, and OAuth expiry (no secrets)
3739
reply auth status
3840

3941
# Validate the active credential against the API and print the principal
@@ -58,59 +60,79 @@ Resolved strictly in this order — first hit wins:
5860
The flag and env var are **ephemeral** — used for the current invocation only,
5961
never written to disk. There is no `.env` file lookup.
6062

61-
## Profiles
63+
## Output & exit codes
64+
65+
- Data goes to **stdout**; status and error messages go to **stderr**.
66+
- `--json` emits compact JSON; `--pretty` emits indented JSON.
67+
- On `--json`/`--pretty`, an error is a single machine-readable line:
68+
`{"error":{"status":…,"code":…,"title":…,"detail":…,"hint":…}}`.
69+
70+
Exit codes: `0` success · `1` API/runtime failure · `2` usage error.
6271

63-
There is no built-in environment enum. A **profile** is a named backend bundle
64-
you define yourself. Which profile a command uses is resolved in this order:
72+
## Multiple accounts (optional — profiles)
73+
74+
Skip this section unless you juggle more than one Reply.io account. A **profile**
75+
is a named credential slot; each keeps its own login, so you can switch between
76+
accounts without re-authenticating. Most people name profiles after the account
77+
email.
78+
79+
```bash
80+
reply profile add alice@reply.io # create (no URLs needed — inherits prod)
81+
reply profile add bob@reply.io
82+
reply profile use alice@reply.io # make it current (persisted until changed)
83+
reply auth login # logs in as alice; stored under alice@reply.io
84+
85+
reply profile use bob@reply.io && reply auth login # separate login for bob
86+
87+
reply profile list # * marks the current profile
88+
reply profile current # print the current profile name
89+
reply --profile bob@reply.io auth whoami # one-off override, ignores current
90+
```
91+
92+
Which profile a command uses is resolved as:
6593

6694
```
6795
--profile <name> > REPLY_PROFILE > current profile > default (prod)
6896
```
6997

70-
Define your own profiles in the config file:
98+
Credentials are keyed by profile name, so accounts never collide even though
99+
they all hit the same prod backend.
71100

72-
```jsonc
73-
// ~/.config/reply/config.json (Windows: %APPDATA%\reply\config.json)
74-
{
75-
"profiles": {
76-
"dev": {
77-
"authority": "https://oauth.dev.replyapp.io",
78-
"api_base": "https://api.dev.reply.io/v3"
79-
}
80-
}
81-
}
82-
```
101+
### Advanced: a non-prod backend
83102

84-
Use a profile per-command, or set a **current** profile so you don't repeat
85-
`--profile`:
103+
A profile can also point at a different backend by overriding the URLs; any
104+
field you omit is inherited from the embedded default (prod). This is mainly for
105+
internal testing against dev:
86106

87107
```bash
88-
reply --profile dev auth whoami # one-off override
89-
90-
reply profile use dev # make dev current (persisted until changed)
91-
reply profile list # list profiles; * marks the current one
92-
reply profile current # print the current profile name
93-
reply auth whoami # now uses dev, no flag needed
108+
reply profile add dev \
109+
--authority https://oauth.dev.replyapp.io \
110+
--api-base https://api.dev.reply.io/v3
111+
reply --profile dev auth login
94112
```
95113

96-
Each profile resolves to its own backend host, and credentials are stored
97-
per-host — so a `dev` session and your default (prod) session never collide.
98-
An unknown profile is a usage error.
114+
Or hand-edit the config file:
115+
116+
```jsonc
117+
// ~/.config/reply/config.json (Windows: %APPDATA%\reply\config.json)
118+
{ "profiles": { "dev": { "authority": "https://…", "api_base": "https://…/v3" } } }
119+
```
99120

100121
## Credential storage
101122

102123
Credentials live in a JSON file in the per-user config dir, created with strict
103-
permissions (`0600` file inside a `0700` dir) — the same plaintext-file model as
104-
`gh`, `aws`, and `az`:
124+
permissions (`0600` file in a `0700` dir) — the gh/aws/az plaintext-file model:
105125

106-
- Linux/macOS: `~/.config/reply/hosts.json` (honors `$XDG_CONFIG_HOME`)
107-
- Windows: `%APPDATA%\reply\hosts.json`
126+
- Linux/macOS: `~/.config/reply/credentials.json` (honors `$XDG_CONFIG_HOME`)
127+
- Windows: `%APPDATA%\reply\credentials.json`
128+
(on Windows the strict perms are a no-op — it relies on your per-user
129+
`%APPDATA%` ACLs, same as gh/aws/az)
108130

109-
Each record is keyed by authority host and is either an OAuth record
110-
(access + refresh token + expiry) or an API-key record. An expired OAuth access
111-
token is refreshed automatically; if the refresh fails the record is cleared and
112-
you are asked to log in again. The store sits behind a `CredentialStore`
113-
interface — an OS-keychain backend is a planned opt-in addition.
131+
Records are keyed by profile name; each is either an OAuth record (access +
132+
refresh token + expiry) or an API-key record. Expired OAuth tokens refresh
133+
automatically; if refresh fails the record is cleared and you're asked to log in
134+
again. The store sits behind a `CredentialStore` interface — an OS-keychain
135+
backend is a planned opt-in addition.
114136

115137
## Configuration (env vars)
116138

@@ -120,15 +142,6 @@ interface — an OS-keychain backend is a planned opt-in addition.
120142
| `REPLY_PROFILE` | Profile to use (same as `--profile`) |
121143
| `REPLY_CONFIG_DIR` | Override the per-user config directory |
122144

123-
## Output & exit codes
124-
125-
- Data goes to **stdout**; status and error messages go to **stderr**.
126-
- `--json` emits compact JSON; `--pretty` emits indented JSON.
127-
- On `--json`/`--pretty`, an error is a single machine-readable line:
128-
`{"error":{"status":…,"code":…,"title":…,"detail":…,"hint":…}}`.
129-
130-
Exit codes: `0` success · `1` API/runtime failure · `2` usage error.
131-
132145
## Development
133146

134147
```bash
@@ -140,7 +153,8 @@ npm link # puts the real `reply` binary on your PATH to drive by h
140153

141154
Tests never contact the network: `fetch` is stubbed and the OAuth loopback is
142155
exercised against a local `127.0.0.1` listener with an injected fake browser.
156+
CI runs the build + tests on Linux and Windows.
143157

144-
**Releasing:** the `build` workflow publishes every `main` build to `@next`.
145-
When a build has been tested, the `release` workflow promotes that exact version
146-
to `@latest` (a dist-tag move — no rebuild) and tags the source commit.
158+
**Releasing:** the `build` workflow publishes every `main` build to `@next`;
159+
the `release` workflow promotes a tested version to `@latest` (a dist-tag move —
160+
no rebuild) and tags the source commit.

‎src/__tests__/auth/resolve.test.ts‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const fake_store = (seed: Record<string, Credential_record> = {})=>{
1818
delete map[h];
1919
return had;
2020
}),
21-
hosts: vi.fn(async()=>Object.keys(map)),
21+
keys: vi.fn(async()=>Object.keys(map)),
2222
};
2323
return {store, map};
2424
};
@@ -30,34 +30,34 @@ const fresh_oauth = (over: Partial<Oauth_record> = {}): Oauth_record=>({
3030
describe('auth/resolve — credential precedence', ()=>{
3131
it('1) --api-key flag wins, is ephemeral, and never touches the store', async()=>{
3232
const {store} = fake_store({[HOST]: {type: 'api_key', key: 'stored'}});
33-
const r = await resolve_credential({api_key: 'flagkey'}, {host: HOST, store, env: {[API_KEY_ENV]: 'envkey'}, now: NOW});
33+
const r = await resolve_credential({api_key: 'flagkey'}, {key: HOST, store, env: {[API_KEY_ENV]: 'envkey'}, now: NOW});
3434
expect(r).toMatchObject({token: 'flagkey', type: 'api_key', source: 'flag', ephemeral: true});
3535
expect(store.get).not.toHaveBeenCalled();
3636
});
3737

3838
it('2) <PREFIX>_API_KEY env is used when no flag, ephemeral, store untouched', async()=>{
3939
const {store} = fake_store({[HOST]: {type: 'api_key', key: 'stored'}});
40-
const r = await resolve_credential({}, {host: HOST, store, env: {[API_KEY_ENV]: 'envkey'}, now: NOW});
40+
const r = await resolve_credential({}, {key: HOST, store, env: {[API_KEY_ENV]: 'envkey'}, now: NOW});
4141
expect(r).toMatchObject({token: 'envkey', source: 'env', ephemeral: true});
4242
expect(store.get).not.toHaveBeenCalled();
4343
});
4444

4545
it('3) flag beats env', async()=>{
4646
const {store} = fake_store();
47-
const r = await resolve_credential({api_key: 'flagkey'}, {host: HOST, store, env: {[API_KEY_ENV]: 'envkey'}, now: NOW});
47+
const r = await resolve_credential({api_key: 'flagkey'}, {key: HOST, store, env: {[API_KEY_ENV]: 'envkey'}, now: NOW});
4848
expect(r.token).toBe('flagkey');
4949
});
5050

5151
it('3) falls back to a stored api_key record (not ephemeral)', async()=>{
5252
const {store} = fake_store({[HOST]: {type: 'api_key', key: 'stored', user: {username: 'u'}}});
53-
const r = await resolve_credential({}, {host: HOST, store, env: {}, now: NOW});
53+
const r = await resolve_credential({}, {key: HOST, store, env: {}, now: NOW});
5454
expect(r).toMatchObject({token: 'stored', type: 'api_key', source: 'store', ephemeral: false});
5555
});
5656

5757
it('returns a stored oauth access token when it is still fresh', async()=>{
5858
const {store} = fake_store({[HOST]: fresh_oauth()});
5959
const refresh = vi.fn();
60-
const r = await resolve_credential({}, {host: HOST, store, env: {}, now: NOW, refresh});
60+
const r = await resolve_credential({}, {key: HOST, store, env: {}, now: NOW, refresh});
6161
expect(r).toMatchObject({token: 'at', type: 'oauth', source: 'store'});
6262
expect(refresh).not.toHaveBeenCalled();
6363
});
@@ -66,30 +66,30 @@ describe('auth/resolve — credential precedence', ()=>{
6666
const {store} = fake_store({[HOST]: fresh_oauth({expires_at: NOW - 1})});
6767
const refreshed: Oauth_record = fresh_oauth({access_token: 'new_at', expires_at: NOW + 3_600_000});
6868
const refresh = vi.fn(async()=>refreshed);
69-
const r = await resolve_credential({}, {host: HOST, store, env: {}, now: NOW, refresh});
69+
const r = await resolve_credential({}, {key: HOST, store, env: {}, now: NOW, refresh});
7070
expect(refresh).toHaveBeenCalledOnce();
7171
expect(store.set).toHaveBeenCalledWith(HOST, refreshed);
7272
expect(r.token).toBe('new_at');
7373
});
7474

7575
it('clears the record and errors when an expired token has no refresh token', async()=>{
7676
const {store} = fake_store({[HOST]: fresh_oauth({expires_at: NOW - 1, refresh_token: undefined})});
77-
await expect(resolve_credential({}, {host: HOST, store, env: {}, now: NOW, refresh: vi.fn()}))
77+
await expect(resolve_credential({}, {key: HOST, store, env: {}, now: NOW, refresh: vi.fn()}))
7878
.rejects.toBeInstanceOf(UsageError);
7979
expect(store.remove).toHaveBeenCalledWith(HOST);
8080
});
8181

8282
it('clears the record and errors when refresh fails', async()=>{
8383
const {store} = fake_store({[HOST]: fresh_oauth({expires_at: NOW - 1})});
8484
const refresh = vi.fn(async()=>{throw new Error('token endpoint said no');});
85-
await expect(resolve_credential({}, {host: HOST, store, env: {}, now: NOW, refresh}))
85+
await expect(resolve_credential({}, {key: HOST, store, env: {}, now: NOW, refresh}))
8686
.rejects.toBeInstanceOf(UsageError);
8787
expect(store.remove).toHaveBeenCalledWith(HOST);
8888
});
8989

9090
it('errors (UsageError) when nothing is available and writes nothing', async()=>{
9191
const {store} = fake_store();
92-
await expect(resolve_credential({}, {host: HOST, store, env: {}, now: NOW}))
92+
await expect(resolve_credential({}, {key: HOST, store, env: {}, now: NOW}))
9393
.rejects.toBeInstanceOf(UsageError);
9494
expect(store.set).not.toHaveBeenCalled();
9595
});

‎src/__tests__/credentials/file-store.test.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ let store: FileCredentialStore;
2323

2424
beforeEach(()=>{
2525
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-store-'));
26-
file = path.join(dir, 'nested', 'hosts.json');
26+
file = path.join(dir, 'nested', 'credentials.json');
2727
store = new FileCredentialStore(file);
2828
});
2929

@@ -34,7 +34,7 @@ afterEach(()=>{
3434
describe('credentials/FileCredentialStore', ()=>{
3535
it('returns undefined for a host before anything is stored', async()=>{
3636
expect(await store.get('oauth.dev.replyapp.io')).toBeUndefined();
37-
expect(await store.hosts()).toEqual([]);
37+
expect(await store.keys()).toEqual([]);
3838
});
3939

4040
it('round-trips a record for a host', async()=>{
@@ -47,7 +47,7 @@ describe('credentials/FileCredentialStore', ()=>{
4747
await store.set('api.reply.io', API_KEY);
4848
expect(await store.get('oauth.dev.replyapp.io')).toEqual(OAUTH);
4949
expect(await store.get('api.reply.io')).toEqual(API_KEY);
50-
expect((await store.hosts()).sort()).toEqual(['api.reply.io', 'oauth.dev.replyapp.io']);
50+
expect((await store.keys()).sort()).toEqual(['api.reply.io', 'oauth.dev.replyapp.io']);
5151
});
5252

5353
it('overwrites an existing host record', async()=>{
@@ -92,7 +92,7 @@ describe('credentials/FileCredentialStore', ()=>{
9292
it('treats an empty file as no credentials', async()=>{
9393
fs.mkdirSync(path.dirname(file), {recursive: true});
9494
fs.writeFileSync(file, '');
95-
expect(await store.hosts()).toEqual([]);
95+
expect(await store.keys()).toEqual([]);
9696
});
9797

9898
it('throws a RuntimeError on a corrupt store file', async()=>{
@@ -105,6 +105,6 @@ describe('credentials/FileCredentialStore', ()=>{
105105
it('throws a RuntimeError when the file holds a non-object', async()=>{
106106
fs.mkdirSync(path.dirname(file), {recursive: true});
107107
fs.writeFileSync(file, '["nope"]');
108-
await expect(store.hosts()).rejects.toBeInstanceOf(RuntimeError);
108+
await expect(store.keys()).rejects.toBeInstanceOf(RuntimeError);
109109
});
110110
});

‎src/__tests__/profile.test.ts‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {describe, it, expect, beforeEach, afterEach} from 'vitest';
22
import fs from 'fs';
33
import os from 'os';
44
import path from 'path';
5-
import {resolve_profile, current_profile_name, list_profiles, set_current_profile, PROD} from '../profile';
5+
import {resolve_profile, current_profile_name, list_profiles, set_current_profile, add_profile, PROD} from '../profile';
66
import {UsageError, RuntimeError} from '../utils/errors';
77

88
let dir: string;
@@ -62,9 +62,17 @@ describe('profile — no environment abstraction, only a default + user profiles
6262
expect(p.api_base).toBe('https://b/v3');
6363
});
6464

65-
it('throws a UsageError when a profile is missing a URL', ()=>{
66-
write_config({profiles: {dev: {authority: 'https://a'}}});
67-
expect(()=>resolve_profile('dev', env_for())).toThrow(UsageError);
65+
it('inherits missing URLs from the embedded default (prod)', ()=>{
66+
write_config({profiles: {'alice@reply.io': {}}});
67+
const p = resolve_profile('alice@reply.io', env_for());
68+
expect(p).toEqual({name: 'alice@reply.io', authority: PROD.authority, api_base: PROD.api_base});
69+
});
70+
71+
it('inherits per-field: overrides api_base but keeps the prod authority', ()=>{
72+
write_config({profiles: {stg: {api_base: 'https://api.stage.reply.io/v3'}}});
73+
const p = resolve_profile('stg', env_for());
74+
expect(p.authority).toBe(PROD.authority);
75+
expect(p.api_base).toBe('https://api.stage.reply.io/v3');
6876
});
6977

7078
it('throws a RuntimeError on a corrupt config file', ()=>{
@@ -123,3 +131,33 @@ describe('profile — current (persisted) profile', ()=>{
123131
expect(()=>set_current_profile('nope', env_for())).toThrow(UsageError);
124132
});
125133
});
134+
135+
describe('profile — add (create, URLs optional)', ()=>{
136+
it('creates a URL-less profile that inherits prod', ()=>{
137+
add_profile('alice@reply.io', {}, env_for());
138+
expect(resolve_profile('alice@reply.io', env_for())).toEqual({
139+
name: 'alice@reply.io', authority: PROD.authority, api_base: PROD.api_base,
140+
});
141+
});
142+
143+
it('creates a profile with explicit URLs', ()=>{
144+
add_profile('dev', {authority: 'https://oauth.dev.replyapp.io', api_base: 'https://api.dev.reply.io/v3'}, env_for());
145+
expect(resolve_profile('dev', env_for())).toMatchObject({
146+
authority: 'https://oauth.dev.replyapp.io', api_base: 'https://api.dev.reply.io/v3',
147+
});
148+
});
149+
150+
it('preserves other profiles and lets set_current select the new one', ()=>{
151+
add_profile('alice@reply.io', {}, env_for());
152+
add_profile('bob@reply.io', {}, env_for());
153+
set_current_profile('bob@reply.io', env_for());
154+
const l = list_profiles(env_for());
155+
expect([...l.available].sort()).toEqual(['alice@reply.io', 'bob@reply.io', 'default']);
156+
expect(l.current).toBe('bob@reply.io');
157+
});
158+
159+
it('rejects adding the built-in default and empty names', ()=>{
160+
expect(()=>add_profile('default', {}, env_for())).toThrow(UsageError);
161+
expect(()=>add_profile(' ', {}, env_for())).toThrow(UsageError);
162+
});
163+
});

‎src/__tests__/utils/errors.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@ describe('utils/errors', ()=>{
2626

2727
it('to_json carries title, code, detail and hint', ()=>{
2828
const e = new RuntimeError('Credential store is corrupt', {
29-
code: 'store.corrupt', detail: '/p/hosts.json', hint: 'delete it and log in again',
29+
code: 'store.corrupt', detail: '/p/credentials.json', hint: 'delete it and log in again',
3030
});
3131
expect(e.to_json()).toEqual({error: {
3232
code: 'store.corrupt',
3333
title: 'Credential store is corrupt',
34-
detail: '/p/hosts.json',
34+
detail: '/p/credentials.json',
3535
hint: 'delete it and log in again',
3636
}});
3737
});

0 commit comments

Comments
 (0)