Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
156 changes: 156 additions & 0 deletions src/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof makeCtx>['ctx']) => void) {
const bundle = createRecordingClient();
Expand Down Expand Up @@ -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();
});
});
11 changes: 8 additions & 3 deletions src/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ReturnType<typeof fn>> = {};
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' },
Expand Down
126 changes: 73 additions & 53 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -40,28 +41,38 @@ function collectStream(stream: NodeJS.ReadableStream): Promise<string> {
});
}

function question(stdin: NodeJS.ReadableStream, stdout: Writable, query: string): Promise<string> {
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<string> {
const rl = readline.createInterface({
input: stdin,
output: stdout,
terminal: false,
});
try {
return await rl.question(query);
} finally {
rl.close();
pauseStdin(stdin);
}
}

async function hiddenQuestion(
Expand All @@ -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> = {}): CliContext {
Expand Down
14 changes: 10 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading