chore: format to 2-space and bump changelog

This commit is contained in:
Peter Steinberger
2025-11-26 00:53:53 +01:00
parent a67f4db5e2
commit e5f677803f
81 changed files with 7086 additions and 6999 deletions

View File

@@ -6,9 +6,9 @@ import { ensurePortAvailable, handlePortError } from "../infra/ports.js";
import { ensureFunnel, getTailnetHostname } from "../infra/tailscale.js";
import { ensureMediaHosted } from "../media/host.js";
import {
logWebSelfId,
monitorWebProvider,
sendMessageWeb,
logWebSelfId,
monitorWebProvider,
sendMessageWeb,
} from "../providers/web/index.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { createClient } from "../twilio/client.js";
@@ -22,89 +22,89 @@ import { updateWebhook } from "../webhook/update.js";
import { waitForever } from "./wait.js";
export type CliDeps = {
sendMessage: typeof sendMessage;
sendMessageWeb: typeof sendMessageWeb;
waitForFinalStatus: typeof waitForFinalStatus;
assertProvider: typeof assertProvider;
createClient?: typeof createClient;
monitorTwilio: typeof monitorTwilio;
listRecentMessages: typeof listRecentMessages;
ensurePortAvailable: typeof ensurePortAvailable;
startWebhook: typeof startWebhook;
waitForever: typeof waitForever;
ensureBinary: typeof ensureBinary;
ensureFunnel: typeof ensureFunnel;
getTailnetHostname: typeof getTailnetHostname;
readEnv: typeof readEnv;
findWhatsappSenderSid: typeof findWhatsappSenderSid;
updateWebhook: typeof updateWebhook;
handlePortError: typeof handlePortError;
monitorWebProvider: typeof monitorWebProvider;
resolveTwilioMediaUrl: (
source: string,
opts: { serveMedia: boolean; runtime: RuntimeEnv },
) => Promise<string>;
sendMessage: typeof sendMessage;
sendMessageWeb: typeof sendMessageWeb;
waitForFinalStatus: typeof waitForFinalStatus;
assertProvider: typeof assertProvider;
createClient?: typeof createClient;
monitorTwilio: typeof monitorTwilio;
listRecentMessages: typeof listRecentMessages;
ensurePortAvailable: typeof ensurePortAvailable;
startWebhook: typeof startWebhook;
waitForever: typeof waitForever;
ensureBinary: typeof ensureBinary;
ensureFunnel: typeof ensureFunnel;
getTailnetHostname: typeof getTailnetHostname;
readEnv: typeof readEnv;
findWhatsappSenderSid: typeof findWhatsappSenderSid;
updateWebhook: typeof updateWebhook;
handlePortError: typeof handlePortError;
monitorWebProvider: typeof monitorWebProvider;
resolveTwilioMediaUrl: (
source: string,
opts: { serveMedia: boolean; runtime: RuntimeEnv },
) => Promise<string>;
};
export async function monitorTwilio(
intervalSeconds: number,
lookbackMinutes: number,
clientOverride?: ReturnType<typeof createClient>,
maxIterations = Infinity,
intervalSeconds: number,
lookbackMinutes: number,
clientOverride?: ReturnType<typeof createClient>,
maxIterations = Infinity,
) {
// Adapter that wires default deps/runtime for the Twilio monitor loop.
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
client: clientOverride,
maxIterations,
deps: {
autoReplyIfConfigured,
listRecentMessages,
readEnv,
createClient,
sleep,
},
runtime: defaultRuntime,
});
// Adapter that wires default deps/runtime for the Twilio monitor loop.
return monitorTwilioImpl(intervalSeconds, lookbackMinutes, {
client: clientOverride,
maxIterations,
deps: {
autoReplyIfConfigured,
listRecentMessages,
readEnv,
createClient,
sleep,
},
runtime: defaultRuntime,
});
}
export function createDefaultDeps(): CliDeps {
// Default dependency bundle used by CLI commands and tests.
return {
sendMessage,
sendMessageWeb,
waitForFinalStatus,
assertProvider,
createClient,
monitorTwilio,
listRecentMessages,
ensurePortAvailable,
startWebhook,
waitForever,
ensureBinary,
ensureFunnel,
getTailnetHostname,
readEnv,
findWhatsappSenderSid,
updateWebhook,
handlePortError,
monitorWebProvider,
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
if (/^https?:\/\//i.test(source)) return source;
const hosted = await ensureMediaHosted(source, {
startServer: serveMedia,
runtime,
});
return hosted.url;
},
};
// Default dependency bundle used by CLI commands and tests.
return {
sendMessage,
sendMessageWeb,
waitForFinalStatus,
assertProvider,
createClient,
monitorTwilio,
listRecentMessages,
ensurePortAvailable,
startWebhook,
waitForever,
ensureBinary,
ensureFunnel,
getTailnetHostname,
readEnv,
findWhatsappSenderSid,
updateWebhook,
handlePortError,
monitorWebProvider,
resolveTwilioMediaUrl: async (source, { serveMedia, runtime }) => {
if (/^https?:\/\//i.test(source)) return source;
const hosted = await ensureMediaHosted(source, {
startServer: serveMedia,
runtime,
});
return hosted.url;
},
};
}
export function logTwilioFrom(runtime: RuntimeEnv = defaultRuntime) {
// Log the configured Twilio sender for clarity in CLI output.
const env = readEnv(runtime);
runtime.log(
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
);
// Log the configured Twilio sender for clarity in CLI output.
const env = readEnv(runtime);
runtime.log(
info(`Provider: twilio (polling inbound) | from ${env.whatsappFrom}`),
);
}
export { logWebSelfId };

View File

@@ -14,11 +14,11 @@ const waitForever = vi.fn();
const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
vi.mock("../commands/send.js", () => ({ sendCommand }));
@@ -27,63 +27,63 @@ vi.mock("../commands/webhook.js", () => ({ webhookCommand }));
vi.mock("../env.js", () => ({ ensureTwilioEnv }));
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
vi.mock("../provider-web.js", () => ({
loginWeb,
monitorWebProvider,
pickProvider,
loginWeb,
monitorWebProvider,
pickProvider,
}));
vi.mock("./deps.js", () => ({
createDefaultDeps: () => ({ waitForever }),
logTwilioFrom,
logWebSelfId,
monitorTwilio,
createDefaultDeps: () => ({ waitForever }),
logTwilioFrom,
logWebSelfId,
monitorTwilio,
}));
vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux }));
const { buildProgram } = await import("./program.js");
describe("cli program", () => {
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
});
it("runs send with required options", async () => {
const program = buildProgram();
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
from: "user",
});
expect(sendCommand).toHaveBeenCalled();
});
it("runs send with required options", async () => {
const program = buildProgram();
await program.parseAsync(["send", "--to", "+1", "--message", "hi"], {
from: "user",
});
expect(sendCommand).toHaveBeenCalled();
});
it("rejects invalid relay provider", async () => {
const program = buildProgram();
await expect(
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
).rejects.toThrow("exit");
expect(runtime.error).toHaveBeenCalledWith(
"--provider must be auto, web, or twilio",
);
});
it("rejects invalid relay provider", async () => {
const program = buildProgram();
await expect(
program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }),
).rejects.toThrow("exit");
expect(runtime.error).toHaveBeenCalledWith(
"--provider must be auto, web, or twilio",
);
});
it("falls back to twilio when web relay fails", async () => {
pickProvider.mockResolvedValue("web");
monitorWebProvider.mockRejectedValue(new Error("no web"));
const program = buildProgram();
await program.parseAsync(
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
{ from: "user" },
);
expect(logWebSelfId).toHaveBeenCalled();
expect(ensureTwilioEnv).toHaveBeenCalled();
expect(monitorTwilio).toHaveBeenCalledWith(2, 1);
});
it("falls back to twilio when web relay fails", async () => {
pickProvider.mockResolvedValue("web");
monitorWebProvider.mockRejectedValue(new Error("no web"));
const program = buildProgram();
await program.parseAsync(
["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"],
{ from: "user" },
);
expect(logWebSelfId).toHaveBeenCalled();
expect(ensureTwilioEnv).toHaveBeenCalled();
expect(monitorTwilio).toHaveBeenCalledWith(2, 1);
});
it("runs relay tmux attach command", async () => {
const program = buildProgram();
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
expect(spawnRelayTmux).toHaveBeenCalledWith(
"pnpm warelay relay --verbose",
true,
false,
);
});
it("runs relay tmux attach command", async () => {
const program = buildProgram();
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
expect(spawnRelayTmux).toHaveBeenCalledWith(
"pnpm warelay relay --verbose",
true,
false,
);
});
});

