diff --git a/skills/observe-cli/SKILL.md b/skills/observe-cli/SKILL.md index abf2943..1a6d48b 100644 --- a/skills/observe-cli/SKILL.md +++ b/skills/observe-cli/SKILL.md @@ -14,7 +14,7 @@ description: >- pod, status code, etc.; or pull any observability data out of the platform for analysis, dashboards, or scripting. Also covers the underlying primitives the CLI exposes (datasets, OPAL queries, - knowledge-graph tag search, alerts, AI agent skills, auth) when the + knowledge-graph tag search, alerts, monitors, AI agent skills, auth) when the user asks about them directly. --- @@ -282,6 +282,41 @@ emit that tag — use it to jump directly to the right resources without a separate `dataset list` / `metric list` call. Default search mode is semantic; switch to `--mode regex` when you need an exact pattern. +### Monitors — `observe monitor ...` + +```bash +observe monitor list --json # all monitors +observe monitor list --match "checkout" --json # filter by name + +observe monitor view --json # full monitor definition + +observe monitor create --file ./monitor.json --json # create from JSON file; --json returns the created object + +# Edit flow (view → edit → update): +observe monitor view --json > monitor.json +# edit monitor.json +observe monitor update --file monitor.json --json # update from JSON file; --json returns the updated object + +observe monitor enable # un-suppress a monitor +observe monitor disable # suppress without deleting + +observe monitor delete # permanent removal; prompts for confirmation +observe monitor delete --yes # skip confirmation (non-interactive / scripting) +``` + +Notes: + +- `--file` on `create` expects a JSON file with `name`, `ruleKind`, and + `definition` fields. Optional: `actionRules`. +- `--file` on `update` accepts the full monitor JSON (e.g. from + `monitor view --json`). Patchable fields: `name`, `description`, + `ruleKind`, `definition`, `actionRules`, `disabled`. Read-only fields + (`id`, `effectiveScheduling`) are ignored. +- `enable`/`disable` are a boolean `disabled` patch under the hood — + use them instead of patching `disabled` via `--file` for clarity. +- `--json` on mutation commands (`create`, `update`, `enable`, `disable`) + fetches and returns the full monitor after the operation. + ### Skills — `observe skill ...` Skills are reusable AI-agent instruction documents stored in Observe. diff --git a/src/app.ts b/src/app.ts index fe6d89c..d5588d9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,6 +12,7 @@ import { datastreamRoutes } from "./commands/datastream/index.js"; import { helpCommand } from "./commands/help.js"; import { ingestTokenRoutes } from "./commands/ingest-token/index.js"; import { metricRoutes } from "./commands/metric/index.js"; +import { monitorRoutes } from "./commands/monitor/index.js"; import { queryCommand } from "./commands/query.js"; import { skillRoutes } from "./commands/skill/index.js"; import { tagKeyRoutes } from "./commands/tag-key/index.js"; @@ -31,6 +32,7 @@ export const routes = defineRoutes({ query: queryCommand, skill: skillRoutes, alert: alertRoutes, + monitor: monitorRoutes, content: contentRoutes, "ingest-token": ingestTokenRoutes, datasource: datasourceRoutes, diff --git a/src/commands/monitor/create.test.ts b/src/commands/monitor/create.test.ts new file mode 100644 index 0000000..8474402 --- /dev/null +++ b/src/commands/monitor/create.test.ts @@ -0,0 +1,371 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import type { LocalContext } from "../../context"; +import type { Config } from "../../lib/config"; +import { + MonitorV2RuleKind, + type MonitorV2, + type MonitorV2Definition, +} from "../../rest/generated"; +import { createWriter } from "../../lib/writer"; + +const loadConfigFn = mock( + (): Config => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", + }), +); + +const STUB_DEFINITION: MonitorV2Definition = { + inputQuery: { outputStage: "main", stages: [] }, + rules: [], +}; + +const STUB_CREATE_FILE = JSON.stringify({ + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definition: STUB_DEFINITION, +}); + +function monitorStub(overrides: Partial = {}): MonitorV2 { + return { + id: "99001", + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definition: STUB_DEFINITION, + ...overrides, + }; +} + +const createMonitorFn = mock( + (_params: { config: Config; monitorV2: object }): Promise => + Promise.resolve(monitorStub()), +); + +const getMonitorFn = mock( + (_params: { config: Config; id: number }): Promise => + Promise.resolve(monitorStub()), +); + +const readFileFn = mock((_path: string): string => STUB_CREATE_FILE); + +let create: (typeof import("./create"))["create"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, + createMonitor: createMonitorFn, + getMonitor: getMonitorFn, + readFile: readFileFn, +} as Parameters<(typeof import("./create"))["create"]>[1]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + const mod = await import("./create.ts"); + create = mod.create; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor create — API forwarding", () => { + beforeEach(() => { + createMonitorFn.mockClear(); + getMonitorFn.mockClear(); + readFileFn.mockClear(); + loadConfigFn.mockClear(); + }); + + test("passes name, ruleKind, and definition from --file to createMonitor", async () => { + const { context } = createMockContext(); + await create.call(context, { file: "/fake/monitor.json" }, deps); + expect(createMonitorFn).toHaveBeenCalledTimes(1); + const call = createMonitorFn.mock.calls[0]![0] as unknown as { + monitorV2: { name: string; ruleKind: string; definition: unknown }; + }; + expect(call.monitorV2.name).toBe("My Monitor"); + expect(call.monitorV2.ruleKind).toBe(MonitorV2RuleKind.Count); + expect(call.monitorV2.definition).toMatchObject(STUB_DEFINITION); + }); + + test("reads file from the provided path", async () => { + const { context } = createMockContext(); + await create.call(context, { file: "/path/to/monitor.json" }, deps); + expect(readFileFn).toHaveBeenCalledWith("/path/to/monitor.json"); + }); + + test("includes actionRules when present in --file", async () => { + const actionRules = [{ actionId: "act-1" }]; + readFileFn.mockImplementationOnce(() => + JSON.stringify({ + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definition: STUB_DEFINITION, + actionRules, + }), + ); + const { context } = createMockContext(); + await create.call(context, { file: "/monitor.json" }, deps); + const call = createMonitorFn.mock.calls[0]![0] as unknown as { + monitorV2: { actionRules: unknown[] }; + }; + expect(call.monitorV2.actionRules).toEqual(actionRules); + }); + + test("omits actionRules when not present in --file", async () => { + const { context } = createMockContext(); + await create.call(context, { file: "/monitor.json" }, deps); + const call = createMonitorFn.mock.calls[0]![0] as unknown as { + monitorV2: object; + }; + expect(call.monitorV2).not.toHaveProperty("actionRules"); + }); +}); + +describe("monitor create — output", () => { + beforeEach(() => { + createMonitorFn.mockClear(); + getMonitorFn.mockClear(); + readFileFn.mockClear(); + }); + + test("prints success message with monitor ID", async () => { + createMonitorFn.mockImplementationOnce(() => + Promise.resolve(monitorStub({ id: "99001" })), + ); + const { context, stdout } = createMockContext(); + await create.call(context, { file: "/monitor.json" }, deps); + expect(stdout.join("")).toContain("99001"); + expect(stdout.join("")).toContain("created"); + }); + + test("--json fetches and writes the full created monitor", async () => { + createMonitorFn.mockImplementationOnce(() => + Promise.resolve(monitorStub({ id: "99001" })), + ); + getMonitorFn.mockImplementationOnce(() => + Promise.resolve(monitorStub({ id: "99001", name: "My Monitor" })), + ); + const { context, stdout } = createMockContext(); + await create.call(context, { file: "/monitor.json", json: true }, deps); + expect(getMonitorFn).toHaveBeenCalledTimes(1); + expect(getMonitorFn.mock.calls[0]![0]).toMatchObject({ id: 99001 }); + const result = JSON.parse(stdout.join("")) as MonitorV2; + expect(result).toMatchObject({ id: "99001", name: "My Monitor" }); + }); +}); + +describe("monitor create — file validation", () => { + beforeEach(() => { + createMonitorFn.mockClear(); + readFileFn.mockClear(); + }); + + test("missing name field exits with code 1", async () => { + readFileFn.mockImplementationOnce(() => + JSON.stringify({ + ruleKind: MonitorV2RuleKind.Count, + definition: STUB_DEFINITION, + }), + ); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call(context, { file: "/monitor.json" }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("name"); + expect(createMonitorFn).not.toHaveBeenCalled(); + }); + + test("missing ruleKind field exits with code 1", async () => { + readFileFn.mockImplementationOnce(() => + JSON.stringify({ name: "My Monitor", definition: STUB_DEFINITION }), + ); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call(context, { file: "/monitor.json" }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("ruleKind"); + expect(createMonitorFn).not.toHaveBeenCalled(); + }); + + test("missing definition field exits with code 1", async () => { + readFileFn.mockImplementationOnce(() => + JSON.stringify({ name: "My Monitor", ruleKind: MonitorV2RuleKind.Count }), + ); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call(context, { file: "/monitor.json" }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("definition"); + expect(createMonitorFn).not.toHaveBeenCalled(); + }); + + test("invalid JSON in --file exits with code 1 and mentions the flag", async () => { + readFileFn.mockImplementationOnce(() => "{ invalid json {{"); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call(context, { file: "/monitor.json" }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("--file"); + }); +}); + +describe("monitor create — error handling", () => { + beforeEach(() => { + createMonitorFn.mockClear(); + getMonitorFn.mockClear(); + readFileFn.mockClear(); + loadConfigFn.mockClear(); + }); + + test("createMonitor rejection exits with code 1 and prints to stderr", async () => { + createMonitorFn.mockImplementationOnce(() => + Promise.reject(new Error("network failure")), + ); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call(context, { file: "/monitor.json" }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); + + test("loadConfig error exits with code 1 and prints to stderr", async () => { + loadConfigFn.mockImplementationOnce((): never => { + throw new Error("no config file found"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call(context, { file: "/monitor.json" }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); + + test("readFile throwing exits with code 1", async () => { + readFileFn.mockImplementationOnce((): never => { + throw new Error( + "ENOENT: no such file or directory, open '/monitor.json'", + ); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call(context, { file: "/monitor.json" }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); + + test("createMonitor returning invalid id exits with code 1", async () => { + createMonitorFn.mockImplementationOnce(() => + Promise.resolve({ id: undefined } as unknown as MonitorV2), + ); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call(context, { file: "/monitor.json" }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); + + test("getMonitor returning null after create exits with code 1", async () => { + createMonitorFn.mockImplementationOnce(() => + Promise.resolve(monitorStub({ id: "99001" })), + ); + getMonitorFn.mockImplementationOnce(() => Promise.resolve(null)); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call(context, { file: "/monitor.json", json: true }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/monitor/create.ts b/src/commands/monitor/create.ts new file mode 100644 index 0000000..2dd7e88 --- /dev/null +++ b/src/commands/monitor/create.ts @@ -0,0 +1,145 @@ +import { defineCommand } from "../../lib/stricli-wrappers"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../context"; +import { createMonitor } from "../../rest/monitor/create-monitor"; +import { getMonitor } from "../../rest/monitor/get-monitor"; +import { + MonitorV2RuleKind, + type MonitorV2ActionRule, + type MonitorV2Definition, +} from "../../rest/generated"; +import { loadConfig } from "../../lib/config"; +import { formatApiError } from "../../lib/format-error"; +import { muteStatusWriter } from "../../lib/writer"; +import { parseMonitorId, parseJsonFile } from "../../lib/parsers"; + +interface CreateMonitorFlags { + file: string; + json?: boolean; +} + +export interface CreateMonitorDeps { + loadConfig?: typeof loadConfig; + createMonitor?: typeof createMonitor; + getMonitor?: typeof getMonitor; + readFile?: (path: string) => string; +} + +export async function create( + this: LocalContext, + flags: CreateMonitorFlags, + deps: CreateMonitorDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + createMonitor: createMonitorImpl = createMonitor, + getMonitor: getMonitorImpl = getMonitor, + readFile: readFileImpl = (p) => readFileSync(p, "utf-8"), + } = deps; + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { muted: flags.json === true }); + + try { + const config = loadConfigImpl(); + + writer.info("Creating monitor..."); + + const raw = readFileImpl(resolve(flags.file)); + const parsed = parseJsonFile>(raw, "--file"); + + if (!parsed.name || typeof parsed.name !== "string") { + writer.error('--file must contain a "name" field (string).'); + process.exit(1); + return; + } + if (!parsed.ruleKind) { + writer.error( + `--file must contain a "ruleKind" field (${Object.values(MonitorV2RuleKind).join(", ")}).`, + ); + process.exit(1); + return; + } + if (!parsed.definition) { + writer.error('--file must contain a "definition" field.'); + process.exit(1); + return; + } + + const created = await createMonitorImpl({ + config, + monitorV2: { + name: parsed.name, + ruleKind: parsed.ruleKind as MonitorV2RuleKind, + definition: parsed.definition as MonitorV2Definition, + ...(parsed.actionRules != null && { + actionRules: parsed.actionRules as MonitorV2ActionRule[], + }), + }, + }); + + let createdId: number; + try { + createdId = parseMonitorId(created.id); + } catch { + throw new Error( + `Create API returned unexpected monitor ID: "${created.id}"`, + ); + } + + if (flags.json) { + const result = await getMonitorImpl({ config, id: createdId }); + if (!result) { + throw new Error(`Monitor ${created.id} not found after creation`); + } + writer.write(JSON.stringify(result, null, 2)); + return; + } + + writer.success(`Monitor ${created.id} created.`); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const createCommand = defineCommand({ + experimental: true, + loader: async () => create, + parameters: { + positional: { kind: "tuple", parameters: [] }, + flags: { + file: { + kind: "parsed", + parse: String, + brief: "Path to JSON file containing the monitor to create", + optional: false, + }, + json: { + kind: "boolean", + brief: "Output the created monitor as JSON", + optional: true, + }, + }, + aliases: {}, + }, + docs: { + brief: "Create a new monitor", + fullDescription: [ + "Create a new MonitorV2 monitor from a JSON file.", + "", + "Required fields in the JSON file:", + " name (string) Monitor name", + ` ruleKind (${Object.values(MonitorV2RuleKind).join(" | ")})`, + " definition (object) MonitorV2Definition", + "", + "Optional fields:", + " actionRules (array) Array", + "", + "Full schema reference: https://developer.observeinc.com/#model/monitorv2definition", + "", + "Minimal definition example:", + ' {"inputQuery":{"outputStage":"main","stages":[{"stageID":"main","pipeline":"filter true"}]},"rules":[]}', + ].join("\n"), + }, +}); diff --git a/src/commands/monitor/delete.test.ts b/src/commands/monitor/delete.test.ts new file mode 100644 index 0000000..a2fbda9 --- /dev/null +++ b/src/commands/monitor/delete.test.ts @@ -0,0 +1,283 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import type { LocalContext } from "../../context"; +import type { Config } from "../../lib/config"; +import { createWriter } from "../../lib/writer"; + +const TEST_MONITOR_ID = "41076897"; + +const loadConfigFn = mock( + (): Config => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", + }), +); + +const deleteMonitorFn = mock( + (_params: { config: Config; id: number }): Promise => Promise.resolve(), +); + +let deleteMonitorCommand: (typeof import("./delete"))["deleteMonitorCommand"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, + deleteMonitor: deleteMonitorFn, +} as Parameters<(typeof import("./delete"))["deleteMonitorCommand"]>[2]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + const mod = await import("./delete.ts"); + deleteMonitorCommand = mod.deleteMonitorCommand; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor delete — ID validation", () => { + beforeEach(() => deleteMonitorFn.mockClear()); + + test("ID exceeding MAX_SAFE_INTEGER exits with code 1", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await deleteMonitorCommand.call( + context, + {}, + String(Number.MAX_SAFE_INTEGER + 1), + deps, + ); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + expect(deleteMonitorFn).not.toHaveBeenCalled(); + }); + + test("non-integer ID exits with code 1 and does not call deleteMonitor", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await deleteMonitorCommand.call(context, {}, "abc", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + expect(deleteMonitorFn).not.toHaveBeenCalled(); + }); + + test("float ID exits with code 1", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await deleteMonitorCommand.call(context, {}, "1.5", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + }); +}); + +describe("monitor delete — yes guard", () => { + beforeEach(() => deleteMonitorFn.mockClear()); + + test("without --yes exits 1 with irreversible message and does not call deleteMonitor", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await deleteMonitorCommand.call(context, {}, TEST_MONITOR_ID, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("irreversible"); + expect(deleteMonitorFn).not.toHaveBeenCalled(); + }); + + test("with --yes proceeds to delete", async () => { + const { context, stdout } = createMockContext(); + await deleteMonitorCommand.call( + context, + { yes: true }, + TEST_MONITOR_ID, + deps, + ); + expect(deleteMonitorFn).toHaveBeenCalledTimes(1); + expect(stdout.join("")).toContain("deleted"); + }); + + test("with confirmFn returning true proceeds to delete", async () => { + const getMonitorFn = mock((_params: { config: Config; id: number }) => + Promise.resolve({ id: Number(TEST_MONITOR_ID), name: "Test Monitor" }), + ); + const { context, stdout } = createMockContext(); + await deleteMonitorCommand.call(context, {}, TEST_MONITOR_ID, { + ...deps, + getMonitor: getMonitorFn as never, + confirmFn: async () => true, + }); + expect(deleteMonitorFn).toHaveBeenCalledTimes(1); + expect(stdout.join("")).toContain("deleted"); + }); + + test("with confirmFn returning false exits 1 without deleting", async () => { + const getMonitorFn = mock((_params: { config: Config; id: number }) => + Promise.resolve({ id: Number(TEST_MONITOR_ID), name: "Test Monitor" }), + ); + const { context, stderr, getExitCode } = createMockContext(); + try { + await deleteMonitorCommand.call(context, {}, TEST_MONITOR_ID, { + ...deps, + getMonitor: getMonitorFn as never, + confirmFn: async () => false, + }); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("cancelled"); + expect(deleteMonitorFn).not.toHaveBeenCalled(); + }); +}); + +describe("monitor delete — API forwarding", () => { + beforeEach(() => deleteMonitorFn.mockClear()); + + test("passes the correct numeric ID to deleteMonitor", async () => { + const { context } = createMockContext(); + await deleteMonitorCommand.call( + context, + { yes: true }, + TEST_MONITOR_ID, + deps, + ); + expect(deleteMonitorFn).toHaveBeenCalledTimes(1); + expect(deleteMonitorFn.mock.calls[0]![0]).toMatchObject({ + id: Number(TEST_MONITOR_ID), + }); + }); +}); + +describe("monitor delete — output", () => { + beforeEach(() => deleteMonitorFn.mockClear()); + + test("prints success message containing the monitor ID", async () => { + const { context, stdout } = createMockContext(); + await deleteMonitorCommand.call( + context, + { yes: true }, + TEST_MONITOR_ID, + deps, + ); + expect(stdout.join("")).toContain(TEST_MONITOR_ID); + expect(stdout.join("")).toContain("deleted"); + }); +}); + +describe("monitor delete — error handling", () => { + beforeEach(() => { + deleteMonitorFn.mockClear(); + loadConfigFn.mockClear(); + }); + + test("loadConfig error exits with code 1 and prints to stderr", async () => { + loadConfigFn.mockImplementationOnce((): never => { + throw new Error("no config file found"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await deleteMonitorCommand.call( + context, + { yes: true }, + TEST_MONITOR_ID, + deps, + ); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); + + test("deleteMonitor rejection exits with code 1 and prints to stderr", async () => { + deleteMonitorFn.mockImplementationOnce(() => + Promise.reject(new Error("not found")), + ); + const { context, stderr, getExitCode } = createMockContext(); + try { + await deleteMonitorCommand.call( + context, + { yes: true }, + TEST_MONITOR_ID, + deps, + ); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/monitor/delete.ts b/src/commands/monitor/delete.ts new file mode 100644 index 0000000..ef50cfb --- /dev/null +++ b/src/commands/monitor/delete.ts @@ -0,0 +1,130 @@ +import * as readline from "node:readline"; +import { defineCommand } from "../../lib/stricli-wrappers"; +import type { LocalContext } from "../../context"; +import { deleteMonitor } from "../../rest/monitor/delete-monitor"; +import { getMonitor } from "../../rest/monitor/get-monitor"; +import { loadConfig } from "../../lib/config"; +import { formatApiError } from "../../lib/format-error"; +import { parseMonitorId } from "../../lib/parsers"; + +interface DeleteMonitorFlags { + yes?: boolean; +} + +export interface DeleteMonitorDeps { + loadConfig?: typeof loadConfig; + deleteMonitor?: typeof deleteMonitor; + getMonitor?: typeof getMonitor; + confirmFn?: (monitorName: string) => Promise; +} + +function makeDefaultConfirm(proc: NodeJS.Process) { + return (name: string): Promise => + new Promise((resolve) => { + const rl = readline.createInterface({ + input: proc.stdin, + output: proc.stdout, + }); + rl.question( + `Are you sure you want to delete monitor "${name}"? This action is irreversible. [y/N]: `, + (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y"); + }, + ); + rl.on("SIGINT", () => { + rl.close(); + resolve(false); + }); + }); +} + +export async function deleteMonitorCommand( + this: LocalContext, + flags: DeleteMonitorFlags, + monitorId: string, + deps: DeleteMonitorDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + deleteMonitor: deleteMonitorImpl = deleteMonitor, + getMonitor: getMonitorImpl = getMonitor, + confirmFn, + } = deps; + const { process, writer } = this; + + let id: number; + try { + id = parseMonitorId(monitorId); + } catch { + writer.error( + `Invalid monitor ID: "${monitorId}". Must be a positive integer.`, + ); + process.exit(1); + return; + } + + try { + const config = loadConfigImpl(); + + if (!flags.yes) { + // When no confirmFn is injected (real usage), require a TTY. + if ( + !confirmFn && + !(process.stdin as NodeJS.ReadStream | undefined)?.isTTY + ) { + writer.error( + "Deleting a monitor is irreversible. Use --yes to confirm deletion in non-interactive mode.", + ); + process.exit(1); + return; + } + + const monitor = await getMonitorImpl({ config, id }); + if (!monitor) { + writer.error(`Monitor not found: ${monitorId}`); + process.exit(1); + return; + } + + const confirm = confirmFn ?? makeDefaultConfirm(process); + const confirmed = await confirm(monitor.name); + if (!confirmed) { + writer.error("Deletion cancelled."); + process.exit(1); + return; + } + } + + writer.info("Deleting monitor..."); + + await deleteMonitorImpl({ config, id }); + + writer.success(`Monitor ${monitorId} deleted.`); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const deleteCommand = defineCommand({ + experimental: true, + loader: async () => deleteMonitorCommand, + parameters: { + positional: { + kind: "tuple", + parameters: [{ brief: "Monitor ID", parse: String }], + }, + flags: { + yes: { + kind: "boolean", + brief: "Skip confirmation prompt (required in non-interactive mode)", + optional: true, + }, + }, + aliases: {}, + }, + docs: { + brief: "Delete a monitor", + }, +}); diff --git a/src/commands/monitor/disable.test.ts b/src/commands/monitor/disable.test.ts new file mode 100644 index 0000000..2a9b5b1 --- /dev/null +++ b/src/commands/monitor/disable.test.ts @@ -0,0 +1,272 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import type { LocalContext } from "../../context"; +import type { Config } from "../../lib/config"; +import { MonitorV2RuleKind, type MonitorV2 } from "../../rest/generated"; +import { createWriter } from "../../lib/writer"; + +const TEST_MONITOR_ID = "41076897"; + +const loadConfigFn = mock( + (): Config => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", + }), +); + +function monitorStub(overrides: Partial = {}): MonitorV2 { + return { + id: TEST_MONITOR_ID, + name: "Joe - Test Monitor", + ruleKind: MonitorV2RuleKind.Count, + definition: {} as MonitorV2["definition"], + disabled: true, + ...overrides, + }; +} + +const updateMonitorFn = mock( + (_params: { config: Config; id: number; disabled: boolean }): Promise => + Promise.resolve(), +); + +const getMonitorFn = mock( + (_params: { config: Config; id: number }): Promise => + Promise.resolve(monitorStub({ disabled: true })), +); + +let disable: (typeof import("./disable"))["disable"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, + updateMonitor: updateMonitorFn, + getMonitor: getMonitorFn, +} as Parameters<(typeof import("./disable"))["disable"]>[2]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + const mod = await import("./disable.ts"); + disable = mod.disable; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor disable — ID validation", () => { + beforeEach(() => { + updateMonitorFn.mockClear(); + getMonitorFn.mockClear(); + }); + + test("ID exceeding MAX_SAFE_INTEGER exits with code 1", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await disable.call( + context, + {}, + String(Number.MAX_SAFE_INTEGER + 1), + deps, + ); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + expect(updateMonitorFn).not.toHaveBeenCalled(); + }); + + test("non-integer ID exits with code 1 and does not call updateMonitor", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await disable.call(context, {}, "abc", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + expect(updateMonitorFn).not.toHaveBeenCalled(); + }); + + test("float ID exits with code 1", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await disable.call(context, {}, "1.5", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + }); +}); + +describe("monitor disable — API forwarding", () => { + beforeEach(() => { + updateMonitorFn.mockClear(); + getMonitorFn.mockClear(); + }); + + test("calls updateMonitor with disabled: true and the correct numeric ID", async () => { + const { context } = createMockContext(); + await disable.call(context, {}, TEST_MONITOR_ID, deps); + expect(updateMonitorFn).toHaveBeenCalledTimes(1); + expect(updateMonitorFn.mock.calls[0]![0]).toMatchObject({ + id: Number(TEST_MONITOR_ID), + disabled: true, + }); + }); + + test("does not call getMonitor when --json is not set", async () => { + const { context } = createMockContext(); + await disable.call(context, {}, TEST_MONITOR_ID, deps); + expect(getMonitorFn).not.toHaveBeenCalled(); + }); + + test("calls getMonitor with numeric ID when --json is set", async () => { + const { context } = createMockContext(); + await disable.call(context, { json: true }, TEST_MONITOR_ID, deps); + expect(getMonitorFn).toHaveBeenCalledTimes(1); + expect(getMonitorFn.mock.calls[0]![0]).toMatchObject({ + id: Number(TEST_MONITOR_ID), + }); + }); +}); + +describe("monitor disable — output", () => { + beforeEach(() => { + updateMonitorFn.mockClear(); + getMonitorFn.mockClear(); + }); + + test("prints success message with monitor ID", async () => { + const { context, stdout } = createMockContext(); + await disable.call(context, {}, TEST_MONITOR_ID, deps); + expect(stdout.join("")).toContain(TEST_MONITOR_ID); + expect(stdout.join("")).toContain("disabled"); + }); + + test("JSON output contains monitor with disabled: true", async () => { + getMonitorFn.mockImplementationOnce(() => + Promise.resolve(monitorStub({ disabled: true })), + ); + const { context, stdout } = createMockContext(); + await disable.call(context, { json: true }, TEST_MONITOR_ID, deps); + const result = JSON.parse(stdout.join("")) as MonitorV2; + expect(result).toMatchObject({ + id: TEST_MONITOR_ID, + name: "Joe - Test Monitor", + disabled: true, + }); + }); +}); + +describe("monitor disable — error handling", () => { + beforeEach(() => { + updateMonitorFn.mockClear(); + getMonitorFn.mockClear(); + loadConfigFn.mockClear(); + }); + + test("loadConfig error exits with code 1 and prints to stderr", async () => { + loadConfigFn.mockImplementationOnce((): never => { + throw new Error("no config file found"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await disable.call(context, {}, TEST_MONITOR_ID, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); + + test("updateMonitor error exits with code 1 and prints to stderr", async () => { + updateMonitorFn.mockImplementationOnce(() => + Promise.reject(new Error("network failure")), + ); + const { context, stderr, getExitCode } = createMockContext(); + try { + await disable.call(context, {}, TEST_MONITOR_ID, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); + + test("getMonitor returning null after disable exits with code 1", async () => { + getMonitorFn.mockImplementationOnce(() => Promise.resolve(null)); + const { context, stderr, getExitCode } = createMockContext(); + try { + await disable.call(context, { json: true }, TEST_MONITOR_ID, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/monitor/disable.ts b/src/commands/monitor/disable.ts new file mode 100644 index 0000000..3687ff4 --- /dev/null +++ b/src/commands/monitor/disable.ts @@ -0,0 +1,93 @@ +import { defineCommand } from "../../lib/stricli-wrappers"; +import type { LocalContext } from "../../context"; +import { updateMonitor } from "../../rest/monitor/update-monitor"; +import { getMonitor } from "../../rest/monitor/get-monitor"; +import { loadConfig } from "../../lib/config"; +import { formatApiError } from "../../lib/format-error"; +import { muteStatusWriter } from "../../lib/writer"; +import { parseMonitorId } from "../../lib/parsers"; + +interface DisableMonitorFlags { + json?: boolean; +} + +export interface DisableMonitorDeps { + loadConfig?: typeof loadConfig; + updateMonitor?: typeof updateMonitor; + getMonitor?: typeof getMonitor; +} + +export async function disable( + this: LocalContext, + flags: DisableMonitorFlags, + monitorId: string, + deps: DisableMonitorDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + updateMonitor: updateMonitorImpl = updateMonitor, + getMonitor: getMonitorImpl = getMonitor, + } = deps; + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { muted: flags.json === true }); + + let id: number; + try { + id = parseMonitorId(monitorId); + } catch { + writer.error( + `Invalid monitor ID: "${monitorId}". Must be a positive integer.`, + ); + process.exit(1); + return; + } + + try { + const config = loadConfigImpl(); + + writer.info("Disabling monitor..."); + + await updateMonitorImpl({ config, id, disabled: true }); + + if (flags.json) { + const result = await getMonitorImpl({ config, id }); + if (!result) { + throw new Error(`Monitor ${monitorId} not found after disabling`); + } + writer.write(JSON.stringify(result, null, 2)); + return; + } + + writer.success(`Monitor ${monitorId} disabled.`); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const disableCommand = defineCommand({ + experimental: true, + loader: async () => disable, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Monitor ID", + parse: String, + }, + ], + }, + flags: { + json: { + kind: "boolean", + brief: "Output the updated monitor as JSON", + optional: true, + }, + }, + aliases: {}, + }, + docs: { + brief: "Disable a monitor", + }, +}); diff --git a/src/commands/monitor/enable.test.ts b/src/commands/monitor/enable.test.ts new file mode 100644 index 0000000..cfe97e5 --- /dev/null +++ b/src/commands/monitor/enable.test.ts @@ -0,0 +1,267 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import type { LocalContext } from "../../context"; +import type { Config } from "../../lib/config"; +import { MonitorV2RuleKind, type MonitorV2 } from "../../rest/generated"; +import { createWriter } from "../../lib/writer"; + +const TEST_MONITOR_ID = "41076897"; + +const loadConfigFn = mock( + (): Config => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", + }), +); + +function monitorStub(overrides: Partial = {}): MonitorV2 { + return { + id: TEST_MONITOR_ID, + name: "Joe - Test Monitor", + ruleKind: MonitorV2RuleKind.Count, + definition: {} as MonitorV2["definition"], + disabled: false, + ...overrides, + }; +} + +const updateMonitorFn = mock( + (_params: { config: Config; id: number; disabled: boolean }): Promise => + Promise.resolve(), +); + +const getMonitorFn = mock( + (_params: { config: Config; id: number }): Promise => + Promise.resolve(monitorStub({ disabled: false })), +); + +let enable: (typeof import("./enable"))["enable"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, + updateMonitor: updateMonitorFn, + getMonitor: getMonitorFn, +} as Parameters<(typeof import("./enable"))["enable"]>[2]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + const mod = await import("./enable.ts"); + enable = mod.enable; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor enable — ID validation", () => { + beforeEach(() => { + updateMonitorFn.mockClear(); + getMonitorFn.mockClear(); + }); + + test("ID exceeding MAX_SAFE_INTEGER exits with code 1", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await enable.call(context, {}, String(Number.MAX_SAFE_INTEGER + 1), deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + expect(updateMonitorFn).not.toHaveBeenCalled(); + }); + + test("non-integer ID exits with code 1 and does not call updateMonitor", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await enable.call(context, {}, "abc", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + expect(updateMonitorFn).not.toHaveBeenCalled(); + }); + + test("float ID exits with code 1", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await enable.call(context, {}, "1.5", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + }); +}); + +describe("monitor enable — API forwarding", () => { + beforeEach(() => { + updateMonitorFn.mockClear(); + getMonitorFn.mockClear(); + }); + + test("calls updateMonitor with disabled: false and the correct numeric ID", async () => { + const { context } = createMockContext(); + await enable.call(context, {}, TEST_MONITOR_ID, deps); + expect(updateMonitorFn).toHaveBeenCalledTimes(1); + expect(updateMonitorFn.mock.calls[0]![0]).toMatchObject({ + id: Number(TEST_MONITOR_ID), + disabled: false, + }); + }); + + test("does not call getMonitor when --json is not set", async () => { + const { context } = createMockContext(); + await enable.call(context, {}, TEST_MONITOR_ID, deps); + expect(getMonitorFn).not.toHaveBeenCalled(); + }); + + test("calls getMonitor with numeric ID when --json is set", async () => { + const { context } = createMockContext(); + await enable.call(context, { json: true }, TEST_MONITOR_ID, deps); + expect(getMonitorFn).toHaveBeenCalledTimes(1); + expect(getMonitorFn.mock.calls[0]![0]).toMatchObject({ + id: Number(TEST_MONITOR_ID), + }); + }); +}); + +describe("monitor enable — output", () => { + beforeEach(() => { + updateMonitorFn.mockClear(); + getMonitorFn.mockClear(); + }); + + test("prints success message with monitor ID", async () => { + const { context, stdout } = createMockContext(); + await enable.call(context, {}, TEST_MONITOR_ID, deps); + expect(stdout.join("")).toContain(TEST_MONITOR_ID); + expect(stdout.join("")).toContain("enabled"); + }); + + test("JSON output contains monitor with disabled: false", async () => { + getMonitorFn.mockImplementationOnce(() => + Promise.resolve(monitorStub({ disabled: false })), + ); + const { context, stdout } = createMockContext(); + await enable.call(context, { json: true }, TEST_MONITOR_ID, deps); + const result = JSON.parse(stdout.join("")) as MonitorV2; + expect(result).toMatchObject({ + id: TEST_MONITOR_ID, + name: "Joe - Test Monitor", + disabled: false, + }); + }); +}); + +describe("monitor enable — error handling", () => { + beforeEach(() => { + updateMonitorFn.mockClear(); + getMonitorFn.mockClear(); + loadConfigFn.mockClear(); + }); + + test("loadConfig error exits with code 1 and prints to stderr", async () => { + loadConfigFn.mockImplementationOnce((): never => { + throw new Error("no config file found"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await enable.call(context, {}, TEST_MONITOR_ID, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); + + test("updateMonitor error exits with code 1 and prints to stderr", async () => { + updateMonitorFn.mockImplementationOnce(() => + Promise.reject(new Error("network failure")), + ); + const { context, stderr, getExitCode } = createMockContext(); + try { + await enable.call(context, {}, TEST_MONITOR_ID, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); + + test("getMonitor returning null after enable exits with code 1", async () => { + getMonitorFn.mockImplementationOnce(() => Promise.resolve(null)); + const { context, stderr, getExitCode } = createMockContext(); + try { + await enable.call(context, { json: true }, TEST_MONITOR_ID, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/monitor/enable.ts b/src/commands/monitor/enable.ts new file mode 100644 index 0000000..1bf7572 --- /dev/null +++ b/src/commands/monitor/enable.ts @@ -0,0 +1,93 @@ +import { defineCommand } from "../../lib/stricli-wrappers"; +import type { LocalContext } from "../../context"; +import { updateMonitor } from "../../rest/monitor/update-monitor"; +import { getMonitor } from "../../rest/monitor/get-monitor"; +import { loadConfig } from "../../lib/config"; +import { formatApiError } from "../../lib/format-error"; +import { muteStatusWriter } from "../../lib/writer"; +import { parseMonitorId } from "../../lib/parsers"; + +interface EnableMonitorFlags { + json?: boolean; +} + +export interface EnableMonitorDeps { + loadConfig?: typeof loadConfig; + updateMonitor?: typeof updateMonitor; + getMonitor?: typeof getMonitor; +} + +export async function enable( + this: LocalContext, + flags: EnableMonitorFlags, + monitorId: string, + deps: EnableMonitorDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + updateMonitor: updateMonitorImpl = updateMonitor, + getMonitor: getMonitorImpl = getMonitor, + } = deps; + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { muted: flags.json === true }); + + let id: number; + try { + id = parseMonitorId(monitorId); + } catch { + writer.error( + `Invalid monitor ID: "${monitorId}". Must be a positive integer.`, + ); + process.exit(1); + return; + } + + try { + const config = loadConfigImpl(); + + writer.info("Enabling monitor..."); + + await updateMonitorImpl({ config, id, disabled: false }); + + if (flags.json) { + const result = await getMonitorImpl({ config, id }); + if (!result) { + throw new Error(`Monitor ${monitorId} not found after enabling`); + } + writer.write(JSON.stringify(result, null, 2)); + return; + } + + writer.success(`Monitor ${monitorId} enabled.`); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const enableCommand = defineCommand({ + experimental: true, + loader: async () => enable, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Monitor ID", + parse: String, + }, + ], + }, + flags: { + json: { + kind: "boolean", + brief: "Output the updated monitor as JSON", + optional: true, + }, + }, + aliases: {}, + }, + docs: { + brief: "Enable a monitor", + }, +}); diff --git a/src/commands/monitor/index.ts b/src/commands/monitor/index.ts new file mode 100644 index 0000000..b427b8c --- /dev/null +++ b/src/commands/monitor/index.ts @@ -0,0 +1,35 @@ +import { defineRoutes } from "../../lib/stricli-wrappers"; +import { listCommand } from "./list"; +import { viewCommand } from "./view"; +import { createCommand } from "./create"; +import { updateCommand } from "./update"; +import { deleteCommand } from "./delete"; +import { enableCommand } from "./enable"; +import { disableCommand } from "./disable"; + +export const monitorRoutes = defineRoutes({ + routes: { + list: listCommand, + view: viewCommand, + create: createCommand, + update: updateCommand, + delete: deleteCommand, + enable: enableCommand, + disable: disableCommand, + }, + docs: { + brief: "Manage observe monitors", + fullDescription: [ + "View and manage monitors in Observe", + "", + "Commands:", + " list Search and list monitors in Observe", + " view View details of a specific monitor", + " create Create a new monitor", + " update Update a monitor", + " delete Delete a monitor", + " enable Enable a monitor", + " disable Disable a monitor", + ].join("\n"), + }, +}); diff --git a/src/commands/monitor/list.test.ts b/src/commands/monitor/list.test.ts new file mode 100644 index 0000000..2e7b055 --- /dev/null +++ b/src/commands/monitor/list.test.ts @@ -0,0 +1,323 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import type { LocalContext } from "../../context"; +import type { Config } from "../../lib/config"; +import { MonitorV2RuleKind, type MonitorV2Terse } from "../../rest/generated"; +import { createWriter } from "../../lib/writer"; + +const loadConfigFn = mock( + (): Config => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", + }), +); + +function monitorTerseStub( + id: string, + name: string, + overrides: Partial = {}, +): MonitorV2Terse { + return { + id, + name, + description: "", + disabled: false, + ruleKind: MonitorV2RuleKind.Count, + ...overrides, + }; +} + +const STUB_MONITORS: MonitorV2Terse[] = [ + monitorTerseStub("1", "Alpha Monitor", { ruleKind: MonitorV2RuleKind.Count }), + monitorTerseStub("2", "Beta Monitor", { + ruleKind: MonitorV2RuleKind.Threshold, + disabled: true, + }), + monitorTerseStub("3", "Gamma Monitor", { + ruleKind: MonitorV2RuleKind.Promote, + }), +]; + +const listMonitorsFn = mock( + (_params: { config: Config; nameSubstring?: string }) => + Promise.resolve([...STUB_MONITORS]), +); + +let list: (typeof import("./list"))["list"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, + listMonitors: listMonitorsFn, +} as Parameters<(typeof import("./list"))["list"]>[1]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + const mod = await import("./list.ts"); + list = mod.list; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor list — API forwarding", () => { + beforeEach(() => { + listMonitorsFn.mockClear(); + loadConfigFn.mockClear(); + }); + + test("calls listMonitors with nameSubstring when --match is set", async () => { + const { context } = createMockContext(); + await list.call(context, { match: "alpha", json: true }, deps); + expect(listMonitorsFn).toHaveBeenCalledTimes(1); + expect(listMonitorsFn.mock.calls[0]![0]).toMatchObject({ + nameSubstring: "alpha", + }); + }); + + test("calls listMonitors without nameSubstring when --match is absent", async () => { + const { context } = createMockContext(); + await list.call(context, { json: true }, deps); + expect(listMonitorsFn).toHaveBeenCalledTimes(1); + expect(listMonitorsFn.mock.calls[0]![0].nameSubstring).toBeUndefined(); + }); +}); + +describe("monitor list — kind filter", () => { + beforeEach(() => listMonitorsFn.mockClear()); + + test("--kind Count returns only Count monitors", async () => { + const { context, stdout } = createMockContext(); + await list.call( + context, + { kind: [MonitorV2RuleKind.Count], json: true }, + deps, + ); + const result = JSON.parse(stdout.join("")) as MonitorV2Terse[]; + expect(result.every((m) => m.ruleKind === MonitorV2RuleKind.Count)).toBe( + true, + ); + }); + + test("--kind Count,Promote returns Count and Promote monitors", async () => { + const { context, stdout } = createMockContext(); + await list.call( + context, + { + kind: [MonitorV2RuleKind.Count, MonitorV2RuleKind.Promote], + json: true, + }, + deps, + ); + const result = JSON.parse(stdout.join("")) as MonitorV2Terse[]; + expect( + result.every((m) => m.ruleKind !== MonitorV2RuleKind.Threshold), + ).toBe(true); + expect(result.some((m) => m.ruleKind === MonitorV2RuleKind.Count)).toBe( + true, + ); + expect(result.some((m) => m.ruleKind === MonitorV2RuleKind.Promote)).toBe( + true, + ); + }); +}); + +describe("monitor list — disabled filter", () => { + beforeEach(() => listMonitorsFn.mockClear()); + + test("--disabled returns only disabled monitors", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { disabled: true, json: true }, deps); + const result = JSON.parse(stdout.join("")) as MonitorV2Terse[]; + expect(result.length).toBeGreaterThan(0); + expect(result.every((m) => m.disabled === true)).toBe(true); + }); + + test("--no-disabled returns only enabled monitors", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { disabled: false, json: true }, deps); + const result = JSON.parse(stdout.join("")) as MonitorV2Terse[]; + expect(result.length).toBeGreaterThan(0); + expect(result.every((m) => !m.disabled)).toBe(true); + }); +}); + +describe("monitor list — sorting", () => { + beforeEach(() => listMonitorsFn.mockClear()); + + test("--sort name returns monitors in alphabetical order", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { sort: "name", json: true }, deps); + const result = JSON.parse(stdout.join("")) as MonitorV2Terse[]; + const names = result.map((m) => m.name ?? ""); + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); + }); + + test("--sort id returns monitors in ascending numeric id order", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { sort: "id", json: true }, deps); + const result = JSON.parse(stdout.join("")) as MonitorV2Terse[]; + const ids = result.map((m) => Number(m.id)); + expect(ids).toEqual([...ids].sort((a, b) => a - b)); + }); +}); + +describe("monitor list — output", () => { + beforeEach(() => listMonitorsFn.mockClear()); + + test("JSON output matches MonitorV2Terse shape", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { json: true }, deps); + const result = JSON.parse(stdout.join("")) as MonitorV2Terse[]; + expect(result[0]).toMatchObject({ + id: "1", + name: "Alpha Monitor", + ruleKind: MonitorV2RuleKind.Count, + disabled: false, + }); + }); + + test("shows warning when no monitors are returned", async () => { + listMonitorsFn.mockImplementationOnce(() => Promise.resolve([])); + const { context, stdout } = createMockContext(); + await list.call(context, {}, deps); + expect(stdout.join("")).toContain("No monitors found"); + }); +}); + +describe("monitor list — pagination", () => { + beforeEach(() => listMonitorsFn.mockClear()); + + test("--limit 2 returns only the first 2 results", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { limit: 2, json: true }, deps); + const result = JSON.parse(stdout.join("")) as MonitorV2Terse[]; + expect(result.length).toBe(2); + expect(result[0]!.id).toBe("1"); + expect(result[1]!.id).toBe("2"); + }); + + test("--offset 1 skips the first result", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { offset: 1, json: true }, deps); + const result = JSON.parse(stdout.join("")) as MonitorV2Terse[]; + expect(result[0]!.id).toBe("2"); + }); + + test("--limit 2 --offset 1 returns one result starting from index 1", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { limit: 2, offset: 1, json: true }, deps); + const result = JSON.parse(stdout.join("")) as MonitorV2Terse[]; + expect(result.length).toBe(2); + expect(result[0]!.id).toBe("2"); + expect(result[1]!.id).toBe("3"); + }); + + test("pagination hint shown in table output when results equal limit", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { limit: 3 }, deps); + const out = stdout.join(""); + expect(out).toContain("--offset 3"); + }); + + test("no pagination hint when results fewer than limit", async () => { + const { context, stdout } = createMockContext(); + await list.call(context, { limit: 100 }, deps); + const out = stdout.join(""); + expect(out).not.toContain("--offset"); + }); +}); + +describe("monitor list — error handling", () => { + beforeEach(() => { + listMonitorsFn.mockClear(); + loadConfigFn.mockClear(); + }); + + test("loadConfig error exits with code 1 and prints to stderr", async () => { + loadConfigFn.mockImplementationOnce((): never => { + throw new Error("no config file found"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await list.call(context, { json: true }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); + + test("API error exits with code 1 and prints to stderr", async () => { + listMonitorsFn.mockImplementationOnce(() => + Promise.reject(new Error("network failure")), + ); + const { context, stderr, getExitCode } = createMockContext(); + try { + await list.call(context, { json: true }, deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/monitor/list.ts b/src/commands/monitor/list.ts new file mode 100644 index 0000000..f1635dc --- /dev/null +++ b/src/commands/monitor/list.ts @@ -0,0 +1,292 @@ +import { defineCommand } from "../../lib/stricli-wrappers"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { listMonitors } from "../../rest/monitor/list-monitors"; +import { type MonitorV2Terse, MonitorV2RuleKind } from "../../rest/generated"; +import { ruleKindColor } from "./monitor-utils"; +import { loadConfig } from "../../lib/config"; +import { formatApiError } from "../../lib/format-error"; +import { muteStatusWriter } from "../../lib/writer"; +import { + formatTable, + createColumnHelper, + type ColumnDef, +} from "../../lib/formatters/table"; +import { renderAsCSV } from "../../lib/formatters/csv"; +import { parseNonNegativeInt } from "../../lib/parsers"; + +type OutputFormat = "json" | "csv"; +type SortField = "id" | "name" | "kind" | "disabled"; + +interface ListMonitorsFlags { + match?: string; + kind?: MonitorV2RuleKind[]; + disabled?: boolean; + sort?: SortField; + format?: OutputFormat; + json?: boolean; + fields?: FieldName[]; + limit?: number; + offset?: number; +} + +const MAX_LIMIT = 1000; +const MIN_LIMIT = 1; +const DEFAULT_LIMIT = 100; + +function parseLimit(value: string): number { + const num = Number(value); + if (isNaN(num) || num < MIN_LIMIT || num > MAX_LIMIT) { + throw new Error(`Limit must be between ${MIN_LIMIT} and ${MAX_LIMIT}`); + } + return num; +} + +const AVAILABLE_FIELDS = [ + "id", + "name", + "description", + "ruleKind", + "disabled", +] as const; + +type FieldName = (typeof AVAILABLE_FIELDS)[number]; + +const DEFAULT_FIELDS: FieldName[] = ["id", "name", "ruleKind", "disabled"]; + +const RULE_KIND_ORDER: Record = { + [MonitorV2RuleKind.Count]: 0, + [MonitorV2RuleKind.Promote]: 1, + [MonitorV2RuleKind.Threshold]: 2, +}; + +function sortMonitors( + monitors: MonitorV2Terse[], + sort: SortField, +): MonitorV2Terse[] { + return [...monitors].sort((a, b) => { + switch (sort) { + case "id": + return Number(a.id ?? 0) - Number(b.id ?? 0); + case "name": + return (a.name ?? "").localeCompare(b.name ?? ""); + case "kind": + return ( + (RULE_KIND_ORDER[a.ruleKind ?? ""] ?? 99) - + (RULE_KIND_ORDER[b.ruleKind ?? ""] ?? 99) + ); + case "disabled": + return Number(a.disabled ?? false) - Number(b.disabled ?? false); + } + }); +} + +const col = createColumnHelper(); + +const FIELD_COLUMNS = { + id: col.accessor((row) => row.id ?? "-", { + header: "ID", + }), + name: col.accessor((row) => row.name ?? "-", { + header: "NAME", + }), + description: col.accessor((row) => row.description ?? "-", { + header: "DESCRIPTION", + }), + ruleKind: col.accessor((row) => row.ruleKind, { + header: "KIND", + format: (value) => ruleKindColor(value), + }), + disabled: col.accessor((row) => row.disabled ?? false, { + header: "DISABLED", + format: (value) => (value ? chalk.yellow("Yes") : chalk.dim("No")), + }), +} satisfies Record>; + +export interface ListMonitorsDeps { + loadConfig?: typeof loadConfig; + listMonitors?: typeof listMonitors; +} + +export async function list( + this: LocalContext, + flags: ListMonitorsFlags, + deps: ListMonitorsDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + listMonitors: listMonitorsImpl = listMonitors, + } = deps; + const { process, writer: _writer } = this; + + const format = flags.json ? ("json" as const) : flags.format; + const writer = muteStatusWriter(_writer, { + muted: format === "json" || format === "csv", + }); + + try { + const config = loadConfigImpl(); + + writer.info("Searching monitors..."); + + let monitors = await listMonitorsImpl({ + config, + nameSubstring: flags.match, + }); + + if (flags.kind) { + const filterKinds = flags.kind; + monitors = monitors.filter( + (m) => m.ruleKind != null && filterKinds.includes(m.ruleKind), + ); + } + + if (flags.disabled != null) { + monitors = monitors.filter( + (m) => (m.disabled ?? false) === flags.disabled, + ); + } + + if (flags.sort) { + monitors = sortMonitors(monitors, flags.sort); + } + + const limit = flags.limit ?? DEFAULT_LIMIT; + const start = flags.offset ?? 0; + const totalBeforePaging = monitors.length; + monitors = monitors.slice(start, start + limit); + + const fieldNames = flags.fields ?? DEFAULT_FIELDS; + + if (format === "json") { + writer.write(JSON.stringify(monitors, null, 2)); + return; + } + + if (format === "csv") { + writer.write(renderAsCSV(monitors)); + return; + } + + if (monitors.length === 0) { + writer.warn("No monitors found."); + return; + } + + writer.write(chalk.green(`Found ${totalBeforePaging} monitor(s):\n`)); + + const columns = fieldNames.map((field) => FIELD_COLUMNS[field]); + writer.write(formatTable(monitors, columns)); + + if (monitors.length === limit) { + const nextOffset = start + limit; + writer.info( + `\nMore results may be available. Use --offset ${nextOffset} to see the next page.`, + ); + } + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +const VALID_KINDS = Object.values(MonitorV2RuleKind); + +function parseKind(value: string): MonitorV2RuleKind[] { + return value.split(",").map((k) => { + const normalized = VALID_KINDS.find( + (v) => v.toLowerCase() === k.trim().toLowerCase(), + ); + if (!normalized) { + throw new Error( + `Invalid kind: "${k.trim()}". Available kinds: ${VALID_KINDS.join(", ")}`, + ); + } + return normalized; + }); +} + +function parseFields(value: string): FieldName[] { + const fields = value.split(",").map((f) => f.trim()) as FieldName[]; + for (const field of fields) { + if (!AVAILABLE_FIELDS.includes(field)) { + throw new Error( + `Invalid field: "${field}". Available fields: ${AVAILABLE_FIELDS.join(", ")}`, + ); + } + } + return fields; +} + +export const listCommand = defineCommand({ + experimental: true, + loader: async () => list, + parameters: { + positional: { + kind: "tuple", + parameters: [], + }, + flags: { + match: { + kind: "parsed", + parse: String, + brief: "Search monitors by name substring", + optional: true, + }, + kind: { + kind: "parsed", + parse: parseKind, + brief: `Filter by rule kind(s): ${VALID_KINDS.join(", ")}`, + optional: true, + }, + disabled: { + kind: "boolean", + brief: + "Filter by disabled status (--disabled shows only disabled, --no-disabled shows only enabled)", + optional: true, + }, + sort: { + kind: "enum", + values: ["id", "name", "kind", "disabled"], + brief: "Sort results by field", + optional: true, + }, + format: { + kind: "enum", + values: ["json", "csv"], + brief: "Output format (json, csv)", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output as JSON (shorthand for --format=json)", + optional: true, + }, + fields: { + kind: "parsed", + parse: parseFields, + brief: `Comma-separated list of fields: ${AVAILABLE_FIELDS.join(", ")}`, + optional: true, + }, + limit: { + kind: "parsed", + parse: parseLimit, + brief: `Maximum monitors to return (${MIN_LIMIT}-${MAX_LIMIT}, default ${DEFAULT_LIMIT})`, + optional: true, + }, + offset: { + kind: "parsed", + parse: parseNonNegativeInt, + brief: "Offset for pagination (skip this many results)", + optional: true, + }, + }, + aliases: { + m: "match", + s: "sort", + }, + }, + docs: { + brief: "Search and list monitors in Observe", + }, +}); diff --git a/src/commands/monitor/monitor-utils.ts b/src/commands/monitor/monitor-utils.ts new file mode 100644 index 0000000..28a260a --- /dev/null +++ b/src/commands/monitor/monitor-utils.ts @@ -0,0 +1,16 @@ +import chalk from "chalk"; +import { MonitorV2RuleKind } from "../../rest/generated"; + +export function ruleKindColor(kind: MonitorV2RuleKind | undefined): string { + if (!kind) return chalk.dim("-"); + switch (kind) { + case MonitorV2RuleKind.Threshold: + return chalk.cyan(kind); + case MonitorV2RuleKind.Count: + return chalk.green(kind); + case MonitorV2RuleKind.Promote: + return chalk.magenta(kind); + default: + return chalk.dim(kind); + } +} diff --git a/src/commands/monitor/update.test.ts b/src/commands/monitor/update.test.ts new file mode 100644 index 0000000..3b7fe83 --- /dev/null +++ b/src/commands/monitor/update.test.ts @@ -0,0 +1,402 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import type { LocalContext } from "../../context"; +import type { Config } from "../../lib/config"; +import { + MonitorV2RuleKind, + type MonitorV2, + type MonitorV2Definition, +} from "../../rest/generated"; +import { createWriter } from "../../lib/writer"; + +const TEST_MONITOR_ID = "41076897"; + +const loadConfigFn = mock( + (): Config => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", + }), +); + +const STUB_DEFINITION: MonitorV2Definition = { + inputQuery: { outputStage: "main", stages: [] }, + rules: [], +}; + +function monitorStub(overrides: Partial = {}): MonitorV2 { + return { + id: TEST_MONITOR_ID, + name: "Joe - Test Monitor", + ruleKind: MonitorV2RuleKind.Count, + definition: STUB_DEFINITION, + ...overrides, + }; +} + +const STUB_FILE = JSON.stringify(monitorStub()); + +const updateMonitorFn = mock( + (_params: { config: Config; id: number }): Promise => Promise.resolve(), +); + +const getMonitorFn = mock( + (_params: { config: Config; id: number }): Promise => + Promise.resolve(monitorStub()), +); + +const readFileFn = mock((_path: string): string => STUB_FILE); + +let update: (typeof import("./update"))["update"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, + updateMonitor: updateMonitorFn, + getMonitor: getMonitorFn, + readFile: readFileFn, +} as Parameters<(typeof import("./update"))["update"]>[2]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + const mod = await import("./update.ts"); + update = mod.update; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor update — ID validation", () => { + beforeEach(() => { + updateMonitorFn.mockClear(); + getMonitorFn.mockClear(); + }); + + test("non-integer ID exits with code 1 and does not call updateMonitor", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await update.call(context, { file: "/monitor.json" }, "abc", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + expect(updateMonitorFn).not.toHaveBeenCalled(); + }); + + test("float ID exits with code 1", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await update.call(context, { file: "/monitor.json" }, "1.5", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + }); + + test("ID exceeding MAX_SAFE_INTEGER exits with code 1", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await update.call( + context, + { file: "/monitor.json" }, + String(Number.MAX_SAFE_INTEGER + 1), + deps, + ); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + expect(updateMonitorFn).not.toHaveBeenCalled(); + }); +}); + +describe("monitor update — API forwarding", () => { + beforeEach(() => { + updateMonitorFn.mockClear(); + getMonitorFn.mockClear(); + readFileFn.mockClear(); + }); + + test("passes numeric ID and patchable fields from --file to updateMonitor", async () => { + const { context } = createMockContext(); + await update.call( + context, + { file: "/monitor.json" }, + TEST_MONITOR_ID, + deps, + ); + expect(updateMonitorFn).toHaveBeenCalledTimes(1); + const call = updateMonitorFn.mock.calls[0]![0] as unknown as { + id: number; + name: string; + ruleKind: string; + }; + expect(call.id).toBe(Number(TEST_MONITOR_ID)); + expect(call.name).toBe("Joe - Test Monitor"); + expect(call.ruleKind).toBe(MonitorV2RuleKind.Count); + }); + + test("excludes effectiveScheduling from patch and uses numeric id", async () => { + readFileFn.mockImplementationOnce(() => + JSON.stringify({ + ...monitorStub(), + effectiveScheduling: { type: "Default" }, + }), + ); + const { context } = createMockContext(); + await update.call( + context, + { file: "/monitor.json" }, + TEST_MONITOR_ID, + deps, + ); + const call = updateMonitorFn.mock.calls[0]![0] as unknown as { + id: unknown; + effectiveScheduling?: unknown; + }; + expect(call).not.toHaveProperty("effectiveScheduling"); + expect(call.id).toBe(Number(TEST_MONITOR_ID)); + }); + + test("excludes fields absent from the file (undefined) from patch", async () => { + readFileFn.mockImplementationOnce(() => + JSON.stringify({ name: "Only Name", ruleKind: MonitorV2RuleKind.Count }), + ); + const { context } = createMockContext(); + await update.call( + context, + { file: "/monitor.json" }, + TEST_MONITOR_ID, + deps, + ); + const call = updateMonitorFn.mock.calls[0]![0] as unknown as object; + expect(call).not.toHaveProperty("description"); + expect(call).not.toHaveProperty("definition"); + expect(call).not.toHaveProperty("actionRules"); + }); + + test("does not call getMonitor when --json is not set", async () => { + const { context } = createMockContext(); + await update.call( + context, + { file: "/monitor.json" }, + TEST_MONITOR_ID, + deps, + ); + expect(getMonitorFn).not.toHaveBeenCalled(); + }); + + test("calls getMonitor with numeric ID when --json is set", async () => { + const { context } = createMockContext(); + await update.call( + context, + { file: "/monitor.json", json: true }, + TEST_MONITOR_ID, + deps, + ); + expect(getMonitorFn).toHaveBeenCalledTimes(1); + expect(getMonitorFn.mock.calls[0]![0]).toMatchObject({ + id: Number(TEST_MONITOR_ID), + }); + }); +}); + +describe("monitor update — output", () => { + beforeEach(() => { + updateMonitorFn.mockClear(); + getMonitorFn.mockClear(); + }); + + test("prints success message with monitor ID", async () => { + const { context, stdout } = createMockContext(); + await update.call( + context, + { file: "/monitor.json" }, + TEST_MONITOR_ID, + deps, + ); + expect(stdout.join("")).toContain(TEST_MONITOR_ID); + expect(stdout.join("")).toContain("updated"); + }); + + test("--json writes the updated monitor", async () => { + getMonitorFn.mockImplementationOnce(() => + Promise.resolve(monitorStub({ name: "Updated Name" })), + ); + const { context, stdout } = createMockContext(); + await update.call( + context, + { file: "/monitor.json", json: true }, + TEST_MONITOR_ID, + deps, + ); + const result = JSON.parse(stdout.join("")) as MonitorV2; + expect(result).toMatchObject({ id: TEST_MONITOR_ID, name: "Updated Name" }); + }); +}); + +describe("monitor update — error handling", () => { + beforeEach(() => { + updateMonitorFn.mockClear(); + loadConfigFn.mockClear(); + readFileFn.mockClear(); + }); + + test("updateMonitor rejection exits with code 1 and prints to stderr", async () => { + updateMonitorFn.mockImplementationOnce(() => + Promise.reject(new Error("network failure")), + ); + const { context, stderr, getExitCode } = createMockContext(); + try { + await update.call( + context, + { file: "/monitor.json" }, + TEST_MONITOR_ID, + deps, + ); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); + + test("loadConfig error exits with code 1 and prints to stderr", async () => { + loadConfigFn.mockImplementationOnce((): never => { + throw new Error("no config file found"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await update.call( + context, + { file: "/monitor.json" }, + TEST_MONITOR_ID, + deps, + ); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); + + test("readFile throwing exits with code 1", async () => { + readFileFn.mockImplementationOnce((): never => { + throw new Error("ENOENT: no such file or directory"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await update.call( + context, + { file: "/monitor.json" }, + TEST_MONITOR_ID, + deps, + ); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); + + test("invalid JSON in --file exits with code 1 and mentions the flag", async () => { + readFileFn.mockImplementationOnce(() => "{ bad json }"); + const { context, stderr, getExitCode } = createMockContext(); + try { + await update.call( + context, + { file: "/monitor.json" }, + TEST_MONITOR_ID, + deps, + ); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("--file"); + }); + + test("getMonitor returning null after update exits with code 1", async () => { + getMonitorFn.mockImplementationOnce(() => Promise.resolve(null)); + const { context, stderr, getExitCode } = createMockContext(); + try { + await update.call( + context, + { file: "/monitor.json", json: true }, + TEST_MONITOR_ID, + deps, + ); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/monitor/update.ts b/src/commands/monitor/update.ts new file mode 100644 index 0000000..82d9c36 --- /dev/null +++ b/src/commands/monitor/update.ts @@ -0,0 +1,141 @@ +import { defineCommand } from "../../lib/stricli-wrappers"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { LocalContext } from "../../context"; +import { updateMonitor } from "../../rest/monitor/update-monitor"; +import { getMonitor } from "../../rest/monitor/get-monitor"; +import { + MonitorV2RuleKind, + type MonitorV2ActionRule, + type MonitorV2Definition, + type MonitorV2PatchRequest, +} from "../../rest/generated"; +import { loadConfig } from "../../lib/config"; +import { formatApiError } from "../../lib/format-error"; +import { muteStatusWriter } from "../../lib/writer"; +import { parseMonitorId, parseJsonFile } from "../../lib/parsers"; + +interface UpdateMonitorFlags { + file: string; + json?: boolean; +} + +export interface UpdateMonitorDeps { + loadConfig?: typeof loadConfig; + updateMonitor?: typeof updateMonitor; + getMonitor?: typeof getMonitor; + readFile?: (path: string) => string; +} + +export async function update( + this: LocalContext, + flags: UpdateMonitorFlags, + monitorId: string, + deps: UpdateMonitorDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + updateMonitor: updateMonitorImpl = updateMonitor, + getMonitor: getMonitorImpl = getMonitor, + readFile: readFileImpl = (p) => readFileSync(p, "utf-8"), + } = deps; + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { muted: flags.json === true }); + + let id: number; + try { + id = parseMonitorId(monitorId); + } catch { + writer.error( + `Invalid monitor ID: "${monitorId}". Must be a positive integer.`, + ); + process.exit(1); + return; + } + + try { + const config = loadConfigImpl(); + + writer.info("Updating monitor..."); + + const raw = readFileImpl(resolve(flags.file)); + const parsed = parseJsonFile>(raw, "--file"); + + const { name, description, ruleKind, definition, actionRules, disabled } = + parsed as { + name?: string; + description?: string; + ruleKind?: MonitorV2RuleKind; + definition?: MonitorV2Definition; + actionRules?: MonitorV2ActionRule[]; + disabled?: boolean; + }; + + const patch = Object.fromEntries( + Object.entries({ + name, + description, + ruleKind, + definition, + actionRules, + disabled, + }).filter(([, v]) => v !== undefined), + ) as MonitorV2PatchRequest; + + await updateMonitorImpl({ config, id, ...patch }); + + if (flags.json) { + const result = await getMonitorImpl({ config, id }); + if (!result) { + throw new Error(`Monitor ${monitorId} not found after update`); + } + writer.write(JSON.stringify(result, null, 2)); + return; + } + + writer.success(`Monitor ${monitorId} updated.`); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const updateCommand = defineCommand({ + experimental: true, + loader: async () => update, + parameters: { + positional: { + kind: "tuple", + parameters: [{ brief: "Monitor ID", parse: String }], + }, + flags: { + file: { + kind: "parsed", + parse: String, + brief: + "Path to a full monitor JSON file (e.g. from `monitor view --json`)", + optional: false, + }, + json: { + kind: "boolean", + brief: "Output the updated monitor as JSON", + optional: true, + }, + }, + aliases: {}, + }, + docs: { + brief: "Update a monitor", + fullDescription: [ + "Update a monitor from a JSON file.", + "", + "Edit flow:", + " observe monitor view --json > monitor.json", + " # edit monitor.json", + " observe monitor update --file monitor.json", + "", + "Patchable fields: name, description, ruleKind, definition, actionRules, disabled.", + "Read-only fields (id, effectiveScheduling) are ignored.", + ].join("\n"), + }, +}); diff --git a/src/commands/monitor/view.test.ts b/src/commands/monitor/view.test.ts new file mode 100644 index 0000000..455e8b7 --- /dev/null +++ b/src/commands/monitor/view.test.ts @@ -0,0 +1,235 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import type { LocalContext } from "../../context"; +import type { Config } from "../../lib/config"; +import { MonitorV2RuleKind, type MonitorV2 } from "../../rest/generated"; +import { createWriter } from "../../lib/writer"; + +const loadConfigFn = mock( + (): Config => ({ + customerId: "test-customer", + token: "test-token", + domain: "observeinc.com", + }), +); + +function monitorStub( + id: string, + overrides: Partial = {}, +): MonitorV2 { + return { + id, + name: "Test Monitor", + ruleKind: MonitorV2RuleKind.Count, + definition: {} as MonitorV2["definition"], + ...overrides, + }; +} + +const getMonitorFn = mock( + (_params: { config: Config; id: number }): Promise => + Promise.resolve(monitorStub("42")), +); + +let view: (typeof import("./view"))["view"]; + +let previousNoColor: string | undefined; +let previousForceColor: string | undefined; + +const deps = { + loadConfig: loadConfigFn, + getMonitor: getMonitorFn, +} as Parameters<(typeof import("./view"))["view"]>[2]; + +beforeAll(async () => { + previousNoColor = process.env.NO_COLOR; + previousForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + + const mod = await import("./view.ts"); + view = mod.view; +}); + +afterAll(() => { + mock.restore(); + if (previousNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previousNoColor; + } + if (previousForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = previousForceColor; + } +}); + +function createMockContext() { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | undefined; + + const processMock = { + stdout: { + write: (msg: string) => { + stdout.push(msg); + return true; + }, + }, + stderr: { + write: (msg: string) => { + stderr.push(msg); + return true; + }, + }, + exit: (code?: number) => { + exitCode = code ?? 0; + throw new Error("process.exit"); + }, + }; + + const context = { + process: processMock, + writer: createWriter({ process: processMock }), + } as unknown as LocalContext; + + return { context, stdout, stderr, getExitCode: () => exitCode }; +} + +describe("monitor view — ID validation", () => { + beforeEach(() => getMonitorFn.mockClear()); + + test("ID exceeding MAX_SAFE_INTEGER exits with code 1", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await view.call(context, {}, String(Number.MAX_SAFE_INTEGER + 1), deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + expect(getMonitorFn).not.toHaveBeenCalled(); + }); + + test("non-integer ID exits with code 1", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await view.call(context, {}, "abc", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + expect(getMonitorFn).not.toHaveBeenCalled(); + }); + + test("float ID exits with code 1", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await view.call(context, {}, "1.5", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Invalid monitor ID"); + }); +}); + +describe("monitor view — not found", () => { + beforeEach(() => getMonitorFn.mockClear()); + + test("exits with code 1 and includes ID in error when monitor not found", async () => { + getMonitorFn.mockImplementationOnce(() => Promise.resolve(null)); + const { context, stderr, getExitCode } = createMockContext(); + try { + await view.call(context, {}, "99999", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("99999"); + }); +}); + +describe("monitor view — API forwarding", () => { + beforeEach(() => getMonitorFn.mockClear()); + + test("passes numeric ID to getMonitor", async () => { + const { context } = createMockContext(); + await view.call(context, { json: true }, "42", deps); + expect(getMonitorFn).toHaveBeenCalledTimes(1); + expect(getMonitorFn.mock.calls[0]![0]).toMatchObject({ id: 42 }); + }); +}); + +describe("monitor view — output", () => { + beforeEach(() => getMonitorFn.mockClear()); + + test("JSON output matches MonitorV2 shape", async () => { + getMonitorFn.mockImplementationOnce(() => + Promise.resolve( + monitorStub("42", { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Threshold, + }), + ), + ); + const { context, stdout } = createMockContext(); + await view.call(context, { json: true }, "42", deps); + const result = JSON.parse(stdout.join("")) as MonitorV2; + expect(result).toMatchObject({ + id: "42", + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Threshold, + }); + }); +}); + +describe("monitor view — error handling", () => { + beforeEach(() => { + getMonitorFn.mockClear(); + loadConfigFn.mockClear(); + }); + + test("loadConfig error exits with code 1 and prints to stderr", async () => { + loadConfigFn.mockImplementationOnce((): never => { + throw new Error("no config file found"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await view.call(context, {}, "42", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); + + test("API error exits with code 1 and prints to stderr", async () => { + getMonitorFn.mockImplementationOnce(() => + Promise.reject(new Error("network failure")), + ); + const { context, stderr, getExitCode } = createMockContext(); + try { + await view.call(context, { json: true }, "42", deps); + throw new Error("expected process.exit"); + } catch (error) { + expect((error as Error).message).toBe("process.exit"); + } + expect(getExitCode()).toBe(1); + expect(stderr.join("")).toContain("Error"); + }); +}); diff --git a/src/commands/monitor/view.ts b/src/commands/monitor/view.ts new file mode 100644 index 0000000..058cfb8 --- /dev/null +++ b/src/commands/monitor/view.ts @@ -0,0 +1,157 @@ +import { defineCommand } from "../../lib/stricli-wrappers"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { getMonitor } from "../../rest/monitor/get-monitor"; +import { loadConfig } from "../../lib/config"; +import { formatApiError } from "../../lib/format-error"; +import { muteStatusWriter } from "../../lib/writer"; +import { renderObject } from "../../lib/formatters/object"; +import { renderAsCSV } from "../../lib/formatters/csv"; +import { parseMonitorId } from "../../lib/parsers"; +import { ruleKindColor } from "./monitor-utils"; + +type OutputFormat = "json" | "csv"; + +interface ViewMonitorFlags { + format?: OutputFormat; + json?: boolean; +} + +export interface ViewMonitorDeps { + loadConfig?: typeof loadConfig; + getMonitor?: typeof getMonitor; +} + +export async function view( + this: LocalContext, + flags: ViewMonitorFlags, + monitorId: string, + deps: ViewMonitorDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + getMonitor: getMonitorImpl = getMonitor, + } = deps; + const format = flags.json ? ("json" as const) : flags.format; + const { process, writer: _writer } = this; + const writer = muteStatusWriter(_writer, { + muted: format === "json" || format === "csv", + }); + + let id: number; + try { + id = parseMonitorId(monitorId); + } catch { + writer.error( + `Invalid monitor ID: "${monitorId}". Must be a positive integer.`, + ); + process.exit(1); + return; + } + + try { + const config = loadConfigImpl(); + + writer.info("Fetching monitor..."); + + const monitor = await getMonitorImpl({ config, id }); + + if (!monitor) { + writer.error(`Monitor not found: ${monitorId}`); + process.exit(1); + return; + } + + if (format === "json") { + writer.write(JSON.stringify(monitor, null, 2)); + return; + } + + if (format === "csv") { + writer.write(renderAsCSV(monitor)); + return; + } + + writer.write(""); + writer.write(chalk.bold.white(`Monitor ${monitor.id}`)); + writer.write( + ruleKindColor(monitor.ruleKind) + + (monitor.disabled ? " " + chalk.yellow("DISABLED") : ""), + ); + + const viewData = { + id: monitor.id, + name: monitor.name, + disabled: monitor.disabled ?? false, + ruleKind: monitor.ruleKind, + actionRules: (monitor.actionRules ?? []).map((r) => ({ + actionId: r.actionId, + inline: r.definition?.inline ?? false, + type: r.definition?.type ?? "-", + })), + scheduling: monitor.effectiveScheduling + ? { + transform: monitor.effectiveScheduling.transform + ? "transform-driven" + : undefined, + scheduled: + monitor.effectiveScheduling.scheduled?.cronConfig ?? undefined, + } + : undefined, + }; + + renderObject(viewData, (text) => writer.write(text)); + + writer.write(""); + writer.write(chalk.bold("Definition:")); + writer.write(JSON.stringify(stripLayout(monitor.definition), null, 2)); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +function stripLayout(obj: unknown): unknown { + if (Array.isArray(obj)) return obj.map(stripLayout); + if (obj !== null && typeof obj === "object") { + return Object.fromEntries( + Object.entries(obj as Record) + .filter(([key]) => key !== "layout") + .map(([key, val]) => [key, stripLayout(val)]), + ); + } + return obj; +} + +export const viewCommand = defineCommand({ + experimental: true, + loader: async () => view, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Monitor ID", + parse: String, + }, + ], + }, + flags: { + format: { + kind: "enum", + values: ["json", "csv"], + brief: "Output format (json, csv)", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output as JSON (shorthand for --format=json)", + optional: true, + }, + }, + aliases: {}, + }, + docs: { + brief: "View details of a monitor", + }, +}); diff --git a/src/lib/parsers.test.ts b/src/lib/parsers.test.ts index f39977e..26f3993 100644 --- a/src/lib/parsers.test.ts +++ b/src/lib/parsers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { parseNonNegativeInt } from "./parsers"; +import { parseNonNegativeInt, parseMonitorId } from "./parsers"; describe("parseNonNegativeInt", () => { test("parses zero and positive integers", () => { @@ -13,3 +13,43 @@ describe("parseNonNegativeInt", () => { expect(() => parseNonNegativeInt("foo")).toThrow(); }); }); + +describe("parseMonitorId", () => { + test("parses valid positive integers", () => { + expect(parseMonitorId("1")).toBe(1); + expect(parseMonitorId("42")).toBe(42); + expect(parseMonitorId("41076897")).toBe(41076897); + expect(parseMonitorId(String(Number.MAX_SAFE_INTEGER))).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); + + test("rejects zero", () => { + expect(() => parseMonitorId("0")).toThrow(); + }); + + test("rejects negative integers", () => { + expect(() => parseMonitorId("-1")).toThrow(); + expect(() => parseMonitorId("-100")).toThrow(); + }); + + test("rejects non-integer floats", () => { + expect(() => parseMonitorId("1.5")).toThrow(); + expect(() => parseMonitorId("3.14")).toThrow(); + }); + + test("rejects non-numeric strings", () => { + expect(() => parseMonitorId("abc")).toThrow(); + expect(() => parseMonitorId("")).toThrow(); + }); + + test("rejects integers exceeding MAX_SAFE_INTEGER", () => { + expect(() => parseMonitorId(String(Number.MAX_SAFE_INTEGER + 1))).toThrow(); + }); + + test("rejects whitespace-padded values", () => { + expect(() => parseMonitorId(" 42")).toThrow(); + expect(() => parseMonitorId("42 ")).toThrow(); + expect(() => parseMonitorId(" 42 ")).toThrow(); + }); +}); diff --git a/src/lib/parsers.ts b/src/lib/parsers.ts index d0f8757..8a068e1 100644 --- a/src/lib/parsers.ts +++ b/src/lib/parsers.ts @@ -5,3 +5,31 @@ export function parseNonNegativeInt(value: string): number { } return num; } + +export function parseMonitorId(value: string): number { + if (value !== value.trim()) { + throw new Error("Monitor ID must be a positive integer"); + } + const num = Number(value); + if ( + isNaN(num) || + !Number.isInteger(num) || + num <= 0 || + num > Number.MAX_SAFE_INTEGER + ) { + throw new Error("Monitor ID must be a positive integer"); + } + return num; +} + +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +export function parseJsonFile(content: string, flag: string): T { + try { + return JSON.parse(content) as T; + } catch (cause) { + throw new Error( + `Failed to parse ${flag}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ); + } +} diff --git a/src/rest/api-config.ts b/src/rest/api-config.ts new file mode 100644 index 0000000..fedc8e2 --- /dev/null +++ b/src/rest/api-config.ts @@ -0,0 +1,11 @@ +import { Configuration } from "./generated"; +import { getApiBaseUrl, type Config } from "../lib/config"; +import { observeApiHeaders } from "../lib/user-agent"; + +export function createApiConfiguration(config: Config) { + return new Configuration({ + basePath: getApiBaseUrl(config), + accessToken: async () => `${config.customerId} ${config.token}`, + headers: observeApiHeaders(), + }); +} diff --git a/src/rest/client.ts b/src/rest/client.ts index 0a663bc..a825de6 100644 --- a/src/rest/client.ts +++ b/src/rest/client.ts @@ -1,35 +1,29 @@ import { SkillsApi, AlertApi, - Configuration, DatasetApi, ExportApi, + MonitorApi, V2KnowledgeGraphApi, } from "./generated"; -import { getApiBaseUrl, type Config } from "../lib/config"; -import { observeApiHeaders } from "../lib/user-agent"; - -function createConfiguration(config: Config) { - return new Configuration({ - basePath: getApiBaseUrl(config), - accessToken: async () => `${config.customerId} ${config.token}`, - headers: observeApiHeaders(), - }); -} +import type { Config } from "../lib/config"; +import { createApiConfiguration } from "./api-config"; export class ObserveRestSDK { public exportApi: ExportApi; public datasetApi: DatasetApi; public alertApi: AlertApi; + public monitorApi: MonitorApi; public knowledgeGraphApi: V2KnowledgeGraphApi; public skillsApi: SkillsApi; constructor(_config: Config) { - const config = createConfiguration(_config); + const config = createApiConfiguration(_config); this.exportApi = new ExportApi(config); this.datasetApi = new DatasetApi(config); this.alertApi = new AlertApi(config); + this.monitorApi = new MonitorApi(config); this.knowledgeGraphApi = new V2KnowledgeGraphApi(config); this.skillsApi = new SkillsApi(config); } diff --git a/src/rest/monitor/create-monitor.ts b/src/rest/monitor/create-monitor.ts new file mode 100644 index 0000000..2c41880 --- /dev/null +++ b/src/rest/monitor/create-monitor.ts @@ -0,0 +1,11 @@ +import type { Config } from "../../lib/config"; +import { ObserveRestSDK } from "../client"; +import type { MonitorApiCreateMonitorRequest, MonitorV2 } from "../generated"; + +export async function createMonitor({ + config, + ...params +}: { config: Config } & MonitorApiCreateMonitorRequest): Promise { + const sdk = new ObserveRestSDK(config); + return sdk.monitorApi.createMonitor(params); +} diff --git a/src/rest/monitor/delete-monitor.ts b/src/rest/monitor/delete-monitor.ts new file mode 100644 index 0000000..f0d6aa5 --- /dev/null +++ b/src/rest/monitor/delete-monitor.ts @@ -0,0 +1,13 @@ +import type { Config } from "../../lib/config"; +import { ObserveRestSDK } from "../client"; + +export async function deleteMonitor({ + config, + id, +}: { + config: Config; + id: number; +}): Promise { + const sdk = new ObserveRestSDK(config); + await sdk.monitorApi.deleteMonitor({ id }); +} diff --git a/src/rest/monitor/get-monitor.ts b/src/rest/monitor/get-monitor.ts new file mode 100644 index 0000000..beb11c9 --- /dev/null +++ b/src/rest/monitor/get-monitor.ts @@ -0,0 +1,21 @@ +import type { Config } from "../../lib/config"; +import { ObserveRestSDK } from "../client"; +import { type MonitorV2, ResponseError } from "../generated"; + +export async function getMonitor({ + config, + id, +}: { + config: Config; + id: number; +}): Promise { + const sdk = new ObserveRestSDK(config); + try { + return await sdk.monitorApi.getMonitor({ id }); + } catch (error) { + if (error instanceof ResponseError && error.response.status === 404) { + return null; + } + throw error; + } +} diff --git a/src/rest/monitor/list-monitors.ts b/src/rest/monitor/list-monitors.ts new file mode 100644 index 0000000..32cb63c --- /dev/null +++ b/src/rest/monitor/list-monitors.ts @@ -0,0 +1,11 @@ +import type { Config } from "../../lib/config"; +import { ObserveRestSDK } from "../client"; +import type { MonitorApiListMonitorsRequest } from "../generated"; + +export async function listMonitors({ + config, + ...params +}: { config: Config } & MonitorApiListMonitorsRequest) { + const sdk = new ObserveRestSDK(config); + return sdk.monitorApi.listMonitors(params); +} diff --git a/src/rest/monitor/update-monitor.ts b/src/rest/monitor/update-monitor.ts new file mode 100644 index 0000000..b8db73e --- /dev/null +++ b/src/rest/monitor/update-monitor.ts @@ -0,0 +1,12 @@ +import type { Config } from "../../lib/config"; +import { ObserveRestSDK } from "../client"; +import type { MonitorV2PatchRequest } from "../generated"; + +export async function updateMonitor({ + config, + id, + ...patch +}: { config: Config; id: number } & MonitorV2PatchRequest): Promise { + const sdk = new ObserveRestSDK(config); + await sdk.monitorApi.updateMonitor({ id, monitorV2PatchRequest: patch }); +}