From 233b9a8672b80b5e57792d37f47ae95d02b25b4a Mon Sep 17 00:00:00 2001 From: Alibek Seksenali Date: Mon, 14 Sep 2026 14:56:54 +0500 Subject: [PATCH] fix: return the terminal after init and clarify me output Init left stdin/raw-mode open so the process never exited; me dumped a raw GET /me body. Exit after every command, print a labeled profile, and read init --base-url/--instance-id from globals. Bump to 0.1.4. Co-authored-by: Cursor --- package-lock.json | 4 +- package.json | 2 +- src/__tests__/commands.test.ts | 156 +++++++++++++++++++++++++++++++++ src/__tests__/helpers.ts | 11 ++- src/context.ts | 126 +++++++++++++++----------- src/index.ts | 14 ++- src/local.ts | 28 ++++-- src/output.ts | 91 +++++++++++++++++++ src/rest.ts | 28 ++++-- src/version.ts | 2 +- 10 files changed, 384 insertions(+), 78 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3d96322..79e4812 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@1msg/cli", - "version": "0.1.2", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@1msg/cli", - "version": "0.1.2", + "version": "0.1.4", "license": "MIT", "dependencies": { "@1msg/sdk": "^1.0.4", diff --git a/package.json b/package.json index bef3658..e8a0e5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@1msg/cli", - "version": "0.1.3", + "version": "0.1.4", "description": "Command-line client for the 1msg WhatsApp Business API", "license": "MIT", "repository": { diff --git a/src/__tests__/commands.test.ts b/src/__tests__/commands.test.ts index a16161e..6ac6cc8 100644 --- a/src/__tests__/commands.test.ts +++ b/src/__tests__/commands.test.ts @@ -3,6 +3,7 @@ import { runCli } from '../run'; import { configFilePath } from '../paths'; import { CLI_VERSION } from '../version'; import { createRecordingClient, makeCtx } from './helpers'; +import { releaseStdio } from '../context'; function withChannel(ctxFilesWrite: (ctx: ReturnType['ctx']) => void) { const bundle = createRecordingClient(); @@ -191,4 +192,159 @@ describe('commands', () => { expect(code).toBe(5); expect(stderr.toString()).toContain('no channel configured — run 1msg init'); }); + + it('me prints labeled profile fields in stable order', async () => { + const { ctx, stdout } = withChannel(() => undefined); + const code = await runCli(['me'], ctx); + expect(code).toBe(0); + expect(stdout.toString()).toBe('phone 12020721369\nabout Available\n'); + }); + + it('me --json is the raw GET /me body', async () => { + const { ctx, stdout } = withChannel(() => undefined); + const code = await runCli(['me', '--json'], ctx); + expect(code).toBe(0); + expect(JSON.parse(stdout.toString())).toEqual({ about: 'Available', phone: '12020721369' }); + }); + + it('me unwraps data[] and flattens phone objects', async () => { + const bundle = createRecordingClient(); + (bundle.client.profile.getMe as jest.Mock).mockResolvedValueOnce({ + data: [ + { + about: 'Shop', + websites: ['https://a.example', 'https://b.example'], + profile_picture_url: 'https://cdn.example/p.jpg', + }, + ], + phone: { display_phone_number: '12020721369' }, + }); + const { ctx, stdout } = makeCtx({ createClient: () => bundle.client }); + ctx.fs.writeFileSync( + configFilePath(ctx), + stringify({ + version: 1, + default_channel: 'prod', + channels: { + prod: { base_url: 'https://api.1msg.io', instance_id: 'ODI1', token: 'file-token' }, + }, + }), + ); + const code = await runCli(['me'], ctx); + expect(code).toBe(0); + expect(stdout.toString()).toContain('phone 12020721369'); + expect(stdout.toString()).toContain('about Shop'); + expect(stdout.toString()).toContain('websites https://a.example, https://b.example'); + expect(stdout.toString()).toContain('photo https://cdn.example/p.jpg'); + expect(stdout.toString()).not.toContain('display_phone_number'); + expect(stdout.toString()).not.toContain('profile_picture_url'); + }); + + it('me with only {error} exits 2 and explains', async () => { + const bundle = createRecordingClient(); + (bundle.client.profile.getMe as jest.Mock).mockResolvedValueOnce({ error: 'wrong request' }); + const { ctx, stdout, stderr } = makeCtx({ createClient: () => bundle.client }); + ctx.fs.writeFileSync( + configFilePath(ctx), + stringify({ + version: 1, + default_channel: 'prod', + channels: { + prod: { base_url: 'https://api.1msg.io', instance_id: 'ODI1', token: 'file-token' }, + }, + }), + ); + const code = await runCli(['me'], ctx); + expect(code).toBe(2); + expect(stdout.toString()).toBe(''); + expect(stderr.toString()).toContain('wrong request'); + }); + + it('me empty profile tells the user what happened', async () => { + const bundle = createRecordingClient(); + (bundle.client.profile.getMe as jest.Mock).mockResolvedValueOnce({}); + const { ctx, stdout, stderr } = makeCtx({ createClient: () => bundle.client }); + ctx.fs.writeFileSync( + configFilePath(ctx), + stringify({ + version: 1, + default_channel: 'prod', + channels: { + prod: { base_url: 'https://api.1msg.io', instance_id: 'ODI1', token: 'file-token' }, + }, + }), + ); + const code = await runCli(['me'], ctx); + expect(code).toBe(0); + expect(stdout.toString()).toBe(''); + expect(stderr.toString()).toContain('empty WhatsApp Business profile'); + expect(stderr.toString()).toContain('1msg me --json'); + }); + + it('init --token-stdin writes config and verifies status', async () => { + const bundle = createRecordingClient(); + const { ctx, stdout, stderr, files } = makeCtx({ createClient: () => bundle.client, env: {} }); + ctx.readStdin = async () => 'tok-from-stdin'; + const code = await runCli( + [ + 'init', + '--name', + 'prod', + '--base-url', + 'https://api.1msg.io', + '--instance-id', + 'ODI1', + '--token-stdin', + ], + ctx, + ); + expect(code).toBe(0); + expect(stderr.toString()).toMatch(/^Wrote /); + expect(stdout.toString()).toContain('status'); + expect(bundle.calls.some((c) => c.method === 'channel.getStatus')).toBe(true); + const configPath = [...files.keys()].find((k) => k.endsWith('config.yaml')); + expect(configPath).toBeDefined(); + expect(String(files.get(configPath!))).toContain('tok-from-stdin'); + }); + + it('init verify error still returns and does not hang the command', async () => { + const bundle = createRecordingClient(); + (bundle.client.channel.getStatus as jest.Mock).mockRejectedValueOnce({ + response: { + status: 401, + text: async () => JSON.stringify({ error: 'invalid or expired token' }), + }, + }); + const { ctx, stderr } = makeCtx({ createClient: () => bundle.client, env: {} }); + ctx.readStdin = async () => 'bad-token'; + const code = await runCli( + [ + 'init', + '--name', + 'prod', + '--base-url', + 'https://api.1msg.io', + '--instance-id', + 'ODI1', + '--token-stdin', + ], + ctx, + ); + expect(code).toBe(2); + expect(stderr.toString()).toContain('Wrote '); + expect(stderr.toString()).toContain('invalid or expired token (HTTP 401)'); + }); +}); + +describe('releaseStdio', () => { + it('restores cooked TTY and pauses stdin', () => { + const stdin = { + isTTY: true, + setRawMode: jest.fn(), + pause: jest.fn(), + }; + releaseStdio(stdin as unknown as NodeJS.ReadStream); + expect(stdin.setRawMode).toHaveBeenCalledWith(false); + expect(stdin.pause).toHaveBeenCalled(); + }); }); diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts index 55fb893..4bbb972 100644 --- a/src/__tests__/helpers.ts +++ b/src/__tests__/helpers.ts @@ -54,13 +54,18 @@ export function createRecordingClient(): { client: Client; calls: Array<{ method return { ok: true }; }); - const proxy = (prefix: string) => - new Proxy( + const proxy = (prefix: string) => { + const cache: Record> = {}; + return new Proxy( {}, { - get: (_t, prop: string) => fn(`${prefix}.${prop}`), + get: (_t, prop: string) => { + if (!cache[prop]) cache[prop] = fn(`${prefix}.${prop}`); + return cache[prop]; + }, }, ); + }; const client = { config: { token: 'test-token', baseUrl: 'https://api.1msg.io', instanceId: 'ODI1' }, diff --git a/src/context.ts b/src/context.ts index 1395523..6d4cb56 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import readline from 'node:readline/promises'; import { Writable } from 'stream'; import { createClient, type Client, type ClientConfigOptions } from '@1msg/sdk'; @@ -40,28 +41,38 @@ function collectStream(stream: NodeJS.ReadableStream): Promise { }); } -function question(stdin: NodeJS.ReadableStream, stdout: Writable, query: string): Promise { - stdout.write(query); - return new Promise((resolve, reject) => { - const readable = stdin as NodeJS.ReadableStream & { - setEncoding?: (enc: BufferEncoding) => void; - }; - readable.setEncoding?.('utf8'); - const onData = (chunk: string | Buffer) => { - cleanup(); - resolve(String(chunk).replace(/\r?\n$/, '')); - }; - const onError = (err: Error) => { - cleanup(); - reject(err); - }; - const cleanup = () => { - stdin.off('data', onData); - stdin.off('error', onError); - }; - stdin.on('data', onData); - stdin.on('error', onError); +function pauseStdin(stdin: NodeJS.ReadableStream): void { + const stream = stdin as NodeJS.ReadStream; + try { + if (typeof stream.pause === 'function') stream.pause(); + } catch { + /* ignore */ + } +} + +/** Restore cooked TTY and drop stdin so the process can exit after prompts. */ +export function releaseStdio(stdin: NodeJS.ReadableStream = process.stdin): void { + const tty = stdin as NodeJS.ReadStream; + try { + if (typeof tty.setRawMode === 'function' && tty.isTTY) tty.setRawMode(false); + } catch { + /* ignore */ + } + pauseStdin(stdin); +} + +async function question(stdin: NodeJS.ReadableStream, stdout: Writable, query: string): Promise { + const rl = readline.createInterface({ + input: stdin, + output: stdout, + terminal: false, }); + try { + return await rl.question(query); + } finally { + rl.close(); + pauseStdin(stdin); + } } async function hiddenQuestion( @@ -74,40 +85,49 @@ async function hiddenQuestion( if (typeof tty.setRawMode !== 'function') { return question(stdin, stdout, ''); } + const wasRaw = Boolean(tty.isRaw); tty.setRawMode(true); - return new Promise((resolve, reject) => { - let value = ''; - const onData = (chunk: Buffer | string) => { - const text = String(chunk); - if (text === '\n' || text === '\r' || text === '\u0004') { + try { + return await new Promise((resolve, reject) => { + let value = ''; + const onData = (chunk: Buffer | string) => { + const text = String(chunk); + if (text === '\n' || text === '\r' || text === '\u0004') { + cleanup(); + stdout.write('\n'); + resolve(value); + return; + } + if (text === '\u0003') { + cleanup(); + reject(new Error('cancelled')); + return; + } + if (text === '\u007f' || text === '\b') { + value = value.slice(0, -1); + return; + } + value += text; + }; + const onError = (err: Error) => { cleanup(); - stdout.write('\n'); - resolve(value); - return; - } - if (text === '\u0003') { - cleanup(); - reject(new Error('cancelled')); - return; - } - if (text === '\u007f' || text === '\b') { - value = value.slice(0, -1); - return; - } - value += text; - }; - const onError = (err: Error) => { - cleanup(); - reject(err); - }; - const cleanup = () => { - if (typeof tty.setRawMode === 'function') tty.setRawMode(false); - stdin.off('data', onData); - stdin.off('error', onError); - }; - stdin.on('data', onData); - stdin.on('error', onError); - }); + reject(err); + }; + const cleanup = () => { + stdin.off('data', onData); + stdin.off('error', onError); + }; + stdin.on('data', onData); + stdin.on('error', onError); + }); + } finally { + try { + tty.setRawMode(wasRaw); + } catch { + /* ignore */ + } + pauseStdin(stdin); + } } export function createDefaultContext(overrides: Partial = {}): CliContext { diff --git a/src/index.ts b/src/index.ts index 2ad9a4d..27c2df7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,18 @@ #!/usr/bin/env node +import { releaseStdio } from './context'; import { runCli } from './run'; +function exitProcess(code: number): void { + releaseStdio(process.stdin); + // Prompts resume stdin; undici may keep sockets. Without an explicit + // exit the shell never gets the console back after `1msg init`. + process.exit(code); +} + runCli(process.argv.slice(2)) - .then((code) => { - if (code !== 0) process.exitCode = code; - }) + .then((code) => exitProcess(code)) .catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; + exitProcess(1); }); diff --git a/src/local.ts b/src/local.ts index 90c0780..1fc53c9 100644 --- a/src/local.ts +++ b/src/local.ts @@ -156,8 +156,15 @@ export async function handleInit( verify?: boolean; }, ): Promise { - const interactive = !opts.tokenStdin && ctx.stdinIsTTY && (!opts.baseUrl || !opts.instanceId); - const channel = await promptChannel(ctx, opts, interactive); + const flags = globalsFrom(cmd); + const input = { + name: opts.name, + baseUrl: opts.baseUrl ?? flags.baseUrl, + instanceId: opts.instanceId ?? flags.instanceId, + tokenStdin: opts.tokenStdin, + }; + const interactive = !input.tokenStdin && ctx.stdinIsTTY && (!input.baseUrl || !input.instanceId); + const channel = await promptChannel(ctx, input, interactive); let makeDefault = true; if (interactive) { const answer = (await ctx.prompt.question('Set as default? [Y/n]: ')).trim().toLowerCase(); @@ -166,7 +173,13 @@ export async function handleInit( upsertChannel(ctx, { ...channel, makeDefault }); ctx.stderr.write(`Wrote ${configFilePath(ctx)}\n`); if (opts.verify === false) return ok(); - await verifyStatus(ctx, { ...globalsFrom(cmd), channel: channel.name, token: channel.token, baseUrl: channel.baseUrl, instanceId: channel.instanceId }); + await verifyStatus(ctx, { + ...flags, + channel: channel.name, + token: channel.token, + baseUrl: channel.baseUrl, + instanceId: channel.instanceId, + }); return ok(); } @@ -182,15 +195,18 @@ export async function handleChannelAdd( verify?: boolean; }, ): Promise { - const nonInteractive = Boolean(opts.name && opts.baseUrl && (opts.instanceId || envInstanceId(ctx))); + const flags = globalsFrom(cmd); + const nonInteractive = Boolean( + opts.name && (opts.baseUrl || flags.baseUrl) && (opts.instanceId || flags.instanceId || envInstanceId(ctx)), + ); const interactive = !nonInteractive; if (!interactive && !opts.name) usage('--name is required', '1msg channel add --help'); const channel = await promptChannel( ctx, { name: opts.name, - baseUrl: opts.baseUrl, - instanceId: opts.instanceId || envInstanceId(ctx), + baseUrl: opts.baseUrl ?? flags.baseUrl, + instanceId: opts.instanceId || flags.instanceId || envInstanceId(ctx), tokenStdin: opts.tokenStdin, }, interactive, diff --git a/src/output.ts b/src/output.ts index d6a7c40..1e713de 100644 --- a/src/output.ts +++ b/src/output.ts @@ -128,3 +128,94 @@ export function pickString(obj: Record, keys: string[]): string } return ''; } + +const ME_FIELDS = [ + 'phone', + 'about', + 'description', + 'address', + 'email', + 'vertical', + 'websites', + 'photo', +] as const; + +const ME_SKIP = new Set([ + ...ME_FIELDS, + 'token', + 'messaging_product', + 'profile_picture_url', + 'error', +]); + +function flattenPhone(value: unknown): unknown { + if (value === undefined || value === null) return value; + if (typeof value !== 'object' || Array.isArray(value)) return value; + return ( + pickString(value as Record, [ + 'phone', + 'display_phone_number', + 'displayPhoneNumber', + 'phone_number', + 'id', + ]) || value + ); +} + +/** Normalize GET /me bodies (envelope, phone object, websites[]) into kv fields. */ +export function normalizeMe(body: unknown): Record { + if (body === undefined || body === null) return {}; + if (typeof body === 'string' || typeof body === 'number' || typeof body === 'boolean') { + return { phone: String(body) }; + } + let rec = asRecord(body); + if (Array.isArray(rec.data) && rec.data[0] && typeof rec.data[0] === 'object') { + const inner = asRecord(rec.data[0]); + const { data: _data, ...outer } = rec; + rec = { ...inner }; + for (const [key, value] of Object.entries(outer)) { + if (value !== undefined) rec[key] = value; + } + } + if (rec.phone !== undefined) rec.phone = flattenPhone(rec.phone); + if (!rec.photo && typeof rec.profile_picture_url === 'string') { + rec.photo = rec.profile_picture_url; + } + if (Array.isArray(rec.websites)) { + rec.websites = rec.websites.map((item) => String(item).trim()).filter(Boolean).join(', '); + } + return rec; +} + +export function meErrorMessage(body: unknown): string | undefined { + const rec = asRecord(body); + if (typeof rec.error !== 'string' || !rec.error.trim()) return undefined; + const profile = normalizeMe(body); + const hasProfile = ME_FIELDS.some((key) => { + const value = profile[key]; + return value !== undefined && value !== null && value !== ''; + }); + if (hasProfile) return undefined; + return rec.error.trim(); +} + +export function meProfileRows(body: unknown): Array<[string, unknown]> { + const rec = normalizeMe(body); + const rows: Array<[string, unknown]> = ME_FIELDS.map((key) => [key, rec[key]]); + for (const [key, value] of Object.entries(rec)) { + if (ME_SKIP.has(key)) continue; + rows.push([key, value]); + } + return rows; +} + +export function printMe(stdout: Writable, body: unknown, emptyHint?: Writable): void { + const rows = meProfileRows(body); + const present = rows.filter(([, value]) => value !== undefined && value !== null && value !== ''); + if (present.length === 0) { + emptyHint?.write('empty WhatsApp Business profile (GET /me)\n'); + emptyHint?.write('See: 1msg me --json\n'); + return; + } + printKv(stdout, rows); +} diff --git a/src/rest.ts b/src/rest.ts index 5e8460c..e732697 100644 --- a/src/rest.ts +++ b/src/rest.ts @@ -1,8 +1,8 @@ import type { Command } from 'commander'; import type { CliContext } from './context'; import { parsePhone } from './dest'; -import { usage } from './errors'; -import { ok, printApiResult, withClient } from './execute'; +import { CliExit, EXIT_API_4XX, usage } from './errors'; +import { ok, outputOpts, printApiResult, withClient } from './execute'; import { basenameOf, fileToDataUri, @@ -12,7 +12,17 @@ import { readJsonFile, } from './io'; import { globalsFrom } from './local'; -import { asArray, asRecord, pickString, printKv, printTable } from './output'; +import { + asArray, + asRecord, + meErrorMessage, + meProfileRows, + pickString, + printError, + printKv, + printMe, + printTable, +} from './output'; function settingsApi(client: { channel: unknown }) { return client.channel as { @@ -36,12 +46,11 @@ export async function handleChannelInfo(ctx: CliContext, cmd: Command): Promise< const combined = { status, me }; printApiResult(ctx, flags, combined, () => { const s = asRecord(status); - const m = asRecord(me); printKv(ctx.stdout, [ ['status', s.status], ['accountStatus', s.accountStatus], ['mm_lite_available', s.mm_lite_available], - ...Object.entries(m).filter(([key]) => key !== 'token'), + ...meProfileRows(me), ]); }); return ok(); @@ -64,9 +73,12 @@ export async function handleStatus(ctx: CliContext, cmd: Command): Promise { const flags = globalsFrom(cmd); const body = await withClient(ctx, flags, (client) => client.profile.getMe(client.config.token)); - printApiResult(ctx, flags, body, () => { - printKv(ctx.stdout, Object.entries(asRecord(body))); - }); + const errorMessage = meErrorMessage(body); + if (errorMessage && !flags.json) { + printError(ctx.stderr, outputOpts(flags, ctx), new Error(errorMessage)); + throw new CliExit(EXIT_API_4XX); + } + printApiResult(ctx, flags, body, () => printMe(ctx.stdout, body, ctx.stderr)); return ok(); } diff --git a/src/version.ts b/src/version.ts index d10241b..1812e16 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ export const CLI_NAME = '@1msg/cli'; -export const CLI_VERSION = '0.1.3'; +export const CLI_VERSION = '0.1.4';