diff --git a/README.md b/README.md index 73d71f1..8377dab 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ `reply` is the command-line interface for [Reply.io](https://reply.io). Sign in once and every Reply.io API request runs as you — from your terminal or your -scripts. Today it handles authentication and identity; resource commands for -sequences, contacts, and the inbox are on the way. +scripts. Today it handles authentication and identity, and `reply api` gives you +authenticated access to the full v3 API; higher-level commands for sequences, +contacts, and the inbox are on the way. ## Installation @@ -73,12 +74,71 @@ reply --profile bob@reply.io auth whoami # override for a single command The active profile is resolved as `--profile` → `REPLY_PROFILE` → the profile set with `profile use` → the built-in default. +Manage profiles after creating them: + +```sh +reply profile show # inspect the current profile (no secrets) +reply profile show alice@reply.io # inspect a specific one +reply profile rename alice@reply.io ally # also moves the stored credential +reply profile unset ally team-id # clear a field (authority|api_base|team-id) +reply profile delete ally # remove it and its stored credential +``` + +`profile show` lists the backend URLs, pinned team, and which authorization +would be used (in priority order: `--api-key` → `REPLY_API_KEY` → stored +credential) — it never prints tokens or keys. `--authority` and `--api-base` +must be `http(s)` URLs. + +## Teams + +A profile can pin a team (workspace); it's sent as `X-TEAM-ID`, with precedence +`--team-id` → `REPLY_TEAM_ID` → the profile's team. The `team` command sees and +sets the **current profile's** team: + +```sh +reply team list # teams you can act in (* marks the profile's team) +reply team current # the profile's pinned team + the effective team (from whoami) +reply team use 1045 # verify 1045 is one of your teams, then pin it on the current profile +reply team clear # remove the pin +``` + +If a call needs a team and you're in more than one, the API answers with a +`TEAM_REQUIRED` error listing your teams — run `reply team use ` to pin one. + +## Raw API access + +`reply api` is a raw, authenticated passthrough to any v3 endpoint — the +agent/CI escape hatch. See the +[Reply API reference](https://docs.reply.io/api-reference/introduction) for the +full surface. + +Use the path exactly as it appears in the docs (starting with `/v3`); the query +string goes in the path. The request URL is literally `api_base + path`, and the +profile stores the host **without** `/v3`, so the call's URL matches the docs. A +`--body` switches the method to POST (it also accepts `@file` or `-` for stdin). + +```sh +reply api /v3/whoami # your identity + team +reply api /v3/sequences # list sequences +reply api /v3/contacts --pretty # list contacts, indented +reply api /v3/sequences/12345 # one sequence by id +reply api /v3/contacts --body @contact.json # create a contact (POST; body schema per the docs) +echo '' | reply api /v3/contacts --body - # body from stdin +reply api /v3/whoami --verbose # full request/response on stderr +``` + +It prints `{ "code": , "data": }` and exits non-zero on HTTP +`>= 400`. On a team/user-resolution conflict it adds a short fix-it hint on +stderr. Add `--verbose` for a full request/response trace on stderr with +credentials redacted; stdout stays the plain JSON, so pipes keep working. + ## Environment variables | Variable | Description | |----------|-------------| | `REPLY_API_KEY` | API key used as the credential for the current invocation | | `REPLY_PROFILE` | Profile to use (same as `--profile`) | +| `REPLY_TEAM_ID` | Team/workspace id sent as `X-TEAM-ID` (same as `--team-id`) | | `REPLY_CONFIG_DIR` | Config directory (default `~/.config/reply`; `%APPDATA%\reply` on Windows) | ## Contributing diff --git a/src/__tests__/commands/api.test.ts b/src/__tests__/commands/api.test.ts new file mode 100644 index 0000000..8db1813 --- /dev/null +++ b/src/__tests__/commands/api.test.ts @@ -0,0 +1,139 @@ +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const mock_fetch = vi.fn(); +vi.stubGlobal('fetch', mock_fetch); + +import {handle_api, read_body_arg} from '../../commands/api'; +import {UsageError} from '../../utils/errors'; +import type {Cli_context} from '../../context'; +import type {CredentialStore, Api_key_record} from '../../credentials/types'; + +const res = (data: unknown, status = 200)=> + new Response(JSON.stringify(data), {status, headers: {'Content-Type': 'application/json'}}); + +const api_key_record: Api_key_record = {type: 'api_key', key: 'k', user: {id: 1}}; +const fake_store = (): CredentialStore=>({ + get: async()=>api_key_record, set: async()=>{}, remove: async()=>true, keys: async()=>['dev'], +}); +const ctx = (): Cli_context=>({ + profile: 'dev', authority: 'https://auth', api_base: 'https://api', key: 'dev', + store: fake_store(), refresh: async(r)=>r, +}); + +const capture = async(fn: () => Promise): Promise<{out: string; err: string}>=>{ + const out: string[] = []; + const err: string[] = []; + const log = console.log; + const error = console.error; + const write = process.stdout.write; + console.log = (...a: unknown[])=>{ out.push(a.join(' ')); }; + console.error = (...a: unknown[])=>{ err.push(a.join(' ')); }; + process.stdout.write = ((c: unknown): boolean=>{ out.push(String(c)); return true; }) as typeof process.stdout.write; + try { await fn(); } finally { console.log = log; console.error = error; process.stdout.write = write; } + return {out: out.join('\n').trim(), err: err.join('\n').trim()}; +}; + +const method_of = ()=>mock_fetch.mock.calls[0][1].method as string; + +let dir: string; +beforeEach(()=>{ + vi.clearAllMocks(); + process.exitCode = 0; + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-api-')); +}); +afterEach(()=>{ + process.exitCode = 0; + fs.rmSync(dir, {recursive: true, force: true}); +}); + +describe('handle_api', ()=>{ + it('GETs by default and builds the URL as base + path literally', async()=>{ + mock_fetch.mockResolvedValue(res({ok: true}, 200)); + const {out} = await capture(()=>handle_api('/v3/whoami', {}, ctx(), {})); + expect(method_of()).toBe('GET'); + expect(mock_fetch.mock.calls[0][0]).toBe('https://api/v3/whoami'); + expect(JSON.parse(out)).toEqual({code: 200, data: {ok: true}}); + }); + + it('POSTs when a --body is given and sends it', async()=>{ + mock_fetch.mockResolvedValue(res({id: 1}, 201)); + await capture(()=>handle_api('/x', {body: '{"a":1}'}, ctx(), {})); + expect(method_of()).toBe('POST'); + expect(mock_fetch.mock.calls[0][1].body).toBe('{"a":1}'); + }); + + it('honors an explicit --method override', async()=>{ + mock_fetch.mockResolvedValue(res({}, 200)); + await capture(()=>handle_api('/x/9', {method: 'delete'}, ctx(), {})); + expect(method_of()).toBe('DELETE'); + }); + + it('rejects an invalid JSON body', async()=>{ + await expect(handle_api('/x', {body: '{bad'}, ctx(), {})).rejects.toThrow(UsageError); + }); + + it('rejects a disallowed method', async()=>{ + await expect(handle_api('/x', {method: 'FROB'}, ctx(), {})).rejects.toThrow(UsageError); + }); + + it('prints {code,data} and sets exit 1 on a non-2xx', async()=>{ + mock_fetch.mockResolvedValue(res({code: 'contact.notFound'}, 404)); + const {out} = await capture(()=>handle_api('/x', {}, ctx(), {})); + expect(JSON.parse(out).code).toBe(404); + expect(process.exitCode).toBe(1); + }); + + it('--verbose prints a redacted req/resp trace to stderr (stdout stays {code,data})', async()=>{ + mock_fetch.mockResolvedValue(res({ok: true}, 200)); + const secret_store: CredentialStore = { + get: async()=>({type: 'api_key', key: 'SEKRET-TOKEN', user: {id: 1}}), + set: async()=>{}, remove: async()=>true, keys: async()=>['dev'], + }; + const c: Cli_context = { + profile: 'dev', authority: 'https://auth', api_base: 'https://api', key: 'dev', + store: secret_store, refresh: async(r)=>r, + }; + const {out, err} = await capture(()=>handle_api('/v3/whoami', {}, c, {verbose: true})); + expect(err).toContain('> GET https://api/v3/whoami'); + expect(err).toMatch(/> Authorization: Bearer •+/); + expect(err).toContain('< 200'); + expect(err).not.toContain('SEKRET-TOKEN'); + expect(JSON.parse(out).code).toBe(200); + }); + + it('prints team-conflict guidance to stderr on TEAM_REQUIRED', async()=>{ + mock_fetch.mockResolvedValue(res({code: 'TEAM_REQUIRED', teams: [{teamId: 1045, teamName: 'Acme'}]}, 403)); + const {out, err} = await capture(()=>handle_api('/contacts', {}, ctx(), {})); + expect(JSON.parse(out).code).toBe(403); + expect(err).toMatch(/multiple teams/i); + expect(err).toContain('1045'); + expect(process.exitCode).toBe(1); + }); +}); + +describe('read_body_arg', ()=>{ + it('parses inline JSON', ()=>{ + expect(read_body_arg('{"a":1}')).toEqual({a: 1}); + }); + + it('reads @file', ()=>{ + const f = path.join(dir, 'body.json'); + fs.writeFileSync(f, '{"b":2}'); + expect(read_body_arg(`@${f}`)).toEqual({b: 2}); + }); + + it('reads - from stdin (injected)', ()=>{ + expect(read_body_arg('-', ()=>'{"c":3}')).toEqual({c: 3}); + }); + + it('returns undefined when absent', ()=>{ + expect(read_body_arg(undefined)).toBeUndefined(); + }); + + it('throws UsageError on invalid JSON', ()=>{ + expect(()=>read_body_arg('nope')).toThrow(UsageError); + }); +}); diff --git a/src/__tests__/commands/profile.test.ts b/src/__tests__/commands/profile.test.ts new file mode 100644 index 0000000..9897022 --- /dev/null +++ b/src/__tests__/commands/profile.test.ts @@ -0,0 +1,179 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {handle_rename, handle_delete, handle_show, parse_url, profile_command} from '../../commands/profile'; +import {FileCredentialStore} from '../../credentials/file-store'; +import {add_profile, set_current_profile, list_profiles, current_profile_name} from '../../profile'; +import {UsageError} from '../../utils/errors'; +import type {Credential_record} from '../../credentials/types'; + +let dir: string; +const env_for = (over: Record = {})=>({REPLY_CONFIG_DIR: dir, ...over}); +const store_for = ()=>new FileCredentialStore(path.join(dir, 'credentials.json')); +const api_key: Credential_record = {type: 'api_key', key: 'secret-xyz', user: {id: 1, username: 'alice'}}; + +// Capture both console.log (human path) and process.stdout.write (print/JSON path). +const capture = async(fn: () => Promise): Promise=>{ + const out: string[] = []; + const log = console.log; + const write = process.stdout.write; + console.log = (...a: unknown[])=>{ out.push(a.join(' ')); }; + process.stdout.write = ((chunk: unknown): boolean=>{ out.push(String(chunk)); return true; }) as typeof process.stdout.write; + try { await fn(); } finally { console.log = log; process.stdout.write = write; } + return out.join('\n').trim(); +}; + +beforeEach(()=>{ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-cmd-')); }); +afterEach(()=>{ fs.rmSync(dir, {recursive: true, force: true}); }); + +describe('handle_rename', ()=>{ + it('moves the stored credential from old key to new key', async()=>{ + add_profile('alice@reply.io', {}, env_for()); + const store = store_for(); + await store.set('alice@reply.io', api_key); + await handle_rename('alice@reply.io', 'ally@reply.io', {}, {store, env: env_for()}); + expect(await store.get('alice@reply.io')).toBeUndefined(); + expect(await store.get('ally@reply.io')).toEqual(api_key); + expect(list_profiles(env_for()).available).toContain('ally@reply.io'); + }); + + it('renames a profile that has no stored credential (no-op on store)', async()=>{ + add_profile('dev', {}, env_for()); + const store = store_for(); + await handle_rename('dev', 'staging', {}, {store, env: env_for()}); + expect(list_profiles(env_for()).available).toContain('staging'); + expect(await store.keys()).toEqual([]); + }); + + it('refuses when a credential already exists under the target key', async()=>{ + add_profile('dev', {}, env_for()); + const store = store_for(); + await store.set('taken', api_key); + await expect(handle_rename('dev', 'taken', {}, {store, env: env_for()})).rejects.toThrow(UsageError); + }); + + it('repoints the current profile when the renamed one was current', async()=>{ + add_profile('dev', {}, env_for()); + set_current_profile('dev', env_for()); + await handle_rename('dev', 'staging', {}, {store: store_for(), env: env_for()}); + expect(current_profile_name(env_for())).toBe('staging'); + }); +}); + +describe('handle_delete', ()=>{ + const yes = async()=>true; + const no = async()=>false; + + it('removes the profile def and its stored credential (with --yes)', async()=>{ + add_profile('dev', {}, env_for()); + const store = store_for(); + await store.set('dev', api_key); + await handle_delete('dev', {yes: true}, {}, {store, env: env_for()}); + expect(list_profiles(env_for()).available).not.toContain('dev'); + expect(await store.get('dev')).toBeUndefined(); + }); + + it('resets current to default when the deleted profile was current', async()=>{ + add_profile('dev', {}, env_for()); + set_current_profile('dev', env_for()); + await handle_delete('dev', {yes: true}, {}, {store: store_for(), env: env_for()}); + expect(current_profile_name(env_for())).toBe('default'); + }); + + it('prompts and aborts without mutating when the user declines', async()=>{ + add_profile('dev', {}, env_for()); + const store = store_for(); + await store.set('dev', api_key); + await handle_delete('dev', {}, {}, {store, env: env_for(), is_tty: true, confirm: no}); + expect(list_profiles(env_for()).available).toContain('dev'); + expect(await store.get('dev')).toEqual(api_key); + }); + + it('proceeds when the interactive prompt is accepted', async()=>{ + add_profile('dev', {}, env_for()); + await handle_delete('dev', {}, {}, {store: store_for(), env: env_for(), is_tty: true, confirm: yes}); + expect(list_profiles(env_for()).available).not.toContain('dev'); + }); + + it('refuses in a non-interactive shell without --yes', async()=>{ + add_profile('dev', {}, env_for()); + await expect( + handle_delete('dev', {}, {}, {store: store_for(), env: env_for(), is_tty: false}), + ).rejects.toThrow(UsageError); + }); + + it('rejects deleting the built-in default', async()=>{ + await expect( + handle_delete('default', {yes: true}, {}, {store: store_for(), env: env_for()}), + ).rejects.toThrow(UsageError); + }); +}); + +describe('handle_show', ()=>{ + const oauth: Credential_record = { + type: 'oauth', access_token: 'tok-SECRET', refresh_token: 'refresh-SECRET', + expires_at: 4102444800000, user: {id: 3, username: 'alice', team_id: 5}, + }; + + it('never prints a token or refresh token value', async()=>{ + add_profile('dev', {}, env_for()); + const store = store_for(); + await store.set('dev', oauth); + const text = await capture(()=>handle_show('dev', {}, {store, env: env_for()})); + expect(text).not.toContain('tok-SECRET'); + expect(text).not.toContain('refresh-SECRET'); + expect(text.toLowerCase()).toContain('oauth'); + expect(text).toContain('alice'); + }); + + it('does not leak a stored api key value', async()=>{ + add_profile('dev', {}, env_for()); + const store = store_for(); + await store.set('dev', api_key); // key: 'secret-xyz' + const text = await capture(()=>handle_show('dev', {}, {store, env: env_for()})); + expect(text).not.toContain('secret-xyz'); + }); + + it('defaults to the current profile when no name is given', async()=>{ + add_profile('dev', {}, env_for()); + set_current_profile('dev', env_for()); + const text = await capture(()=>handle_show(undefined, {}, {store: store_for(), env: env_for()})); + expect(text).toContain('dev'); + }); + + it('emits JSON with no secret values', async()=>{ + add_profile('dev', {api_base: 'https://api.dev.reply.io/v3'}, env_for()); + const store = store_for(); + await store.set('dev', api_key); + const text = await capture(()=>handle_show('dev', {json: true}, {store, env: env_for()})); + const parsed = JSON.parse(text); + expect(parsed.name).toBe('dev'); + expect(parsed.backend.inherited.authority).toBe(true); + expect(parsed.authorization.stored.present).toBe(true); + expect(parsed.authorization.stored.method).toBe('api_key'); + expect(JSON.stringify(parsed)).not.toContain('secret-xyz'); + }); +}); + +describe('parse_url', ()=>{ + it('accepts http and https URLs', ()=>{ + expect(parse_url('https://api.reply.io/v3')).toBe('https://api.reply.io/v3'); + expect(parse_url('http://localhost:5000')).toBe('http://localhost:5000'); + expect(parse_url(undefined)).toBeUndefined(); + }); + + it('rejects non-URLs and non-http(s) schemes', ()=>{ + expect(()=>parse_url('not a url')).toThrow(UsageError); + expect(()=>parse_url('ftp://example.com')).toThrow(UsageError); + expect(()=>parse_url('file:///etc/passwd')).toThrow(UsageError); + }); +}); + +describe('profile add — name is required (name-on-create)', ()=>{ + it('errors when no name is given', async()=>{ + profile_command.exitOverride(); + for (const c of profile_command.commands) { c.exitOverride(); } + await expect(profile_command.parseAsync(['add'], {from: 'user'})).rejects.toThrow(); + }); +}); diff --git a/src/__tests__/commands/team.test.ts b/src/__tests__/commands/team.test.ts new file mode 100644 index 0000000..1682b76 --- /dev/null +++ b/src/__tests__/commands/team.test.ts @@ -0,0 +1,112 @@ +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const mock_fetch = vi.fn(); +vi.stubGlobal('fetch', mock_fetch); + +import {handle_team_list, handle_team_current, handle_team_use, handle_team_clear} from '../../commands/team'; +import {add_profile, resolve_profile} from '../../profile'; +import {UsageError} from '../../utils/errors'; +import type {Cli_context} from '../../context'; +import type {CredentialStore, Api_key_record} from '../../credentials/types'; + +const res = (data: unknown, status = 200)=> + new Response(JSON.stringify(data), {status, headers: {'Content-Type': 'application/json'}}); + +const api_key_record: Api_key_record = {type: 'api_key', key: 'k', user: {id: 1}}; +const fake_store = (): CredentialStore=>({ + get: async()=>api_key_record, set: async()=>{}, remove: async()=>true, keys: async()=>['dev'], +}); + +const real_set_timeout = globalThis.setTimeout; + +let dir: string; +const env_dir = ()=>({REPLY_CONFIG_DIR: dir}); +const ctx = (profile: string, team_id?: number): Cli_context=>({ + profile, authority: 'https://auth', api_base: 'https://api', key: profile, team_id, + store: fake_store(), refresh: async(r)=>r, +}); + +const capture = async(fn: () => unknown | Promise): Promise=>{ + const out: string[] = []; + const log = console.log; + const write = process.stdout.write; + console.log = (...a: unknown[])=>{ out.push(a.join(' ')); }; + process.stdout.write = ((c: unknown): boolean=>{ out.push(String(c)); return true; }) as typeof process.stdout.write; + try { await fn(); } finally { console.log = log; process.stdout.write = write; } + // Strip ANSI colour codes so assertions are stable under FORCE_COLOR (CI). + return out.join('\n').replace(/\x1b\[[0-9;]*m/g, '').trim(); +}; + +beforeEach(()=>{ + vi.clearAllMocks(); + vi.stubGlobal('setTimeout', ((fn: (...a: unknown[])=>void)=>real_set_timeout(fn, 0)) as unknown as typeof setTimeout); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-team-')); + process.env.REPLY_CONFIG_DIR = dir; +}); +afterEach(()=>{ + vi.stubGlobal('setTimeout', real_set_timeout); + delete process.env.REPLY_CONFIG_DIR; + fs.rmSync(dir, {recursive: true, force: true}); +}); + +describe('handle_team_list', ()=>{ + it('lists teams and marks the profile team with *', async()=>{ + mock_fetch.mockResolvedValue(res([{teamId: 1045, teamName: 'Acme'}, {teamId: 2087, teamName: 'Beta'}])); + const text = await capture(()=>handle_team_list(ctx('dev', 1045), {})); + expect(text).toContain('Acme'); + expect(text).toContain('2087'); + expect(text).toMatch(/\*\s*1045/); + }); + + it('emits JSON with current_team_id + teams', async()=>{ + mock_fetch.mockResolvedValue(res([{teamId: 1045, teamName: 'Acme'}])); + const text = await capture(()=>handle_team_list(ctx('dev', 1045), {json: true})); + const parsed = JSON.parse(text); + expect(parsed.current_team_id).toBe(1045); + expect(parsed.teams).toEqual([{team_id: 1045, team_name: 'Acme'}]); + }); +}); + +describe('handle_team_current', ()=>{ + it('shows pinned (offline) and effective (from whoami)', async()=>{ + mock_fetch.mockResolvedValue(res({userId: 1, username: 'a', teamId: 2087})); + const text = await capture(()=>handle_team_current(ctx('dev', 1045), {})); + expect(text).toContain('pinned team'); + expect(text).toContain('1045'); + expect(text).toContain('effective team'); + expect(text).toContain('2087'); + }); + + it('degrades to failed-to-retrieve when whoami errors', async()=>{ + mock_fetch.mockRejectedValue(new TypeError('offline')); + const text = await capture(()=>handle_team_current(ctx('dev', 1045), {})); + expect(text).toContain('pinned team'); + expect(text).toMatch(/failed to retrieve/i); + }); +}); + +describe('handle_team_use', ()=>{ + it('verifies membership and writes team_id to the current profile', async()=>{ + add_profile('dev', {}, env_dir()); + mock_fetch.mockResolvedValue(res([{teamId: 1045, teamName: 'Acme'}, {teamId: 2087, teamName: 'Beta'}])); + await capture(()=>handle_team_use('2087', ctx('dev'), {})); + expect(resolve_profile('dev', env_dir()).team_id).toBe(2087); + }); + + it('rejects a team the user is not in', async()=>{ + add_profile('dev', {}, env_dir()); + mock_fetch.mockResolvedValue(res([{teamId: 1045, teamName: 'Acme'}])); + await expect(handle_team_use('9999', ctx('dev'), {})).rejects.toThrow(UsageError); + }); +}); + +describe('handle_team_clear', ()=>{ + it('clears the current profile team', async()=>{ + add_profile('dev', {team_id: 1045}, env_dir()); + await capture(()=>handle_team_clear(ctx('dev'), {})); + expect(resolve_profile('dev', env_dir()).team_id).toBeUndefined(); + }); +}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index fe9eb3c..49fba2a 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -1,6 +1,6 @@ import {describe, it, expect} from 'vitest'; import path from 'path'; -import {APP_NAME, env_var, default_config_dir, config_dir} from '../config'; +import {APP_NAME, env_var, default_config_dir, config_dir, cli_version, user_agent} from '../config'; describe('config', ()=>{ describe('env_var', ()=>{ @@ -51,4 +51,16 @@ describe('config', ()=>{ expect(config_dir(override)).toBe(default_config_dir(process.platform, override, require('os').homedir())); }); }); + + describe('cli_version / user_agent', ()=>{ + it('cli_version returns the package.json version', ()=>{ + const pkg = require('../../package.json'); + expect(cli_version()).toBe(pkg.version); + }); + + it('user_agent is -cli/ and identifies the CLI', ()=>{ + expect(user_agent()).toBe(`${APP_NAME}-cli/${cli_version()}`); + expect(user_agent().startsWith('reply-cli/')).toBe(true); + }); + }); }); diff --git a/src/__tests__/profile.test.ts b/src/__tests__/profile.test.ts index 22e094d..dda426b 100644 --- a/src/__tests__/profile.test.ts +++ b/src/__tests__/profile.test.ts @@ -2,7 +2,7 @@ import {describe, it, expect, beforeEach, afterEach} from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; -import {resolve_profile, current_profile_name, list_profiles, set_current_profile, add_profile, set_profile, PROD} from '../profile'; +import {resolve_profile, current_profile_name, list_profiles, set_current_profile, add_profile, set_profile, rename_profile_def, delete_profile_def, unset_profile_field, describe_profile, PROD} from '../profile'; import {UsageError, RuntimeError} from '../utils/errors'; let dir: string; @@ -23,14 +23,14 @@ describe('profile — no environment abstraction, only a default + user profiles it('resolves the built-in default to prod when nothing is set and no config exists', ()=>{ const p = resolve_profile(undefined, env_for()); expect(p).toEqual({name: 'default', authority: PROD.authority, api_base: PROD.api_base}); - expect(p.api_base).toBe('https://api.reply.io/v3'); + expect(p.api_base).toBe('https://api.reply.io'); expect(p.authority).toBe('https://oauth.reply.io'); }); it('selects a user-defined profile via --profile', ()=>{ - write_config({profiles: {dev: {authority: 'https://oauth.dev.replyapp.io', api_base: 'https://api.dev.reply.io/v3'}}}); + write_config({profiles: {dev: {authority: 'https://oauth.dev.replyapp.io', api_base: 'https://api.dev.reply.io'}}}); const p = resolve_profile('dev', env_for()); - expect(p).toMatchObject({name: 'dev', authority: 'https://oauth.dev.replyapp.io', api_base: 'https://api.dev.reply.io/v3'}); + expect(p).toMatchObject({name: 'dev', authority: 'https://oauth.dev.replyapp.io', api_base: 'https://api.dev.reply.io'}); }); it('uses REPLY_PROFILE when no flag is given', ()=>{ @@ -56,10 +56,10 @@ describe('profile — no environment abstraction, only a default + user profiles }); it('strips trailing slashes from profile URLs', ()=>{ - write_config({profiles: {dev: {authority: 'https://a/', api_base: 'https://b/v3/'}}}); + write_config({profiles: {dev: {authority: 'https://a/', api_base: 'https://b/'}}}); const p = resolve_profile('dev', env_for()); expect(p.authority).toBe('https://a'); - expect(p.api_base).toBe('https://b/v3'); + expect(p.api_base).toBe('https://b'); }); it('inherits missing URLs from the embedded default (prod)', ()=>{ @@ -69,10 +69,10 @@ describe('profile — no environment abstraction, only a default + user profiles }); it('inherits per-field: overrides api_base but keeps the prod authority', ()=>{ - write_config({profiles: {stg: {api_base: 'https://api.stage.reply.io/v3'}}}); + write_config({profiles: {stg: {api_base: 'https://api.stage.reply.io'}}}); const p = resolve_profile('stg', env_for()); expect(p.authority).toBe(PROD.authority); - expect(p.api_base).toBe('https://api.stage.reply.io/v3'); + expect(p.api_base).toBe('https://api.stage.reply.io'); }); it('throws a RuntimeError on a corrupt config file', ()=>{ @@ -141,9 +141,9 @@ describe('profile — add (create, URLs optional)', ()=>{ }); it('creates a profile with explicit URLs', ()=>{ - add_profile('dev', {authority: 'https://oauth.dev.replyapp.io', api_base: 'https://api.dev.reply.io/v3'}, env_for()); + add_profile('dev', {authority: 'https://oauth.dev.replyapp.io', api_base: 'https://api.dev.reply.io'}, env_for()); expect(resolve_profile('dev', env_for())).toMatchObject({ - authority: 'https://oauth.dev.replyapp.io', api_base: 'https://api.dev.reply.io/v3', + authority: 'https://oauth.dev.replyapp.io', api_base: 'https://api.dev.reply.io', }); }); @@ -207,3 +207,131 @@ describe('profile — set (edit an existing profile, merge-safe)', ()=>{ expect(()=>set_profile('nope', {team_id: 1}, env_for())).toThrow(UsageError); }); }); + +describe('profile — rename_profile_def (config-only)', ()=>{ + it('renames a profile def and preserves siblings', ()=>{ + add_profile('alice@reply.io', {team_id: 5}, env_for()); + add_profile('bob@reply.io', {}, env_for()); + rename_profile_def('alice@reply.io', 'ally@reply.io', env_for()); + const l = list_profiles(env_for()); + expect([...l.available].sort()).toEqual(['ally@reply.io', 'bob@reply.io', 'default']); + expect(resolve_profile('ally@reply.io', env_for()).team_id).toBe(5); + expect(()=>resolve_profile('alice@reply.io', env_for())).toThrow(UsageError); + }); + + it('repoints current_profile when the renamed profile was current', ()=>{ + add_profile('dev', {authority: 'https://a', api_base: 'https://b'}, env_for()); + set_current_profile('dev', env_for()); + rename_profile_def('dev', 'staging', env_for()); + expect(current_profile_name(env_for())).toBe('staging'); + }); + + it('leaves current_profile alone when a non-current profile is renamed', ()=>{ + add_profile('dev', {}, env_for()); + add_profile('qa', {}, env_for()); + set_current_profile('dev', env_for()); + rename_profile_def('qa', 'qa2', env_for()); + expect(current_profile_name(env_for())).toBe('dev'); + }); + + it('rejects renaming the built-in default', ()=>{ + expect(()=>rename_profile_def('default', 'x', env_for())).toThrow(UsageError); + }); + + it('rejects an unknown source profile', ()=>{ + expect(()=>rename_profile_def('nope', 'x', env_for())).toThrow(UsageError); + }); + + it('rejects an empty, default, or same-as-old target', ()=>{ + add_profile('dev', {}, env_for()); + expect(()=>rename_profile_def('dev', ' ', env_for())).toThrow(UsageError); + expect(()=>rename_profile_def('dev', 'default', env_for())).toThrow(UsageError); + expect(()=>rename_profile_def('dev', 'dev', env_for())).toThrow(UsageError); + }); + + it('rejects a target that already exists', ()=>{ + add_profile('dev', {}, env_for()); + add_profile('qa', {}, env_for()); + expect(()=>rename_profile_def('dev', 'qa', env_for())).toThrow(UsageError); + }); +}); + +describe('profile — delete_profile_def (config-only)', ()=>{ + it('removes a profile def and preserves siblings', ()=>{ + add_profile('dev', {}, env_for()); + add_profile('qa', {}, env_for()); + const {was_current} = delete_profile_def('dev', env_for()); + expect(was_current).toBe(false); + expect([...list_profiles(env_for()).available].sort()).toEqual(['default', 'qa']); + }); + + it('resets current to default when the deleted profile was current', ()=>{ + add_profile('dev', {}, env_for()); + set_current_profile('dev', env_for()); + const {was_current} = delete_profile_def('dev', env_for()); + expect(was_current).toBe(true); + expect(current_profile_name(env_for())).toBe('default'); + }); + + it('rejects deleting the built-in default and unknown profiles', ()=>{ + expect(()=>delete_profile_def('default', env_for())).toThrow(UsageError); + expect(()=>delete_profile_def('nope', env_for())).toThrow(UsageError); + }); +}); + +describe('profile — unset_profile_field', ()=>{ + it('clears team_id, dropping the pin', ()=>{ + add_profile('dev', {authority: 'https://a', api_base: 'https://b', team_id: 5}, env_for()); + const {changed} = unset_profile_field('dev', 'team_id', env_for()); + expect(changed).toBe(true); + expect(resolve_profile('dev', env_for()).team_id).toBeUndefined(); + expect(resolve_profile('dev', env_for()).authority).toBe('https://a'); + }); + + it('clears a URL, reverting it to the inherited prod default', ()=>{ + add_profile('dev', {authority: 'https://a', api_base: 'https://b'}, env_for()); + unset_profile_field('dev', 'api_base', env_for()); + expect(resolve_profile('dev', env_for()).api_base).toBe(PROD.api_base); + expect(resolve_profile('dev', env_for()).authority).toBe('https://a'); + }); + + it('is an idempotent no-op when the field is already unset', ()=>{ + add_profile('dev', {}, env_for()); + expect(unset_profile_field('dev', 'team_id', env_for()).changed).toBe(false); + }); + + it('works on the built-in default (clears an override)', ()=>{ + set_profile('default', {team_id: 99}, env_for()); + expect(unset_profile_field('default', 'team_id', env_for()).changed).toBe(true); + expect(resolve_profile(undefined, env_for()).team_id).toBeUndefined(); + }); + + it('rejects an unknown profile', ()=>{ + expect(()=>unset_profile_field('nope', 'team_id', env_for())).toThrow(UsageError); + }); +}); + +describe('profile — describe_profile', ()=>{ + it('marks inherited URLs and reports team_id + current', ()=>{ + add_profile('dev', {api_base: 'https://api.dev.reply.io', team_id: 7}, env_for()); + set_current_profile('dev', env_for()); + const d = describe_profile('dev', env_for()); + expect(d.name).toBe('dev'); + expect(d.api_base).toBe('https://api.dev.reply.io'); + expect(d.authority).toBe(PROD.authority); + expect(d.inherited).toEqual({authority: true, api_base: false}); + expect(d.team_id).toBe(7); + expect(d.is_current).toBe(true); + }); + + it('describes the built-in default as fully inherited', ()=>{ + const d = describe_profile('default', env_for()); + expect(d.inherited).toEqual({authority: true, api_base: true}); + expect(d.is_current).toBe(true); + expect(d.team_id).toBeUndefined(); + }); + + it('throws for an unknown profile', ()=>{ + expect(()=>describe_profile('nope', env_for())).toThrow(UsageError); + }); +}); diff --git a/src/__tests__/teams.test.ts b/src/__tests__/teams.test.ts new file mode 100644 index 0000000..17835c2 --- /dev/null +++ b/src/__tests__/teams.test.ts @@ -0,0 +1,91 @@ +import {describe, it, expect, beforeEach, vi} from 'vitest'; + +const mock_fetch = vi.fn(); +vi.stubGlobal('fetch', mock_fetch); + +import {parse_teams, resolve_my_teams, team_error_guidance} from '../teams'; +import {RuntimeError} from '../utils/errors'; + +const res = (data: unknown, status = 200)=> + new Response(JSON.stringify(data), {status, headers: {'Content-Type': 'application/json'}}); + +beforeEach(()=>vi.clearAllMocks()); + +describe('parse_teams', ()=>{ + it('dedupes by id, reads case-insensitive keys, drops malformed', ()=>{ + const teams = parse_teams([ + {teamId: 1, teamName: 'A'}, + {TeamId: 1, TeamName: 'A dup'}, + {teamId: 2, teamName: 'B', userId: 9}, + {teamName: 'no id'}, + 'garbage', + ]); + expect(teams).toEqual([{team_id: 1, team_name: 'A'}, {team_id: 2, team_name: 'B'}]); + }); + + it('returns [] for non-arrays', ()=>{ + expect(parse_teams(undefined)).toEqual([]); + expect(parse_teams({})).toEqual([]); + }); +}); + +describe('resolve_my_teams', ()=>{ + const deps = {api_base: 'https://api', token: 'tok'}; + + it('maps a 200 team-users array to distinct teams', async()=>{ + mock_fetch.mockResolvedValue(res([{teamId: 1, teamName: 'A'}, {teamId: 2, teamName: 'B'}])); + expect(await resolve_my_teams(deps)).toEqual([{team_id: 1, team_name: 'A'}, {team_id: 2, team_name: 'B'}]); + }); + + it('falls back to teams[] from a TEAM_REQUIRED 403', async()=>{ + mock_fetch.mockResolvedValue(res({code: 'TEAM_REQUIRED', teams: [{teamId: 7, teamName: 'G'}]}, 403)); + expect(await resolve_my_teams(deps)).toEqual([{team_id: 7, team_name: 'G'}]); + }); + + it('throws when the list is genuinely unavailable (USER_NOT_FOUND 401)', async()=>{ + mock_fetch.mockResolvedValue(res({code: 'USER_NOT_FOUND'}, 401)); + await expect(resolve_my_teams(deps)).rejects.toThrow(RuntimeError); + }); + + it('falls back to the single whoami team when team-users is org-only', async()=>{ + mock_fetch + .mockResolvedValueOnce(res({code: 'workspace.organizationRequired', title: 'Forbidden'}, 403)) + .mockResolvedValueOnce(res({userId: 1, username: 'a', teamId: 1045}, 200)); + expect(await resolve_my_teams(deps)).toEqual([{team_id: 1045, team_name: ''}]); + }); + + it('returns [] when org-only and whoami has no team', async()=>{ + mock_fetch + .mockResolvedValueOnce(res({code: 'workspace.organizationRequired'}, 403)) + .mockResolvedValueOnce(res({userId: 1}, 200)); + expect(await resolve_my_teams(deps)).toEqual([]); + }); +}); + +describe('team_error_guidance', ()=>{ + it('TEAM_REQUIRED → multi-team fix + renders teams', ()=>{ + const g = team_error_guidance(403, {code: 'TEAM_REQUIRED', teams: [{teamId: 1, teamName: 'A'}]}); + expect(g).toMatch(/multiple teams/i); + expect(g).toMatch(/reply team use/); + expect(g).toContain('1 A'); + }); + + it('TEAM_NOT_ACCESSIBLE → lists your teams', ()=>{ + const g = team_error_guidance(403, {code: 'TEAM_NOT_ACCESSIBLE', teams: [{teamId: 2, teamName: 'B'}]}); + expect(g).toMatch(/act in/i); + expect(g).toContain('2 B'); + }); + + it('USER_REQUIRED → acting-user hint', ()=>{ + expect(team_error_guidance(403, {code: 'USER_REQUIRED'})).toMatch(/--user-id|--user-email/); + }); + + it('USER_NOT_FOUND → no-user hint', ()=>{ + expect(team_error_guidance(401, {code: 'USER_NOT_FOUND'})).toMatch(/No Reply user/i); + }); + + it('returns undefined for unrelated bodies', ()=>{ + expect(team_error_guidance(404, {code: 'contact.notFound'})).toBeUndefined(); + expect(team_error_guidance(500, 'oops')).toBeUndefined(); + }); +}); diff --git a/src/__tests__/utils/client.test.ts b/src/__tests__/utils/client.test.ts index b166e04..54ccbba 100644 --- a/src/__tests__/utils/client.test.ts +++ b/src/__tests__/utils/client.test.ts @@ -120,4 +120,67 @@ describe('utils/client', ()=>{ expect.objectContaining({headers: expect.objectContaining({'X-TEAM-ID': '9'})}), ); }); + + describe('base+path joining is slash-safe', ()=>{ + it('collapses a double slash (base trailing / + path leading /)', async()=>{ + mock_fetch.mockResolvedValue(json_res({ok: true})); + await get('https://api.reply.io/', 'tok', '/v3/whoami'); + expect(mock_fetch.mock.calls[0][0]).toBe('https://api.reply.io/v3/whoami'); + }); + it('inserts a missing slash (base no-slash + path no-slash)', async()=>{ + mock_fetch.mockResolvedValue(json_res({ok: true})); + await get('https://api.reply.io', 'tok', 'v3/whoami'); + expect(mock_fetch.mock.calls[0][0]).toBe('https://api.reply.io/v3/whoami'); + }); + it('leaves a well-formed base+path unchanged (incl. query)', async()=>{ + mock_fetch.mockResolvedValue(json_res({ok: true})); + await get('https://api.reply.io', 'tok', '/v3/contacts?limit=10'); + expect(mock_fetch.mock.calls[0][0]).toBe('https://api.reply.io/v3/contacts?limit=10'); + }); + }); + + describe('request_raw', ()=>{ + it('returns {status,data,…} for a 2xx without throwing, Authorization redacted', async()=>{ + mock_fetch.mockResolvedValue(json_res({ok: true}, 200)); + const {request_raw} = await import('../../utils/client'); + const r = await request_raw(BASE, 'tok', 'GET', '/x'); + expect(r).toMatchObject({status: 200, data: {ok: true}}); + expect(r.request.headers.Authorization).toMatch(/^Bearer •+$/); + expect(JSON.stringify(r)).not.toContain('tok'); + }); + it('returns {status,data} for a 4xx without throwing', async()=>{ + mock_fetch.mockResolvedValue(err_res(403, {code: 'TEAM_REQUIRED', teams: []})); + const {request_raw} = await import('../../utils/client'); + const r = await request_raw(BASE, 'tok', 'GET', '/x'); + expect(r.status).toBe(403); + expect((r.data as {code: string}).code).toBe('TEAM_REQUIRED'); + }); + it('serializes a body and sets the method', async()=>{ + mock_fetch.mockResolvedValue(json_res({id: 1}, 201)); + const {request_raw} = await import('../../utils/client'); + await request_raw(BASE, 'tok', 'POST', '/x', {a: 1}); + const [, init] = mock_fetch.mock.calls[0]; + expect(init.method).toBe('POST'); + expect(init.body).toBe('{"a":1}'); + }); + it('retries a transient 503 then returns', async()=>{ + instant_timers(); + mock_fetch.mockResolvedValueOnce(err_res(503)).mockResolvedValueOnce(json_res({ok: true})); + const {request_raw} = await import('../../utils/client'); + expect((await request_raw(BASE, 'tok', 'GET', '/x')).status).toBe(200); + }); + it('throws RuntimeError on network failure after retries', async()=>{ + instant_timers(); + mock_fetch.mockRejectedValue(new TypeError('fetch failed')); + const {request_raw} = await import('../../utils/client'); + await expect(request_raw(BASE, 'tok', 'GET', '/x')).rejects.toThrow(RuntimeError); + }); + }); + + it('sends a User-Agent identifying the CLI on every request', async()=>{ + mock_fetch.mockResolvedValue(json_res({ok: true})); + await get(BASE, 'tok', '/x'); + const [, init] = mock_fetch.mock.calls[0]; + expect(init.headers['User-Agent']).toMatch(/^reply-cli\/\d+\.\d+\.\d+/); + }); }); diff --git a/src/commands/api.ts b/src/commands/api.ts new file mode 100644 index 0000000..3083a67 --- /dev/null +++ b/src/commands/api.ts @@ -0,0 +1,161 @@ +import fs from 'fs'; +import {Command} from 'commander'; +import {build_context, type Cli_context} from '../context'; +import {request_raw, type Raw_response} from '../utils/client'; +import {team_error_guidance} from '../teams'; +import {authed} from './authed'; +import {UsageError} from '../utils/errors'; +import {print, warn, REDACTED, type Print_opts} from '../utils/output'; + +type Global_opts = { + apiKey?: string; + profile?: string; + teamId?: string; + userId?: string; + userEmail?: string; + json?: boolean; + pretty?: boolean; + verbose?: boolean; +}; + +const read_globals = (cmd: Command): Global_opts=>{ + const o = cmd.optsWithGlobals(); + return { + apiKey: o.apiKey, profile: o.profile, + teamId: o.teamId, userId: o.userId, userEmail: o.userEmail, + json: o.json, pretty: o.pretty, verbose: o.verbose, + }; +}; + +// curl -v-style request/response trace to stderr (keeps stdout the clean +// {code, data}). Authorization is already redacted by request_raw; redact any +// cookie header defensively. +const print_trace = (r: Raw_response): void=>{ + const lines = [`> ${r.request.method} ${r.request.url}`]; + for (const [k, v] of Object.entries(r.request.headers)) + { + lines.push(`> ${k}: ${v}`); + } + if (r.request.body) + { + lines.push('>', `> ${r.request.body}`); + } + lines.push(`< ${r.status}`); + for (const [k, v] of Object.entries(r.response_headers)) + { + lines.push(`< ${k}: ${/cookie/i.test(k) ? REDACTED : v}`); + } + console.error(lines.join('\n')); +}; + +const print_opts = (g: Global_opts): Print_opts=>({json: g.json, pretty: g.pretty}); + +const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; + +const read_all_stdin = (): string=>{ + try { + return fs.readFileSync(0, 'utf8'); + } catch { + return ''; + } +}; + +// Parse the --body argument: inline JSON, @, or '-' for stdin. Injectable +// stdin reader keeps it unit-testable. +const read_body_arg = (raw: string | undefined, read_stdin: () => string = read_all_stdin): unknown=>{ + if (raw === undefined) + { + return undefined; + } + let text: string; + if (raw === '-') + { + text = read_stdin(); + } + else if (raw.startsWith('@')) + { + try { + text = fs.readFileSync(raw.slice(1), 'utf8'); + } catch (e) { + throw new UsageError(`Could not read body file '${raw.slice(1)}'.`, { + code: 'usage.api', hint: (e as Error).message, + }); + } + } + else + { + text = raw; + } + try { + return JSON.parse(text); + } catch { + throw new UsageError('--body must be valid JSON (inline, @file, or - for stdin).', {code: 'usage.api'}); + } +}; + +const resolve_method = (flag: string | undefined, has_body: boolean): string=>{ + if (flag === undefined) + { + return has_body ? 'POST' : 'GET'; + } + const m = flag.trim().toUpperCase(); + if (!ALLOWED_METHODS.includes(m)) + { + throw new UsageError(`--method must be one of ${ALLOWED_METHODS.join(', ')}.`, { + code: 'usage.api', hint: `Got: ${flag}`, + }); + } + return m; +}; + +// Raw passthrough to a v3 endpoint. Prints {code, data} for any status; exits 1 +// on >=400. On a team/user-resolution conflict, adds tailored guidance to stderr +// — this is the workload surface where such guidance belongs. +const handle_api = async( + path: string, opts: {method?: string; body?: string}, ctx: Cli_context, g: Global_opts, +): Promise=>{ + const body = read_body_arg(opts.body); + const method = resolve_method(opts.method, body !== undefined); + const {token, headers} = await authed(ctx, g); + // Literal: the request URL is exactly api_base + the path the caller typed. + const resp = await request_raw(ctx.api_base, token, method, path, body, {headers}); + const {status, data} = resp; + if (g.verbose) + { + print_trace(resp); + } + print({code: status, data}, print_opts(g)); + if (status >= 400) + { + process.exitCode = 1; + const guidance = team_error_guidance(status, data); + if (guidance) + { + warn(guidance); + } + } +}; + +const api_command = new Command('api') + .argument('', 'v3 path as in the docs, e.g. /v3/whoami; query goes in the path') + .option('--method ', 'HTTP method (default GET; POST when --body is given)') + .option('--body ', 'JSON body: inline, @file, or - for stdin') + .description('Raw authenticated request to a v3 endpoint; prints {code, data}') + .addHelpText('after', + '\nDocs: https://docs.reply.io/api-reference/introduction\n' + + '\nUse the path exactly as in the docs (starts with /v3); the query string\n' + + 'goes in the path.\n' + + '\nExamples:\n' + + ' reply api /v3/whoami # your identity + team\n' + + ' reply api /v3/sequences # list sequences\n' + + ' reply api /v3/contacts --pretty # list contacts (indented)\n' + + ' reply api /v3/sequences/12345 # one sequence by id\n' + + ' reply api /v3/contacts --body @contact.json # create a contact (POST; body per docs)\n' + + ' echo \'\' | reply api /v3/contacts --body - # body from stdin') + .action(async function(this: Command, path: string) { + const g = read_globals(this); + const o = this.opts(); + await handle_api(path, {method: o.method, body: o.body}, build_context({profile: g.profile}), g); + }); + +export {api_command, handle_api, read_body_arg}; diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 8f7ead6..a5517a5 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -81,7 +81,7 @@ const principal_label = (p: Principal): string=>{ const fetch_whoami = async( api_base: string, token: string, headers?: Record, ): Promise>=>{ - const raw = await create_client(api_base, token, headers).get>('/whoami'); + const raw = await create_client(api_base, token, headers).get>('/v3/whoami'); return raw ?? {}; }; @@ -300,6 +300,8 @@ auth_command auth_command .command('status') .description('Show the active credential source, method, user and OAuth expiry (no secrets)') + .addHelpText('after', + `\nExamples:\n ${PROGRAM_NAME} auth status\n ${PROGRAM_NAME} auth status --json\n\nRuns offline — no API call.`) .action(async function(this: Command) { const g = read_globals(this); await handle_status(build_context({profile: g.profile}), g); diff --git a/src/commands/authed.ts b/src/commands/authed.ts new file mode 100644 index 0000000..3f2d884 --- /dev/null +++ b/src/commands/authed.ts @@ -0,0 +1,25 @@ +import {resolve_credential} from '../auth/resolve'; +import {resolve_request_identity} from '../auth/request-identity'; +import {warn} from '../utils/output'; +import type {Cli_context} from '../context'; + +type Identity_opts = {apiKey?: string; teamId?: string; userId?: string; userEmail?: string}; + +// Resolve the credential + team/acting-user headers for an authenticated call, +// surfacing identity warnings to stderr. Shared by the team and api commands +// (mirrors how the auth commands resolve the same pair). +const authed = async( + ctx: Cli_context, g: Identity_opts, +): Promise<{token: string; headers: Record}>=>{ + const resolved = await resolve_credential( + {api_key: g.apiKey}, {key: ctx.key, store: ctx.store, env: process.env, refresh: ctx.refresh}); + const identity = resolve_request_identity({ + team_id_flag: g.teamId, user_id_flag: g.userId, user_email_flag: g.userEmail, + env: process.env, profile_team_id: ctx.team_id, credential_type: resolved.type, + }); + identity.warnings.forEach(w=>warn(w)); + return {token: resolved.token, headers: identity.headers}; +}; + +export {authed}; +export type {Identity_opts}; diff --git a/src/commands/profile.ts b/src/commands/profile.ts index eff67f6..0ea07b8 100644 --- a/src/commands/profile.ts +++ b/src/commands/profile.ts @@ -1,13 +1,23 @@ +import readline from 'readline'; import {Command} from 'commander'; -import {current_profile_name, list_profiles, set_current_profile, add_profile, set_profile} from '../profile'; +import { + current_profile_name, list_profiles, set_current_profile, add_profile, set_profile, + rename_profile_def, delete_profile_def, unset_profile_field, describe_profile, + type Clearable_field, type Profile_description, +} from '../profile'; +import {default_credential_store} from '../credentials/file-store'; +import {describe_status, type Auth_status} from '../auth/status'; +import {principal_label} from './auth'; import {UsageError} from '../utils/errors'; -import {success, print, pc, type Print_opts} from '../utils/output'; +import {PROGRAM_NAME, get_env, env_var, type Env} from '../config'; +import {success, info, print, pc, type Print_opts} from '../utils/output'; +import type {CredentialStore, Credential_record} from '../credentials/types'; -type Global_opts = {json?: boolean; pretty?: boolean}; +type Global_opts = {json?: boolean; pretty?: boolean; apiKey?: string}; const read_globals = (cmd: Command): Global_opts=>{ const o = cmd.optsWithGlobals(); - return {json: o.json, pretty: o.pretty}; + return {json: o.json, pretty: o.pretty, apiKey: o.apiKey}; }; const wants_json = (g: Global_opts): boolean=>Boolean(g.json || g.pretty); @@ -25,6 +35,231 @@ const parse_team_id = (v?: string): number | undefined=>{ return parseInt(v.trim(), 10); }; +const parse_url = (v?: string): string | undefined=>{ + if (v === undefined) + { + return undefined; + } + const s = v.trim(); + let parsed: URL; + try { + parsed = new URL(s); + } catch { + throw new UsageError('--authority/--api-base must be an http(s) URL.', { + code: 'usage.profile', hint: `Got: ${v}`, + }); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') + { + throw new UsageError('--authority/--api-base must be an http(s) URL.', { + code: 'usage.profile', hint: `Got: ${v}`, + }); + } + return s; +}; + +// User-facing field names for `unset` (hyphenated) mapped to config keys. +const CLEARABLE_INPUT: Record = { + 'authority': 'authority', + 'api_base': 'api_base', + 'api-base': 'api_base', + 'team_id': 'team_id', + 'team-id': 'team_id', +}; + +const map_clearable = (field: string): Clearable_field=>{ + const mapped = CLEARABLE_INPUT[field.trim().toLowerCase()]; + if (!mapped) + { + throw new UsageError(`Can't clear '${field}'.`, { + code: 'usage.profile', hint: 'Clearable fields: authority, api_base, team-id.', + }); + } + return mapped; +}; + +// Interactive y/N prompt on stderr (so JSON stdout stays clean). Injected in +// tests. Anything not starting with y/yes is treated as "no". +const confirm = (question: string): Promise=>{ + const rl = readline.createInterface({input: process.stdin, output: process.stderr}); + return new Promise(resolve=>{ + rl.question(`${question} [y/N] `, answer=>{ + rl.close(); + resolve(/^y(es)?$/i.test(answer.trim())); + }); + }); +}; + +type Rename_deps = {store: CredentialStore; env?: Env}; + +// Rename orchestration. Ordering (two files, no cross-file transaction — never +// lose or clobber a login): (1) refuse if a credential already lives under the +// target key; (2) read the old credential; (3) rename the config def; (4) move +// the credential. A partial failure orphans the old credential (recoverable by +// re-login), never destroys it. +const handle_rename = async( + old_name: string, new_name: string, g: Global_opts, deps: Rename_deps, +): Promise=>{ + const env = deps.env ?? process.env; + if (await deps.store.get(new_name)) + { + throw new UsageError(`A credential already exists for '${new_name}'.`, { + code: 'usage.profile', hint: `Log out of '${new_name}' or pick another name.`, + }); + } + const cred = await deps.store.get(old_name); + rename_profile_def(old_name, new_name, env); + let credential_moved = false; + if (cred) + { + await deps.store.set(new_name, cred); + await deps.store.remove(old_name); + credential_moved = true; + } + const current_updated = current_profile_name(env) === new_name; + if (wants_json(g)) + { + print({renamed: {from: old_name, to: new_name}, current_updated, credential_moved}, print_opts(g)); + return; + } + success(`Renamed profile '${old_name}' → '${new_name}'.`); + if (current_updated) + { + info(' (was the current profile)'); + } +}; + +type Delete_deps = { + store: CredentialStore; + env?: Env; + is_tty?: boolean; + confirm?: (q: string) => Promise; +}; + +// Delete a profile and its stored credential. Validated before prompting so a +// bad name fails fast. Confirm-by-default: interactive y/N on a TTY, `--yes` +// skips it, and a non-interactive shell without `--yes` is refused. +const handle_delete = async( + name: string, opts: {yes?: boolean}, g: Global_opts, deps: Delete_deps, +): Promise=>{ + const env = deps.env ?? process.env; + if (name === 'default') + { + throw new UsageError('The built-in default profile can\'t be deleted.', {code: 'usage.profile'}); + } + if (!list_profiles(env).available.includes(name)) + { + throw new UsageError(`Unknown profile '${name}'.`, { + code: 'usage.profile', hint: 'List profiles with `profile list`.', + }); + } + if (!opts.yes) + { + const is_tty = deps.is_tty ?? Boolean(process.stdin.isTTY); + if (!is_tty) + { + throw new UsageError('Refusing to delete without confirmation.', { + code: 'usage.profile', hint: 'Pass --yes to confirm in a non-interactive shell.', + }); + } + const ask = deps.confirm ?? confirm; + if (!await ask(`Delete profile '${name}' and its stored credential?`)) + { + info('Aborted.'); + return; + } + } + const {was_current} = delete_profile_def(name, env); + const credential_removed = await deps.store.remove(name); + if (wants_json(g)) + { + print({deleted: name, credential_removed, current_reset: was_current}, print_opts(g)); + return; + } + success(`Deleted profile '${name}'.`); + if (credential_removed) + { + info(' Removed its stored credential.'); + } + if (was_current) + { + info(' Current profile reset to default.'); + } +}; + +type Show_deps = {store: CredentialStore; env?: Env; api_key_flag?: string}; + +// Redacted, one-line summary of the stored credential — never a raw secret. +const stored_summary = (status: Auth_status, record?: Credential_record): string=>{ + if (!record) + { + return 'none — run `reply auth login`'; + } + const who = record.user ? principal_label(record.user) : 'unknown'; + if (status.method === 'oauth') + { + return `oauth · ${who} · expires ${status.expires_at}${status.expired ? pc.red(' (expired)') : ''}`; + } + return `api_key · ${who}`; +}; + +const show_json = ( + d: Profile_description, status: Auth_status, + has_flag: boolean, has_env: boolean, record?: Credential_record, +)=>({ + name: d.name, + current: d.is_current, + backend: {authority: d.authority, api_base: d.api_base, inherited: d.inherited}, + team_id: d.team_id ?? null, + authorization: { + flag: has_flag, + env: has_env, + stored: record + ? { + present: true, + method: status.method, + ...(status.user ? {user: status.user} : {}), + ...(status.expires_at ? {expires_at: status.expires_at, expired: status.expired} : {}), + } + : null, + effective_source: status.authenticated ? status.source : null, + }, +}); + +// Show a profile's backend, team, and authorization. Reads the stored credential +// raw (no refresh, no write) and reuses describe_status for a secret-free summary. +const handle_show = async( + name: string | undefined, g: Global_opts, deps: Show_deps, +): Promise=>{ + const env = deps.env ?? process.env; + const target = name ?? current_profile_name(env); + const d = describe_profile(target, env); + const api_key_env = get_env('API_KEY', env); + const record = await deps.store.get(target); + const status = describe_status({ + profile: target, api_key_flag: deps.api_key_flag, api_key_env, record, now: Date.now(), + }); + if (wants_json(g)) + { + print(show_json(d, status, Boolean(deps.api_key_flag), Boolean(api_key_env), record), print_opts(g)); + return; + } + const inh = (on: boolean): string=>on ? ' (inherited)' : ''; + const lines = [ + `Profile: ${d.name}${d.is_current ? ' (current)' : ''}`, + ' Backend:', + ` authority : ${d.authority}${inh(d.inherited.authority)}`, + ` api_base : ${d.api_base}${inh(d.inherited.api_base)}`, + ' Team:', + ` team_id : ${d.team_id ?? '—'}`, + ' Authorization (first available is used):', + ` 1. --api-key flag : ${deps.api_key_flag ? 'provided' : 'not provided'}`, + ` 2. ${env_var('API_KEY')} env : ${api_key_env ? 'set' : 'not set'}`, + ` 3. stored credential : ${stored_summary(status, record)}`, + ]; + console.log(lines.join('\n')); +}; + const profile_command = new Command('profile') .description('Manage profiles — optional; most users never need one (default = prod)'); @@ -35,11 +270,14 @@ profile_command .option('--authority ', 'Override the OAuth authority (advanced; defaults to prod)') .option('--api-base ', 'Override the API base (advanced; defaults to prod)') .description('Create a profile (URLs optional — omitted fields inherit the default/prod)') + .addHelpText('after', + `\nExamples:\n ${PROGRAM_NAME} profile add alice@reply.io` + + `\n ${PROGRAM_NAME} profile add dev --api-base https://api.dev.reply.io --team-id 1045`) .action(function(this: Command, name: string) { const g = read_globals(this); const opts = this.optsWithGlobals(); add_profile(name, { - authority: opts.authority, api_base: opts.apiBase, team_id: parse_team_id(opts.teamId), + authority: parse_url(opts.authority), api_base: parse_url(opts.apiBase), team_id: parse_team_id(opts.teamId), }); if (wants_json(g)) { @@ -56,11 +294,14 @@ profile_command .option('--authority ', 'Override the OAuth authority (advanced)') .option('--api-base ', 'Override the API base (advanced)') .description('Edit an existing profile in place — only the fields you pass change') + .addHelpText('after', + `\nExamples:\n ${PROGRAM_NAME} profile set alice@reply.io --team-id 1045` + + `\n ${PROGRAM_NAME} profile set default --team-id 1045 # pin a team globally`) .action(function(this: Command, name: string) { const g = read_globals(this); const opts = this.optsWithGlobals(); set_profile(name, { - authority: opts.authority, api_base: opts.apiBase, team_id: parse_team_id(opts.teamId), + authority: parse_url(opts.authority), api_base: parse_url(opts.apiBase), team_id: parse_team_id(opts.teamId), }); if (wants_json(g)) { @@ -70,10 +311,37 @@ profile_command success(`Profile '${name}' updated.`); }); +profile_command + .command('rename') + .argument('', 'Existing profile to rename') + .argument('', 'New name') + .description('Rename a profile (also moves its stored credential)') + .addHelpText('after', `\nExamples:\n ${PROGRAM_NAME} profile rename alice@reply.io ally`) + .action(async function(this: Command, old_name: string, new_name: string) { + const g = read_globals(this); + await handle_rename(old_name, new_name, g, {store: default_credential_store()}); + }); + +profile_command + .command('delete') + .alias('rm') + .argument('', 'Profile to delete') + .option('-y, --yes', 'Skip the confirmation prompt') + .description('Delete a profile and its stored credential') + .addHelpText('after', + `\nExamples:\n ${PROGRAM_NAME} profile delete ally # confirm y/N (interactive)` + + `\n ${PROGRAM_NAME} profile delete ally --yes # skip the prompt (required in scripts)`) + .action(async function(this: Command, name: string) { + const g = read_globals(this); + await handle_delete(name, {yes: Boolean(this.opts().yes)}, g, {store: default_credential_store()}); + }); + profile_command .command('use') .argument('', 'Profile to make current (a user-defined profile, or "default" for prod)') .description('Set the current profile, used until changed') + .addHelpText('after', + `\nExamples:\n ${PROGRAM_NAME} profile use dev\n ${PROGRAM_NAME} profile use default`) .action(function(this: Command, name: string) { const g = read_globals(this); set_current_profile(name); @@ -85,6 +353,29 @@ profile_command success(`Current profile set to ${name}.`); }); +profile_command + .command('unset') + .argument('', 'Profile to edit (or "default")') + .argument('', 'Field to clear: authority | api_base | team-id') + .description('Clear a config field on a profile (reverts to inherited/unset)') + .addHelpText('after', `\nExamples:\n ${PROGRAM_NAME} profile unset dev team-id`) + .action(function(this: Command, name: string, field: string) { + const g = read_globals(this); + const mapped = map_clearable(field); + const {changed} = unset_profile_field(name, mapped, process.env); + if (wants_json(g)) + { + print({unset: mapped, profile: name, changed}, print_opts(g)); + return; + } + if (changed) + { + success(`Cleared ${mapped} on profile '${name}'.`); + return; + } + info(`${mapped} was already not set on '${name}'.`); + }); + profile_command .command('list') .description('List available profiles and show which is current') @@ -112,4 +403,16 @@ profile_command print(wants_json(g) ? {current} : current, print_opts(g)); }); -export {profile_command}; +profile_command + .command('show') + .argument('[name]', 'Profile to show (default: current)') + .description('Show a profile\'s backend, team, and authorization (no secrets)') + .addHelpText('after', + `\nExamples:\n ${PROGRAM_NAME} profile show # the current profile` + + `\n ${PROGRAM_NAME} profile show dev --json`) + .action(async function(this: Command, name?: string) { + const g = read_globals(this); + await handle_show(name, g, {store: default_credential_store(), api_key_flag: g.apiKey}); + }); + +export {profile_command, handle_rename, handle_delete, handle_show, parse_url}; diff --git a/src/commands/team.ts b/src/commands/team.ts new file mode 100644 index 0000000..2039f6e --- /dev/null +++ b/src/commands/team.ts @@ -0,0 +1,171 @@ +import {Command} from 'commander'; +import {PROGRAM_NAME} from '../config'; +import {build_context, type Cli_context} from '../context'; +import {create_client} from '../utils/client'; +import {resolve_my_teams} from '../teams'; +import {set_profile, unset_profile_field} from '../profile'; +import {normalize_principal} from './auth'; +import {authed} from './authed'; +import {UsageError} from '../utils/errors'; +import {success, info, print, pc, type Print_opts} from '../utils/output'; + +type Global_opts = { + apiKey?: string; + profile?: string; + teamId?: string; + userId?: string; + userEmail?: string; + json?: boolean; + pretty?: boolean; +}; + +const read_globals = (cmd: Command): Global_opts=>{ + const o = cmd.optsWithGlobals(); + return { + apiKey: o.apiKey, profile: o.profile, + teamId: o.teamId, userId: o.userId, userEmail: o.userEmail, + json: o.json, pretty: o.pretty, + }; +}; + +const wants_json = (g: Global_opts): boolean=>Boolean(g.json || g.pretty); +const print_opts = (g: Global_opts): Print_opts=>({json: g.json, pretty: g.pretty}); +const profile_note = (name: string): string=>name === 'default' ? '' : ` (profile: ${name})`; + +const parse_positive_int = (v: string, label: string): number=>{ + if (!/^\d+$/.test(v.trim())) + { + throw new UsageError(`${label} must be a positive integer.`, {code: 'usage.team', hint: `Got: ${v}`}); + } + return parseInt(v.trim(), 10); +}; + +const handle_team_list = async(ctx: Cli_context, g: Global_opts): Promise=>{ + const {token, headers} = await authed(ctx, g); + const teams = await resolve_my_teams({api_base: ctx.api_base, token, headers}); + if (wants_json(g)) + { + print({current_team_id: ctx.team_id ?? null, teams}, print_opts(g)); + return; + } + console.log(`Teams you can act in${profile_note(ctx.profile)}`); + if (!teams.length) + { + info(' (none returned)'); + return; + } + for (const t of teams) + { + const marker = t.team_id === ctx.team_id ? pc.green('*') : ' '; + console.log(` ${marker} ${t.team_id}${t.team_name ? ' ' + t.team_name : ''}`); + } +}; + +// Pinned team is the profile's team_id (offline). Effective team is what the +// server actually resolves — read from /whoami, best-effort: any failure becomes +// a "failed to retrieve" note rather than an error. +const handle_team_current = async(ctx: Cli_context, g: Global_opts): Promise=>{ + const pinned = ctx.team_id ?? null; + let effective: {team_id?: number; error?: string}; + try { + const {token, headers} = await authed(ctx, g); + const raw = await create_client(ctx.api_base, token, headers).get>('/v3/whoami'); + effective = {team_id: normalize_principal(raw ?? {}).team_id}; + } catch (e) { + effective = {error: (e as Error).message}; + } + if (wants_json(g)) + { + print({profile: ctx.profile, pinned_team_id: pinned, effective}, print_opts(g)); + return; + } + const effective_str = effective.error + ? `failed to retrieve (${effective.error})` + : (effective.team_id ?? '(none)'); + console.log(`Profile '${ctx.profile}'`); + console.log(` pinned team : ${pinned ?? '(none)'}`); + console.log(` effective team : ${effective_str}`); +}; + +const handle_team_use = async(id_arg: string, ctx: Cli_context, g: Global_opts): Promise=>{ + const id = parse_positive_int(id_arg, 'Team id'); + const {token, headers} = await authed(ctx, g); + const teams = await resolve_my_teams({api_base: ctx.api_base, token, headers}); + const match = teams.find(t=>t.team_id === id); + if (!match) + { + throw new UsageError(`Team ${id} isn't one you can act in.`, { + code: 'usage.team', + hint: teams.length + ? `Your teams: ${teams.map(t=>`${t.team_id} (${t.team_name})`).join(', ')}` + : 'Run `reply team list` to see your teams.', + }); + } + set_profile(ctx.profile, {team_id: id}); + if (wants_json(g)) + { + print({profile: ctx.profile, team_id: id, team_name: match.team_name}, print_opts(g)); + return; + } + const label = match.team_name ? ` (${match.team_name})` : ''; + success(`Profile '${ctx.profile}' team set to ${id}${label}.`); +}; + +const handle_team_clear = (ctx: Cli_context, g: Global_opts): void=>{ + const {changed} = unset_profile_field(ctx.profile, 'team_id'); + if (wants_json(g)) + { + print({profile: ctx.profile, cleared: changed}, print_opts(g)); + return; + } + if (changed) + { + success(`Cleared team on profile '${ctx.profile}'.`); + return; + } + info(`No team was set on '${ctx.profile}'.`); +}; + +const team_command = new Command('team') + .description('See and set the current profile\'s team'); + +team_command + .command('list') + .description('List the teams you can act in') + .addHelpText('after', + `\nExamples:\n ${PROGRAM_NAME} team list\n\nCalls the API (needs a stored login or --api-key).`) + .action(async function(this: Command) { + const g = read_globals(this); + await handle_team_list(build_context({profile: g.profile}), g); + }); + +team_command + .command('current') + .description('Show the current profile\'s pinned and effective team') + .addHelpText('after', + `\nExamples:\n ${PROGRAM_NAME} team current\n\n` + + 'Pinned team is read from the profile (offline); the effective team is fetched from the API.') + .action(async function(this: Command) { + const g = read_globals(this); + await handle_team_current(build_context({profile: g.profile}), g); + }); + +team_command + .command('use') + .argument('', 'Team id to pin on the current profile') + .description('Pin a team on the current profile (verified against your teams)') + .addHelpText('after', `\nExamples:\n ${PROGRAM_NAME} team use 1045`) + .action(async function(this: Command, id: string) { + const g = read_globals(this); + await handle_team_use(id, build_context({profile: g.profile}), g); + }); + +team_command + .command('clear') + .description('Remove the current profile\'s team pin') + .action(function(this: Command) { + const g = read_globals(this); + handle_team_clear(build_context({profile: g.profile}), g); + }); + +export {team_command, handle_team_list, handle_team_current, handle_team_use, handle_team_clear}; diff --git a/src/config.ts b/src/config.ts index 4ac6d60..aa93b12 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -19,6 +20,34 @@ const env_var = (suffix: string, app: string = APP_NAME): string=> const get_env = (suffix: string, env: Env = process.env): string | undefined=> env[env_var(suffix)]; +let cached_version: string | undefined; + +// The CLI version, read once from package.json (same file for src and dist — +// ../package.json resolves to the repo root either way). Falls back to '0.0.0' +// if the file is missing or malformed. +const cli_version = (): string=>{ + if (cached_version !== undefined) + { + return cached_version; + } + let version = '0.0.0'; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8')); + if (typeof pkg.version === 'string' && pkg.version) + { + version = pkg.version; + } + } catch { + // Unreadable/malformed package.json — keep the '0.0.0' fallback. + } + cached_version = version; + return version; +}; + +// Identifies CLI requests for telemetry (not security). Product token derives +// from APP_NAME to keep the single build identity, e.g. 'reply-cli/0.1.0'. +const user_agent = (): string=>`${APP_NAME}-cli/${cli_version()}`; + // Default per-user config dir, mirroring gh/aws/az conventions. // linux/mac: $XDG_CONFIG_HOME/ (fallback ~/.config/) // windows: %APPDATA%\ (fallback \AppData\Roaming\) @@ -59,7 +88,7 @@ const config_file = (env: Env = process.env): string=> export { PROGRAM_NAME, APP_NAME, - env_prefix, env_var, get_env, + env_prefix, env_var, get_env, cli_version, user_agent, default_config_dir, config_dir, credentials_file, config_file, }; export type {Env}; diff --git a/src/index.ts b/src/index.ts index 764178a..f0e396c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,12 @@ #!/usr/bin/env node -import fs from 'fs'; -import path from 'path'; import {Command, CommanderError} from 'commander'; -import {PROGRAM_NAME} from './config'; +import {PROGRAM_NAME, cli_version} from './config'; import {auth_command} from './commands/auth'; import {profile_command} from './commands/profile'; +import {team_command} from './commands/team'; +import {api_command} from './commands/api'; import {CliError} from './utils/errors'; -const read_version = (): string=>{ - try { - const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf-8')); - return pkg.version || '0.0.0'; - } catch { - return '0.0.0'; - } -}; - // Route every command through commander's throwing mode so usage errors reach // our handler and map to exit code 2 (vs 1 for runtime/API failures). const set_exit_override = (cmd: Command): void=>{ @@ -32,7 +23,7 @@ const build_program = (): Command=>{ program .name(PROGRAM_NAME) .description('Command-line interface for Reply.io — authentication and identity (v1).') - .version(read_version(), '-v, --version') + .version(cli_version(), '-v, --version') .option('-k, --api-key ', 'API key (overrides env var and stored credential)') .option('-p, --profile ', 'Named backend profile (default: prod)') .option('--team-id ', `Team/workspace to act in (X-TEAM-ID); else ${PREFIX}_TEAM_ID or the profile`) @@ -40,10 +31,13 @@ const build_program = (): Command=>{ .option('--user-email ', 'Act as this user email — organization API keys only (needs a team id)') .option('--json', 'Output compact JSON to stdout') .option('--pretty', 'Output indented JSON to stdout') + .option('--verbose', 'Print the full request/response to stderr, credentials redacted (api)') .showHelpAfterError(); program.addCommand(auth_command); program.addCommand(profile_command); + program.addCommand(team_command); + program.addCommand(api_command); program.addHelpText('after', ` Credential precedence: @@ -52,17 +46,34 @@ Credential precedence: Profiles (which backend to talk to): Precedence: --profile > ${PREFIX}_PROFILE > current profile > default (prod). Define your own profiles under "profiles" in the config file, e.g.: - { "profiles": { "dev": { "authority": "https://…", "api_base": "https://…/v3" } } } + { "profiles": { "dev": { "authority": "https://…", "api_base": "https://…" } } } Then set one as current so you don't repeat --profile: ${PROGRAM_NAME} profile use dev # used until you change it ${PROGRAM_NAME} profile list # see all, * marks current ${PROGRAM_NAME} profile current + ${PROGRAM_NAME} profile show [name] # backend, team, auth (no secrets) + ${PROGRAM_NAME} profile rename + ${PROGRAM_NAME} profile delete # also removes its stored credential + ${PROGRAM_NAME} profile unset # clear authority|api_base|team-id Team & acting user (headers): --team-id Team/workspace to act in. Precedence: --team-id > ${PREFIX}_TEAM_ID > profile team_id. Pin one on a profile: ${PROGRAM_NAME} profile set --team-id --user-id / --user-email Identify the acting user for an ORGANIZATION API key. Flag-only (never env, never stored); pass exactly one; --user-email also needs a team id. + Team commands (see & set the current profile's team): + ${PROGRAM_NAME} team list # teams you can act in (* marks the profile's) + ${PROGRAM_NAME} team current # pinned + effective team + ${PROGRAM_NAME} team use # pin a team on the current profile + ${PROGRAM_NAME} team clear # remove the pin + +Raw API (agent/CI escape hatch) — docs: https://docs.reply.io/api-reference/introduction + Use the path as in the docs (starts with /v3); the query string goes in the path. + ${PROGRAM_NAME} api /v3/whoami # GET your identity + team + ${PROGRAM_NAME} api /v3/sequences # GET list of sequences + ${PROGRAM_NAME} api /v3/contacts --body @c.json # POST (a body switches method; schema per docs) + ${PROGRAM_NAME} api /v3/whoami --verbose # full req/resp to stderr (creds redacted) + Prints {code, data}; exits non-zero on HTTP >= 400. Configuration (env vars): ${PREFIX}_API_KEY API key used as the bearer credential @@ -75,6 +86,8 @@ Examples: echo | ${PROGRAM_NAME} auth login --with-token ${PROGRAM_NAME} --profile dev auth whoami --json ${PROGRAM_NAME} auth status + ${PROGRAM_NAME} team list + ${PROGRAM_NAME} api /v3/sequences `); return program; diff --git a/src/profile.ts b/src/profile.ts index 93c8cfb..9ec7667 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -17,10 +17,12 @@ type Profile = { const DEFAULT_NAME = 'default'; -// The embedded default profile: prod. Everything inherits from this. +// The embedded default profile: prod. Everything inherits from this. The +// api_base is the HOST only — the /v3 version prefix lives in the request path +// (so a raw `api` call's URL matches the docs), not in the profile. const EMBEDDED = { authority: 'https://oauth.reply.io', - api_base: 'https://api.reply.io/v3', + api_base: 'https://api.reply.io', }; // Back-compat alias for callers/tests referencing the prod target. const PROD = EMBEDDED; @@ -132,6 +134,33 @@ const resolve_profile = (flag?: string, env: Env = process.env): Profile=>{ const current_profile_name = (env: Env = process.env): string=> persisted_current(read_config(env)) || DEFAULT_NAME; +type Profile_description = { + name: string; + authority: string; + api_base: string; + team_id?: number; + is_current: boolean; + inherited: {authority: boolean; api_base: boolean}; +}; + +// Resolved view of a profile for `profile show`: resolved values plus flags for +// which URLs are inherited (not explicitly set) and whether it's current. No +// credentials/secrets — the command layer adds a redacted credential summary. +const describe_profile = (name: string, env: Env = process.env): Profile_description=>{ + const resolved = resolve_profile(name, env); // throws UsageError if unknown + const cfg = read_config(env); + const raw = get_profiles(cfg, env)[name] ?? {}; + const explicit = (v: unknown): boolean=>typeof v === 'string' && v.trim().length > 0; + return { + name: resolved.name, + authority: resolved.authority, + api_base: resolved.api_base, + ...(resolved.team_id !== undefined ? {team_id: resolved.team_id} : {}), + is_current: (persisted_current(cfg) || DEFAULT_NAME) === name, + inherited: {authority: !explicit(raw.authority), api_base: !explicit(raw.api_base)}, + }; +}; + const list_profiles = (env: Env = process.env): {current: string; available: string[]}=>{ const cfg = read_config(env); const names = new Set([DEFAULT_NAME, ...Object.keys(get_profiles(cfg, env))]); @@ -146,6 +175,9 @@ const write_config = (cfg: Record, env: Env): void=>{ type Profile_fields = {authority?: string; api_base?: string; team_id?: number}; +type Clearable_field = 'authority' | 'api_base' | 'team_id'; +const CLEARABLE_FIELDS: readonly Clearable_field[] = ['authority', 'api_base', 'team_id']; + // Apply the given fields onto a profile def, in place (only fields actually // provided are written, so merges are non-destructive). const apply_fields = (def: Profile_def, fields: Profile_fields): void=>{ @@ -218,6 +250,116 @@ const set_profile = ( write_config(cfg, env); }; +// Rename a profile's config entry. Config-only — the credential move is done by +// the command layer (see commands/profile.ts handle_rename), which also guards +// against clobbering a login. `default` is the built-in slot: neither source nor +// target. Validation order matches the spec. +const rename_profile_def = ( + old_name: string, + new_name: string, + env: Env = process.env, +): void=>{ + if (old_name === DEFAULT_NAME) + { + throw new UsageError('The built-in default profile can\'t be renamed.', {code: 'usage.profile'}); + } + const cfg = read_config(env) as Record; + const profiles = get_profiles(cfg as Config, env); + if (!profiles[old_name]) + { + throw new UsageError(`Unknown profile '${old_name}'.`, { + code: 'usage.profile', hint: 'List profiles with `profile list`.', + }); + } + if (!new_name.trim()) + { + throw new UsageError('A profile name is required.', {code: 'usage.profile'}); + } + if (new_name === DEFAULT_NAME) + { + throw new UsageError('Can\'t rename to the built-in default.', {code: 'usage.profile'}); + } + if (new_name === old_name) + { + throw new UsageError('New name is the same as the old name.', {code: 'usage.profile'}); + } + if (profiles[new_name]) + { + throw new UsageError(`Profile '${new_name}' already exists.`, { + code: 'usage.profile', hint: 'Delete or rename it first, or pick another name.', + }); + } + profiles[new_name] = profiles[old_name]; + delete profiles[old_name]; + cfg.profiles = profiles; + if (persisted_current(cfg as Config) === old_name) + { + cfg.current_profile = new_name; + } + write_config(cfg, env); +}; + +// Remove a profile's config entry. Config-only — the credential removal is done +// by the command layer (handle_delete). Returns whether the deleted profile was +// current so the caller can report the reset to default. +const delete_profile_def = (name: string, env: Env = process.env): {was_current: boolean}=>{ + if (name === DEFAULT_NAME) + { + throw new UsageError('The built-in default profile can\'t be deleted.', {code: 'usage.profile'}); + } + const cfg = read_config(env) as Record; + const profiles = get_profiles(cfg as Config, env); + if (!profiles[name]) + { + throw new UsageError(`Unknown profile '${name}'.`, { + code: 'usage.profile', hint: 'List profiles with `profile list`.', + }); + } + delete profiles[name]; + cfg.profiles = profiles; + const was_current = persisted_current(cfg as Config) === name; + if (was_current) + { + delete cfg.current_profile; // revert to the built-in default + } + write_config(cfg, env); + return {was_current}; +}; + +// Clear one config field on a profile, reverting it to inherited (URLs) or unset +// (team_id). `default` is allowed (to clear an override / team pin). Never touches +// the profile name or credentials. Idempotent — a no-op returns {changed:false}. +const unset_profile_field = ( + name: string, + field: Clearable_field, + env: Env = process.env, +): {changed: boolean}=>{ + if (!CLEARABLE_FIELDS.includes(field)) + { + throw new UsageError(`Can't clear '${field}'.`, { + code: 'usage.profile', hint: `Clearable fields: ${CLEARABLE_FIELDS.join(', ')}.`, + }); + } + const cfg = read_config(env) as Record; + const profiles = get_profiles(cfg as Config, env); + if (name !== DEFAULT_NAME && !profiles[name]) + { + throw new UsageError(`Unknown profile '${name}'.`, { + code: 'usage.profile', hint: 'List profiles with `profile list`.', + }); + } + const def: Profile_def = {...(profiles[name] ?? {})}; + if (!(field in def)) + { + return {changed: false}; + } + delete (def as Record)[field]; + profiles[name] = def; + cfg.profiles = profiles; + write_config(cfg, env); + return {changed: true}; +}; + // Persist the current profile. Validates the name resolves first (so you can't // set current to an unknown profile), then writes current_profile. const set_current_profile = (name: string, env: Env = process.env): void=>{ @@ -227,5 +369,5 @@ const set_current_profile = (name: string, env: Env = process.env): void=>{ write_config(cfg, env); }; -export {resolve_profile, current_profile_name, list_profiles, set_current_profile, add_profile, set_profile, EMBEDDED, PROD}; -export type {Profile, Profile_fields}; +export {resolve_profile, current_profile_name, list_profiles, set_current_profile, add_profile, set_profile, rename_profile_def, delete_profile_def, unset_profile_field, describe_profile, EMBEDDED, PROD}; +export type {Profile, Profile_fields, Clearable_field, Profile_description}; diff --git a/src/teams.ts b/src/teams.ts new file mode 100644 index 0000000..29e6473 --- /dev/null +++ b/src/teams.ts @@ -0,0 +1,126 @@ +import {request_raw} from './utils/client'; +import {RuntimeError} from './utils/errors'; + +type Team = {team_id: number; team_name: string}; + +const TEAM_USERS_ENDPOINT = '/v3/whoami/team-users'; +const WHOAMI_ENDPOINT = '/v3/whoami'; +const TEAMS_IN_BODY_CODES = ['TEAM_REQUIRED', 'TEAM_NOT_ACCESSIBLE']; +// team-users is organization-only; a personal account has just its own team. +const ORG_ONLY_CODE = 'workspace.organizationRequired'; + +const read_team = (item: unknown): Team | undefined=>{ + if (typeof item !== 'object' || item === null) + { + return undefined; + } + const rec = item as Record; + const id = rec.teamId ?? rec.TeamId; + const name = rec.teamName ?? rec.TeamName; + if (typeof id !== 'number' || !Number.isInteger(id)) + { + return undefined; + } + return {team_id: id, team_name: typeof name === 'string' ? name : ''}; +}; + +// Reduce an array of team-ish objects to distinct teams (by id), dropping malformed. +const parse_teams = (raw: unknown): Team[]=>{ + if (!Array.isArray(raw)) + { + return []; + } + const seen = new Set(); + const out: Team[] = []; + for (const item of raw) + { + const t = read_team(item); + if (t && !seen.has(t.team_id)) + { + seen.add(t.team_id); + out.push(t); + } + } + return out; +}; + +type Teams_deps = {api_base: string; token: string; headers?: Record}; + +// A personal (non-org) account has exactly one team — its own — which team-users +// won't list but whoami reports. Return that single team ({team_name} unknown). +const single_team_from_whoami = async(deps: Teams_deps): Promise=>{ + const {status, data} = await request_raw( + deps.api_base, deps.token, 'GET', WHOAMI_ENDPOINT, undefined, {headers: deps.headers}); + if (status >= 200 && status < 300 && typeof data === 'object' && data !== null) + { + const id = (data as Record).teamId; + if (typeof id === 'number' && Number.isInteger(id)) + { + return [{team_id: id, team_name: ''}]; + } + } + return []; +}; + +// The teams the caller can act in. Resilient: the list is the same whether +// /whoami/team-users returns 200, or a TEAM_REQUIRED/TEAM_NOT_ACCESSIBLE 403 +// (whose body carries the same teams[]). When team-users is organization-only, +// fall back to the single whoami team. Any other outcome throws — the caller +// decides how to degrade. +const resolve_my_teams = async(deps: Teams_deps): Promise=>{ + const {status, data} = await request_raw( + deps.api_base, deps.token, 'GET', TEAM_USERS_ENDPOINT, undefined, {headers: deps.headers}); + if (status >= 200 && status < 300) + { + return parse_teams(data); + } + const body = typeof data === 'object' && data !== null ? data as Record : {}; + const code = typeof body.code === 'string' ? body.code : undefined; + if (status === 403 && code && TEAMS_IN_BODY_CODES.includes(code)) + { + return parse_teams(body.teams); + } + if (status === 403 && code === ORG_ONLY_CODE) + { + return single_team_from_whoami(deps); + } + throw new RuntimeError('Could not list your teams.', { + code: 'teams.unavailable', + detail: code ? `HTTP ${status}: ${code}` : `HTTP ${status}`, + hint: 'Make sure you are logged in.', + }); +}; + +const RESOLUTION_CODES = ['TEAM_REQUIRED', 'TEAM_NOT_ACCESSIBLE', 'USER_REQUIRED', 'USER_NOT_FOUND']; + +const render_teams = (teams: Team[]): string=> + teams.map(t=>` ${t.team_id} ${t.team_name}`).join('\n'); + +// Tailored guidance for a WORKLOAD team/user-resolution conflict (used by `api`). +// Returns undefined when the body is not a recognized resolution error. +const team_error_guidance = (status: number, data: unknown): string | undefined=>{ + const body = typeof data === 'object' && data !== null ? data as Record : {}; + const code = typeof body.code === 'string' ? body.code : undefined; + if (!code || !RESOLUTION_CODES.includes(code)) + { + return undefined; + } + const teams = parse_teams(body.teams); + const list = teams.length ? `\n${render_teams(teams)}` : ''; + switch (code) + { + case 'TEAM_REQUIRED': + return 'You belong to multiple teams. Choose one:\n' + + ' reply team use (or --team-id , REPLY_TEAM_ID, or profile set --team-id )' + + list; + case 'TEAM_NOT_ACCESSIBLE': + return `That team isn't one you can act in. Your teams:${list}`; + case 'USER_REQUIRED': + return 'This organization API key needs an acting user — pass --user-id or --user-email .'; + default: // USER_NOT_FOUND + return "No Reply user maps to this credential — check you're logged in with the right account."; + } +}; + +export {parse_teams, resolve_my_teams, team_error_guidance}; +export type {Team, Teams_deps}; diff --git a/src/utils/client.ts b/src/utils/client.ts index 3e200e5..23aef31 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -1,4 +1,5 @@ -import {PROGRAM_NAME} from '../config'; +import {PROGRAM_NAME, user_agent} from '../config'; +import {REDACTED} from './output'; import {Api_error, RuntimeError, type Api_error_body} from './errors'; // The v3 API auto-detects JWT (OAuth) vs API key from the same @@ -31,6 +32,17 @@ const hint_for = (status: number): string | undefined=>{ const sleep = (ms: number): Promise=>new Promise(resolve=>setTimeout(resolve, ms)); +// Join base + endpoint with exactly one slash, tolerating a trailing slash on +// the base or a missing leading slash on the endpoint (a query string rides along). +const join_url = (base: string, endpoint: string): string=> + `${base.replace(/\/+$/, '')}/${endpoint.replace(/^\/+/, '')}`; + +const headers_to_object = (h: Headers): Record=>{ + const o: Record = {}; + h.forEach((v, k)=>{ o[k] = v; }); + return o; +}; + const parse_body = (text: string): Api_error_body | string=>{ if (!text) { @@ -64,10 +76,11 @@ const request = async( body?: unknown, opts: Request_opts = {}, ): Promise=>{ - const url = `${base_url}${endpoint}`; + const url = join_url(base_url, endpoint); const headers: Record = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', + 'User-Agent': user_agent(), // identifies CLI traffic for telemetry (REPLY-51325) ...(opts.headers ?? {}), }; const init: RequestInit = {method, headers}; @@ -120,6 +133,79 @@ const get = ( base_url: string, token: string, endpoint: string, opts?: Request_opts, ): Promise=>request(base_url, token, 'GET', endpoint, undefined, opts); +type Raw_response = { + status: number; + data: unknown; + response_headers: Record; + // The request as sent — Authorization pre-redacted so the raw token never + // leaves this function (used by `api --verbose`). + request: {method: string; url: string; headers: Record; body?: string}; +}; + +// Like `request`, but returns {status, data, …} for ANY final HTTP status instead +// of throwing on non-2xx — the workload `api` command needs the raw response. +// Still retries transient statuses and throws RuntimeError only on network failure. +const request_raw = async( + base_url: string, + token: string, + method: string, + endpoint: string, + body?: unknown, + opts: Request_opts = {}, +): Promise=>{ + const url = join_url(base_url, endpoint); + const headers: Record = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': user_agent(), + ...(opts.headers ?? {}), + }; + const body_str = body !== undefined ? JSON.stringify(body) : undefined; + const init: RequestInit = {method, headers}; + if (body_str !== undefined) + { + init.body = body_str; + } + const request_view = { + method, url, + headers: {...headers, Authorization: `Bearer ${REDACTED}`}, + ...(body_str !== undefined ? {body: body_str} : {}), + }; + let attempt = 0; + while (attempt <= MAX_RETRIES) + { + let res: Response; + try { + res = await fetch(url, init); + } catch (e) { + if (attempt < MAX_RETRIES) + { + await sleep(RETRY_BASE_MS * 2 ** attempt); + attempt++; + continue; + } + throw new RuntimeError('Network request failed.', { + code: 'network', detail: (e as Error).message, + hint: 'Check your connection and try again.', + }); + } + if (TRANSIENT_STATUSES.includes(res.status) && attempt < MAX_RETRIES) + { + await sleep(retry_delay_ms(res, attempt)); + attempt++; + continue; + } + const text = await res.text(); + return { + status: res.status, + data: text ? parse_body(text) : null, + response_headers: headers_to_object(res.headers), + request: request_view, + }; + } + throw new RuntimeError('Max retries exceeded.', {code: 'network'}); +}; + type Client = { get(endpoint: string, opts?: Request_opts): Promise; }; @@ -131,5 +217,5 @@ const create_client = ( get(base_url, token, endpoint, {...opts, headers: {...headers, ...opts?.headers}}), }); -export {request, get, create_client}; -export type {Request_opts, Client}; +export {request, get, request_raw, create_client}; +export type {Request_opts, Client, Raw_response};