diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5f956eb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to `@gethookmyapp/cli` are documented here. + +## [0.4.0] - 2026-04-16 + +### Breaking Changes + +- Renamed `accounts` command group to `channels` (6 subcommands: `list`, + `show`, `connect`, `disconnect`, `enable`, `disable`). Rationale: + product-wide rename from `Account` to `Channel` as the canonical noun + for a WhatsApp connection (industry standard: Twilio, Intercom, Front, + Zendesk). Leaves room for future non-WhatsApp channels. See HookMyApp + Phase 116 in the monorepo. +- The `--next accounts` flag on `hookmyapp login` is now `--next channels`. +- `hookmyapp workspace current` now labels the count row as `Channels:` + (was `Accounts:`) and reads the `channelCount` field from the API. +- Requires backend Phase 116 or later; the CLI talks to + `/meta/channels/*` endpoints — old builds that still serve + `/meta/accounts/*` will 404. + +### Migration + +Replace `hookmyapp accounts ` with +`hookmyapp channels ` in scripts or documentation. The +6 subcommands are identical in behavior; only the noun has changed. + +There is no alias; `hookmyapp accounts list` now exits with +`unknown command 'accounts'` per the pre-production hard-rename policy. + +## [0.3.0] - 2026-04-14 + +- CLI output primitives + global `--workspace` flag (see Phase 108-04). + +## [0.2.0] and earlier + +Historical releases; no changelog was tracked prior to this version. diff --git a/package.json b/package.json index 76f5743..3d9b265 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gethookmyapp/cli", - "version": "0.3.0", + "version": "0.4.0", "description": "HookMyApp CLI - connect WhatsApp Business API in minutes", "type": "module", "bin": { diff --git a/src/__tests__/accounts.test.ts b/src/__tests__/channels.test.ts similarity index 70% rename from src/__tests__/accounts.test.ts rename to src/__tests__/channels.test.ts index 92f78b7..f14b8f7 100644 --- a/src/__tests__/accounts.test.ts +++ b/src/__tests__/channels.test.ts @@ -39,7 +39,7 @@ const mockedApiClient = vi.mocked(apiClient); const mockedOutput = vi.mocked(output); const mockedOpen = vi.mocked(open); -const fakeAccounts = [ +const fakeChannels = [ { id: '11111111-1111-1111-1111-111111111111', workspaceId: '10101010-1010-1010-1010-101010101010', @@ -89,8 +89,8 @@ const fakeDetailResponse = { verifyToken: null, }; -describe('accounts commands', () => { - let registerAccountsCommand: typeof import('../commands/accounts.js').registerAccountsCommand; +describe('channels commands', () => { + let registerChannelsCommand: typeof import('../commands/channels.js').registerChannelsCommand; let Command: typeof import('commander').Command; beforeEach(async () => { @@ -102,22 +102,22 @@ describe('accounts commands', () => { const commander = await import('commander'); Command = commander.Command; - const mod = await import('../commands/accounts.js'); - registerAccountsCommand = mod.registerAccountsCommand; + const mod = await import('../commands/channels.js'); + registerChannelsCommand = mod.registerChannelsCommand; }); - it('listAccounts calls apiClient /meta/accounts with workspaceId and passes display fields to output', async () => { - mockedApiClient.mockResolvedValue(fakeAccounts); + it('listChannels calls apiClient /meta/channels with workspaceId and passes display fields to output', async () => { + mockedApiClient.mockResolvedValue(fakeChannels); const program = new Command(); - registerAccountsCommand(program); - await program.parseAsync(['accounts', 'list'], { from: 'user' }); + registerChannelsCommand(program); + await program.parseAsync(['channels', 'list'], { from: 'user' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts', { workspaceId: '10101010-1010-1010-1010-101010101010' }); - // Verify output was called with filtered + display-picked accounts + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels', { workspaceId: '10101010-1010-1010-1010-101010101010' }); + // Verify output was called with filtered + display-picked channels expect(mockedOutput).toHaveBeenCalledTimes(1); const outputArgs = mockedOutput.mock.calls[0][0] as Record[]; - // Both accounts are metaConnected=true, so both should be in output + // Both channels are metaConnected=true, so both should be in output expect(outputArgs).toHaveLength(2); // pickDisplayFields removes id, workspaceId, and qualityRating (re-adds only for non-coexistence with value) expect(outputArgs[0]).not.toHaveProperty('id'); @@ -127,17 +127,17 @@ describe('accounts commands', () => { expect(outputArgs[1]).not.toHaveProperty('qualityRating'); // coexistence, null quality }); - it('showAccount calls list to resolve, then calls detail endpoint, outputs without routing keys', async () => { + it('showChannel calls list to resolve, then calls detail endpoint, outputs without routing keys', async () => { mockedApiClient - .mockResolvedValueOnce(fakeAccounts) // list call for resolveAccount (with workspaceId) + .mockResolvedValueOnce(fakeChannels) // list call for resolveChannel (with workspaceId) .mockResolvedValueOnce(fakeDetailResponse); // detail endpoint call const program = new Command(); - registerAccountsCommand(program); - await program.parseAsync(['accounts', 'show', 'waba-2'], { from: 'user' }); + registerChannelsCommand(program); + await program.parseAsync(['channels', 'show', 'waba-2'], { from: 'user' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts', { workspaceId: '10101010-1010-1010-1010-101010101010' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts/22222222-2222-2222-2222-222222222222'); + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels', { workspaceId: '10101010-1010-1010-1010-101010101010' }); + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels/22222222-2222-2222-2222-222222222222'); // pickDisplayFields removes id, workspaceId, and qualityRating (coexistence + null = not re-added) expect(mockedOutput).toHaveBeenCalledTimes(1); const outputArgs = mockedOutput.mock.calls[0][0]; @@ -148,46 +148,46 @@ describe('accounts commands', () => { expect(outputArgs).toHaveProperty('accessToken', 'real-token-value'); }); - it('throws CliError when account not found', async () => { - mockedApiClient.mockResolvedValue(fakeAccounts); + it('throws CliError when channel not found', async () => { + mockedApiClient.mockResolvedValue(fakeChannels); const program = new Command(); - registerAccountsCommand(program); + registerChannelsCommand(program); await expect( - program.parseAsync(['accounts', 'show', '999'], { from: 'user' }), - ).rejects.toThrow('account not found'); + program.parseAsync(['channels', 'show', '999'], { from: 'user' }), + ).rejects.toThrow('channel not found'); }); - it('disconnectAccount calls apiClient with POST and workspaceId from account lookup', async () => { + it('disconnectChannel calls apiClient with POST and workspaceId from channel lookup', async () => { mockedApiClient - .mockResolvedValueOnce(fakeAccounts) // account lookup (with workspaceId) + .mockResolvedValueOnce(fakeChannels) // channel lookup (with workspaceId) .mockResolvedValueOnce({ success: true }); // disconnect call const program = new Command(); - registerAccountsCommand(program); - await program.parseAsync(['accounts', 'disconnect', 'waba-1'], { from: 'user' }); + registerChannelsCommand(program); + await program.parseAsync(['channels', 'disconnect', 'waba-1'], { from: 'user' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts', { workspaceId: '10101010-1010-1010-1010-101010101010' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts/11111111-1111-1111-1111-111111111111/disconnect', { + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels', { workspaceId: '10101010-1010-1010-1010-101010101010' }); + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels/11111111-1111-1111-1111-111111111111/disconnect', { method: 'POST', workspaceId: '10101010-1010-1010-1010-101010101010', }); }); - it('connectAccount calls forceTokenRefresh and opens Embedded Signup URL', async () => { + it('connectChannel calls forceTokenRefresh and opens Embedded Signup URL', async () => { // First call: fetchAppConfig -> /config - // Second call: snapshot accounts -> /meta/accounts (the poll will timeout, but we test the initial flow) + // Second call: snapshot channels -> /meta/channels (the poll will timeout, but we test the initial flow) mockedApiClient .mockResolvedValueOnce({ metaAppId: '123456', metaConfigId: 'config-1' }) // /config - .mockResolvedValueOnce([]); // initial snapshot /meta/accounts + .mockResolvedValueOnce([]); // initial snapshot /meta/channels // Suppress console.log vi.spyOn(console, 'log').mockImplementation(() => {}); const program = new Command(); - registerAccountsCommand(program); + registerChannelsCommand(program); // Don't await -- it will poll for 15 min. Just verify the initial flow. - const p = program.parseAsync(['accounts', 'connect'], { from: 'user' }); + const p = program.parseAsync(['channels', 'connect'], { from: 'user' }); // Wait a tick for the async calls to resolve await new Promise((r) => setTimeout(r, 50)); @@ -200,37 +200,52 @@ describe('accounts commands', () => { vi.mocked(console.log).mockRestore(); }); - it('enableAccount calls apiClient with POST and workspaceId', async () => { + it('enableChannel calls apiClient with POST and workspaceId', async () => { mockedApiClient - .mockResolvedValueOnce(fakeAccounts) + .mockResolvedValueOnce(fakeChannels) .mockResolvedValueOnce({ enabled: true }); const program = new Command(); - registerAccountsCommand(program); - await program.parseAsync(['accounts', 'enable', 'waba-2'], { from: 'user' }); + registerChannelsCommand(program); + await program.parseAsync(['channels', 'enable', 'waba-2'], { from: 'user' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts', { workspaceId: '10101010-1010-1010-1010-101010101010' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts/22222222-2222-2222-2222-222222222222/enable', { + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels', { workspaceId: '10101010-1010-1010-1010-101010101010' }); + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels/22222222-2222-2222-2222-222222222222/enable', { method: 'POST', workspaceId: '20202020-2020-2020-2020-202020202020', }); }); - it('disableAccount calls apiClient with POST and workspaceId', async () => { + it('disableChannel calls apiClient with POST and workspaceId', async () => { mockedApiClient - .mockResolvedValueOnce(fakeAccounts) + .mockResolvedValueOnce(fakeChannels) .mockResolvedValueOnce({ disabled: true }); const program = new Command(); - registerAccountsCommand(program); - await program.parseAsync(['accounts', 'disable', 'waba-2'], { from: 'user' }); + registerChannelsCommand(program); + await program.parseAsync(['channels', 'disable', 'waba-2'], { from: 'user' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts', { workspaceId: '10101010-1010-1010-1010-101010101010' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts/22222222-2222-2222-2222-222222222222/disable', { + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels', { workspaceId: '10101010-1010-1010-1010-101010101010' }); + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels/22222222-2222-2222-2222-222222222222/disable', { method: 'POST', workspaceId: '20202020-2020-2020-2020-202020202020', }); }); + + // Nyquist Dimension 3 — boundary assertion: old `accounts` command must NOT exist. + // Proves the rename is absolute — not just that `channels` works, but that `accounts` + // is genuinely gone. If someone ever re-registers an `accounts` alias, this test + // catches it. + it('boundary: old `accounts` command is unknown (Nyquist Dim-3)', async () => { + const program = new Command(); + // Disable commander's default process.exit on unknown command so we can assert. + program.exitOverride(); + registerChannelsCommand(program); + + await expect( + program.parseAsync(['accounts', 'list'], { from: 'user' }), + ).rejects.toThrow(/unknown command|accounts/i); + }); }); describe('health command', () => { @@ -251,15 +266,15 @@ describe('health command', () => { it('health command calls refresh with POST and workspaceId', async () => { mockedApiClient - .mockResolvedValueOnce(fakeAccounts) // account lookup + .mockResolvedValueOnce(fakeChannels) // channel lookup .mockResolvedValueOnce({ metaConnected: true, forwardingEnabled: true, wabaName: 'Test' }); // health result const program = new Command(); registerHealthCommand(program); await program.parseAsync(['health', 'waba-1'], { from: 'user' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts', { workspaceId: '10101010-1010-1010-1010-101010101010' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts/11111111-1111-1111-1111-111111111111/refresh', { + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels', { workspaceId: '10101010-1010-1010-1010-101010101010' }); + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels/11111111-1111-1111-1111-111111111111/refresh', { method: 'POST', workspaceId: '10101010-1010-1010-1010-101010101010', }); @@ -270,9 +285,9 @@ describe('health command', () => { }); }); -describe('accounts connect — npx prefix roll-out (cliCommandPrefix)', () => { +describe('channels connect — npx prefix roll-out (cliCommandPrefix)', () => { let Command: typeof import('commander').Command; - let runAccountsConnect: typeof import('../commands/accounts.js').runAccountsConnect; + let runChannelsConnect: typeof import('../commands/channels.js').runChannelsConnect; let mockConsoleLog: ReturnType; beforeEach(async () => { @@ -285,8 +300,8 @@ describe('accounts connect — npx prefix roll-out (cliCommandPrefix)', () => { const commander = await import('commander'); Command = commander.Command; - const mod = await import('../commands/accounts.js'); - runAccountsConnect = mod.runAccountsConnect; + const mod = await import('../commands/channels.js'); + runChannelsConnect = mod.runChannelsConnect; }); afterEach(() => { @@ -295,33 +310,33 @@ describe('accounts connect — npx prefix roll-out (cliCommandPrefix)', () => { mockConsoleLog.mockRestore(); }); - it('connect flow after account detected without webhook prints "npx hookmyapp webhook set" + "npx hookmyapp env" hints', async () => { + it('connect flow after channel detected without webhook prints "npx hookmyapp webhook set" + "npx hookmyapp env" hints', async () => { // Speed up the 5s poll interval by stubbing setTimeout. vi.useFakeTimers(); - const newAccount = { - id: 'new-acc', + const newChannel = { + id: 'new-ch', metaWabaId: 'waba-new', displayPhoneNumber: '+1 555 0000', phoneVerifiedName: 'New Co', webhookUrl: null, }; - // apiClient call order: /config, snapshot /meta/accounts (empty), then inside poll — nothing (uses fetch). + // apiClient call order: /config, snapshot /meta/channels (empty), then inside poll — nothing (uses fetch). mockedApiClient .mockResolvedValueOnce({ metaAppId: '123', metaConfigId: 'cfg-1' }) // /config .mockResolvedValueOnce([]); // initial snapshot - // Stub global fetch: first polled /meta/accounts returns [newAccount]. + // Stub global fetch: first polled /meta/channels returns [newChannel]. const origFetch = globalThis.fetch; const fetchMock = vi.fn(async () => ({ ok: true, - json: async () => [newAccount], + json: async () => [newChannel], } as any)); globalThis.fetch = fetchMock as any; try { - const p = runAccountsConnect(); + const p = runChannelsConnect(); // Advance fake timers past the 5s poll interval await vi.advanceTimersByTimeAsync(5000); // Let any microtasks run @@ -338,11 +353,11 @@ describe('accounts connect — npx prefix roll-out (cliCommandPrefix)', () => { } }, 10000); - it('connect flow after account detected WITH webhook prints "npx hookmyapp env" hint', async () => { + it('connect flow after channel detected WITH webhook prints "npx hookmyapp env" hint', async () => { vi.useFakeTimers(); - const newAccount = { - id: 'new-acc-2', + const newChannel = { + id: 'new-ch-2', metaWabaId: 'waba-hook', displayPhoneNumber: '+1 555 1111', phoneVerifiedName: 'HookCo', @@ -356,11 +371,11 @@ describe('accounts connect — npx prefix roll-out (cliCommandPrefix)', () => { const origFetch = globalThis.fetch; globalThis.fetch = vi.fn(async () => ({ ok: true, - json: async () => [newAccount], + json: async () => [newChannel], } as any)) as any; try { - const p = runAccountsConnect(); + const p = runChannelsConnect(); await vi.advanceTimersByTimeAsync(5000); await vi.runAllTimersAsync(); await p; diff --git a/src/__tests__/env.test.ts b/src/__tests__/env.test.ts index 8572751..02de37f 100644 --- a/src/__tests__/env.test.ts +++ b/src/__tests__/env.test.ts @@ -18,7 +18,7 @@ import { apiClient } from '../api/client.js'; const mockedApiClient = vi.mocked(apiClient); -const fakeAccounts = [ +const fakeChannels = [ { id: '11111111-1111-1111-1111-111111111111', metaWabaId: 'waba-111', phoneNumberId: 'phone-222', workspaceId: '10101010-1010-1010-1010-101010101010' }, ]; @@ -37,9 +37,9 @@ describe('env command', () => { registerEnvCommand = mod.registerEnvCommand; }); - it('envCommand fetches account list + token, outputs dotenv format lines', async () => { + it('envCommand fetches channel list + token, outputs dotenv format lines', async () => { mockedApiClient - .mockResolvedValueOnce(fakeAccounts) // accounts list + .mockResolvedValueOnce(fakeChannels) // channels list .mockResolvedValueOnce({ accessToken: 'EAABtoken123' }); // token const mockWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -48,8 +48,8 @@ describe('env command', () => { registerEnvCommand(program); await program.parseAsync(['env', 'waba-111'], { from: 'user' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts', { workspaceId: '10101010-1010-1010-1010-101010101010' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts/11111111-1111-1111-1111-111111111111/token'); + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels', { workspaceId: '10101010-1010-1010-1010-101010101010' }); + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels/11111111-1111-1111-1111-111111111111/token'); const written = mockWrite.mock.calls.map((c) => c[0]).join(''); expect(written).toContain('WABA_ID=waba-111'); @@ -58,14 +58,14 @@ describe('env command', () => { mockWrite.mockRestore(); }); - it('throws CliError when account not found', async () => { - mockedApiClient.mockResolvedValueOnce(fakeAccounts); + it('throws CliError when channel not found', async () => { + mockedApiClient.mockResolvedValueOnce(fakeChannels); const program = new Command(); registerEnvCommand(program); await expect( program.parseAsync(['env', '999'], { from: 'user' }), - ).rejects.toThrow('account not found'); + ).rejects.toThrow('channel not found'); }); }); diff --git a/src/__tests__/webhook.test.ts b/src/__tests__/webhook.test.ts index 23d87d1..e3d1b09 100644 --- a/src/__tests__/webhook.test.ts +++ b/src/__tests__/webhook.test.ts @@ -31,7 +31,7 @@ import { output } from '../output/format.js'; const mockedApiClient = vi.mocked(apiClient); const mockedOutput = vi.mocked(output); -const fakeAccounts = [ +const fakeChannels = [ { id: '11111111-1111-1111-1111-111111111111', metaWabaId: 'waba-1', wabaName: 'Test WABA', phoneNumberId: 'phone-1', workspaceId: '10101010-1010-1010-1010-101010101010' }, ]; @@ -56,27 +56,27 @@ describe('webhook commands', () => { registerWebhookCommand = mod.registerWebhookCommand; }); - it('showWebhook calls apiClient /webhook-config/:accountId', async () => { + it('showWebhook calls apiClient /webhook-config/:channelId', async () => { const config = { webhookUrl: 'https://example.com/hook', verifyToken: 'abc123' }; - // First call: resolveAccount -> /meta/accounts + // First call: resolveChannel -> /meta/channels // Second call: /webhook-config/:id mockedApiClient - .mockResolvedValueOnce(fakeAccounts) + .mockResolvedValueOnce(fakeChannels) .mockResolvedValueOnce(config); const program = new Command(); registerWebhookCommand(program); await program.parseAsync(['webhook', 'show', 'waba-1'], { from: 'user' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts', { workspaceId: '10101010-1010-1010-1010-101010101010' }); + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels', { workspaceId: '10101010-1010-1010-1010-101010101010' }); expect(mockedApiClient).toHaveBeenCalledWith('/webhook-config/11111111-1111-1111-1111-111111111111'); expect(mockedOutput).toHaveBeenCalledWith(config, expect.objectContaining({})); }); - it('setWebhook calls apiClient to configure webhook for account', async () => { - // resolveAccount -> /meta/accounts + it('setWebhook calls apiClient to configure webhook for channel', async () => { + // resolveChannel -> /meta/channels mockedApiClient - .mockResolvedValueOnce(fakeAccounts) + .mockResolvedValueOnce(fakeChannels) .mockResolvedValueOnce({ updated: true }); // PUT webhook-config // Mock fetch for the direct check call (returns 200 = existing config) @@ -89,7 +89,7 @@ describe('webhook commands', () => { registerWebhookCommand(program); await program.parseAsync(['webhook', 'set', 'waba-1', '--url', 'https://example.com/hook', '--verify-token', 'secret'], { from: 'user' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts', { workspaceId: '10101010-1010-1010-1010-101010101010' }); + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels', { workspaceId: '10101010-1010-1010-1010-101010101010' }); expect(mockedApiClient).toHaveBeenCalledWith('/webhook-config/11111111-1111-1111-1111-111111111111', { method: 'PUT', body: JSON.stringify({ webhookUrl: 'https://example.com/hook', verifyToken: 'secret' }), @@ -120,10 +120,10 @@ describe('token command', () => { registerTokenCommand = mod.registerTokenCommand; }); - it('token command calls apiClient /meta/accounts/:id/token and writes raw token to stdout', async () => { - // resolveAccount -> /meta/accounts, then /meta/accounts/:id/token + it('token command calls apiClient /meta/channels/:id/token and writes raw token to stdout', async () => { + // resolveChannel -> /meta/channels, then /meta/channels/:id/token mockedApiClient - .mockResolvedValueOnce(fakeAccounts) + .mockResolvedValueOnce(fakeChannels) .mockResolvedValueOnce({ accessToken: 'EAABxyz123' }); const mockWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -131,8 +131,8 @@ describe('token command', () => { registerTokenCommand(program); await program.parseAsync(['token', 'waba-1'], { from: 'user' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts', { workspaceId: '10101010-1010-1010-1010-101010101010' }); - expect(mockedApiClient).toHaveBeenCalledWith('/meta/accounts/11111111-1111-1111-1111-111111111111/token'); + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels', { workspaceId: '10101010-1010-1010-1010-101010101010' }); + expect(mockedApiClient).toHaveBeenCalledWith('/meta/channels/11111111-1111-1111-1111-111111111111/token'); expect(mockWrite).toHaveBeenCalledWith('EAABxyz123\n'); mockWrite.mockRestore(); }); diff --git a/src/__tests__/workspace.test.ts b/src/__tests__/workspace.test.ts index 35b5ac5..883a2c4 100644 --- a/src/__tests__/workspace.test.ts +++ b/src/__tests__/workspace.test.ts @@ -40,7 +40,7 @@ const fakeWorkspaceDetail = { id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', name: 'Alpha Workspace', memberCount: 3, - accountCount: 2, + channelCount: 2, createdAt: '2026-01-01', updatedAt: '2026-01-15', }; diff --git a/src/auth/__tests__/login.test.ts b/src/auth/__tests__/login.test.ts index 1b71993..8779b2f 100644 --- a/src/auth/__tests__/login.test.ts +++ b/src/auth/__tests__/login.test.ts @@ -39,10 +39,10 @@ vi.mock('../../commands/sandbox-listen/index.js', () => ({ registerListenCommand: vi.fn(), })); -const runAccountsConnectMock = vi.fn(); -vi.mock('../../commands/accounts.js', () => ({ - runAccountsConnect: runAccountsConnectMock, - registerAccountsCommand: vi.fn(), +const runChannelsConnectMock = vi.fn(); +vi.mock('../../commands/channels.js', () => ({ + runChannelsConnect: runChannelsConnectMock, + registerChannelsCommand: vi.fn(), })); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -55,7 +55,7 @@ beforeEach(async () => { apiClientMock.mockReset(); writeWorkspaceConfigMock.mockClear(); runSandboxListenFlowMock.mockReset(); - runAccountsConnectMock.mockReset(); + runChannelsConnectMock.mockReset(); workspaceConfigState = {}; vi.resetModules(); const mod = await import('../login.js'); @@ -86,10 +86,10 @@ describe('post-login wizard', () => { const out = logSpy.mock.calls.flat().join('\n'); expect(out).toContain('Next steps'); expect(out).toContain('hookmyapp sandbox start'); - expect(out).toContain('hookmyapp accounts connect'); + expect(out).toContain('hookmyapp channels connect'); expect(out).toContain('hookmyapp help'); expect(runSandboxListenFlowMock).not.toHaveBeenCalled(); - expect(runAccountsConnectMock).not.toHaveBeenCalled(); + expect(runChannelsConnectMock).not.toHaveBeenCalled(); logSpy.mockRestore(); }); @@ -142,7 +142,7 @@ describe('post-login wizard', () => { logSpy.mockRestore(); }); - it('--next exit → no next-steps block, no sandbox/accounts call', async () => { + it('--next exit → no next-steps block, no sandbox/channels call', async () => { apiClientMock.mockResolvedValueOnce([ { id: 'w1', @@ -156,7 +156,7 @@ describe('post-login wizard', () => { const out = logSpy.mock.calls.flat().join('\n'); expect(out).not.toContain('Next steps'); expect(runSandboxListenFlowMock).not.toHaveBeenCalled(); - expect(runAccountsConnectMock).not.toHaveBeenCalled(); + expect(runChannelsConnectMock).not.toHaveBeenCalled(); logSpy.mockRestore(); }); @@ -195,7 +195,7 @@ describe('post-login wizard', () => { logSpy.mockRestore(); }); - it('--next accounts → delegates to runAccountsConnect', async () => { + it('--next channels → delegates to runChannelsConnect', async () => { apiClientMock.mockResolvedValueOnce([ { id: 'w1', @@ -205,10 +205,10 @@ describe('post-login wizard', () => { }, ]); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - await runWizard({ next: 'accounts' }); + await runWizard({ next: 'channels' }); const out = logSpy.mock.calls.flat().join('\n'); expect(out).not.toContain('Next steps'); - expect(runAccountsConnectMock).toHaveBeenCalledTimes(1); + expect(runChannelsConnectMock).toHaveBeenCalledTimes(1); expect(runSandboxListenFlowMock).not.toHaveBeenCalled(); logSpy.mockRestore(); }); @@ -239,7 +239,7 @@ describe('post-login wizard', () => { }); expect(Array.isArray(payload.nextSteps)).toBe(true); expect(payload.nextSteps.join(' ')).toContain('hookmyapp sandbox start'); - expect(payload.nextSteps.join(' ')).toContain('hookmyapp accounts connect'); + expect(payload.nextSteps.join(' ')).toContain('hookmyapp channels connect'); expect(payload.nextSteps.join(' ')).toContain('hookmyapp help'); const humanOut = logSpy.mock.calls.flat().join('\n'); expect(humanOut).not.toContain('Next steps'); diff --git a/src/auth/login.ts b/src/auth/login.ts index 9c061cc..ae662cc 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -28,7 +28,7 @@ export interface WizardOpts { /** Skip sandbox session picker; use this phone (creates it if missing). */ phone?: string; /** Non-interactive action selector (integration-test hook). */ - next?: 'sandbox' | 'accounts' | 'exit'; + next?: 'sandbox' | 'channels' | 'exit'; /** Emit a final JSON completion payload instead of human-friendly logs. */ json?: boolean; } @@ -176,8 +176,8 @@ export async function runWizard(opts: WizardOpts = {}): Promise { await runSandboxFlow({ phone: opts.phone, json: opts.json }); return; } - if (opts.next === 'accounts') { - await runAccountsConnectFlow(); + if (opts.next === 'channels') { + await runChannelsConnectFlow(); return; } @@ -195,7 +195,7 @@ export async function runWizard(opts: WizardOpts = {}): Promise { const cmd = cliCommandPrefix(); const entries: ReadonlyArray = [ ['sandbox start', 'create a sandbox session and get a WhatsApp test number'], - ['accounts connect', 'connect a real WhatsApp Business account'], + ['channels connect', 'connect a real WhatsApp Business channel'], ['help', 'see all commands'], ]; const col = cmd.length + 1 + Math.max(...entries.map(([s]) => s.length)) + 4; @@ -356,11 +356,11 @@ async function startListen( await runSandboxListenFlow(fullSession); } -async function runAccountsConnectFlow(): Promise { +async function runChannelsConnectFlow(): Promise { // Direct function import — never subprocess spawn. Lazy import breaks the - // index.ts → commands/accounts.ts → auth/login.ts cycle. - const { runAccountsConnect } = await import('../commands/accounts.js'); - await runAccountsConnect(); + // index.ts → commands/channels.ts → auth/login.ts cycle. + const { runChannelsConnect } = await import('../commands/channels.js'); + await runChannelsConnect(); } export function loginCommand(program: Command): void { @@ -375,12 +375,12 @@ export function loginCommand(program: Command): void { ) .option( '--next ', - 'Non-interactive next-action for scripts/CI (sandbox|accounts|exit)', + 'Non-interactive next-action for scripts/CI (sandbox|channels|exit)', ) .action( async (opts: { phone?: string; wizard?: boolean; next?: string }) => { const nextAction = - opts.next === 'sandbox' || opts.next === 'accounts' || opts.next === 'exit' + opts.next === 'sandbox' || opts.next === 'channels' || opts.next === 'exit' ? opts.next : undefined; const json = program.opts().json === true; diff --git a/src/commands/__tests__/sandbox-listen-env.test.ts b/src/commands/__tests__/sandbox-listen-env.test.ts new file mode 100644 index 0000000..26c198e --- /dev/null +++ b/src/commands/__tests__/sandbox-listen-env.test.ts @@ -0,0 +1,58 @@ +// Quick task 260415-nym: prove that `hookmyapp sandbox listen` sources the +// tunnel `env` field from resolveEnv() (config precedence: HOOKMYAPP_ENV > +// config.json "env" > DEFAULT_ENV), NOT from a URL-substring heuristic on +// the effective API base URL. The old detectEnv(apiBaseUrl) would return +// 'local' for any localhost URL and 'staging' only when the substring +// "staging" appeared — both of which are wrong when the operator uses a +// surgical HOOKMYAPP_API_URL override against a non-standard host. +// +// This is a focused unit test against resolveEnv(), which is the source of +// truth after the fix. The full sandbox-listen flow (cloudflared spawn, +// proxy bind, heartbeat) stays covered by existing integration work. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { resolveEnv } from '../../config/env-profiles.js'; + +describe('sandbox-listen: tunnel env is sourced from resolveEnv(), not URL sniffing', () => { + let tmpConfigDir: string; + let savedEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + // Fresh temp config dir per test so config.json does NOT leak in from + // the developer's ~/.hookmyapp or from a previous test's writes. + tmpConfigDir = mkdtempSync(join(tmpdir(), 'hookmyapp-cli-nym-')); + savedEnv = { ...process.env }; + process.env.HOOKMYAPP_CONFIG_DIR = tmpConfigDir; + delete process.env.HOOKMYAPP_ENV; + delete process.env.HOOKMYAPP_API_URL; + }); + + afterEach(() => { + process.env = savedEnv; + rmSync(tmpConfigDir, { recursive: true, force: true }); + }); + + it('returns "staging" when HOOKMYAPP_ENV=staging even if API URL does not contain "staging"', () => { + process.env.HOOKMYAPP_ENV = 'staging'; + // Old bug: detectEnv("https://api.hookmyapp.com") returned 'production'. + process.env.HOOKMYAPP_API_URL = 'https://api.hookmyapp.com'; + expect(resolveEnv()).toBe('staging'); + }); + + it('returns "production" when HOOKMYAPP_ENV is unset even if API URL points at localhost', () => { + // Old bug: detectEnv("http://localhost:4312") returned 'local', which the + // backend would then reject (or route to the wrong sandbox ingress). + process.env.HOOKMYAPP_API_URL = 'http://localhost:4312'; + expect(resolveEnv()).toBe('production'); + }); + + it('returns "local" when HOOKMYAPP_ENV=local regardless of API URL', () => { + process.env.HOOKMYAPP_ENV = 'local'; + process.env.HOOKMYAPP_API_URL = 'https://api.hookmyapp.com'; + expect(resolveEnv()).toBe('local'); + }); +}); diff --git a/src/commands/accounts.ts b/src/commands/channels.ts similarity index 53% rename from src/commands/accounts.ts rename to src/commands/channels.ts index d538ba2..72c3031 100644 --- a/src/commands/accounts.ts +++ b/src/commands/channels.ts @@ -13,33 +13,33 @@ async function fetchAppConfig(): Promise<{ metaAppId: string; metaConfigId: stri } /** Pick only customer-facing fields for CLI display output */ -function pickDisplayFields(account: any): any { - const { id, workspaceId, qualityRating, ...display } = account; - if (account.connectionType !== 'coexistence' && qualityRating) { +function pickDisplayFields(channel: any): any { + const { id, workspaceId, qualityRating, ...display } = channel; + if (channel.connectionType !== 'coexistence' && qualityRating) { display.qualityRating = qualityRating; } return display; } -/** Resolve a WABA ID to the full account object with workspaceId */ -export async function resolveAccount(wabaId: string): Promise { +/** Resolve a WABA ID to the full channel object with workspaceId */ +export async function resolveChannel(wabaId: string): Promise { const { getDefaultWorkspaceId } = await import('./_helpers.js'); const workspaceId = await getDefaultWorkspaceId(); - const accounts = await apiClient('/meta/accounts', { workspaceId }); - const account = accounts.find((a: any) => a.metaWabaId === wabaId); - if (!account) { - throw new ValidationError(`account not found for WABA ID ${wabaId}`); + const channels = await apiClient('/meta/channels', { workspaceId }); + const channel = channels.find((c: any) => c.metaWabaId === wabaId); + if (!channel) { + throw new ValidationError(`channel not found for WABA ID ${wabaId}`); } - return account; + return channel; } /** * Exported helper: drive the Embedded Signup flow end-to-end. * * Called directly by the post-login wizard (src/auth/login.ts) and by the - * `accounts connect` subcommand action below. Never subprocess-spawned. + * `channels connect` subcommand action below. Never subprocess-spawned. */ -export async function runAccountsConnect(): Promise { +export async function runChannelsConnect(): Promise { // Force a fresh 15-min token right before opening signup await forceTokenRefresh(); const creds = readCredentials(); @@ -66,17 +66,17 @@ export async function runAccountsConnect(): Promise { u.searchParams.set('extras', extras); u.searchParams.set('state', `cli:${creds.accessToken}`); - // Snapshot existing accounts before signup - const existingAccounts = await apiClient('/meta/accounts'); + // Snapshot existing channels before signup + const existingChannels = await apiClient('/meta/channels'); console.log('\nOpening Embedded Signup in browser...\nComplete the signup, then return here.\n'); await open(u.toString()); - console.log('Waiting for account...'); + console.log('Waiting for channel...'); - // Poll for new account (check every 5s, timeout after 15 min) + // Poll for new channel (check every 5s, timeout after 15 min) const maxWait = 15 * 60 * 1000; const pollInterval = 5000; const start = Date.now(); - let newAccount: any = null; + let newChannel: any = null; const baseUrl = getEffectiveApiUrl(); while (Date.now() - start < maxWait) { @@ -86,178 +86,178 @@ export async function runAccountsConnect(): Promise { const freshCreds = readCredentials(); if (!freshCreds) continue; - const res = await fetch(`${baseUrl}/meta/accounts`, { + const res = await fetch(`${baseUrl}/meta/channels`, { headers: { Authorization: `Bearer ${freshCreds.accessToken}`, 'Content-Type': 'application/json' }, }); if (!res.ok) continue; const current = await res.json(); - newAccount = current.find((a: any) => - !existingAccounts.some((e: any) => e.id === a.id) + newChannel = current.find((c: any) => + !existingChannels.some((e: any) => e.id === c.id) ); - if (newAccount) break; + if (newChannel) break; } catch { // Network error — keep trying } } - if (!newAccount) { - console.log(`\nTimed out waiting for account.\nRun "${cliCommandPrefix()} accounts list" to check.\n`); + if (!newChannel) { + console.log(`\nTimed out waiting for channel.\nRun "${cliCommandPrefix()} channels list" to check.\n`); return; } - const name = newAccount.phoneVerifiedName ?? newAccount.wabaName ?? ''; - console.log(`\n✓ Account connected`); - console.log(` waba: ${newAccount.metaWabaId}`); - console.log(` phone: ${newAccount.displayPhoneNumber}`); + const name = newChannel.phoneVerifiedName ?? newChannel.wabaName ?? ''; + console.log(`\n✓ Channel connected`); + console.log(` waba: ${newChannel.metaWabaId}`); + console.log(` phone: ${newChannel.displayPhoneNumber}`); if (name) console.log(` name: ${name}`); // Check if webhook is configured - if (!newAccount.webhookUrl) { + if (!newChannel.webhookUrl) { console.log(`\n→ Next, configure your webhook to receive WhatsApp messages.`); console.log(` The webhook URL should be a publicly accessible HTTPS`); console.log(` endpoint that returns 200 OK.\n`); - console.log(` ${cliCommandPrefix()} webhook set ${newAccount.metaWabaId} --url \n`); + console.log(` ${cliCommandPrefix()} webhook set ${newChannel.metaWabaId} --url \n`); console.log(`→ Then get your credentials:`); - console.log(` ${cliCommandPrefix()} env ${newAccount.metaWabaId}\n`); + console.log(` ${cliCommandPrefix()} env ${newChannel.metaWabaId}\n`); } else { - console.log(`\n✓ Webhook configured: ${newAccount.webhookUrl}`); + console.log(`\n✓ Webhook configured: ${newChannel.webhookUrl}`); console.log(`\n→ Get your credentials:`); - console.log(` ${cliCommandPrefix()} env ${newAccount.metaWabaId}\n`); + console.log(` ${cliCommandPrefix()} env ${newChannel.metaWabaId}\n`); } } -export function registerAccountsCommand(program: Command): void { - const accounts = program.command('accounts').description('Manage WhatsApp accounts'); +export function registerChannelsCommand(program: Command): void { + const channels = program.command('channels').description('Manage WhatsApp channels'); - const accountsList = accounts + const channelsList = channels .command('list') - .description('List all accounts') + .description('List all channels') .action(async () => { const { getDefaultWorkspaceId } = await import('./_helpers.js'); const workspaceId = await getDefaultWorkspaceId(); - const data = await apiClient('/meta/accounts', { workspaceId }); - const connectedAccounts = data.filter((a: any) => a.metaConnected !== false); - output(connectedAccounts.map(pickDisplayFields), { human: !program.opts().json }); + const data = await apiClient('/meta/channels', { workspaceId }); + const connectedChannels = data.filter((c: any) => c.metaConnected !== false); + output(connectedChannels.map(pickDisplayFields), { human: !program.opts().json }); }); - const accountsShow = accounts + const channelsShow = channels .command('show') - .description('Show account details') + .description('Show channel details') .argument('', 'WABA ID') .action(async (wabaId: string) => { - const account = await resolveAccount(wabaId); - const detail = await apiClient(`/meta/accounts/${account.id}`); + const channel = await resolveChannel(wabaId); + const detail = await apiClient(`/meta/channels/${channel.id}`); output(pickDisplayFields(detail), { human: !program.opts().json }); }); - const accountsConnect = accounts + const channelsConnect = channels .command('connect') - .description('Connect a WhatsApp account via Embedded Signup') + .description('Connect a WhatsApp channel via Embedded Signup') .action(async () => { - await runAccountsConnect(); + await runChannelsConnect(); }); - const accountsDisconnect = accounts + const channelsDisconnect = channels .command('disconnect') - .description('Disconnect an account') + .description('Disconnect a channel') .argument('', 'WABA ID') .action(async (wabaId: string) => { - const account = await resolveAccount(wabaId); - const result = await apiClient(`/meta/accounts/${account.id}/disconnect`, { + const channel = await resolveChannel(wabaId); + const result = await apiClient(`/meta/channels/${channel.id}/disconnect`, { method: 'POST', - workspaceId: account.workspaceId, + workspaceId: channel.workspaceId, }); output(result, { human: !program.opts().json }); }); - const accountsEnable = accounts + const channelsEnable = channels .command('enable') - .description('Enable forwarding for an account') + .description('Enable forwarding for a channel') .argument('', 'WABA ID') .action(async (wabaId: string) => { - const account = await resolveAccount(wabaId); - const result = await apiClient(`/meta/accounts/${account.id}/enable`, { + const channel = await resolveChannel(wabaId); + const result = await apiClient(`/meta/channels/${channel.id}/enable`, { method: 'POST', - workspaceId: account.workspaceId, + workspaceId: channel.workspaceId, }); output(result, { human: !program.opts().json }); }); - const accountsDisable = accounts + const channelsDisable = channels .command('disable') - .description('Disable forwarding for an account') + .description('Disable forwarding for a channel') .argument('', 'WABA ID') .action(async (wabaId: string) => { - const account = await resolveAccount(wabaId); - const result = await apiClient(`/meta/accounts/${account.id}/disable`, { + const channel = await resolveChannel(wabaId); + const result = await apiClient(`/meta/channels/${channel.id}/disable`, { method: 'POST', - workspaceId: account.workspaceId, + workspaceId: channel.workspaceId, }); output(result, { human: !program.opts().json }); }); addExamples( - accounts, + channels, ` EXAMPLES: - $ hookmyapp accounts list - $ hookmyapp accounts connect - $ hookmyapp accounts disconnect 1234567890 + $ hookmyapp channels list + $ hookmyapp channels connect + $ hookmyapp channels disconnect 1234567890 `, ); addExamples( - accountsList, + channelsList, ` EXAMPLES: - $ hookmyapp accounts list - $ hookmyapp accounts list --json + $ hookmyapp channels list + $ hookmyapp channels list --json `, ); addExamples( - accountsShow, + channelsShow, ` EXAMPLES: - $ hookmyapp accounts show 1234567890 - $ hookmyapp accounts show 1234567890 --json + $ hookmyapp channels show 1234567890 + $ hookmyapp channels show 1234567890 --json `, ); addExamples( - accountsConnect, + channelsConnect, ` EXAMPLES: - $ hookmyapp accounts connect - $ hookmyapp accounts connect --workspace acme-corp + $ hookmyapp channels connect + $ hookmyapp channels connect --workspace acme-corp `, ); addExamples( - accountsDisconnect, + channelsDisconnect, ` EXAMPLES: - $ hookmyapp accounts disconnect 1234567890 - $ hookmyapp accounts disconnect 1234567890 --workspace acme-corp + $ hookmyapp channels disconnect 1234567890 + $ hookmyapp channels disconnect 1234567890 --workspace acme-corp `, ); addExamples( - accountsEnable, + channelsEnable, ` EXAMPLES: - $ hookmyapp accounts enable 1234567890 - $ hookmyapp accounts enable 1234567890 --workspace acme-corp + $ hookmyapp channels enable 1234567890 + $ hookmyapp channels enable 1234567890 --workspace acme-corp `, ); addExamples( - accountsDisable, + channelsDisable, ` EXAMPLES: - $ hookmyapp accounts disable 1234567890 - $ hookmyapp accounts disable 1234567890 --workspace acme-corp + $ hookmyapp channels disable 1234567890 + $ hookmyapp channels disable 1234567890 --workspace acme-corp `, ); } diff --git a/src/commands/env.ts b/src/commands/env.ts index 51d566b..e5f4a3e 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -1,7 +1,7 @@ import type { Command } from 'commander'; import { apiClient } from '../api/client.js'; import { addExamples } from '../output/help.js'; -import { resolveAccount } from './accounts.js'; +import { resolveChannel } from './channels.js'; export function registerEnvCommand(program: Command): void { const env = program @@ -9,11 +9,11 @@ export function registerEnvCommand(program: Command): void { .description('Output credentials as .env format') .argument('', 'WABA ID') .action(async (wabaId: string) => { - const account = await resolveAccount(wabaId); - const tokenData = await apiClient(`/meta/accounts/${account.id}/token`); + const channel = await resolveChannel(wabaId); + const tokenData = await apiClient(`/meta/channels/${channel.id}/token`); process.stdout.write( - `WABA_ID=${account.metaWabaId}\nACCESS_TOKEN=${tokenData.accessToken}\nPHONE_NUMBER_ID=${account.phoneNumberId}\n`, + `WABA_ID=${channel.metaWabaId}\nACCESS_TOKEN=${tokenData.accessToken}\nPHONE_NUMBER_ID=${channel.phoneNumberId}\n`, ); }); diff --git a/src/commands/health.ts b/src/commands/health.ts index fe0b997..b9e458d 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -2,18 +2,18 @@ import type { Command } from 'commander'; import { apiClient } from '../api/client.js'; import { output } from '../output/format.js'; import { addExamples } from '../output/help.js'; -import { resolveAccount } from './accounts.js'; +import { resolveChannel } from './channels.js'; export function registerHealthCommand(program: Command): void { const health = program .command('health') - .description('Check account health') + .description('Check channel health') .argument('', 'WABA ID') .action(async (wabaId: string) => { - const account = await resolveAccount(wabaId); - const result = await apiClient(`/meta/accounts/${account.id}/refresh`, { + const channel = await resolveChannel(wabaId); + const result = await apiClient(`/meta/channels/${channel.id}/refresh`, { method: 'POST', - workspaceId: account.workspaceId, + workspaceId: channel.workspaceId, }); output(result, { human: !program.opts().json }); }); diff --git a/src/commands/sandbox-listen/index.ts b/src/commands/sandbox-listen/index.ts index df6d292..d1337ec 100644 --- a/src/commands/sandbox-listen/index.ts +++ b/src/commands/sandbox-listen/index.ts @@ -13,7 +13,7 @@ import type { Command } from 'commander'; import { apiClient } from '../../api/client.js'; import { CliError, AuthError, ConflictError } from '../../output/error.js'; -import { getEffectiveApiUrl } from '../../config/env-profiles.js'; +import { resolveEnv } from '../../config/env-profiles.js'; import { addExamples } from '../../output/help.js'; import { cliCommandPrefix } from '../../output/cli-self.js'; import { readCredentials } from '../../auth/store.js'; @@ -44,24 +44,6 @@ export interface TunnelStartResponse { webhookPath?: string; } -/** Resolve the effective HookMyApp API base URL (mirrors api/client.ts). */ -function getApiBaseUrl(): string { - return ( - getEffectiveApiUrl() - ); -} - -/** Derive tunnel env from the API host the CLI is pointed at. */ -export function detectEnv(apiBaseUrl: string): 'local' | 'staging' | 'production' { - if (apiBaseUrl.includes('localhost') || apiBaseUrl.includes('127.0.0.1')) { - return 'local'; - } - if (apiBaseUrl.includes('staging')) { - return 'staging'; - } - return 'production'; -} - /** * Execute the sandbox-listen flow against an already-resolved session. * @@ -104,7 +86,7 @@ export async function runSandboxListenFlow( // LISTENER_ACTIVE / PHONE_TAKEN_ANOTHER — let those propagate as // ConflictError (exit 6) so users see the remediation text. Non-conflict // provisioning failures (5xx, timeouts) still map to exit 3. - const env = detectEnv(getApiBaseUrl()); + const env = resolveEnv(); let tunnel: TunnelStartResponse; try { tunnel = (await apiClient( diff --git a/src/commands/token.ts b/src/commands/token.ts index 72244da..f1db01e 100644 --- a/src/commands/token.ts +++ b/src/commands/token.ts @@ -1,7 +1,7 @@ import type { Command } from 'commander'; import { apiClient } from '../api/client.js'; import { addExamples } from '../output/help.js'; -import { resolveAccount } from './accounts.js'; +import { resolveChannel } from './channels.js'; export function registerTokenCommand(program: Command): void { const token = program @@ -9,8 +9,8 @@ export function registerTokenCommand(program: Command): void { .description('Reveal access token') .argument('', 'WABA ID') .action(async (wabaId: string) => { - const account = await resolveAccount(wabaId); - const data = await apiClient(`/meta/accounts/${account.id}/token`); + const channel = await resolveChannel(wabaId); + const data = await apiClient(`/meta/channels/${channel.id}/token`); process.stdout.write(data.accessToken + '\n'); }); diff --git a/src/commands/webhook.ts b/src/commands/webhook.ts index fc07e2e..4391ed6 100644 --- a/src/commands/webhook.ts +++ b/src/commands/webhook.ts @@ -3,7 +3,7 @@ import { apiClient } from '../api/client.js'; import { output } from '../output/format.js'; import { ValidationError } from '../output/error.js'; import { addExamples } from '../output/help.js'; -import { resolveAccount } from './accounts.js'; +import { resolveChannel } from './channels.js'; import { readCredentials } from '../auth/store.js'; import { getEffectiveApiUrl } from '../config/env-profiles.js'; @@ -15,8 +15,8 @@ export function registerWebhookCommand(program: Command): void { .description('Show webhook config') .argument('', 'WABA ID') .action(async (wabaId: string) => { - const account = await resolveAccount(wabaId); - const data = await apiClient(`/webhook-config/${account.id}`); + const channel = await resolveChannel(wabaId); + const data = await apiClient(`/webhook-config/${channel.id}`); output(data, { json: !!program.opts().json, kind: 'read' }); }); @@ -33,25 +33,25 @@ export function registerWebhookCommand(program: Command): void { ); } - const account = await resolveAccount(wabaId); + const channel = await resolveChannel(wabaId); const payload = { webhookUrl: opts.url, verifyToken: opts.verifyToken ?? undefined }; // Check if webhook config already exists const baseUrl = getEffectiveApiUrl(); const creds = readCredentials(); - const checkRes = await fetch(`${baseUrl}/webhook-config/${account.id}`, { + const checkRes = await fetch(`${baseUrl}/webhook-config/${channel.id}`, { headers: { Authorization: `Bearer ${creds!.accessToken}` }, }); if (checkRes.ok) { - await apiClient(`/webhook-config/${account.id}`, { + await apiClient(`/webhook-config/${channel.id}`, { method: 'PUT', body: JSON.stringify(payload), }); } else { await apiClient('/webhook-config', { method: 'POST', - body: JSON.stringify({ ...payload, accountId: account.id }), + body: JSON.stringify({ ...payload, channelId: channel.id }), }); } diff --git a/src/commands/workspace.ts b/src/commands/workspace.ts index 00ffe17..d600011 100644 --- a/src/commands/workspace.ts +++ b/src/commands/workspace.ts @@ -191,7 +191,7 @@ export function registerWorkspaceCommand(program: Command): void { console.log(`ID: ${merged.id}`); console.log(`Role: ${merged.role ?? 'unknown'}`); console.log(`Members: ${merged.memberCount}`); - console.log(`Accounts: ${merged.accountCount}`); + console.log(`Channels: ${merged.channelCount}`); } else { output(merged, { human: false }); } diff --git a/src/config/env-profiles.ts b/src/config/env-profiles.ts index dd2ac5c..2b17cff 100644 --- a/src/config/env-profiles.ts +++ b/src/config/env-profiles.ts @@ -22,7 +22,7 @@ export const ENV_PROFILES: Record = { local: { apiUrl: 'https://uninked-robbi-boughless.ngrok-free.dev', appUrl: 'https://uninked-robbi-boughless.ngrok-free.dev', - workosClientId: 'client_01KM5S4CGX9M2M2P63JTA6AFEH', + workosClientId: 'client_01KPAJA2CKGFMASZMABKDTS2X8', }, staging: { apiUrl: 'https://staging-api.hookmyapp.com', diff --git a/src/index.ts b/src/index.ts index 08fb83f..9ae94bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { Command, CommanderError } from 'commander'; import { loginCommand } from './auth/login.js'; import { logoutCommand } from './auth/logout.js'; -import { registerAccountsCommand } from './commands/accounts.js'; +import { registerChannelsCommand } from './commands/channels.js'; import { registerHealthCommand } from './commands/health.js'; import { registerWebhookCommand } from './commands/webhook.js'; import { registerTokenCommand } from './commands/token.js'; @@ -25,7 +25,7 @@ const program = new Command(); program .name('hookmyapp') - .description('HookMyApp CLI — manage WhatsApp Business accounts') + .description('HookMyApp CLI — manage WhatsApp Business channels') .version(pkg.version); program.option('--json', 'Machine-readable JSON output (scripts/CI)'); @@ -78,7 +78,7 @@ COMMON COMMANDS: sandbox listen Stream Meta webhooks to your local server through a sandbox tunnel sandbox env Print or write the .env values for a sandbox session sandbox send Send a test WhatsApp message via sandbox-proxy - accounts connect Connect a WhatsApp Business account (embedded signup) + channels connect Connect a WhatsApp Business channel (embedded signup) workspace list List workspaces you belong to billing View or change your plan @@ -112,8 +112,8 @@ program.configureOutput({ loginCommand(program); logoutCommand(program); -// Account management -registerAccountsCommand(program); +// Channel management +registerChannelsCommand(program); // Health check registerHealthCommand(program);