feat: add config validation and send dry-run
This commit is contained in:
28
docs/refactor/plan.md
Normal file
28
docs/refactor/plan.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Refactor Roadmap (2025-11-25)
|
||||
|
||||
This is a living note capturing the cleanups underway to keep `warelay` small and maintainable.
|
||||
|
||||
## Goals
|
||||
- Keep `src/index.ts` thin (<150 LOC) and treat it purely as the CLI entry/export surface.
|
||||
- Encapsulate infra helpers (ports, binaries, tailscale) and provider-specific code behind small modules.
|
||||
- Harden configuration validation and logging so users get actionable errors.
|
||||
- Improve UX for experimenting (dry‑run) without hitting Twilio or WhatsApp.
|
||||
|
||||
## Completed
|
||||
- Extracted infra helpers into `src/infra/{ports,binaries,tailscale}.ts`.
|
||||
- Moved CLI dependency wiring into `src/cli/deps.ts`; `monitorWebProvider` now lives in `provider-web.ts`.
|
||||
- Added prompt/wait helpers (`src/cli/{prompt,wait}.ts`) and Twilio sender discovery module (`src/twilio/senders.ts`).
|
||||
- Slimmed `src/index.ts` to ~130 LOC.
|
||||
- README updated to document direct WhatsApp Web support and Claude output handling.
|
||||
|
||||
## In this pass
|
||||
- Added config validation for inbound reply settings (claude output format, command/text shape).
|
||||
- Added `--dry-run` for `send` to print the outbound payload without contacting providers.
|
||||
- Documented roadmap (this file).
|
||||
|
||||
## Next candidates (not yet done)
|
||||
- Centralize logging/verbosity (runtime-aware logger wrapper).
|
||||
- Provider barrels (`src/providers/twilio`, `src/providers/web`) to isolate imports further.
|
||||
- Webhook module grouping (`src/webhook/*`) to house server + Twilio update helpers.
|
||||
- Retry/backoff for webhook bring-up and monitor polling.
|
||||
- More unit tests for infra helpers (`ports`, `tailscale`) and CLI dep wiring.
|
||||
@@ -59,12 +59,14 @@ export function buildProgram() {
|
||||
.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)
|
||||
.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) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function sendCommand(
|
||||
wait: string;
|
||||
poll: string;
|
||||
provider: Provider;
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
@@ -26,6 +27,12 @@ export async function sendCommand(
|
||||
}
|
||||
|
||||
if (opts.provider === "web") {
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via web -> ${opts.to}: ${opts.message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (waitSeconds !== 0) {
|
||||
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
||||
}
|
||||
@@ -33,6 +40,13 @@ export async function sendCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deps.sendMessage(opts.to, opts.message, runtime);
|
||||
if (!result) return;
|
||||
if (waitSeconds === 0) return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import JSON5 from "json5";
|
||||
import { z } from "zod";
|
||||
|
||||
export type ReplyMode = "text" | "command";
|
||||
export type ClaudeOutputFormat = "text" | "json" | "stream-json";
|
||||
@@ -36,6 +37,50 @@ export type WarelayConfig = {
|
||||
|
||||
export const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json");
|
||||
|
||||
const ReplySchema = z
|
||||
.object({
|
||||
mode: z.union([z.literal("text"), z.literal("command")]),
|
||||
text: z.string().optional(),
|
||||
command: z.array(z.string()).optional(),
|
||||
template: z.string().optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
bodyPrefix: z.string().optional(),
|
||||
session: z
|
||||
.object({
|
||||
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
||||
resetTriggers: z.array(z.string()).optional(),
|
||||
idleMinutes: z.number().int().positive().optional(),
|
||||
store: z.string().optional(),
|
||||
sessionArgNew: z.array(z.string()).optional(),
|
||||
sessionArgResume: z.array(z.string()).optional(),
|
||||
sessionArgBeforeBody: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
claudeOutputFormat: z
|
||||
.union([
|
||||
z.literal("text"),
|
||||
z.literal("json"),
|
||||
z.literal("stream-json"),
|
||||
z.undefined(),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
|
||||
{
|
||||
message: "reply.text is required for mode=text; reply.command is required for mode=command",
|
||||
},
|
||||
);
|
||||
|
||||
const WarelaySchema = z.object({
|
||||
inbound: z
|
||||
.object({
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
reply: ReplySchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export function loadConfig(): WarelayConfig {
|
||||
// Read ~/.warelay/warelay.json (JSON5) if present.
|
||||
try {
|
||||
@@ -43,7 +88,13 @@ export function loadConfig(): WarelayConfig {
|
||||
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return {};
|
||||
return parsed as WarelayConfig;
|
||||
const validated = WarelaySchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
console.error("Invalid warelay config:");
|
||||
validated.error.issues.forEach((iss) => console.error(`- ${iss.path.join(".")}: ${iss.message}`));
|
||||
return {};
|
||||
}
|
||||
return validated.data as WarelayConfig;
|
||||
} catch (err) {
|
||||
console.error(`Failed to read config at ${CONFIG_PATH}`, err);
|
||||
return {};
|
||||
|
||||
@@ -62,6 +62,26 @@ describe("CLI commands", () => {
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("send command supports dry-run and skips sending", async () => {
|
||||
const twilio = (await import("twilio")).default;
|
||||
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
|
||||
await index.program.parseAsync(
|
||||
[
|
||||
"send",
|
||||
"--to",
|
||||
"+1555",
|
||||
"--message",
|
||||
"hi",
|
||||
"--wait",
|
||||
"0",
|
||||
"--dry-run",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(twilio._client.messages.create).not.toHaveBeenCalled();
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("login alias calls web login", async () => {
|
||||
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
|
||||
await index.program.parseAsync(["login"], { from: "user" });
|
||||
|
||||
Reference in New Issue
Block a user