From fdfb1df0de8d5f6a6f5a0d5cde6919e3cc8f2f8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Nov 2025 03:57:50 +0100 Subject: [PATCH] feat: add dry-run options and retry helper --- src/cli/program.ts | 2 ++ src/commands/up.ts | 9 ++++++++- src/commands/webhook.ts | 4 ++++ src/infra/retry.ts | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/infra/retry.ts diff --git a/src/cli/program.ts b/src/cli/program.ts index 772dcc199..54575f63a 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -179,6 +179,7 @@ Examples: .option("--path ", "Webhook path", "/webhook/whatsapp") .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", ` @@ -220,6 +221,7 @@ With Tailscale: .option("--path ", "Webhook path", "/webhook/whatsapp") .option("--verbose", "Verbose logging during setup/webhook", false) .option("-y, --yes", "Auto-confirm prompts when possible", false) + .option("--dry-run", "Print planned actions without touching network", false) // istanbul ignore next .action(async (opts) => { setVerbose(Boolean(opts.verbose)); diff --git a/src/commands/up.ts b/src/commands/up.ts index 165437cbe..c2bff8288 100644 --- a/src/commands/up.ts +++ b/src/commands/up.ts @@ -3,7 +3,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { waitForever as defaultWaitForever } from "../cli/wait.js"; export async function upCommand( - opts: { port: string; path: string; verbose?: boolean; yes?: boolean }, + opts: { port: string; path: string; verbose?: boolean; yes?: boolean; dryRun?: boolean }, deps: CliDeps, runtime: RuntimeEnv, waiter: typeof defaultWaitForever = defaultWaitForever, @@ -15,6 +15,13 @@ export async function upCommand( await deps.ensurePortAvailable(port); const env = deps.readEnv(runtime); + if (opts.dryRun) { + runtime.log(`[dry-run] would enable funnel on port ${port}`); + runtime.log(`[dry-run] would start webhook at path ${opts.path}`); + runtime.log(`[dry-run] would update Twilio sender webhook`); + const publicUrl = `https://dry-run${opts.path}`; + return { server: undefined, publicUrl, senderSid: undefined, waiter }; + } await deps.ensureBinary("tailscale", undefined, runtime); await deps.ensureFunnel(port, undefined, runtime); const host = await deps.getTailnetHostname(); diff --git a/src/commands/webhook.ts b/src/commands/webhook.ts index 359f61723..5307a4b69 100644 --- a/src/commands/webhook.ts +++ b/src/commands/webhook.ts @@ -17,6 +17,10 @@ export async function webhookCommand( throw new Error("Port must be between 1 and 65535"); } await deps.ensurePortAvailable(port); + if (opts.reply === "dry-run") { + runtime.log(`[dry-run] would start webhook on port ${port} path ${opts.path}`); + return undefined; + } const server = await deps.startWebhook( port, opts.path, diff --git a/src/infra/retry.ts b/src/infra/retry.ts new file mode 100644 index 000000000..b32a5fc80 --- /dev/null +++ b/src/infra/retry.ts @@ -0,0 +1,18 @@ +export async function retryAsync( + fn: () => Promise, + attempts = 3, + initialDelayMs = 300, +): Promise { + let lastErr: unknown; + for (let i = 0; i < attempts; i += 1) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (i === attempts - 1) break; + const delay = initialDelayMs * 2 ** i; + await new Promise((r) => setTimeout(r, delay)); + } + } + throw lastErr; +}