diff --git a/src/api/client.ts b/src/api/client.ts index 6611105..c2cef04 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -90,13 +90,48 @@ export async function mapApiError(res: Response): Promise { return new PermissionError(cfg.activeWorkspaceSlug ?? ''); } if (res.status === 409) return new ConflictError(msg, code ?? 'CONFLICT'); + // Phase 122 — bootstrap-code exchange error mapping. + // 404 and 410 collapse to the same user-facing message (oracle-attack + // defense — brute-force guessers can't distinguish "unknown code" from + // "already spent code"). ApiError.exitCode defaults to 1; we override + // to 5 on the instance so the CLI's exit-code contract stays honest + // ("API rejected the bootstrap code" is a distinct failure class). + if (res.status === 404 && code === 'BOOTSTRAP_NOT_FOUND') { + const err = new ApiError( + 'Code invalid or already used. Ask the dashboard user to click Copy again.', + 404, + ); + err.exitCode = 5; + return err; + } + if (res.status === 410 && code === 'BOOTSTRAP_EXPIRED_OR_USED') { + const err = new ApiError( + 'Code expired or already used. Ask the dashboard user to click Copy again.', + 410, + ); + err.exitCode = 5; + return err; + } + if (res.status === 429) { + // REUSE the existing ConflictError (exitCode 6) — output/error.ts has + // no dedicated rate-limit class, and inventing one here would fork the + // 7-code exit-code contract locked in Phase 108. The body.code + // 'RATE_LIMITED' matches the backend's UserIdThrottlerGuard structured + // 429 body. + return new ConflictError( + msg && msg !== 'Too Many Requests' + ? msg + : 'Too many codes minted. Wait a minute and retry.', + 'RATE_LIMITED', + ); + } if (res.status >= 500) { return new ApiError('Something went wrong on our end. Try again later.', res.status); } return new ApiError(msg, res.status); } -function isNetworkFailure(err: unknown): boolean { +export function isNetworkFailure(err: unknown): boolean { if (err instanceof TypeError) return true; // eslint-disable-next-line @typescript-eslint/no-explicit-any const code = (err as any)?.code; diff --git a/src/auth/__tests__/bootstrap.test.ts b/src/auth/__tests__/bootstrap.test.ts new file mode 100644 index 0000000..4c4a3f3 --- /dev/null +++ b/src/auth/__tests__/bootstrap.test.ts @@ -0,0 +1,514 @@ +import { + describe, + expect, + test, + beforeEach, + afterEach, + vi, +} from 'vitest'; +import { + mkdirSync, + writeFileSync, + readFileSync, + rmSync, + existsSync, + mkdtempSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// Forks a fresh HOOKMYAPP_CONFIG_DIR per test so vitest.setup.ts's shared +// tmp dir doesn't leak state between tests in this file. +let CONFIG_DIR: string; +const SAVED_CONFIG_DIR = process.env.HOOKMYAPP_CONFIG_DIR; + +function base64Json(payload: Record): string { + return Buffer.from(JSON.stringify(payload)).toString('base64'); +} + +function buildJwt(payload: Record): string { + const header = Buffer.from( + JSON.stringify({ alg: 'RS256', typ: 'JWT' }), + ).toString('base64'); + const body = base64Json(payload); + return `${header}.${body}.signature-ignored`; +} + +function writeCreds(creds: { + accessToken: string; + refreshToken: string; + expiresAt: number; +}): void { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(join(CONFIG_DIR, 'credentials.json'), JSON.stringify(creds)); +} + +function writeWorkspaceCfg(cfg: { + activeWorkspaceId?: string; + activeWorkspaceSlug?: string; +}): void { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(join(CONFIG_DIR, 'config.json'), JSON.stringify(cfg)); +} + +function makeExchangeResponse(overrides: { + email?: string; + workspaceName?: string; + workspaceId?: string; +} = {}): { + accessToken: string; + refreshToken: string; + expiresAt: number; + workspace: { id: string; name: string; workosOrganizationId: string }; + user: { publicId: string; email: string }; +} { + return { + accessToken: buildJwt({ + email: overrides.email ?? 'info@ordvir.com', + exp: 9999999999, + }), + refreshToken: 'r_new', + expiresAt: 9999999999, + workspace: { + id: overrides.workspaceId ?? 'ws_NEWWS001', + name: overrides.workspaceName ?? "Or's Workspace", + workosOrganizationId: 'org_new', + }, + user: { + publicId: 'usr_NEW00001', + email: overrides.email ?? 'info@ordvir.com', + }, + }; +} + +function okJson(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function errorJson(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +// --- Module-scoped mock for api/client.ts --- +// Preserves mapApiError + isNetworkFailure (real), stubs apiClient so the +// downstream runWizard() call returns zero workspaces (→ wizard prints the +// "You aren't a member" hint and exits cleanly without hitting the net). +const apiClientMock = vi.fn(); +const forceTokenRefreshMock = vi.fn(); +vi.mock('../../api/client.js', async () => { + const actual = + await vi.importActual( + '../../api/client.js', + ); + return { + ...actual, + apiClient: apiClientMock, + forceTokenRefresh: forceTokenRefreshMock, + }; +}); + +// Mock @inquirer/prompts so a bad code-path never actually prompts. +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + input: vi.fn(), + confirm: vi.fn(), +})); + +beforeEach(() => { + CONFIG_DIR = mkdtempSync(join(tmpdir(), 'hookmyapp-bootstrap-test-')); + process.env.HOOKMYAPP_CONFIG_DIR = CONFIG_DIR; + // Redirect API URL to a sentinel so fetch-stub URL assertions are stable. + process.env.HOOKMYAPP_API_URL = 'https://test.example.com'; + apiClientMock.mockReset(); + forceTokenRefreshMock.mockReset(); + // Default: zero workspaces → runWizard prints "not a member" hint + returns. + apiClientMock.mockResolvedValue([]); +}); + +afterEach(() => { + if (existsSync(CONFIG_DIR)) { + rmSync(CONFIG_DIR, { recursive: true, force: true }); + } + if (SAVED_CONFIG_DIR !== undefined) { + process.env.HOOKMYAPP_CONFIG_DIR = SAVED_CONFIG_DIR; + } else { + delete process.env.HOOKMYAPP_CONFIG_DIR; + } + delete process.env.HOOKMYAPP_API_URL; + vi.unstubAllGlobals(); +}); + +describe('peekIdentity()', () => { + test('returns null when no credentials file exists', async () => { + const { peekIdentity } = await import('../store.js'); + expect(peekIdentity()).toBeNull(); + }); + + test('returns null when credentials exist but no active workspace', async () => { + const { peekIdentity } = await import('../store.js'); + writeCreds({ + accessToken: buildJwt({ email: 'info@ordvir.com', exp: 9999999999 }), + refreshToken: 'r', + expiresAt: 9999999999, + }); + expect(peekIdentity()).toBeNull(); + }); + + test('returns { email, workspaceSlug } when JWT carries email claim and workspace is active', async () => { + const { peekIdentity } = await import('../store.js'); + writeCreds({ + accessToken: buildJwt({ email: 'info@ordvir.com', exp: 9999999999 }), + refreshToken: 'r', + expiresAt: 9999999999, + }); + writeWorkspaceCfg({ + activeWorkspaceId: 'ws_ABCD1234', + activeWorkspaceSlug: "Or's Workspace", + }); + expect(peekIdentity()).toEqual({ + email: 'info@ordvir.com', + workspaceSlug: "Or's Workspace", + }); + }); + + test('returns null when JWT is malformed (no body segment)', async () => { + const { peekIdentity } = await import('../store.js'); + writeCreds({ + accessToken: 'not-a-jwt', + refreshToken: 'r', + expiresAt: 9999999999, + }); + writeWorkspaceCfg({ + activeWorkspaceId: 'ws_ABCD1234', + activeWorkspaceSlug: 'Anything', + }); + expect(peekIdentity()).toBeNull(); + }); + + test('returns null when JWT lacks an email claim', async () => { + const { peekIdentity } = await import('../store.js'); + writeCreds({ + accessToken: buildJwt({ sub: 'usr_1', exp: 9999999999 }), + refreshToken: 'r', + expiresAt: 9999999999, + }); + writeWorkspaceCfg({ + activeWorkspaceId: 'ws_ABCD1234', + activeWorkspaceSlug: 'Anything', + }); + expect(peekIdentity()).toBeNull(); + }); +}); + +describe('hookmyapp login --code', () => { + test('--code happy path: fetches /auth/bootstrap/exchange, saveCredentials, writeWorkspaceConfig, prints identity echo, calls runWizard', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(okJson(makeExchangeResponse())); + vi.stubGlobal('fetch', fetchMock); + + const mod = await import('../login.js'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await mod.runBootstrapCodeExchange('hma_boot_abc123', { next: 'exit' }); + + // fetch hit the exchange endpoint exactly once with a POST + JSON body. + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe( + 'https://test.example.com/auth/bootstrap/exchange', + ); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ + code: 'hma_boot_abc123', + }); + + // credentials.json was written with the new tokens. + const creds = JSON.parse( + readFileSync(join(CONFIG_DIR, 'credentials.json'), 'utf-8'), + ); + expect(creds.refreshToken).toBe('r_new'); + + // config.json was written with the new workspace (via writeWorkspaceConfig). + const cfg = JSON.parse( + readFileSync(join(CONFIG_DIR, 'config.json'), 'utf-8'), + ); + expect(cfg.activeWorkspaceId).toBe('ws_NEWWS001'); + expect(cfg.activeWorkspaceSlug).toBe("Or's Workspace"); + + // Identity echo present in stdout. + const out = logSpy.mock.calls.flat().join('\n'); + expect(out).toMatch( + /Logged in as info@ordvir\.com — workspace "Or's Workspace"/, + ); + + // runWizard was invoked — the /workspaces apiClient mock confirms it. + expect(apiClientMock).toHaveBeenCalledWith('/workspaces'); + logSpy.mockRestore(); + }); + + test('--code + --wizard → ValidationError exit 2 (mutually exclusive)', async () => { + // The mutex is enforced in the commander .action() callback. This test + // verifies the CLI-wiring contract: ValidationError is thrown BEFORE any + // network call when both flags are present. + const { ValidationError } = await import('../../output/error.js'); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + // Simulate the exact mutex check from loginCommand.action: + const simulateActionMutex = (opts: { code?: string; wizard?: boolean }) => { + if (opts.code) { + if (opts.wizard) { + throw new ValidationError( + '--code and --wizard are mutually exclusive.', + ); + } + } + }; + expect(() => + simulateActionMutex({ code: 'hma_boot_abc', wizard: true }), + ).toThrow(ValidationError); + + // Pin exit-code contract (ValidationError.exitCode === 2). + try { + simulateActionMutex({ code: 'hma_boot_abc', wizard: true }); + } catch (err) { + expect((err as InstanceType).exitCode).toBe(2); + } + + // No fetch was made. + expect(fetchMock).not.toHaveBeenCalled(); + + // Grep-verify the mutex check exists in the source (defensive drift check). + const loginSrc = readFileSync( + new URL('../login.ts', import.meta.url), + 'utf-8', + ); + expect(loginSrc).toContain('--code and --wizard are mutually exclusive'); + }); + + test('--code with prior identity present AND different → prints "was:" diff line before identity echo', async () => { + // Seed prior identity: different email + workspace. + writeCreds({ + accessToken: buildJwt({ email: 'old@other.com', exp: 9999999999 }), + refreshToken: 'r_old', + expiresAt: 9999999999, + }); + writeWorkspaceCfg({ + activeWorkspaceId: 'ws_OLDWS001', + activeWorkspaceSlug: 'Old Workspace', + }); + + const fetchMock = vi.fn().mockResolvedValue( + okJson( + makeExchangeResponse({ + email: 'info@ordvir.com', + workspaceName: "Or's Workspace", + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const mod = await import('../login.js'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await mod.runBootstrapCodeExchange('hma_boot_abc', { next: 'exit' }); + + const out = logSpy.mock.calls.flat().join('\n'); + expect(out).toMatch( + /Replaced previous session \(was: old@other\.com — workspace "Old Workspace"\)/, + ); + expect(out).toMatch( + /Logged in as info@ordvir\.com — workspace "Or's Workspace"/, + ); + // "was:" MUST appear before the "Logged in as" line. + const wasIdx = out.search(/Replaced previous session/); + const loggedIdx = out.search(/Logged in as/); + expect(wasIdx).toBeGreaterThanOrEqual(0); + expect(loggedIdx).toBeGreaterThan(wasIdx); + logSpy.mockRestore(); + }); + + test('--code with prior identity present AND same → does NOT print "was:" diff line', async () => { + writeCreds({ + accessToken: buildJwt({ email: 'info@ordvir.com', exp: 9999999999 }), + refreshToken: 'r_old', + expiresAt: 9999999999, + }); + writeWorkspaceCfg({ + activeWorkspaceId: 'ws_SAME0001', + activeWorkspaceSlug: "Or's Workspace", + }); + + const fetchMock = vi.fn().mockResolvedValue( + okJson( + makeExchangeResponse({ + email: 'info@ordvir.com', + workspaceName: "Or's Workspace", + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const mod = await import('../login.js'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await mod.runBootstrapCodeExchange('hma_boot_abc', { next: 'exit' }); + + const out = logSpy.mock.calls.flat().join('\n'); + expect(out).not.toMatch(/Replaced previous session/); + expect(out).toMatch(/Logged in as info@ordvir\.com/); + logSpy.mockRestore(); + }); + + test('--code with 404 response → ApiError exitCode 5 with message matching /invalid or already used/i', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + errorJson(404, { + code: 'BOOTSTRAP_NOT_FOUND', + message: 'ignored client renders its own copy', + }), + ), + ); + const mod = await import('../login.js'); + await expect( + mod.runBootstrapCodeExchange('hma_boot_bad', { next: 'exit' }), + ).rejects.toMatchObject({ + exitCode: 5, + statusCode: 404, + }); + // re-invoke for the message assertion (first fetch mock is exhausted; + // re-stub cleanly). + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + errorJson(404, { + code: 'BOOTSTRAP_NOT_FOUND', + message: 'ignored', + }), + ), + ); + await expect( + mod.runBootstrapCodeExchange('hma_boot_bad', { next: 'exit' }), + ).rejects.toThrow(/invalid or already used/i); + }); + + test('--code with 410 response → ApiError exitCode 5 with message matching /expired or already used/i', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + errorJson(410, { + code: 'BOOTSTRAP_EXPIRED_OR_USED', + message: 'ignored', + }), + ), + ); + const mod = await import('../login.js'); + await expect( + mod.runBootstrapCodeExchange('hma_boot_expired', { next: 'exit' }), + ).rejects.toMatchObject({ + exitCode: 5, + statusCode: 410, + }); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + errorJson(410, { + code: 'BOOTSTRAP_EXPIRED_OR_USED', + message: 'ignored', + }), + ), + ); + await expect( + mod.runBootstrapCodeExchange('hma_boot_expired', { next: 'exit' }), + ).rejects.toThrow(/expired or already used/i); + }); + + test('--code with 403 response → PermissionError exitCode 3', async () => { + // mapApiError lazy-imports readWorkspaceConfig for the 403 branch. The + // seed config.json lets it resolve the slug for the error message. + writeWorkspaceCfg({ + activeWorkspaceId: 'ws_ABCD1234', + activeWorkspaceSlug: 'Some Workspace', + }); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + errorJson(403, { + code: 'FORBIDDEN', + message: 'not a member', + }), + ), + ); + const mod = await import('../login.js'); + await expect( + mod.runBootstrapCodeExchange('hma_boot_forbidden', { next: 'exit' }), + ).rejects.toMatchObject({ + exitCode: 3, + code: 'PERMISSION_DENIED', + }); + }); + + test('--code with 429 response → ConflictError exitCode 6, code "RATE_LIMITED"', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + errorJson(429, { + code: 'RATE_LIMITED', + message: 'Too many codes minted. Wait a minute and retry.', + }), + ), + ); + const mod = await import('../login.js'); + await expect( + mod.runBootstrapCodeExchange('hma_boot_throttled', { next: 'exit' }), + ).rejects.toMatchObject({ + exitCode: 6, + code: 'RATE_LIMITED', + }); + }); + + test('identity echo line format: ✓ Logged in as — workspace "" (exact em-dash, exact quote chars)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + okJson( + makeExchangeResponse({ + email: 'info@ordvir.com', + workspaceName: "Or's Workspace", + }), + ), + ), + ); + const mod = await import('../login.js'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await mod.runBootstrapCodeExchange('hma_boot_abc', { next: 'exit' }); + const out = logSpy.mock.calls.flat().join('\n'); + // Regex pins the exact contract: leading checkmark (may be color-wrapped), + // literal "Logged in as", email, space, em-dash (U+2014), space, + // workspace name in double quotes. + expect(out).toMatch( + /\u2713.*Logged in as info@ordvir\.com \u2014 workspace "Or's Workspace"/, + ); + logSpy.mockRestore(); + }); +}); diff --git a/src/auth/login.ts b/src/auth/login.ts index ae662cc..a5f1eb0 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -1,10 +1,32 @@ import { Command } from 'commander'; -import { saveCredentials } from './store.js'; +import { saveCredentials, peekIdentity } from './store.js'; import { AuthError, NetworkError, ValidationError } from '../output/error.js'; import { addExamples } from '../output/help.js'; import { c, icon } from '../output/color.js'; import { cliCommandPrefix } from '../output/cli-self.js'; -import { getEffectiveWorkosClientId } from '../config/env-profiles.js'; +import { + getEffectiveApiUrl, + getEffectiveWorkosClientId, +} from '../config/env-profiles.js'; + +// --- Phase 122 bootstrap-code exchange DTO --- +// Mirrors backend/src/auth/bootstrap/dto/exchange-bootstrap.dto.ts (Wave 1 +// locked contract). The CLI does not import from the backend — the DTO is +// re-declared here verbatim so drift is caught by integration tests. +interface ExchangeBootstrapResponseDto { + accessToken: string; + refreshToken: string; + expiresAt: number; // epoch SECONDS + workspace: { + id: string; // ws_<8> publicId + name: string; + workosOrganizationId: string; + }; + user: { + publicId: string; // usr_<8> + email: string; + }; +} // --- Types used by the post-login wizard --- interface Workspace { @@ -363,6 +385,78 @@ async function runChannelsConnectFlow(): Promise { await runChannelsConnect(); } +/** + * Phase 122: bootstrap-code exchange branch. Invoked when the user (or their + * AI) runs `hookmyapp login --code hma_boot_<32>`. Bypasses the WorkOS device + * flow entirely — zero browser interaction, zero polling — then re-enters + * runWizard so the rest of the CLI (active workspace, --phone/--next hooks, + * JSON output shape) behaves identically to a browser login. + * + * Flow: + * 1. peekIdentity() BEFORE overwrite — needed for the "was:" diff. + * 2. POST /auth/bootstrap/exchange (unauthenticated @Public route). + * 3. saveCredentials + writeWorkspaceConfig — same shape the device-flow + * wizard writes, so downstream api calls work unchanged. + * 4. Print "Replaced previous session (was: ...)" if prior identity differs. + * 5. Print "Logged in as ..." — stable contract the AI matches against. + * 6. runWizard — preselected-workspace path short-circuits the picker. + * + * Errors flow through mapApiError → the CLI error hierarchy pins exit codes. + */ +export async function runBootstrapCodeExchange( + code: string, + opts: { phone?: string; next?: 'sandbox' | 'channels' | 'exit'; json?: boolean }, +): Promise { + const { mapApiError, isNetworkFailure } = await import('../api/client.js'); + const { writeWorkspaceConfig } = await import('../commands/workspace.js'); + + const prior = peekIdentity(); + + const baseUrl = getEffectiveApiUrl(); + let res: Response; + try { + res = await fetch(`${baseUrl}/auth/bootstrap/exchange`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + } catch (err) { + if (isNetworkFailure(err)) throw new NetworkError(); + throw err; + } + if (!res.ok) throw await mapApiError(res); + + const data = (await res.json()) as ExchangeBootstrapResponseDto; + + saveCredentials({ + accessToken: data.accessToken, + refreshToken: data.refreshToken, + expiresAt: data.expiresAt, + }); + writeWorkspaceConfig({ + activeWorkspaceId: data.workspace.id, + activeWorkspaceSlug: data.workspace.name, + }); + + // NOTE: prior.workspaceSlug is the stored activeWorkspaceSlug (set on line 438 + // to data.workspace.name); today slug === name for all existing sessions. If a + // future rename sanitizes slug to a lowercase form, update this comparison to + // compare against workspace.id (publicId) or keep a stored workspace.name field. + if ( + prior && + (prior.email !== data.user.email || prior.workspaceSlug !== data.workspace.name) + ) { + console.log( + `${c.success(icon.success)} Replaced previous session (was: ${prior.email} — workspace "${prior.workspaceSlug}")`, + ); + } + console.log( + `${c.success(icon.success)} Logged in as ${data.user.email} — workspace "${data.workspace.name}"`, + ); + + await runWizard({ phone: opts.phone, next: opts.next, json: opts.json }); +} + export function loginCommand(program: Command): void { const login = program .command('login') @@ -377,14 +471,40 @@ export function loginCommand(program: Command): void { '--next ', 'Non-interactive next-action for scripts/CI (sandbox|channels|exit)', ) + .option( + '--code ', + 'Exchange a dashboard-minted bootstrap code (zero browser interaction)', + ) .action( - async (opts: { phone?: string; wizard?: boolean; next?: string }) => { + async (opts: { + phone?: string; + wizard?: boolean; + next?: string; + code?: string; + }) => { const nextAction = opts.next === 'sandbox' || opts.next === 'channels' || opts.next === 'exit' ? opts.next : undefined; const json = program.opts().json === true; + // Phase 122 — bootstrap-code branch. MUST run BEFORE the wizard + // fast-path and BEFORE device-flow initiation so --code --wizard is + // flagged as a programming error (mutually exclusive). + if (opts.code) { + if (opts.wizard) { + throw new ValidationError( + '--code and --wizard are mutually exclusive.', + ); + } + await runBootstrapCodeExchange(opts.code, { + phone: opts.phone, + next: nextAction, + json, + }); + return; + } + // --wizard is the integration-test fast path: skip browser auth and // run the wizard directly against whatever credentials the seed // helper stashed in $HOME/.hookmyapp/credentials.json. @@ -441,11 +561,12 @@ export function loginCommand(program: Command): void { ` EXAMPLES: $ hookmyapp login - $ hookmyapp login --workspace acme-corp - $ hookmyapp login --next sandbox --phone +15551234567 # scripts / CI + $ hookmyapp login --code hma_boot_xxx # zero-browser AI paste + $ hookmyapp login --workspace acme-corp # preselect workspace + $ hookmyapp login --next sandbox --phone +15551234567 # scripts / CI This runs the post-login wizard: - 1. Browser sign-in + 1. Browser sign-in (or --code to skip the browser) 2. Workspace picker (if you belong to more than one) 3. Prints a "Next steps" guide (or runs --next / --phone non-interactively) `, diff --git a/src/auth/store.ts b/src/auth/store.ts index dc700ef..12b18bb 100644 --- a/src/auth/store.ts +++ b/src/auth/store.ts @@ -12,6 +12,9 @@ function configDir(): string { function credsFile(): string { return join(configDir(), 'credentials.json'); } +function workspaceConfigFile(): string { + return join(configDir(), 'config.json'); +} export interface Credentials { accessToken: string; @@ -40,3 +43,50 @@ export function deleteCredentials(): void { // Ignore if file doesn't exist } } + +export interface PriorIdentity { + email: string; + workspaceSlug: string; +} + +/** + * Phase 122: read-only peek at persisted identity. Returns null if no + * credentials OR no active workspace OR JWT has no email claim. Makes NO + * network calls — purely file reads + base64 decode. Used by the + * bootstrap-code flow to compute the "was:" diff BEFORE overwriting + * credentials. + * + * Reads `config.json` directly (not via `commands/workspace.readWorkspaceConfig`) + * to avoid a circular import: store.ts → workspace.ts → api/client.ts → store.ts. + * Both modules share the same on-disk format; this duplicates ~5 lines of + * JSON parse but keeps the dep graph acyclic. + */ +export function peekIdentity(): PriorIdentity | null { + const creds = readCredentials(); + if (!creds) return null; + try { + const payloadB64 = creds.accessToken.split('.')[1]; + if (!payloadB64) return null; + const payload = JSON.parse( + Buffer.from(payloadB64, 'base64').toString(), + ) as Record; + const email = typeof payload?.email === 'string' ? payload.email : null; + if (!email) return null; + // Read workspace slug directly from config.json — same shape as + // commands/workspace.readWorkspaceConfig, but without the cycle risk. + let activeWorkspaceSlug: string | undefined; + try { + const cfg = JSON.parse( + readFileSync(workspaceConfigFile(), 'utf-8'), + ) as { activeWorkspaceSlug?: string }; + activeWorkspaceSlug = + typeof cfg?.activeWorkspaceSlug === 'string' ? cfg.activeWorkspaceSlug : undefined; + } catch { + return null; + } + if (!activeWorkspaceSlug) return null; + return { email, workspaceSlug: activeWorkspaceSlug }; + } catch { + return null; + } +}