From ab387480fa9ce3857c19649675058c5a18473597 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Wed, 15 Apr 2026 17:28:42 +0300 Subject: [PATCH 1/3] fix(sandbox-listen): use resolveEnv() instead of URL-substring detectEnv Tunnel env now follows CLI config precedence (HOOKMYAPP_ENV > config.json 'env' > DEFAULT_ENV) rather than heuristic substring matching on the effective API base URL. The old detectEnv(url) returned 'local' for any localhost URL and 'staging' only when the substring 'staging' appeared, both of which were wrong when an operator used a surgical HOOKMYAPP_API_URL override against a non-standard host, or when the configured env disagreed with the URL pattern. Added unit test src/commands/__tests__/sandbox-listen-env.test.ts locking in the three precedence rules so this cannot regress. Quick task: 260415-nym. --- .../__tests__/sandbox-listen-env.test.ts | 58 +++++++++++++++++++ src/commands/sandbox-listen/index.ts | 22 +------ 2 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 src/commands/__tests__/sandbox-listen-env.test.ts 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/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( From 00410bfb098c9dec478660119aff106e43de541d Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Thu, 16 Apr 2026 11:39:52 +0300 Subject: [PATCH 2/3] chore(env-profiles): point local to new isolated Local HookMyApp WorkOS env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit local.workosClientId → client_01KPAJA2CKGFMASZMABKDTS2X8 (new dedicated Sandbox environment on the separate Local HookMyApp WorkOS account). Prior value was the shared staging client_id. Splitting WorkOS so local dev user/org data no longer cross-contaminates the staging environment used by QA and integration testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config/env-profiles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', From 591ac023cfce36c66d6db0d9df6e48b2d83f929b Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Fri, 17 Apr 2026 01:00:36 +0300 Subject: [PATCH 3/3] feat(channels)!: rename accounts to channels; bump 0.4.0 (breaking) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HookMyApp monorepo Phase 116 renames `Account` to `Channel` as the canonical noun for a WhatsApp connection (industry standard: Twilio, Intercom, Front, Zendesk). This CLI change lands in lockstep. Changes: - commands/accounts.ts -> commands/channels.ts (6 subcommands renamed: list, show, connect, disconnect, enable, disable) - registerAccountsCommand -> registerChannelsCommand - runAccountsConnect -> runChannelsConnect (post-login wizard entrypoint) - resolveAccount -> resolveChannel (shared helper) - API URLs /meta/accounts -> /meta/channels across webhook, token, env, health commands - `hookmyapp login --next accounts` -> `--next channels` - `hookmyapp workspace current` now reads `channelCount` and prints `Channels:` label (was `Accounts:` / `accountCount`) - __tests__/accounts.test.ts -> __tests__/channels.test.ts (renamed, fixtures + assertions flipped) - Added Nyquist Dim-3 boundary test: `accounts list` must exit with unknown-command error (proves absence, not just presence of rename) - package.json 0.3.0 -> 0.4.0 (breaking); CHANGELOG.md created Not renamed (intentionally): - README.md "WhatsApp Business account" — Meta product name - accounts.ts OAuth redirect URI `${appUrl}/cli/callback` — not a /dashboard/accounts/* path Backend Phase 116 lands first and serves /meta/channels/*; the short window of broken v0.3.0 CLI is acceptable pre-prod. --- CHANGELOG.md | 37 ++++ package.json | 2 +- .../{accounts.test.ts => channels.test.ts} | 145 ++++++++------- src/__tests__/env.test.ts | 16 +- src/__tests__/webhook.test.ts | 28 +-- src/__tests__/workspace.test.ts | 2 +- src/auth/__tests__/login.test.ts | 26 +-- src/auth/login.ts | 20 +-- src/commands/{accounts.ts => channels.ts} | 166 +++++++++--------- src/commands/env.ts | 8 +- src/commands/health.ts | 10 +- src/commands/token.ts | 6 +- src/commands/webhook.ts | 14 +- src/commands/workspace.ts | 2 +- src/index.ts | 10 +- 15 files changed, 272 insertions(+), 220 deletions(-) create mode 100644 CHANGELOG.md rename src/__tests__/{accounts.test.ts => channels.test.ts} (70%) rename src/commands/{accounts.ts => channels.ts} (53%) 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/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/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/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);