View File

@@ -10,327 +10,327 @@ import { defaultRuntime } from "../runtime.js";
import type { Provider } from "../utils.js";
import { VERSION } from "../version.js";
import {
createDefaultDeps,
logTwilioFrom,
logWebSelfId,
monitorTwilio,
createDefaultDeps,
logTwilioFrom,
logWebSelfId,
monitorTwilio,
} from "./deps.js";
import { spawnRelayTmux } from "./relay_tmux.js";
export function buildProgram() {
const program = new Command();
const PROGRAM_VERSION = VERSION;
const TAGLINE =
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
const program = new Command();
const PROGRAM_VERSION = VERSION;
const TAGLINE =
"Send, receive, and auto-reply on WhatsApp—Twilio-backed or QR-linked.";
program
.name("warelay")
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
.version(PROGRAM_VERSION);
program
.name("warelay")
.description("WhatsApp relay CLI (Twilio or WhatsApp Web session)")
.version(PROGRAM_VERSION);
const formatIntroLine = (version: string, rich = true) => {
const base = `📡 warelay ${version}${TAGLINE}`;
return rich && chalk.level > 0
? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}`
: base;
};
const formatIntroLine = (version: string, rich = true) => {
const base = `📡 warelay ${version}${TAGLINE}`;
return rich && chalk.level > 0
? `${chalk.bold.cyan("📡 warelay")} ${chalk.white(version)} ${chalk.gray("—")} ${chalk.green(TAGLINE)}`
: base;
};
program.configureHelp({
optionTerm: (option) => chalk.yellow(option.flags),
subcommandTerm: (cmd) => chalk.green(cmd.name()),
});
program.configureHelp({
optionTerm: (option) => chalk.yellow(option.flags),
subcommandTerm: (cmd) => chalk.green(cmd.name()),
});
program.configureOutput({
writeOut: (str) => {
const colored = str
.replace(/^Usage:/gm, chalk.bold.cyan("Usage:"))
.replace(/^Options:/gm, chalk.bold.cyan("Options:"))
.replace(/^Commands:/gm, chalk.bold.cyan("Commands:"));
process.stdout.write(colored);
},
writeErr: (str) => process.stderr.write(str),
outputError: (str, write) => write(chalk.red(str)),
});
program.configureOutput({
writeOut: (str) => {
const colored = str
.replace(/^Usage:/gm, chalk.bold.cyan("Usage:"))
.replace(/^Options:/gm, chalk.bold.cyan("Options:"))
.replace(/^Commands:/gm, chalk.bold.cyan("Commands:"));
process.stdout.write(colored);
},
writeErr: (str) => process.stderr.write(str),
outputError: (str, write) => write(chalk.red(str)),
});
if (process.argv.includes("-V") || process.argv.includes("--version")) {
console.log(formatIntroLine(PROGRAM_VERSION));
process.exit(0);
}
if (process.argv.includes("-V") || process.argv.includes("--version")) {
console.log(formatIntroLine(PROGRAM_VERSION));
process.exit(0);
}
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
const examples = [
[
"warelay login --verbose",
"Link personal WhatsApp Web and show QR + connection logs.",
],
[
'warelay send --to +15551234567 --message "Hi" --provider web --json',
"Send via your web session and print JSON result.",
],
[
"warelay relay --provider auto --interval 5 --lookback 15 --verbose",
"Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.",
],
[
"warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose",
"Start webhook + Tailscale Funnel and update Twilio callbacks.",
],
[
"warelay status --limit 10 --lookback 60 --json",
"Show last 10 messages from the past hour as JSON.",
],
] as const;
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
const examples = [
[
"warelay login --verbose",
"Link personal WhatsApp Web and show QR + connection logs.",
],
[
'warelay send --to +15551234567 --message "Hi" --provider web --json',
"Send via your web session and print JSON result.",
],
[
"warelay relay --provider auto --interval 5 --lookback 15 --verbose",
"Auto-reply loop: prefer Web when logged in, otherwise Twilio polling.",
],
[
"warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose",
"Start webhook + Tailscale Funnel and update Twilio callbacks.",
],
[
"warelay status --limit 10 --lookback 60 --json",
"Show last 10 messages from the past hour as JSON.",
],
] as const;
const fmtExamples = examples
.map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`)
.join("\n");
const fmtExamples = examples
.map(([cmd, desc]) => ` ${chalk.green(cmd)}\n ${chalk.gray(desc)}`)
.join("\n");
program.addHelpText(
"afterAll",
`\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`,
);
program.addHelpText(
"afterAll",
`\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`,
);
program
.command("login")
.description("Link your personal WhatsApp via QR (web provider)")
.option("--verbose", "Verbose connection logs", false)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
try {
await loginWeb(Boolean(opts.verbose));
} catch (err) {
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
defaultRuntime.exit(1);
}
});
program
.command("login")
.description("Link your personal WhatsApp via QR (web provider)")
.option("--verbose", "Verbose connection logs", false)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
try {
await loginWeb(Boolean(opts.verbose));
} catch (err) {
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
defaultRuntime.exit(1);
}
});
program
.command("send")
.description("Send a WhatsApp message")
.requiredOption(
"-t, --to <number>",
"Recipient number in E.164 (e.g. +15551234567)",
)
.requiredOption("-m, --message <text>", "Message body")
.option(
"--media <path-or-url>",
"Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
)
.option(
"--serve-media",
"For Twilio: start a temporary media server if webhook is not running",
false,
)
.option(
"-w, --wait <seconds>",
"Wait for delivery status (0 to skip)",
"20",
)
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
.option("--provider <provider>", "Provider: twilio | web", "twilio")
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
program
.command("send")
.description("Send a WhatsApp message")
.requiredOption(
"-t, --to <number>",
"Recipient number in E.164 (e.g. +15551234567)",
)
.requiredOption("-m, --message <text>", "Message body")
.option(
"--media <path-or-url>",
"Attach image (<=5MB). Web: path or URL. Twilio: https URL or local path hosted via webhook/funnel.",
)
.option(
"--serve-media",
"For Twilio: start a temporary media server if webhook is not running",
false,
)
.option(
"-w, --wait <seconds>",
"Wait for delivery status (0 to skip)",
"20",
)
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
.option("--provider <provider>", "Provider: twilio | web", "twilio")
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
Examples:
warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default)
warelay send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget
warelay send --to +15551234567 --message "Hi" --dry-run # print payload only
warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
await sendCommand(opts, deps, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
await sendCommand(opts, deps, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
program
.command("relay")
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
.option("--provider <provider>", "auto | web | twilio", "auto")
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
.option(
"-l, --lookback <minutes>",
"Initial lookback window for twilio mode",
"5",
)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
program
.command("relay")
.description("Auto-reply to inbound messages (auto-selects web or twilio)")
.option("--provider <provider>", "auto | web | twilio", "auto")
.option("-i, --interval <seconds>", "Polling interval for twilio mode", "5")
.option(
"-l, --lookback <minutes>",
"Initial lookback window for twilio mode",
"5",
)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
Examples:
warelay relay # auto: web if logged-in, else twilio poll
warelay relay --provider web # force personal web session
warelay relay --provider twilio # force twilio poll
warelay relay --provider twilio --interval 2 --lookback 30
`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const providerPref = String(opts.provider ?? "auto");
if (!["auto", "web", "twilio"].includes(providerPref)) {
defaultRuntime.error("--provider must be auto, web, or twilio");
defaultRuntime.exit(1);
}
const intervalSeconds = Number.parseInt(opts.interval, 10);
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
defaultRuntime.error("Interval must be a positive integer");
defaultRuntime.exit(1);
}
if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) {
defaultRuntime.error("Lookback must be >= 0 minutes");
defaultRuntime.exit(1);
}
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const providerPref = String(opts.provider ?? "auto");
if (!["auto", "web", "twilio"].includes(providerPref)) {
defaultRuntime.error("--provider must be auto, web, or twilio");
defaultRuntime.exit(1);
}
const intervalSeconds = Number.parseInt(opts.interval, 10);
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
defaultRuntime.error("Interval must be a positive integer");
defaultRuntime.exit(1);
}
if (Number.isNaN(lookbackMinutes) || lookbackMinutes < 0) {
defaultRuntime.error("Lookback must be >= 0 minutes");
defaultRuntime.exit(1);
}
const provider = await pickProvider(providerPref as Provider | "auto");
const provider = await pickProvider(providerPref as Provider | "auto");
if (provider === "web") {
logWebSelfId(defaultRuntime, true);
try {
await monitorWebProvider(Boolean(opts.verbose));
return;
} catch (err) {
if (providerPref === "auto") {
defaultRuntime.error(
warn("Web session unavailable; falling back to twilio."),
);
} else {
defaultRuntime.error(danger(`Web relay failed: ${String(err)}`));
defaultRuntime.exit(1);
}
}
}
if (provider === "web") {
logWebSelfId(defaultRuntime, true);
try {
await monitorWebProvider(Boolean(opts.verbose));
return;
} catch (err) {
if (providerPref === "auto") {
defaultRuntime.error(
warn("Web session unavailable; falling back to twilio."),
);
} else {
defaultRuntime.error(danger(`Web relay failed: ${String(err)}`));
defaultRuntime.exit(1);
}
}
}
ensureTwilioEnv();
logTwilioFrom();
await monitorTwilio(intervalSeconds, lookbackMinutes);
});
ensureTwilioEnv();
logTwilioFrom();
await monitorTwilio(intervalSeconds, lookbackMinutes);
});
program
.command("status")
.description("Show recent WhatsApp messages (sent and received)")
.option("-l, --limit <count>", "Number of messages to show", "20")
.option("-b, --lookback <minutes>", "How far back to fetch messages", "240")
.option("--json", "Output JSON instead of text", false)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
program
.command("status")
.description("Show recent WhatsApp messages (sent and received)")
.option("-l, --limit <count>", "Number of messages to show", "20")
.option("-b, --lookback <minutes>", "How far back to fetch messages", "240")
.option("--json", "Output JSON instead of text", false)
.option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
Examples:
warelay status # last 20 msgs in past 4h
warelay status --limit 5 --lookback 30 # last 5 msgs in past 30m
warelay status --json --limit 50 # machine-readable output`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
await statusCommand(opts, deps, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
await statusCommand(opts, deps, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
program
.command("webhook")
.description(
"Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.",
)
.option("-p, --port <port>", "Port to listen on", "42873")
.option("-r, --reply <text>", "Optional auto-reply text")
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
.option(
"--ingress <mode>",
"Ingress: tailscale (funnel + Twilio update) | none (local only)",
"tailscale",
)
.option("--verbose", "Log inbound and auto-replies", false)
.option("-y, --yes", "Auto-confirm prompts when possible", false)
.option("--dry-run", "Print planned actions without starting server", false)
.addHelpText(
"after",
`
program
.command("webhook")
.description(
"Run inbound webhook. ingress=tailscale updates Twilio; ingress=none stays local-only.",
)
.option("-p, --port <port>", "Port to listen on", "42873")
.option("-r, --reply <text>", "Optional auto-reply text")
.option("--path <path>", "Webhook path", "/webhook/whatsapp")
.option(
"--ingress <mode>",
"Ingress: tailscale (funnel + Twilio update) | none (local only)",
"tailscale",
)
.option("--verbose", "Log inbound and auto-replies", false)
.option("-y, --yes", "Auto-confirm prompts when possible", false)
.option("--dry-run", "Print planned actions without starting server", false)
.addHelpText(
"after",
`
Examples:
warelay webhook # ingress=tailscale (funnel + Twilio update)
warelay webhook --ingress none # local-only server (no funnel / no Twilio update)
warelay webhook --port 45000 # pick a high, less-colliding port
warelay webhook --reply "Got it!" # static auto-reply; otherwise use config file`,
)
// istanbul ignore next
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
setYes(Boolean(opts.yes));
const deps = createDefaultDeps();
try {
const server = await webhookCommand(opts, deps, defaultRuntime);
if (!server) {
defaultRuntime.log(
info("Webhook dry-run complete; no server started."),
);
return;
}
process.on("SIGINT", () => {
server.close(() => {
console.log("\n👋 Webhook stopped");
defaultRuntime.exit(0);
});
});
await deps.waitForever();
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
)
// istanbul ignore next
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
setYes(Boolean(opts.yes));
const deps = createDefaultDeps();
try {
const server = await webhookCommand(opts, deps, defaultRuntime);
if (!server) {
defaultRuntime.log(
info("Webhook dry-run complete; no server started."),
);
return;
}
process.on("SIGINT", () => {
server.close(() => {
console.log("\n👋 Webhook stopped");
defaultRuntime.exit(0);
});
});
await deps.waitForever();
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
program
.command("relay:tmux")
.description(
"Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach",
)
.action(async () => {
try {
const session = await spawnRelayTmux(
"pnpm warelay relay --verbose",
true,
);
defaultRuntime.log(
info(
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`,
),
);
} catch (err) {
defaultRuntime.error(
danger(`Failed to start relay tmux session: ${String(err)}`),
);
defaultRuntime.exit(1);
}
});
program
.command("relay:tmux")
.description(
"Run relay --verbose inside tmux (session warelay-relay), restarting if already running, then attach",
)
.action(async () => {
try {
const session = await spawnRelayTmux(
"pnpm warelay relay --verbose",
true,
);
defaultRuntime.log(
info(
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`,
),
);
} catch (err) {
defaultRuntime.error(
danger(`Failed to start relay tmux session: ${String(err)}`),
);
defaultRuntime.exit(1);
}
});
program
.command("relay:tmux:attach")
.description(
"Attach to the existing warelay-relay tmux session (no restart)",
)
.action(async () => {
try {
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
defaultRuntime.log(info("Attached to warelay-relay session."));
} catch (err) {
defaultRuntime.error(
danger(`Failed to attach to warelay-relay: ${String(err)}`),
);
defaultRuntime.exit(1);
}
});
program
.command("relay:tmux:attach")
.description(
"Attach to the existing warelay-relay tmux session (no restart)",
)
.action(async () => {
try {
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
defaultRuntime.log(info("Attached to warelay-relay session."));
} catch (err) {
defaultRuntime.error(
danger(`Failed to attach to warelay-relay: ${String(err)}`),
);
defaultRuntime.exit(1);
}
});
return program;
return program;
}

View File

@@ -3,47 +3,47 @@ import { describe, expect, it, vi } from "vitest";
import { isYes, setVerbose, setYes } from "../globals.js";
vi.mock("node:readline/promises", () => {
const question = vi.fn<[], Promise<string>>();
const close = vi.fn();
const createInterface = vi.fn(() => ({ question, close }));
return { default: { createInterface } };
const question = vi.fn<[], Promise<string>>();
const close = vi.fn();
const createInterface = vi.fn(() => ({ question, close }));
return { default: { createInterface } };
});
type ReadlineMock = {
default: {
createInterface: () => {
question: ReturnType<typeof vi.fn<[], Promise<string>>>;
close: ReturnType<typeof vi.fn>;
};
};
default: {
createInterface: () => {
question: ReturnType<typeof vi.fn<[], Promise<string>>>;
close: ReturnType<typeof vi.fn>;
};
};
};
const { promptYesNo } = await import("./prompt.js");
const readline = (await import("node:readline/promises")) as ReadlineMock;
describe("promptYesNo", () => {
it("returns true when global --yes is set", async () => {
setYes(true);
setVerbose(false);
const result = await promptYesNo("Continue?");
expect(result).toBe(true);
expect(isYes()).toBe(true);
});
it("returns true when global --yes is set", async () => {
setYes(true);
setVerbose(false);
const result = await promptYesNo("Continue?");
expect(result).toBe(true);
expect(isYes()).toBe(true);
});
it("asks the question and respects default", async () => {
setYes(false);
setVerbose(false);
const { question: questionMock } = readline.default.createInterface();
questionMock.mockResolvedValueOnce("");
const resultDefaultYes = await promptYesNo("Continue?", true);
expect(resultDefaultYes).toBe(true);
it("asks the question and respects default", async () => {
setYes(false);
setVerbose(false);
const { question: questionMock } = readline.default.createInterface();
questionMock.mockResolvedValueOnce("");
const resultDefaultYes = await promptYesNo("Continue?", true);
expect(resultDefaultYes).toBe(true);
questionMock.mockResolvedValueOnce("n");
const resultNo = await promptYesNo("Continue?", true);
expect(resultNo).toBe(false);
questionMock.mockResolvedValueOnce("n");
const resultNo = await promptYesNo("Continue?", true);
expect(resultNo).toBe(false);
questionMock.mockResolvedValueOnce("y");
const resultYes = await promptYesNo("Continue?", false);
expect(resultYes).toBe(true);
});
questionMock.mockResolvedValueOnce("y");
const resultYes = await promptYesNo("Continue?", false);
expect(resultYes).toBe(true);
});
});

View File

@@ -4,18 +4,18 @@ import readline from "node:readline/promises";
import { isVerbose, isYes } from "../globals.js";
export async function promptYesNo(
question: string,
defaultYes = false,
question: string,
defaultYes = false,
): Promise<boolean> {
// Simple Y/N prompt honoring global --yes and verbosity flags.
if (isVerbose() && isYes()) return true; // redundant guard when both flags set
if (isYes()) return true;
const rl = readline.createInterface({ input, output });
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
const answer = (await rl.question(`${question}${suffix}`))
.trim()
.toLowerCase();
rl.close();
if (!answer) return defaultYes;
return answer.startsWith("y");
// Simple Y/N prompt honoring global --yes and verbosity flags.
if (isVerbose() && isYes()) return true; // redundant guard when both flags set
if (isYes()) return true;
const rl = readline.createInterface({ input, output });
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
const answer = (await rl.question(`${question}${suffix}`))
.trim()
.toLowerCase();
rl.close();
if (!answer) return defaultYes;
return answer.startsWith("y");
}

View File

@@ -2,43 +2,43 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
// Mocks must be defined via vi.hoisted to avoid TDZ with ESM hoisting.
const { monitorWebProvider, pickProvider, logWebSelfId, monitorTwilio } =
vi.hoisted(() => {
return {
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
pickProvider: vi.fn().mockResolvedValue("web"),
logWebSelfId: vi.fn(),
monitorTwilio: vi.fn().mockResolvedValue(undefined),
};
});
vi.hoisted(() => {
return {
monitorWebProvider: vi.fn().mockResolvedValue(undefined),
pickProvider: vi.fn().mockResolvedValue("web"),
logWebSelfId: vi.fn(),
monitorTwilio: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("../provider-web.js", () => ({
monitorWebProvider,
pickProvider,
logWebSelfId,
monitorWebProvider,
pickProvider,
logWebSelfId,
}));
vi.mock("../twilio/monitor.js", () => ({
monitorTwilio,
monitorTwilio,
}));
import { buildProgram } from "./program.js";
describe("CLI relay command (e2e-ish)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
});
it("runs relay in web mode without crashing", async () => {
const program = buildProgram();
program.exitOverride(); // throw instead of exiting process on error
it("runs relay in web mode without crashing", async () => {
const program = buildProgram();
program.exitOverride(); // throw instead of exiting process on error
await expect(
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
).resolves.toBeInstanceOf(Object);
await expect(
program.parseAsync(["relay", "--provider", "web"], { from: "user" }),
).resolves.toBeInstanceOf(Object);
expect(pickProvider).toHaveBeenCalledWith("web");
expect(logWebSelfId).toHaveBeenCalledTimes(1);
expect(monitorWebProvider).toHaveBeenCalledWith(false);
expect(monitorTwilio).not.toHaveBeenCalled();
});
expect(pickProvider).toHaveBeenCalledWith("web");
expect(logWebSelfId).toHaveBeenCalledTimes(1);
expect(monitorWebProvider).toHaveBeenCalledWith(false);
expect(monitorTwilio).not.toHaveBeenCalled();
});
});

View File

@@ -3,45 +3,45 @@ import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", () => {
const spawn = vi.fn((_cmd: string, _args: string[]) => {
const proc = new EventEmitter() as EventEmitter & {
kill: ReturnType<typeof vi.fn>;
};
queueMicrotask(() => {
proc.emit("exit", 0);
});
proc.kill = vi.fn();
return proc;
});
return { spawn };
const spawn = vi.fn((_cmd: string, _args: string[]) => {
const proc = new EventEmitter() as EventEmitter & {
kill: ReturnType<typeof vi.fn>;
};
queueMicrotask(() => {
proc.emit("exit", 0);
});
proc.kill = vi.fn();
return proc;
});
return { spawn };
});
const { spawnRelayTmux } = await import("./relay_tmux.js");
const { spawn } = await import("node:child_process");
describe("spawnRelayTmux", () => {
beforeEach(() => {
vi.clearAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
});
it("kills old session, starts new one, and attaches", async () => {
const session = await spawnRelayTmux("echo hi", true, true);
expect(session).toBe("warelay-relay");
const spawnMock = spawn as unknown as vi.Mock;
expect(spawnMock.mock.calls.length).toBe(3);
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;
expect(calls[0][0]).toBe("tmux"); // kill-session
expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session
expect(calls[2][1][0]).toBe("attach-session");
});
it("kills old session, starts new one, and attaches", async () => {
const session = await spawnRelayTmux("echo hi", true, true);
expect(session).toBe("warelay-relay");
const spawnMock = spawn as unknown as vi.Mock;
expect(spawnMock.mock.calls.length).toBe(3);
const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>;
expect(calls[0][0]).toBe("tmux"); // kill-session
expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session
expect(calls[2][1][0]).toBe("attach-session");
});
it("can skip attach", async () => {
await spawnRelayTmux("echo hi", false, true);
const spawnMock = spawn as unknown as vi.Mock;
const hasAttach = spawnMock.mock.calls.some(
(c) =>
Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"),
);
expect(hasAttach).toBe(false);
});
it("can skip attach", async () => {
await spawnRelayTmux("echo hi", false, true);
const spawnMock = spawn as unknown as vi.Mock;
const hasAttach = spawnMock.mock.calls.some(
(c) =>
Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"),
);
expect(hasAttach).toBe(false);
});
});

View File

@@ -3,48 +3,48 @@ import { spawn } from "node:child_process";
const SESSION = "warelay-relay";
export async function spawnRelayTmux(
cmd = "pnpm warelay relay --verbose",
attach = true,
restart = true,
cmd = "pnpm warelay relay --verbose",
attach = true,
restart = true,
) {
if (restart) {
await killSession(SESSION);
await new Promise<void>((resolve, reject) => {
const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], {
stdio: "inherit",
shell: false,
});
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`tmux exited with code ${code}`));
});
});
}
if (restart) {
await killSession(SESSION);
await new Promise<void>((resolve, reject) => {
const child = spawn("tmux", ["new", "-d", "-s", SESSION, cmd], {
stdio: "inherit",
shell: false,
});
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`tmux exited with code ${code}`));
});
});
}
if (attach) {
await new Promise<void>((resolve, reject) => {
const child = spawn("tmux", ["attach-session", "-t", SESSION], {
stdio: "inherit",
shell: false,
});
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`tmux attach exited with code ${code}`));
});
});
}
if (attach) {
await new Promise<void>((resolve, reject) => {
const child = spawn("tmux", ["attach-session", "-t", SESSION], {
stdio: "inherit",
shell: false,
});
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`tmux attach exited with code ${code}`));
});
});
}
return SESSION;
return SESSION;
}
async function killSession(name: string) {
await new Promise<void>((resolve) => {
const child = spawn("tmux", ["kill-session", "-t", name], {
stdio: "ignore",
});
child.on("exit", () => resolve());
child.on("error", () => resolve());
});
await new Promise<void>((resolve) => {
const child = spawn("tmux", ["kill-session", "-t", name], {
stdio: "ignore",
});
child.on("exit", () => resolve());
child.on("error", () => resolve());
});
}

