chore: format to 2-space and bump changelog
This commit is contained in:
@@ -5,141 +5,141 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { sendCommand } from "./send.js";
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
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");
|
||||
}),
|
||||
};
|
||||
|
||||
const baseDeps = {
|
||||
assertProvider: vi.fn(),
|
||||
sendMessageWeb: vi.fn(),
|
||||
resolveTwilioMediaUrl: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
waitForFinalStatus: vi.fn(),
|
||||
assertProvider: vi.fn(),
|
||||
sendMessageWeb: vi.fn(),
|
||||
resolveTwilioMediaUrl: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
waitForFinalStatus: vi.fn(),
|
||||
} as unknown as CliDeps;
|
||||
|
||||
describe("sendCommand", () => {
|
||||
it("validates wait and poll", async () => {
|
||||
await expect(() =>
|
||||
sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "-1",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
},
|
||||
baseDeps,
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Wait must be >= 0 seconds");
|
||||
it("validates wait and poll", async () => {
|
||||
await expect(() =>
|
||||
sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "-1",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
},
|
||||
baseDeps,
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Wait must be >= 0 seconds");
|
||||
|
||||
await expect(() =>
|
||||
sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "0",
|
||||
provider: "twilio",
|
||||
},
|
||||
baseDeps,
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Poll must be > 0 seconds");
|
||||
});
|
||||
await expect(() =>
|
||||
sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "0",
|
||||
provider: "twilio",
|
||||
},
|
||||
baseDeps,
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Poll must be > 0 seconds");
|
||||
});
|
||||
|
||||
it("handles web dry-run and warns on wait", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
sendMessageWeb: vi.fn(),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "5",
|
||||
poll: "2",
|
||||
provider: "web",
|
||||
dryRun: true,
|
||||
media: "pic.jpg",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
||||
});
|
||||
it("handles web dry-run and warns on wait", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
sendMessageWeb: vi.fn(),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "5",
|
||||
poll: "2",
|
||||
provider: "web",
|
||||
dryRun: true,
|
||||
media: "pic.jpg",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageWeb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends via web and outputs JSON", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "1",
|
||||
poll: "2",
|
||||
provider: "web",
|
||||
json: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageWeb).toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"provider": "web"'),
|
||||
);
|
||||
});
|
||||
it("sends via web and outputs JSON", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
sendMessageWeb: vi.fn().mockResolvedValue({ messageId: "web1" }),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "1",
|
||||
poll: "2",
|
||||
provider: "web",
|
||||
json: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageWeb).toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"provider": "web"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports twilio dry-run", async () => {
|
||||
const deps = { ...baseDeps } as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
dryRun: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
it("supports twilio dry-run", async () => {
|
||||
const deps = { ...baseDeps } as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
dryRun: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends via twilio with media and skips wait when zero", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"),
|
||||
sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }),
|
||||
waitForFinalStatus: vi.fn(),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
media: "pic.jpg",
|
||||
serveMedia: true,
|
||||
json: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", {
|
||||
serveMedia: true,
|
||||
runtime,
|
||||
});
|
||||
expect(deps.waitForFinalStatus).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"provider": "twilio"'),
|
||||
);
|
||||
});
|
||||
it("sends via twilio with media and skips wait when zero", async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
resolveTwilioMediaUrl: vi.fn().mockResolvedValue("https://media"),
|
||||
sendMessage: vi.fn().mockResolvedValue({ sid: "SM1", client: {} }),
|
||||
waitForFinalStatus: vi.fn(),
|
||||
} as CliDeps;
|
||||
await sendCommand(
|
||||
{
|
||||
to: "+1",
|
||||
message: "hi",
|
||||
wait: "0",
|
||||
poll: "2",
|
||||
provider: "twilio",
|
||||
media: "pic.jpg",
|
||||
serveMedia: true,
|
||||
json: true,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.resolveTwilioMediaUrl).toHaveBeenCalledWith("pic.jpg", {
|
||||
serveMedia: true,
|
||||
runtime,
|
||||
});
|
||||
expect(deps.waitForFinalStatus).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"provider": "twilio"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,109 +4,109 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { Provider } from "../utils.js";
|
||||
|
||||
export async function sendCommand(
|
||||
opts: {
|
||||
to: string;
|
||||
message: string;
|
||||
wait: string;
|
||||
poll: string;
|
||||
provider: Provider;
|
||||
json?: boolean;
|
||||
dryRun?: boolean;
|
||||
media?: string;
|
||||
serveMedia?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
opts: {
|
||||
to: string;
|
||||
message: string;
|
||||
wait: string;
|
||||
poll: string;
|
||||
provider: Provider;
|
||||
json?: boolean;
|
||||
dryRun?: boolean;
|
||||
media?: string;
|
||||
serveMedia?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
deps.assertProvider(opts.provider);
|
||||
const waitSeconds = Number.parseInt(opts.wait, 10);
|
||||
const pollSeconds = Number.parseInt(opts.poll, 10);
|
||||
deps.assertProvider(opts.provider);
|
||||
const waitSeconds = Number.parseInt(opts.wait, 10);
|
||||
const pollSeconds = Number.parseInt(opts.poll, 10);
|
||||
|
||||
if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
|
||||
throw new Error("Wait must be >= 0 seconds");
|
||||
}
|
||||
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
|
||||
throw new Error("Poll must be > 0 seconds");
|
||||
}
|
||||
if (Number.isNaN(waitSeconds) || waitSeconds < 0) {
|
||||
throw new Error("Wait must be >= 0 seconds");
|
||||
}
|
||||
if (Number.isNaN(pollSeconds) || pollSeconds <= 0) {
|
||||
throw new Error("Poll must be > 0 seconds");
|
||||
}
|
||||
|
||||
if (opts.provider === "web") {
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (waitSeconds !== 0) {
|
||||
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
||||
}
|
||||
const res = await deps
|
||||
.sendMessageWeb(opts.to, opts.message, {
|
||||
verbose: false,
|
||||
mediaUrl: opts.media,
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error(`❌ Web send failed: ${String(err)}`);
|
||||
throw err;
|
||||
});
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "web",
|
||||
to: opts.to,
|
||||
messageId: res.messageId,
|
||||
mediaUrl: opts.media ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (opts.provider === "web") {
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via web -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (waitSeconds !== 0) {
|
||||
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
||||
}
|
||||
const res = await deps
|
||||
.sendMessageWeb(opts.to, opts.message, {
|
||||
verbose: false,
|
||||
mediaUrl: opts.media,
|
||||
})
|
||||
.catch((err) => {
|
||||
runtime.error(`❌ Web send failed: ${String(err)}`);
|
||||
throw err;
|
||||
});
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "web",
|
||||
to: opts.to,
|
||||
messageId: res.messageId,
|
||||
mediaUrl: opts.media ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}${opts.media ? ` (media ${opts.media})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mediaUrl: string | undefined;
|
||||
if (opts.media) {
|
||||
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
|
||||
serveMedia: Boolean(opts.serveMedia),
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
let mediaUrl: string | undefined;
|
||||
if (opts.media) {
|
||||
mediaUrl = await deps.resolveTwilioMediaUrl(opts.media, {
|
||||
serveMedia: Boolean(opts.serveMedia),
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await deps.sendMessage(
|
||||
opts.to,
|
||||
opts.message,
|
||||
{ mediaUrl },
|
||||
runtime,
|
||||
);
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "twilio",
|
||||
to: opts.to,
|
||||
sid: result?.sid ?? null,
|
||||
mediaUrl: mediaUrl ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!result) return;
|
||||
if (waitSeconds === 0) return;
|
||||
await deps.waitForFinalStatus(
|
||||
result.client,
|
||||
result.sid,
|
||||
waitSeconds,
|
||||
pollSeconds,
|
||||
runtime,
|
||||
);
|
||||
const result = await deps.sendMessage(
|
||||
opts.to,
|
||||
opts.message,
|
||||
{ mediaUrl },
|
||||
runtime,
|
||||
);
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
provider: "twilio",
|
||||
to: opts.to,
|
||||
sid: result?.sid ?? null,
|
||||
mediaUrl: mediaUrl ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!result) return;
|
||||
if (waitSeconds === 0) return;
|
||||
await deps.waitForFinalStatus(
|
||||
result.client,
|
||||
result.sid,
|
||||
waitSeconds,
|
||||
pollSeconds,
|
||||
runtime,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,46 +5,46 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { statusCommand } from "./status.js";
|
||||
|
||||
vi.mock("../twilio/messages.js", () => ({
|
||||
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
|
||||
formatMessageLine: (m: { sid: string }) => `LINE:${m.sid}`,
|
||||
}));
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
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");
|
||||
}),
|
||||
};
|
||||
|
||||
const deps: CliDeps = {
|
||||
listRecentMessages: vi.fn(),
|
||||
listRecentMessages: vi.fn(),
|
||||
} as unknown as CliDeps;
|
||||
|
||||
describe("statusCommand", () => {
|
||||
it("validates limit and lookback", async () => {
|
||||
await expect(
|
||||
statusCommand({ limit: "0", lookback: "10" }, deps, runtime),
|
||||
).rejects.toThrow("limit must be between 1 and 200");
|
||||
await expect(
|
||||
statusCommand({ limit: "10", lookback: "0" }, deps, runtime),
|
||||
).rejects.toThrow("lookback must be > 0 minutes");
|
||||
});
|
||||
it("validates limit and lookback", async () => {
|
||||
await expect(
|
||||
statusCommand({ limit: "0", lookback: "10" }, deps, runtime),
|
||||
).rejects.toThrow("limit must be between 1 and 200");
|
||||
await expect(
|
||||
statusCommand({ limit: "10", lookback: "0" }, deps, runtime),
|
||||
).rejects.toThrow("lookback must be > 0 minutes");
|
||||
});
|
||||
|
||||
it("prints JSON when requested", async () => {
|
||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]);
|
||||
await statusCommand(
|
||||
{ limit: "5", lookback: "10", json: true },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
JSON.stringify([{ sid: "1" }], null, 2),
|
||||
);
|
||||
});
|
||||
it("prints JSON when requested", async () => {
|
||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "1" }]);
|
||||
await statusCommand(
|
||||
{ limit: "5", lookback: "10", json: true },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
JSON.stringify([{ sid: "1" }], null, 2),
|
||||
);
|
||||
});
|
||||
|
||||
it("prints formatted lines otherwise", async () => {
|
||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]);
|
||||
await statusCommand({ limit: "1", lookback: "5" }, deps, runtime);
|
||||
expect(runtime.log).toHaveBeenCalledWith("LINE:123");
|
||||
});
|
||||
it("prints formatted lines otherwise", async () => {
|
||||
(deps.listRecentMessages as jest.Mock).mockResolvedValue([{ sid: "123" }]);
|
||||
await statusCommand({ limit: "1", lookback: "5" }, deps, runtime);
|
||||
expect(runtime.log).toHaveBeenCalledWith("LINE:123");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,29 +3,29 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { formatMessageLine } from "../twilio/messages.js";
|
||||
|
||||
export async function statusCommand(
|
||||
opts: { limit: string; lookback: string; json?: boolean },
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
opts: { limit: string; lookback: string; json?: boolean },
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const limit = Number.parseInt(opts.limit, 10);
|
||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||
if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
|
||||
throw new Error("limit must be between 1 and 200");
|
||||
}
|
||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
|
||||
throw new Error("lookback must be > 0 minutes");
|
||||
}
|
||||
const limit = Number.parseInt(opts.limit, 10);
|
||||
const lookbackMinutes = Number.parseInt(opts.lookback, 10);
|
||||
if (Number.isNaN(limit) || limit <= 0 || limit > 200) {
|
||||
throw new Error("limit must be between 1 and 200");
|
||||
}
|
||||
if (Number.isNaN(lookbackMinutes) || lookbackMinutes <= 0) {
|
||||
throw new Error("lookback must be > 0 minutes");
|
||||
}
|
||||
|
||||
const messages = await deps.listRecentMessages(lookbackMinutes, limit);
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(messages, null, 2));
|
||||
return;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
runtime.log("No messages found in the requested window.");
|
||||
return;
|
||||
}
|
||||
for (const m of messages) {
|
||||
runtime.log(formatMessageLine(m));
|
||||
}
|
||||
const messages = await deps.listRecentMessages(lookbackMinutes, limit);
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(messages, null, 2));
|
||||
return;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
runtime.log("No messages found in the requested window.");
|
||||
return;
|
||||
}
|
||||
for (const m of messages) {
|
||||
runtime.log(formatMessageLine(m));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,72 +5,72 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { upCommand } from "./up.js";
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
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");
|
||||
}),
|
||||
};
|
||||
|
||||
const makeDeps = (): CliDeps => ({
|
||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
readEnv: vi.fn().mockReturnValue({
|
||||
whatsappFrom: "whatsapp:+1555",
|
||||
whatsappSenderSid: "WW",
|
||||
}),
|
||||
ensureBinary: vi.fn().mockResolvedValue(undefined),
|
||||
ensureFunnel: vi.fn().mockResolvedValue(undefined),
|
||||
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
|
||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||
createClient: vi.fn().mockReturnValue({ client: true }),
|
||||
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
|
||||
updateWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
readEnv: vi.fn().mockReturnValue({
|
||||
whatsappFrom: "whatsapp:+1555",
|
||||
whatsappSenderSid: "WW",
|
||||
}),
|
||||
ensureBinary: vi.fn().mockResolvedValue(undefined),
|
||||
ensureFunnel: vi.fn().mockResolvedValue(undefined),
|
||||
getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"),
|
||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||
createClient: vi.fn().mockReturnValue({ client: true }),
|
||||
findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"),
|
||||
updateWebhook: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
describe("upCommand", () => {
|
||||
it("throws on invalid port", async () => {
|
||||
await expect(() =>
|
||||
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
|
||||
).rejects.toThrow("Port must be between 1 and 65535");
|
||||
});
|
||||
it("throws on invalid port", async () => {
|
||||
await expect(() =>
|
||||
upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime),
|
||||
).rejects.toThrow("Port must be between 1 and 65535");
|
||||
});
|
||||
|
||||
it("performs dry run and returns mock data", async () => {
|
||||
runtime.log.mockClear();
|
||||
const result = await upCommand(
|
||||
{ port: "42873", path: "/cb", dryRun: true },
|
||||
makeDeps(),
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[dry-run] would enable funnel on port 42873",
|
||||
);
|
||||
expect(result?.publicUrl).toBe("https://dry-run/cb");
|
||||
expect(result?.senderSid).toBeUndefined();
|
||||
});
|
||||
it("performs dry run and returns mock data", async () => {
|
||||
runtime.log.mockClear();
|
||||
const result = await upCommand(
|
||||
{ port: "42873", path: "/cb", dryRun: true },
|
||||
makeDeps(),
|
||||
runtime,
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[dry-run] would enable funnel on port 42873",
|
||||
);
|
||||
expect(result?.publicUrl).toBe("https://dry-run/cb");
|
||||
expect(result?.senderSid).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enables funnel, starts webhook, and updates Twilio", async () => {
|
||||
const deps = makeDeps();
|
||||
const res = await upCommand(
|
||||
{ port: "42873", path: "/hook", verbose: true },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.ensureBinary).toHaveBeenCalledWith(
|
||||
"tailscale",
|
||||
undefined,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.ensureFunnel).toHaveBeenCalled();
|
||||
expect(deps.startWebhook).toHaveBeenCalled();
|
||||
expect(deps.updateWebhook).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"SID123",
|
||||
"https://tailnet-host/hook",
|
||||
"POST",
|
||||
runtime,
|
||||
);
|
||||
expect(res?.publicUrl).toBe("https://tailnet-host/hook");
|
||||
// waiter is returned to keep the process alive in real use.
|
||||
expect(typeof res?.waiter).toBe("function");
|
||||
});
|
||||
it("enables funnel, starts webhook, and updates Twilio", async () => {
|
||||
const deps = makeDeps();
|
||||
const res = await upCommand(
|
||||
{ port: "42873", path: "/hook", verbose: true },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.ensureBinary).toHaveBeenCalledWith(
|
||||
"tailscale",
|
||||
undefined,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.ensureFunnel).toHaveBeenCalled();
|
||||
expect(deps.startWebhook).toHaveBeenCalled();
|
||||
expect(deps.updateWebhook).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"SID123",
|
||||
"https://tailnet-host/hook",
|
||||
"POST",
|
||||
runtime,
|
||||
);
|
||||
expect(res?.publicUrl).toBe("https://tailnet-host/hook");
|
||||
// waiter is returned to keep the process alive in real use.
|
||||
expect(typeof res?.waiter).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,65 +4,65 @@ import { retryAsync } from "../infra/retry.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export async function upCommand(
|
||||
opts: {
|
||||
port: string;
|
||||
path: string;
|
||||
verbose?: boolean;
|
||||
yes?: boolean;
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
waiter: typeof defaultWaitForever = defaultWaitForever,
|
||||
opts: {
|
||||
port: string;
|
||||
path: string;
|
||||
verbose?: boolean;
|
||||
yes?: boolean;
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
waiter: typeof defaultWaitForever = defaultWaitForever,
|
||||
) {
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
|
||||
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 retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
|
||||
const host = await deps.getTailnetHostname();
|
||||
const publicUrl = `https://${host}${opts.path}`;
|
||||
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
|
||||
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 retryAsync(() => deps.ensureFunnel(port, undefined, runtime), 3, 500);
|
||||
const host = await deps.getTailnetHostname();
|
||||
const publicUrl = `https://${host}${opts.path}`;
|
||||
runtime.log(`🌐 Public webhook URL (via Funnel): ${publicUrl}`);
|
||||
|
||||
const server = await retryAsync(
|
||||
() =>
|
||||
deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
undefined,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
),
|
||||
3,
|
||||
300,
|
||||
);
|
||||
const server = await retryAsync(
|
||||
() =>
|
||||
deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
undefined,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
),
|
||||
3,
|
||||
300,
|
||||
);
|
||||
|
||||
if (!deps.createClient) {
|
||||
throw new Error("Twilio client dependency missing");
|
||||
}
|
||||
const twilioClient = deps.createClient(env);
|
||||
const senderSid = await deps.findWhatsappSenderSid(
|
||||
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
|
||||
env.whatsappFrom,
|
||||
env.whatsappSenderSid,
|
||||
runtime,
|
||||
);
|
||||
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
|
||||
if (!deps.createClient) {
|
||||
throw new Error("Twilio client dependency missing");
|
||||
}
|
||||
const twilioClient = deps.createClient(env);
|
||||
const senderSid = await deps.findWhatsappSenderSid(
|
||||
twilioClient as unknown as import("../twilio/types.js").TwilioSenderListClient,
|
||||
env.whatsappFrom,
|
||||
env.whatsappSenderSid,
|
||||
runtime,
|
||||
);
|
||||
await deps.updateWebhook(twilioClient, senderSid, publicUrl, "POST", runtime);
|
||||
|
||||
runtime.log(
|
||||
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
||||
);
|
||||
runtime.log(
|
||||
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
||||
);
|
||||
|
||||
return { server, publicUrl, senderSid, waiter };
|
||||
return { server, publicUrl, senderSid, waiter };
|
||||
}
|
||||
|
||||
@@ -6,57 +6,57 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { webhookCommand } from "./webhook.js";
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
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");
|
||||
}),
|
||||
};
|
||||
|
||||
const deps: CliDeps = {
|
||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||
ensurePortAvailable: vi.fn().mockResolvedValue(undefined),
|
||||
startWebhook: vi.fn().mockResolvedValue({ server: true }),
|
||||
};
|
||||
|
||||
describe("webhookCommand", () => {
|
||||
it("throws on invalid port", async () => {
|
||||
await expect(() =>
|
||||
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
|
||||
).rejects.toThrow("Port must be between 1 and 65535");
|
||||
});
|
||||
it("throws on invalid port", async () => {
|
||||
await expect(() =>
|
||||
webhookCommand({ port: "70000", path: "/hook" }, deps, runtime),
|
||||
).rejects.toThrow("Port must be between 1 and 65535");
|
||||
});
|
||||
|
||||
it("logs dry run instead of starting server", async () => {
|
||||
runtime.log.mockClear();
|
||||
const res = await webhookCommand(
|
||||
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(res).toBeUndefined();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[dry-run] would start webhook on port 42873 path /hook",
|
||||
);
|
||||
});
|
||||
it("logs dry run instead of starting server", async () => {
|
||||
runtime.log.mockClear();
|
||||
const res = await webhookCommand(
|
||||
{ port: "42873", path: "/hook", reply: "dry-run", ingress: "none" },
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(res).toBeUndefined();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[dry-run] would start webhook on port 42873 path /hook",
|
||||
);
|
||||
});
|
||||
|
||||
it("starts webhook when valid", async () => {
|
||||
const res = await webhookCommand(
|
||||
{
|
||||
port: "42873",
|
||||
path: "/hook",
|
||||
reply: "ok",
|
||||
verbose: true,
|
||||
ingress: "none",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.startWebhook).toHaveBeenCalledWith(
|
||||
42873,
|
||||
"/hook",
|
||||
"ok",
|
||||
true,
|
||||
runtime,
|
||||
);
|
||||
expect(res).toEqual({ server: true });
|
||||
});
|
||||
it("starts webhook when valid", async () => {
|
||||
const res = await webhookCommand(
|
||||
{
|
||||
port: "42873",
|
||||
path: "/hook",
|
||||
reply: "ok",
|
||||
verbose: true,
|
||||
ingress: "none",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.startWebhook).toHaveBeenCalledWith(
|
||||
42873,
|
||||
"/hook",
|
||||
"ok",
|
||||
true,
|
||||
runtime,
|
||||
);
|
||||
expect(res).toEqual({ server: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,60 +4,60 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { upCommand } from "./up.js";
|
||||
|
||||
export async function webhookCommand(
|
||||
opts: {
|
||||
port: string;
|
||||
path: string;
|
||||
reply?: string;
|
||||
verbose?: boolean;
|
||||
yes?: boolean;
|
||||
ingress?: "tailscale" | "none";
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
opts: {
|
||||
port: string;
|
||||
path: string;
|
||||
reply?: string;
|
||||
verbose?: boolean;
|
||||
yes?: boolean;
|
||||
ingress?: "tailscale" | "none";
|
||||
dryRun?: boolean;
|
||||
},
|
||||
deps: CliDeps,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
const port = Number.parseInt(opts.port, 10);
|
||||
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
|
||||
const ingress = opts.ingress ?? "tailscale";
|
||||
const ingress = opts.ingress ?? "tailscale";
|
||||
|
||||
// Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update).
|
||||
if (ingress === "tailscale") {
|
||||
const result = await upCommand(
|
||||
{
|
||||
port: opts.port,
|
||||
path: opts.path,
|
||||
verbose: opts.verbose,
|
||||
yes: opts.yes,
|
||||
dryRun: opts.dryRun,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
return result.server;
|
||||
}
|
||||
// Tailscale ingress: reuse the `up` flow (Funnel + Twilio webhook update).
|
||||
if (ingress === "tailscale") {
|
||||
const result = await upCommand(
|
||||
{
|
||||
port: opts.port,
|
||||
path: opts.path,
|
||||
verbose: opts.verbose,
|
||||
yes: opts.yes,
|
||||
dryRun: opts.dryRun,
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
return result.server;
|
||||
}
|
||||
|
||||
// Local-only webhook (no ingress / no Twilio update).
|
||||
await deps.ensurePortAvailable(port);
|
||||
if (opts.reply === "dry-run" || opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would start webhook on port ${port} path ${opts.path}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const server = await retryAsync(
|
||||
() =>
|
||||
deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
opts.reply,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
),
|
||||
3,
|
||||
300,
|
||||
);
|
||||
return server;
|
||||
// Local-only webhook (no ingress / no Twilio update).
|
||||
await deps.ensurePortAvailable(port);
|
||||
if (opts.reply === "dry-run" || opts.dryRun) {
|
||||
runtime.log(
|
||||
`[dry-run] would start webhook on port ${port} path ${opts.path}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const server = await retryAsync(
|
||||
() =>
|
||||
deps.startWebhook(
|
||||
port,
|
||||
opts.path,
|
||||
opts.reply,
|
||||
Boolean(opts.verbose),
|
||||
runtime,
|
||||
),
|
||||
3,
|
||||
300,
|
||||
);
|
||||
return server;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user