From c041fe45c979fa52ab0f2ee30a130a5b7709896e Mon Sep 17 00:00:00 2001 From: Olostep Dev Date: Fri, 19 Jun 2026 11:57:55 +0000 Subject: [PATCH] add monitors commands to CLI --- src/commands/monitor.test.ts | 211 ++++++++++++++++++++++++++ src/commands/monitor.ts | 280 +++++++++++++++++++++++++++++++++++ src/index.ts | 2 + 3 files changed, 493 insertions(+) create mode 100644 src/commands/monitor.test.ts create mode 100644 src/commands/monitor.ts diff --git a/src/commands/monitor.test.ts b/src/commands/monitor.test.ts new file mode 100644 index 0000000..193f915 --- /dev/null +++ b/src/commands/monitor.test.ts @@ -0,0 +1,211 @@ +import { Command } from "commander"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { registerMonitor } from "./monitor.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeProgram(): Command { + const program = new Command(); + program.exitOverride(); + registerMonitor(program); + return program; +} + +const MONITOR_FIXTURE = { + id: "monitor_abc1234567", + object: "monitor", + status: "provisioning", + query: "Watch the Stripe status page for incidents", + schedule: { frequency: "every hour", cron: "0 * * * ? *", timezone: "UTC", next_run_at: null }, + notification: null, + webhook: null, + last_run: null, + total_count: null, + created: 1760327323, + updated: 1760327323, +}; + +const MONITOR_LIST_FIXTURE = { monitors: [MONITOR_FIXTURE], count: 1 }; + +const MONITOR_EVENTS_FIXTURE = { + data: [ + { id: "run_xyz789", changed: false, summary: "No changes detected.", created: 1760327323 }, + ], + has_more: false, + next_cursor: null, + total_count: 5, +}; + +function stubFetch(response: unknown, ok = true, status = 200): void { + vi.stubGlobal( + "fetch", + vi.fn((): Promise => + Promise.resolve({ + ok, + status, + statusText: ok ? "OK" : "Error", + text: (): Promise => Promise.resolve(JSON.stringify(response)), + }) + ) + ); +} + +function stubEnv(): void { + process.env["OLOSTEP_API_KEY"] = "olostep_test_key"; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("monitor create", () => { + beforeEach(stubEnv); + afterEach(() => { vi.unstubAllGlobals(); delete process.env["OLOSTEP_API_KEY"]; }); + + it("sends correct payload for minimal create", async () => { + stubFetch(MONITOR_FIXTURE); + const program = makeProgram(); + await program.parseAsync(["node", "cli", "monitor", "create", "Watch Stripe status page"]); + const call = (fetch as any).mock.calls[0]; + expect(call[0]).toContain("/monitors"); + const body = JSON.parse(call[1].body); + expect(body.query).toBe("Watch Stripe status page"); + expect(body.frequency).toBeUndefined(); + }); + + it("includes frequency when provided", async () => { + stubFetch(MONITOR_FIXTURE); + const program = makeProgram(); + await program.parseAsync([ + "node", "cli", "monitor", "create", "Watch Stripe status page", + "--frequency", "every day at 9am", + ]); + const body = JSON.parse((fetch as any).mock.calls[0][1].body); + expect(body.frequency).toBe("every day at 9am"); + }); + + it("includes source_policy when --include-urls is given", async () => { + stubFetch(MONITOR_FIXTURE); + const program = makeProgram(); + await program.parseAsync([ + "node", "cli", "monitor", "create", "Watch Stripe", + "--include-urls", "https://stripe.com/pricing,https://stripe.com/docs", + ]); + const body = JSON.parse((fetch as any).mock.calls[0][1].body); + expect(body.source_policy.include_urls).toHaveLength(2); + expect(body.source_policy.include_urls[0]).toBe("https://stripe.com/pricing"); + }); + + it("includes notification when --notification-email is given", async () => { + stubFetch(MONITOR_FIXTURE); + const program = makeProgram(); + await program.parseAsync([ + "node", "cli", "monitor", "create", "Watch Stripe", + "--notification-email", "you@example.com", + ]); + const body = JSON.parse((fetch as any).mock.calls[0][1].body); + expect(body.notification.channels[0].type).toBe("email"); + expect(body.notification.channels[0].target).toBe("you@example.com"); + }); + + it("outputs JSON with --json", async () => { + stubFetch(MONITOR_FIXTURE); + const program = makeProgram(); + const writes: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((s: any) => { writes.push(String(s)); return true; }); + await program.parseAsync(["node", "cli", "monitor", "create", "test", "--json"]); + const combined = writes.join(""); + const parsed = JSON.parse(combined); + expect(parsed.id).toBe("monitor_abc1234567"); + }); +}); + +describe("monitor list", () => { + beforeEach(stubEnv); + afterEach(() => { vi.unstubAllGlobals(); delete process.env["OLOSTEP_API_KEY"]; }); + + it("calls GET /monitors", async () => { + stubFetch(MONITOR_LIST_FIXTURE); + const program = makeProgram(); + await program.parseAsync(["node", "cli", "monitor", "list"]); + expect((fetch as any).mock.calls[0][0]).toContain("/monitors"); + expect((fetch as any).mock.calls[0][1].method).toBe("GET"); + }); + + it("adds include_deleted param when flag is set", async () => { + stubFetch(MONITOR_LIST_FIXTURE); + const program = makeProgram(); + await program.parseAsync(["node", "cli", "monitor", "list", "--include-deleted"]); + expect((fetch as any).mock.calls[0][0]).toContain("include_deleted=true"); + }); +}); + +describe("monitor get", () => { + beforeEach(stubEnv); + afterEach(() => { vi.unstubAllGlobals(); delete process.env["OLOSTEP_API_KEY"]; }); + + it("calls GET /monitors/{id}", async () => { + stubFetch(MONITOR_FIXTURE); + const program = makeProgram(); + await program.parseAsync(["node", "cli", "monitor", "get", "monitor_abc1234567"]); + expect((fetch as any).mock.calls[0][0]).toContain("/monitors/monitor_abc1234567"); + }); +}); + +describe("monitor pause/resume/delete", () => { + beforeEach(stubEnv); + afterEach(() => { vi.unstubAllGlobals(); delete process.env["OLOSTEP_API_KEY"]; }); + + it("pause calls POST /monitors/{id}/pause", async () => { + stubFetch(MONITOR_FIXTURE); + const program = makeProgram(); + await program.parseAsync(["node", "cli", "monitor", "pause", "monitor_abc1234567"]); + expect((fetch as any).mock.calls[0][0]).toContain("/monitors/monitor_abc1234567/pause"); + expect((fetch as any).mock.calls[0][1].method).toBe("POST"); + }); + + it("resume calls POST /monitors/{id}/resume", async () => { + stubFetch(MONITOR_FIXTURE); + const program = makeProgram(); + await program.parseAsync(["node", "cli", "monitor", "resume", "monitor_abc1234567"]); + expect((fetch as any).mock.calls[0][0]).toContain("/monitors/monitor_abc1234567/resume"); + expect((fetch as any).mock.calls[0][1].method).toBe("POST"); + }); + + it("delete calls DELETE /monitors/{id}", async () => { + stubFetch(MONITOR_FIXTURE); + const program = makeProgram(); + await program.parseAsync(["node", "cli", "monitor", "delete", "monitor_abc1234567"]); + expect((fetch as any).mock.calls[0][0]).toContain("/monitors/monitor_abc1234567"); + expect((fetch as any).mock.calls[0][1].method).toBe("DELETE"); + }); +}); + +describe("monitor events", () => { + beforeEach(stubEnv); + afterEach(() => { vi.unstubAllGlobals(); delete process.env["OLOSTEP_API_KEY"]; }); + + it("calls GET /monitors/{id}/events", async () => { + stubFetch(MONITOR_EVENTS_FIXTURE); + const program = makeProgram(); + await program.parseAsync(["node", "cli", "monitor", "events", "monitor_abc1234567"]); + expect((fetch as any).mock.calls[0][0]).toContain("/monitors/monitor_abc1234567/events"); + expect((fetch as any).mock.calls[0][1].method).toBe("GET"); + }); + + it("passes limit and cursor", async () => { + stubFetch(MONITOR_EVENTS_FIXTURE); + const program = makeProgram(); + await program.parseAsync([ + "node", "cli", "monitor", "events", "monitor_abc1234567", + "--limit", "10", "--cursor", "abc123", + ]); + const url = (fetch as any).mock.calls[0][0] as string; + expect(url).toContain("limit=10"); + expect(url).toContain("cursor=abc123"); + }); +}); diff --git a/src/commands/monitor.ts b/src/commands/monitor.ts new file mode 100644 index 0000000..72c7777 --- /dev/null +++ b/src/commands/monitor.ts @@ -0,0 +1,280 @@ +import { Command } from "commander"; + +import { api } from "../lib/api-client.js"; +import { failWith, isJsonMode, parseIntFlag, writeOutput } from "../lib/output.js"; + +const DEFAULT_TIMEOUT_S = 60; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function timeoutMs(opts: { timeout: string }): number { + return Math.round(parseIntFlag(opts.timeout, "--timeout", { min: 1 }) * 1000); +} + +function parseCsvUrls(raw?: string): string[] | undefined { + if (!raw) return undefined; + const parts = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0); + return parts.length > 0 ? parts : undefined; +} + +// --------------------------------------------------------------------------- +// Response types (minimal — we surface the raw API shape) +// --------------------------------------------------------------------------- + +interface MonitorSchedule { frequency?: string; cron?: string; next_run_at?: string | null } +interface MonitorLastRun { id: string; status: string; change_detected: boolean; ran_at?: string | null } +interface Monitor { + id: string; + status: string; + query: string; + schedule?: MonitorSchedule | null; + last_run?: MonitorLastRun | null; + total_count?: number | null; + error_message?: string | null; + [key: string]: unknown; +} +interface MonitorList { monitors: Monitor[]; count: number } +interface MonitorEventsResponse { + data: Array<{ + id: string; changed?: boolean | null; summary?: string | null; + ran_at?: string | null; created?: number | null; snapshot_url?: string | null + }>; + has_more: boolean; + next_cursor?: string | null; + total_count?: number | null; +} + +// --------------------------------------------------------------------------- +// Human-readable formatters +// --------------------------------------------------------------------------- + +function formatMonitor(m: Monitor): string { + const lines: string[] = []; + lines.push(`id: ${m.id}`); + lines.push(`status: ${m.status}`); + lines.push(`query: ${m.query}`); + if (m.schedule?.frequency) lines.push(`freq: ${m.schedule.frequency}`); + if (m.schedule?.next_run_at) lines.push(`next run: ${m.schedule.next_run_at}`); + if (m.last_run) { + const lr = m.last_run; + lines.push(`last run: ${lr.id} [${lr.status}] changed=${lr.change_detected}${lr.ran_at ? ` at ${lr.ran_at}` : ""}`); + } + if (m.total_count != null) lines.push(`snapshots: ${m.total_count}`); + if (m.error_message) lines.push(`error: ${m.error_message}`); + return lines.join("\n") + "\n"; +} + +// --------------------------------------------------------------------------- +// Subcommands +// --------------------------------------------------------------------------- + +function registerMonitorCreate(cmd: Command): void { + cmd + .command("create") + .description("Create a new recurring web monitor from a natural-language query.") + .argument("", 'What to watch, e.g. "Alert when the pricing page changes"') + .option("--frequency ", 'How often to run, e.g. "every hour" or "every day at 9am" (min 10 min)') + .option("--include-urls ", "Comma-separated URLs to monitor") + .option("--notification-email ", "Send email alerts to this address on changes") + .option("--webhook-url ", "POST to this HTTPS URL on each run") + .option("--json", "Machine-readable JSON output", false) + .option("--out ", "Write JSON to this file instead of stdout", "-") + .option("--timeout ", "HTTP timeout in seconds", String(DEFAULT_TIMEOUT_S)) + .action(async (query: string, opts) => { + const ms = timeoutMs(opts); + try { + const body: Record = { query }; + if (opts.frequency) body.frequency = opts.frequency; + + const includeUrls = parseCsvUrls(opts.includeUrls); + if (includeUrls) body.source_policy = { include_urls: includeUrls }; + + if (opts.notificationEmail) { + body.notification = { + channels: [{ type: "email", target: opts.notificationEmail }], + }; + } + if (opts.webhookUrl) body.webhook = { url: opts.webhookUrl }; + + const result = await api.post("/monitors", body, { timeoutMs: ms }); + + if (isJsonMode(opts)) { writeOutput(result, opts.out); return; } + + process.stdout.write(formatMonitor(result)); + } catch (err) { failWith(err); } + }); +} + +function registerMonitorList(cmd: Command): void { + cmd + .command("list") + .description("List all monitors for this API key.") + .option("--include-deleted", "Include soft-deleted monitors", false) + .option("--json", "Machine-readable JSON output", false) + .option("--out ", "Write JSON to this file instead of stdout", "-") + .option("--timeout ", "HTTP timeout in seconds", String(DEFAULT_TIMEOUT_S)) + .action(async (opts) => { + const ms = timeoutMs(opts); + try { + const qs = opts.includeDeleted ? "?include_deleted=true" : ""; + const result = await api.get(`/monitors${qs}`, { timeoutMs: ms }); + + if (isJsonMode(opts)) { writeOutput(result, opts.out); return; } + + if (result.monitors.length === 0) { + process.stdout.write("No monitors found.\n"); + return; + } + for (const m of result.monitors) { + process.stdout.write(formatMonitor(m)); + process.stdout.write("---\n"); + } + process.stdout.write(`Total: ${result.count}\n`); + } catch (err) { failWith(err); } + }); +} + +function registerMonitorGet(cmd: Command): void { + cmd + .command("get") + .description("Get a single monitor by ID.") + .argument("", "Monitor ID (starts with monitor_)") + .option("--diagram", "Include Mermaid DAG diagram in output", false) + .option("--json", "Machine-readable JSON output", false) + .option("--out ", "Write JSON to this file instead of stdout", "-") + .option("--timeout ", "HTTP timeout in seconds", String(DEFAULT_TIMEOUT_S)) + .action(async (monitorId: string, opts) => { + const ms = timeoutMs(opts); + try { + const params = new URLSearchParams(); + if (opts.diagram) params.set("include-diagram", "true"); + const qs = params.toString() ? `?${params}` : ""; + const result = await api.get(`/monitors/${monitorId}${qs}`, { timeoutMs: ms }); + + if (isJsonMode(opts)) { writeOutput(result, opts.out); return; } + process.stdout.write(formatMonitor(result)); + } catch (err) { failWith(err); } + }); +} + +function registerMonitorPause(cmd: Command): void { + cmd + .command("pause") + .description("Pause a monitor, disabling future scheduled runs.") + .argument("", "Monitor ID") + .option("--json", "Machine-readable JSON output", false) + .option("--out ", "Write JSON to this file instead of stdout", "-") + .option("--timeout ", "HTTP timeout in seconds", String(DEFAULT_TIMEOUT_S)) + .action(async (monitorId: string, opts) => { + const ms = timeoutMs(opts); + try { + const result = await api.post(`/monitors/${monitorId}/pause`, undefined, { timeoutMs: ms }); + if (isJsonMode(opts)) { writeOutput(result, opts.out); return; } + process.stdout.write(formatMonitor(result)); + } catch (err) { failWith(err); } + }); +} + +function registerMonitorResume(cmd: Command): void { + cmd + .command("resume") + .description("Resume a paused monitor, re-enabling scheduled runs.") + .argument("", "Monitor ID") + .option("--json", "Machine-readable JSON output", false) + .option("--out ", "Write JSON to this file instead of stdout", "-") + .option("--timeout ", "HTTP timeout in seconds", String(DEFAULT_TIMEOUT_S)) + .action(async (monitorId: string, opts) => { + const ms = timeoutMs(opts); + try { + const result = await api.post(`/monitors/${monitorId}/resume`, undefined, { timeoutMs: ms }); + if (isJsonMode(opts)) { writeOutput(result, opts.out); return; } + process.stdout.write(formatMonitor(result)); + } catch (err) { failWith(err); } + }); +} + +function registerMonitorDelete(cmd: Command): void { + cmd + .command("delete") + .description("Soft-delete a monitor and remove its schedule and shadow agent.") + .argument("", "Monitor ID") + .option("--json", "Machine-readable JSON output", false) + .option("--out ", "Write JSON to this file instead of stdout", "-") + .option("--timeout ", "HTTP timeout in seconds", String(DEFAULT_TIMEOUT_S)) + .action(async (monitorId: string, opts) => { + const ms = timeoutMs(opts); + try { + const result = await api.del(`/monitors/${monitorId}`, { timeoutMs: ms }); + if (isJsonMode(opts)) { writeOutput(result, opts.out); return; } + process.stdout.write(formatMonitor(result)); + } catch (err) { failWith(err); } + }); +} + +function registerMonitorEvents(cmd: Command): void { + cmd + .command("events") + .description("List snapshot events for a monitor, newest first.") + .argument("", "Monitor ID") + .option("--limit ", "Number of events to return (1–100, default 25)") + .option("--cursor ", "Pagination cursor from a previous response") + .option("--json", "Machine-readable JSON output", false) + .option("--out ", "Write JSON to this file instead of stdout", "-") + .option("--timeout ", "HTTP timeout in seconds", String(DEFAULT_TIMEOUT_S)) + .action(async (monitorId: string, opts) => { + const ms = timeoutMs(opts); + try { + const params = new URLSearchParams(); + if (opts.limit) { + const n = parseIntFlag(opts.limit, "--limit", { min: 1 }); + params.set("limit", String(n)); + } + if (opts.cursor) params.set("cursor", opts.cursor); + const qs = params.toString() ? `?${params}` : ""; + + const result = await api.get( + `/monitors/${monitorId}/events${qs}`, + { timeoutMs: ms } + ); + + if (isJsonMode(opts)) { writeOutput(result, opts.out); return; } + + if (result.data.length === 0) { + process.stdout.write("No events found.\n"); + return; + } + for (const ev of result.data) { + const ts = ev.created ? new Date(ev.created * 1000).toISOString() : "unknown"; + const changed = ev.changed == null ? "?" : ev.changed ? "yes" : "no"; + process.stdout.write(`${ev.id} changed=${changed} at=${ts}\n`); + if (ev.summary) process.stdout.write(` ${ev.summary}\n`); + } + if (result.has_more && result.next_cursor) { + process.stdout.write(`\nMore events available. Use --cursor ${result.next_cursor}\n`); + } + if (result.total_count != null) { + process.stdout.write(`\nTotal snapshots: ${result.total_count}\n`); + } + } catch (err) { failWith(err); } + }); +} + +// --------------------------------------------------------------------------- +// Top-level registration +// --------------------------------------------------------------------------- + +export function registerMonitor(program: Command): void { + const monitorCmd = program + .command("monitor") + .description("Manage web monitors that watch pages on a schedule and alert on changes."); + + registerMonitorCreate(monitorCmd); + registerMonitorList(monitorCmd); + registerMonitorGet(monitorCmd); + registerMonitorPause(monitorCmd); + registerMonitorResume(monitorCmd); + registerMonitorDelete(monitorCmd); + registerMonitorEvents(monitorCmd); +} diff --git a/src/index.ts b/src/index.ts index 0ca5bed..5ffbfdc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { registerCrawl } from "./commands/crawl.js"; import { registerBatchScrape } from "./commands/batch-scrape.js"; import { registerBatchUpdate } from "./commands/batch-update.js"; import { registerSearch } from "./commands/search.js"; +import { registerMonitor } from "./commands/monitor.js"; import { registerAddSkills } from "./commands/add-skills.js"; import { registerRemoveSkills } from "./commands/remove-skills.js"; @@ -61,6 +62,7 @@ async function main(): Promise { registerBatchScrape(program); registerBatchUpdate(program); registerSearch(program); + registerMonitor(program); // auth subcommand group registerAuth(program, VERSION);