View File

@@ -3,14 +3,14 @@ import { describe, expect, it, vi } from "vitest";
import { waitForever } from "./wait.js";
describe("waitForever", () => {
it("creates an unref'ed interval and returns a pending promise", () => {
const setIntervalSpy = vi.spyOn(global, "setInterval");
const promise = waitForever();
expect(setIntervalSpy).toHaveBeenCalledWith(
expect.any(Function),
1_000_000,
);
expect(promise).toBeInstanceOf(Promise);
setIntervalSpy.mockRestore();
});
it("creates an unref'ed interval and returns a pending promise", () => {
const setIntervalSpy = vi.spyOn(global, "setInterval");
const promise = waitForever();
expect(setIntervalSpy).toHaveBeenCalledWith(
expect.any(Function),
1_000_000,
);
expect(promise).toBeInstanceOf(Promise);
setIntervalSpy.mockRestore();
});
});

View File

@@ -1,8 +1,8 @@
export function waitForever() {
// Keep event loop alive via an unref'ed interval plus a pending promise.
const interval = setInterval(() => {}, 1_000_000);
interval.unref();
return new Promise<void>(() => {
/* never resolve */
});
// Keep event loop alive via an unref'ed interval plus a pending promise.
const interval = setInterval(() => {}, 1_000_000);
interval.unref();
return new Promise<void>(() => {
/* never resolve */
});
}