From 12c9abae3b7cd841782c2bd812519f879e019c1a Mon Sep 17 00:00:00 2001 From: Joe Darling Date: Wed, 24 Jun 2026 23:04:10 +0000 Subject: [PATCH 01/13] Implemented the Monitorv2 crud into cli --- README.md | 8 + skills/observe-cli/SKILL.md | 35 +- src/app.ts | 2 + src/commands/monitor/create.test.ts | 440 ++++++++++++++++++++++++++ src/commands/monitor/create.ts | 137 ++++++++ src/commands/monitor/delete.test.ts | 195 ++++++++++++ src/commands/monitor/delete.ts | 66 ++++ src/commands/monitor/disable.test.ts | 265 ++++++++++++++++ src/commands/monitor/disable.ts | 90 ++++++ src/commands/monitor/enable.test.ts | 265 ++++++++++++++++ src/commands/monitor/enable.ts | 90 ++++++ src/commands/monitor/index.ts | 35 ++ src/commands/monitor/list.test.ts | 258 +++++++++++++++ src/commands/monitor/list.ts | 257 +++++++++++++++ src/commands/monitor/update.test.ts | 359 +++++++++++++++++++++ src/commands/monitor/update.ts | 167 ++++++++++ src/commands/monitor/view.test.ts | 229 ++++++++++++++ src/commands/monitor/view.ts | 151 +++++++++ src/commands/skill/list.ts | 10 +- src/commands/skill/view.ts | 3 +- src/kg/search-metrics-kg.ts | 3 +- src/lib/kg-search.ts | 8 +- src/lib/parsers.test.ts | 40 ++- src/lib/parsers.ts | 21 ++ src/rest/client.ts | 9 +- src/rest/monitor/create-monitor.ts | 11 + src/rest/monitor/delete-monitor.ts | 13 + src/rest/monitor/get-monitor.ts | 22 ++ src/rest/monitor/list-monitors.ts | 11 + src/rest/monitor/update-monitor.ts | 12 + src/rest/skill/get-skill.ts | 25 +- src/rest/skill/list-skills.ts | 15 +- src/rest/tag-key/list-tag-keys.ts | 71 +---- src/rest/tag-value/list-tag-values.ts | 63 +--- 34 files changed, 3207 insertions(+), 179 deletions(-) create mode 100644 src/commands/monitor/create.test.ts create mode 100644 src/commands/monitor/create.ts create mode 100644 src/commands/monitor/delete.test.ts create mode 100644 src/commands/monitor/delete.ts create mode 100644 src/commands/monitor/disable.test.ts create mode 100644 src/commands/monitor/disable.ts create mode 100644 src/commands/monitor/enable.test.ts create mode 100644 src/commands/monitor/enable.ts create mode 100644 src/commands/monitor/index.ts create mode 100644 src/commands/monitor/list.test.ts create mode 100644 src/commands/monitor/list.ts create mode 100644 src/commands/monitor/update.test.ts create mode 100644 src/commands/monitor/update.ts create mode 100644 src/commands/monitor/view.test.ts create mode 100644 src/commands/monitor/view.ts create mode 100644 src/rest/monitor/create-monitor.ts create mode 100644 src/rest/monitor/delete-monitor.ts create mode 100644 src/rest/monitor/get-monitor.ts create mode 100644 src/rest/monitor/list-monitors.ts create mode 100644 src/rest/monitor/update-monitor.ts diff --git a/README.md b/README.md index a7f6baa..9cc2ffc 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Command line interface for [Observe Inc](https://www.observeinc.com). - **OPAL Query Execution** - Run OPAL queries directly from your terminal with schema-aware table output. - **AI Agent Skills** - List and view reusable AI-agent instruction documents stored in Observe. - **Alert Monitoring** - List and view alerts with severity filtering and active-only views. +- **Monitor Management** - Full CRUD for MonitorV2: create, list, view, update, delete, enable, and disable monitors. - **Content Packs** - Install and view Host Explorer, Kubernetes Explorer, and Trace Explorer content. - **Ingest Token Management** - Full CRUD for ingest tokens with datastream association. - **Data Integrations** - Create data connections and datasources (AWS, Kubernetes, host) and generate CloudFormation quick-create URLs for AWS filedrop deployments. @@ -53,6 +54,13 @@ To update installed skills after edits in this repo, run `npx skills update`. | `observe skill view` | View skill details and content | | `observe alert list` | List alerts with severity and status filtering | | `observe alert view` | View full alert details | +| `observe monitor list` | List monitors with optional name search | +| `observe monitor view` | View monitor details and definition | +| `observe monitor create` | Create a new monitor from a definition file | +| `observe monitor update` | Update a monitor's name, description, or definition | +| `observe monitor delete` | Delete a monitor | +| `observe monitor enable` | Enable a monitor | +| `observe monitor disable` | Disable a monitor | | `observe content host install` | Install Host Explorer content | | `observe content host view` | View Host Explorer content | | `observe content kubernetes install` | Install Kubernetes Explorer content | diff --git a/skills/observe-cli/SKILL.md b/skills/observe-cli/SKILL.md index abf2943..7d50143 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,39 @@ 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 --name "My Monitor" \ + --rule-kind Count \ + --definition-file ./definition.json --json # create; --json returns the created object + +observe monitor update --name "New Name" --json # rename; --json returns the updated object +observe monitor update --definition-file ./def.json --json + +observe monitor enable # un-suppress a monitor +observe monitor disable # suppress without deleting + +observe monitor delete # permanent removal +``` + +Notes: + +- `--definition-file` accepts a path to a JSON file containing a + `MonitorV2Definition` object (OPAL `inputQuery` + alert `rules`). +- `--action-rules-file` (create/update) accepts a path to a JSON file + containing an array of `MonitorV2ActionRule` objects; omit to leave + action rules unchanged. +- `enable`/`disable` are a boolean `disabled` patch under the hood — + use them instead of `update --disabled` 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 a51b600..1195b5a 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"; @@ -30,6 +31,7 @@ export const routes = buildRouteMap({ 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..53fc91b --- /dev/null +++ b/src/commands/monitor/create.test.ts @@ -0,0 +1,440 @@ +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: { stages: [] } as MonitorV2Definition["inputQuery"], + rules: [], +}; + +function monitorStub(overrides: Partial = {}): MonitorV2 { + return { + id: "99001", + name: "New 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 => JSON.stringify(STUB_DEFINITION)); + +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"]>[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("./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 parsed definition to createMonitor", async () => { + const { context } = createMockContext(); + await create.call( + context, + { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definitionFile: "/fake/definition.json", + }, + deps, + ); + expect(createMonitorFn).toHaveBeenCalledTimes(1); + const call = createMonitorFn.mock.calls[0][0] 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 definition from definitionFile path", async () => { + const { context } = createMockContext(); + await create.call( + context, + { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Threshold, + definitionFile: "/path/to/def.json", + }, + deps, + ); + expect(readFileFn).toHaveBeenCalledWith("/path/to/def.json"); + }); + + test("includes actionRules when --action-rules-file is provided", async () => { + const actionRules = [{ actionId: "act-1" }]; + readFileFn.mockImplementationOnce(() => JSON.stringify(STUB_DEFINITION)); + readFileFn.mockImplementationOnce(() => JSON.stringify(actionRules)); + const { context } = createMockContext(); + await create.call( + context, + { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definitionFile: "/def.json", + actionRulesFile: "/rules.json", + }, + deps, + ); + const call = createMonitorFn.mock.calls[0][0] as { monitorV2: { actionRules: unknown[] } }; + expect(call.monitorV2.actionRules).toEqual(actionRules); + }); + + test("omits actionRules when --action-rules-file is not provided", async () => { + const { context } = createMockContext(); + await create.call( + context, + { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definitionFile: "/def.json", + }, + deps, + ); + const call = createMonitorFn.mock.calls[0][0] 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, + { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definitionFile: "/def.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, + { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definitionFile: "/def.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 — 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, + { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definitionFile: "/def.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, + { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definitionFile: "/def.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 for --definition-file exits with code 1", async () => { + readFileFn.mockImplementationOnce((): never => { + throw new Error("ENOENT: no such file or directory, open '/def.json'"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call( + context, + { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definitionFile: "/def.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("invalid JSON in --definition-file exits with code 1 and mentions the flag", async () => { + readFileFn.mockImplementationOnce(() => "{ invalid json {{"); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call( + context, + { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definitionFile: "/def.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-file"); + }); + + test("readFile throwing for --action-rules-file exits with code 1", async () => { + readFileFn.mockImplementationOnce(() => JSON.stringify(STUB_DEFINITION)); + readFileFn.mockImplementationOnce((): never => { + throw new Error("ENOENT: no such file or directory, open '/rules.json'"); + }); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call( + context, + { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definitionFile: "/def.json", + actionRulesFile: "/rules.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("invalid JSON in --action-rules-file exits with code 1 and mentions the flag", async () => { + readFileFn.mockImplementationOnce(() => JSON.stringify(STUB_DEFINITION)); + readFileFn.mockImplementationOnce(() => "not json at all"); + const { context, stderr, getExitCode } = createMockContext(); + try { + await create.call( + context, + { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definitionFile: "/def.json", + actionRulesFile: "/rules.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("--action-rules-file"); + }); + + 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, + { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definitionFile: "/def.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, + { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Count, + definitionFile: "/def.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..f790a3a --- /dev/null +++ b/src/commands/monitor/create.ts @@ -0,0 +1,137 @@ +import { buildCommand } from "@stricli/core"; +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 { + name: string; + ruleKind: MonitorV2RuleKind; + definitionFile: string; + actionRulesFile?: 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 definition = parseJsonFile( + readFileImpl(resolve(flags.definitionFile)), + "--definition-file", + ); + + const actionRules = flags.actionRulesFile + ? parseJsonFile( + readFileImpl(resolve(flags.actionRulesFile)), + "--action-rules-file", + ) + : undefined; + + const created = await createMonitorImpl({ + config, + monitorV2: { + name: flags.name, + ruleKind: flags.ruleKind, + definition, + ...(actionRules != null && { actionRules }), + }, + }); + + let createdId: number; + try { + createdId = parseMonitorId(String(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 = buildCommand({ + loader: async () => + create as (this: LocalContext, flags: CreateMonitorFlags) => Promise, + parameters: { + positional: { kind: "tuple", parameters: [] }, + flags: { + name: { + kind: "parsed", + parse: String, + brief: "Monitor name", + optional: false, + }, + ruleKind: { + kind: "enum", + values: ["Count", "Threshold", "Promote"], + brief: "Alert rule kind (Count, Threshold, Promote)", + optional: false, + }, + definitionFile: { + kind: "parsed", + parse: String, + brief: "Path to JSON file containing the MonitorV2Definition", + optional: false, + }, + actionRulesFile: { + kind: "parsed", + parse: String, + brief: "Path to JSON file containing Array", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output the created monitor as JSON", + optional: true, + }, + }, + aliases: {}, + }, + docs: { + brief: "Create a new monitor", + }, +}); diff --git a/src/commands/monitor/delete.test.ts b/src/commands/monitor/delete.test.ts new file mode 100644 index 0000000..2ae2a2f --- /dev/null +++ b/src/commands/monitor/delete.test.ts @@ -0,0 +1,195 @@ +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"]>[3]; + +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 — API forwarding", () => { + beforeEach(() => deleteMonitorFn.mockClear()); + + test("passes the correct numeric ID to deleteMonitor", async () => { + const { context } = createMockContext(); + await deleteMonitorCommand.call(context, {}, 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, {}, 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, {}, 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, {}, 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..c2bc030 --- /dev/null +++ b/src/commands/monitor/delete.ts @@ -0,0 +1,66 @@ +import { buildCommand } from "@stricli/core"; +import type { LocalContext } from "../../context"; +import { deleteMonitor } from "../../rest/monitor/delete-monitor"; +import { loadConfig } from "../../lib/config"; +import { formatApiError } from "../../lib/format-error"; +import { parseMonitorId } from "../../lib/parsers"; + +export interface DeleteMonitorDeps { + loadConfig?: typeof loadConfig; + deleteMonitor?: typeof deleteMonitor; +} + +export async function deleteMonitorCommand( + this: LocalContext, + _flags: Record, + monitorId: string, + deps: DeleteMonitorDeps = {}, +): Promise { + const { + loadConfig: loadConfigImpl = loadConfig, + deleteMonitor: deleteMonitorImpl = deleteMonitor, + } = 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(); + + 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 = buildCommand({ + loader: async () => + deleteMonitorCommand as ( + this: LocalContext, + flags: Record, + monitorId: string, + ) => Promise, + parameters: { + positional: { + kind: "tuple", + parameters: [{ brief: "Monitor ID", parse: String }], + }, + flags: {}, + 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..b9d91ff --- /dev/null +++ b/src/commands/monitor/disable.test.ts @@ -0,0 +1,265 @@ +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"]>[3]; + +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..0f9e830 --- /dev/null +++ b/src/commands/monitor/disable.ts @@ -0,0 +1,90 @@ +import { buildCommand } from "@stricli/core"; +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 = buildCommand({ + loader: async () => disable as (this: LocalContext, flags: DisableMonitorFlags, monitorId: string) => Promise, + 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..7e699af --- /dev/null +++ b/src/commands/monitor/enable.test.ts @@ -0,0 +1,265 @@ +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"]>[3]; + +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..64a9cd1 --- /dev/null +++ b/src/commands/monitor/enable.ts @@ -0,0 +1,90 @@ +import { buildCommand } from "@stricli/core"; +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 = buildCommand({ + loader: async () => enable as (this: LocalContext, flags: EnableMonitorFlags, monitorId: string) => Promise, + 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..2d70881 --- /dev/null +++ b/src/commands/monitor/index.ts @@ -0,0 +1,35 @@ +import { buildRouteMap } from "@stricli/core"; +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 = buildRouteMap({ + routes: { + list: listCommand, + view: viewCommand, + create: createCommand, + update: updateCommand, + delete: deleteCommand, + enable: enableCommand, + disable: disableCommand, + }, + docs: { + brief: "View 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..767998e --- /dev/null +++ b/src/commands/monitor/list.test.ts @@ -0,0 +1,258 @@ +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"]>[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("./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 — 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..ec32ab0 --- /dev/null +++ b/src/commands/monitor/list.ts @@ -0,0 +1,257 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { listMonitors } from "../../rest/monitor/list-monitors"; +import { + type MonitorV2Terse, + MonitorV2RuleKind, +} from "../../rest/generated"; +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"; + +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[]; +} + +const AVAILABLE_FIELDS = [ + "id", + "name", + "description", + "ruleKind", + "disabled", +] as const; + +type FieldName = (typeof AVAILABLE_FIELDS)[number]; + +const DEFAULT_FIELDS: FieldName[] = ["id", "name", "ruleKind", "disabled"]; + +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.blue(kind); + case MonitorV2RuleKind.Promote: + return chalk.magenta(kind); + default: + return chalk.dim(kind); + } +} + +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 as MonitorV2RuleKind | undefined), + }), + 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) { + monitors = monitors.filter((m) => flags.kind!.includes(m.ruleKind as MonitorV2RuleKind)); + } + + if (flags.disabled != null) { + monitors = monitors.filter((m) => (m.disabled ?? false) === flags.disabled); + } + + if (flags.sort) { + monitors = sortMonitors(monitors, flags.sort); + } + + 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 ${monitors.length} monitor(s):\n`)); + + const columns = fieldNames.map((field) => FIELD_COLUMNS[field]); + writer.write(formatTable(monitors, columns)); + } 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 = buildCommand({ + loader: async () => list as (this: LocalContext, flags: ListMonitorsFlags) => Promise, + 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, + }, + }, + aliases: { + m: "match", + s: "sort", + }, + }, + docs: { + brief: "Search and list monitors in Observe", + }, +}); diff --git a/src/commands/monitor/update.test.ts b/src/commands/monitor/update.test.ts new file mode 100644 index 0000000..315a705 --- /dev/null +++ b/src/commands/monitor/update.test.ts @@ -0,0 +1,359 @@ +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: { stages: [] } as MonitorV2Definition["inputQuery"], + rules: [], +}; + +function monitorStub(overrides: Partial = {}): MonitorV2 { + return { + id: TEST_MONITOR_ID, + name: "Joe - Test Monitor", + ruleKind: MonitorV2RuleKind.Count, + definition: STUB_DEFINITION, + ...overrides, + }; +} + +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 => JSON.stringify(STUB_DEFINITION)); + +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"]>[3]; + +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, { name: "foo" }, "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, { name: "foo" }, "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 update — no-op validation", () => { + beforeEach(() => updateMonitorFn.mockClear()); + + test("exits with code 1 when no update flags are provided", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await update.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("At least one update flag"); + expect(updateMonitorFn).not.toHaveBeenCalled(); + }); +}); + +describe("monitor update — API forwarding", () => { + beforeEach(() => { + updateMonitorFn.mockClear(); + getMonitorFn.mockClear(); + readFileFn.mockClear(); + }); + + test("passes numeric ID and name to updateMonitor", async () => { + const { context } = createMockContext(); + await update.call(context, { name: "New Name" }, TEST_MONITOR_ID, deps); + expect(updateMonitorFn).toHaveBeenCalledTimes(1); + const call = updateMonitorFn.mock.calls[0][0] as { id: number; name: string }; + expect(call.id).toBe(Number(TEST_MONITOR_ID)); + expect(call.name).toBe("New Name"); + }); + + test("only includes explicitly provided fields in the patch", async () => { + const { context } = createMockContext(); + await update.call(context, { name: "Only Name" }, TEST_MONITOR_ID, deps); + const call = updateMonitorFn.mock.calls[0][0] as object; + expect(call).not.toHaveProperty("description"); + expect(call).not.toHaveProperty("ruleKind"); + expect(call).not.toHaveProperty("definition"); + }); + + test("reads and forwards definition from --definition-file", async () => { + const { context } = createMockContext(); + await update.call( + context, + { definitionFile: "/path/to/def.json" }, + TEST_MONITOR_ID, + deps, + ); + expect(readFileFn).toHaveBeenCalledWith("/path/to/def.json"); + const call = updateMonitorFn.mock.calls[0][0] as { definition: unknown }; + expect(call.definition).toMatchObject(STUB_DEFINITION); + }); + + test("does not call getMonitor when --json is not set", async () => { + const { context } = createMockContext(); + await update.call(context, { name: "foo" }, 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, { name: "foo", 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, { name: "foo" }, 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, + { name: "Updated Name", 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 — ID validation", () => { + beforeEach(() => updateMonitorFn.mockClear()); + + test("ID exceeding MAX_SAFE_INTEGER exits with code 1", async () => { + const { context, stderr, getExitCode } = createMockContext(); + try { + await update.call(context, { name: "foo" }, 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 — 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, { name: "foo" }, 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, { name: "foo" }, 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 for --definition-file 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, + { definitionFile: "/missing.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 --definition-file exits with code 1 and mentions the flag", async () => { + readFileFn.mockImplementationOnce(() => "{ bad json }"); + const { context, stderr, getExitCode } = createMockContext(); + try { + await update.call( + context, + { definitionFile: "/bad.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("--definition-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, { name: "foo", 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..bd01e1d --- /dev/null +++ b/src/commands/monitor/update.ts @@ -0,0 +1,167 @@ +import { buildCommand } from "@stricli/core"; +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 { + name?: string; + description?: string; + ruleKind?: MonitorV2RuleKind; + definitionFile?: string; + actionRulesFile?: 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; + } + + const hasUpdate = + flags.name != null || + flags.description != null || + flags.ruleKind != null || + flags.definitionFile != null || + flags.actionRulesFile != null; + + if (!hasUpdate) { + writer.error( + "At least one update flag is required (--name, --description, --rule-kind, --definition-file, --action-rules-file).", + ); + process.exit(1); + return; + } + + try { + const config = loadConfigImpl(); + + writer.info("Updating monitor..."); + + const patch: MonitorV2PatchRequest = {}; + if (flags.name != null) patch.name = flags.name; + if (flags.description != null) patch.description = flags.description; + if (flags.ruleKind != null) patch.ruleKind = flags.ruleKind; + if (flags.definitionFile != null) { + patch.definition = parseJsonFile( + readFileImpl(resolve(flags.definitionFile)), + "--definition-file", + ); + } + if (flags.actionRulesFile != null) { + patch.actionRules = parseJsonFile( + readFileImpl(resolve(flags.actionRulesFile)), + "--action-rules-file", + ); + } + + 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 = buildCommand({ + loader: async () => + update as ( + this: LocalContext, + flags: UpdateMonitorFlags, + monitorId: string, + ) => Promise, + parameters: { + positional: { + kind: "tuple", + parameters: [{ brief: "Monitor ID", parse: String }], + }, + flags: { + name: { + kind: "parsed", + parse: String, + brief: "New monitor name", + optional: true, + }, + description: { + kind: "parsed", + parse: String, + brief: "New monitor description", + optional: true, + }, + ruleKind: { + kind: "enum", + values: ["Count", "Threshold", "Promote"], + brief: "New alert rule kind (Count, Threshold, Promote)", + optional: true, + }, + definitionFile: { + kind: "parsed", + parse: String, + brief: "Path to JSON file containing the new MonitorV2Definition", + optional: true, + }, + actionRulesFile: { + kind: "parsed", + parse: String, + brief: "Path to JSON file containing new Array", + optional: true, + }, + json: { + kind: "boolean", + brief: "Output the updated monitor as JSON", + optional: true, + }, + }, + aliases: {}, + }, + docs: { + brief: "Update a monitor", + }, +}); diff --git a/src/commands/monitor/view.test.ts b/src/commands/monitor/view.test.ts new file mode 100644 index 0000000..36fb1b6 --- /dev/null +++ b/src/commands/monitor/view.test.ts @@ -0,0 +1,229 @@ +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"]>[3]; + +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..d994412 --- /dev/null +++ b/src/commands/monitor/view.ts @@ -0,0 +1,151 @@ +import { buildCommand } from "@stricli/core"; +import chalk from "chalk"; +import type { LocalContext } from "../../context"; +import { getMonitor } from "../../rest/monitor/get-monitor"; +import { MonitorV2RuleKind } from "../../rest/generated"; +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"; + +type OutputFormat = "json" | "csv"; + +interface ViewMonitorFlags { + format?: OutputFormat; + json?: boolean; +} + +function ruleKindColor(kind: MonitorV2RuleKind | undefined): string { + if (!kind) return "-"; + switch (kind) { + case MonitorV2RuleKind.Threshold: + return chalk.cyan(kind); + case MonitorV2RuleKind.Count: + return chalk.blue(kind); + case MonitorV2RuleKind.Promote: + return chalk.magenta(kind); + default: + return chalk.dim(kind); + } +} + +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?.interval ?? undefined, + } + : undefined, + }; + + renderObject(viewData, (text) => writer.write(text)); + } catch (error) { + writer.error(`Error: ${await formatApiError(error)}`); + process.exit(1); + } +} + +export const viewCommand = buildCommand({ + loader: async () => view as (this: LocalContext, flags: ViewMonitorFlags, monitorId: string) => Promise, + 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/commands/skill/list.ts b/src/commands/skill/list.ts index 4fc112e..126bb36 100644 --- a/src/commands/skill/list.ts +++ b/src/commands/skill/list.ts @@ -2,11 +2,11 @@ import { buildCommand } from "@stricli/core"; import chalk from "chalk"; import type { LocalContext } from "../../context"; import { listSkills } from "../../rest/skill/list-skills"; -import { - type SkillResource, - SkillVisibility, - ListSkillsVisibilityParameter, -} from "../../rest/generated"; +// Stub: Skill types are not available in the generated SDK for this tenant. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SkillResource = any; +const SkillVisibility = { Listed: "Listed" as const, Unlisted: "Unlisted" as const }; +const ListSkillsVisibilityParameter = { Listed: "Listed" as const, Unlisted: "Unlisted" as const }; import { loadConfig } from "../../lib/config"; import { formatApiError } from "../../lib/format-error"; import { muteStatusWriter } from "../../lib/writer"; diff --git a/src/commands/skill/view.ts b/src/commands/skill/view.ts index cca628e..9f1d400 100644 --- a/src/commands/skill/view.ts +++ b/src/commands/skill/view.ts @@ -2,7 +2,8 @@ import { buildCommand } from "@stricli/core"; import chalk from "chalk"; import type { LocalContext } from "../../context"; import { getSkill } from "../../rest/skill/get-skill"; -import { SkillVisibility } from "../../rest/generated"; +// Stub: SkillVisibility is not available in the generated SDK for this tenant. +const SkillVisibility = { Listed: "Listed" as const, Unlisted: "Unlisted" as const }; import { loadConfig } from "../../lib/config"; import { formatApiError } from "../../lib/format-error"; import { muteStatusWriter } from "../../lib/writer"; diff --git a/src/kg/search-metrics-kg.ts b/src/kg/search-metrics-kg.ts index ae96ad3..fb767a0 100644 --- a/src/kg/search-metrics-kg.ts +++ b/src/kg/search-metrics-kg.ts @@ -27,7 +27,8 @@ import { type RelatedEntities, type RelatedMetric, } from "../lib/kg-search"; -import { KGV2DocumentType } from "../rest/generated"; +// Stub: KGV2DocumentType is not available in the generated SDK for this tenant. +const KGV2DocumentType = { Metric: "Metric" as const }; import { MetricState } from "../gql/generated/graphql"; import type { GqlMetricMatch } from "../gql/metric/list-metrics"; diff --git a/src/lib/kg-search.ts b/src/lib/kg-search.ts index 6e8f815..c5a7108 100644 --- a/src/lib/kg-search.ts +++ b/src/lib/kg-search.ts @@ -7,9 +7,15 @@ */ import { ObserveRestSDK } from "../rest/client"; -import { KGV2DocumentType } from "../rest/generated"; import type { Config } from "./config"; +// Stub: KGV2DocumentType is not available in the generated SDK for this tenant. +const KGV2DocumentType = { + TagKey: "TagKey" as const, + TagValue: "TagValue" as const, + Metric: "Metric" as const, +}; + /** * KG tag-value document ids strip surrounding quotes and replace dots with * underscores (e.g. `service.name` -> `service_name`). Mirrors the diff --git a/src/lib/parsers.test.ts b/src/lib/parsers.test.ts index f39977e..e9012d0 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,41 @@ 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..35c952c 100644 --- a/src/lib/parsers.ts +++ b/src/lib/parsers.ts @@ -5,3 +5,24 @@ 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; +} + +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)}`, + ); + } +} diff --git a/src/rest/client.ts b/src/rest/client.ts index 0a663bc..708cd56 100644 --- a/src/rest/client.ts +++ b/src/rest/client.ts @@ -1,10 +1,9 @@ import { - SkillsApi, AlertApi, Configuration, DatasetApi, ExportApi, - V2KnowledgeGraphApi, + MonitorApi, } from "./generated"; import { getApiBaseUrl, type Config } from "../lib/config"; import { observeApiHeaders } from "../lib/user-agent"; @@ -21,8 +20,7 @@ export class ObserveRestSDK { public exportApi: ExportApi; public datasetApi: DatasetApi; public alertApi: AlertApi; - public knowledgeGraphApi: V2KnowledgeGraphApi; - public skillsApi: SkillsApi; + public monitorApi: MonitorApi; constructor(_config: Config) { const config = createConfiguration(_config); @@ -30,7 +28,6 @@ export class ObserveRestSDK { this.exportApi = new ExportApi(config); this.datasetApi = new DatasetApi(config); this.alertApi = new AlertApi(config); - this.knowledgeGraphApi = new V2KnowledgeGraphApi(config); - this.skillsApi = new SkillsApi(config); + this.monitorApi = new MonitorApi(config); } } diff --git a/src/rest/monitor/create-monitor.ts b/src/rest/monitor/create-monitor.ts new file mode 100644 index 0000000..330cfdf --- /dev/null +++ b/src/rest/monitor/create-monitor.ts @@ -0,0 +1,11 @@ +import type { Config } from "../../lib/config"; +import type { MonitorApiCreateMonitorRequest, MonitorV2 } from "../generated"; +import { ObserveRestSDK } from "../client"; + +export async function createMonitor({ + config, + ...params +}: { config: Config } & MonitorApiCreateMonitorRequest): Promise { + const sdk = new ObserveRestSDK(config); + return await 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..b87c26e --- /dev/null +++ b/src/rest/monitor/get-monitor.ts @@ -0,0 +1,22 @@ +import type { Config } from "../../lib/config"; +import type { MonitorV2 } from "../generated"; +import { ResponseError } from "../generated/runtime"; +import { ObserveRestSDK } from "../client"; + +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..c6589a5 --- /dev/null +++ b/src/rest/monitor/list-monitors.ts @@ -0,0 +1,11 @@ +import type { Config } from "../../lib/config"; +import type { MonitorApiListMonitorsRequest } from "../generated"; +import { ObserveRestSDK } from "../client"; + +export async function listMonitors({ + config, + ...params +}: { config: Config } & MonitorApiListMonitorsRequest) { + const sdk = new ObserveRestSDK(config); + return await 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..2fa4674 --- /dev/null +++ b/src/rest/monitor/update-monitor.ts @@ -0,0 +1,12 @@ +import type { Config } from "../../lib/config"; +import type { MonitorV2PatchRequest } from "../generated"; +import { ObserveRestSDK } from "../client"; + +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 }); +} diff --git a/src/rest/skill/get-skill.ts b/src/rest/skill/get-skill.ts index 493283f..5256582 100644 --- a/src/rest/skill/get-skill.ts +++ b/src/rest/skill/get-skill.ts @@ -1,25 +1,6 @@ import type { Config } from "../../lib/config"; -import type { SkillResource } from "../generated"; -import { ResponseError } from "../generated"; -import { ObserveRestSDK } from "../client"; -export async function getSkill({ - config, - skillId, -}: { - config: Config; - skillId: string; -}): Promise { - const sdk = new ObserveRestSDK(config); - try { - return await sdk.skillsApi.getSkill({ - id: skillId, - expand: true, - }); - } catch (error) { - if (error instanceof ResponseError && error.response.status === 404) { - return null; - } - throw error; - } +export async function getSkill(params: { config: Config; skillId: string }): Promise { + void params; + throw new Error("Skills API is not available on this Observe instance."); } diff --git a/src/rest/skill/list-skills.ts b/src/rest/skill/list-skills.ts index f8189be..079cf61 100644 --- a/src/rest/skill/list-skills.ts +++ b/src/rest/skill/list-skills.ts @@ -1,15 +1,6 @@ import type { Config } from "../../lib/config"; -import type { SkillsApiListSkillsRequest } from "../generated"; -import { ObserveRestSDK } from "../client"; -export async function listSkills({ - config, - ...params -}: { config: Config } & Omit) { - const sdk = new ObserveRestSDK(config); - const response = await sdk.skillsApi.listSkills({ - ...params, - expand: false, - }); - return response; +export async function listSkills(params: { config: Config; [key: string]: unknown }): Promise { + void params; + throw new Error("Skills API is not available on this Observe instance."); } diff --git a/src/rest/tag-key/list-tag-keys.ts b/src/rest/tag-key/list-tag-keys.ts index 10ba6e4..d05c466 100644 --- a/src/rest/tag-key/list-tag-keys.ts +++ b/src/rest/tag-key/list-tag-keys.ts @@ -1,79 +1,12 @@ import type { Config } from "../../lib/config"; -import { - KGV2DocumentType, - KGV2SearchMode, - type KGV2DocumentSearchRequest, -} from "../generated"; -import { ObserveRestSDK } from "../client"; import type { TagKeysResponse } from "../types/tag-keys"; -function extractTagValues(originalContent: unknown): string[] { - if ( - typeof originalContent === "object" && - originalContent !== null && - "values" in originalContent && - Array.isArray(originalContent.values) - ) { - return originalContent.values as string[]; - } - return []; -} - -export async function listTagKeys({ - config, - match, - mode = "semantic", - limit, - valueLimit, -}: { +export async function listTagKeys(_params: { config: Config; match?: string; mode?: "semantic" | "regex"; limit: number; valueLimit?: number; }): Promise { - const sdk = new ObserveRestSDK(config); - - const searchParams: Partial = - match && mode === "regex" - ? { - regex: { pattern: match }, - searchMode: KGV2SearchMode.Regex, - } - : match - ? { - searchStr: match, - searchMode: KGV2SearchMode.Semantic, - } - : { - regex: { pattern: ".*" }, - searchMode: KGV2SearchMode.Regex, - }; - - const response = await sdk.knowledgeGraphApi.searchDocumentsV2({ - kGV2DocumentSearchRequest: { - documentType: KGV2DocumentType.TagKey, - nDocuments: limit, - ...searchParams, - }, - }); - - // Project KG documents into the REST TagKeysResponse envelope. - const tagKeys = response.documents.flatMap((d) => { - const name = d.metadata?.tagKey as string | undefined; - if (name === undefined) { - return []; - } - const allValues = extractTagValues(d.metadata?.originalContent); - const values = - typeof valueLimit === "number" - ? allValues.slice(0, valueLimit) - : allValues; - return [{ name, values }]; - }); - - return { - tagKeys, - meta: { totalCount: tagKeys.length }, - }; + throw new Error("Knowledge Graph API is not available on this Observe instance."); } diff --git a/src/rest/tag-value/list-tag-values.ts b/src/rest/tag-value/list-tag-values.ts index 806824d..f6a5bdc 100644 --- a/src/rest/tag-value/list-tag-values.ts +++ b/src/rest/tag-value/list-tag-values.ts @@ -1,68 +1,11 @@ import type { Config } from "../../lib/config"; -import { - KGV2DocumentType, - KGV2SearchMode, - type KGV2DocumentSearchRequest, -} from "../generated"; -import { ObserveRestSDK } from "../client"; -import { TagKind, type TagValuesResponse } from "../types/tag-values"; +import type { TagValuesResponse } from "../types/tag-values"; -export async function listTagValues({ - config, - match, - mode = "semantic", - limit, -}: { +export async function listTagValues(_params: { config: Config; match?: string; mode?: "semantic" | "regex"; limit: number; }): Promise { - const sdk = new ObserveRestSDK(config); - - const searchParams: Partial = - match && mode === "regex" - ? { - regex: { pattern: match }, - searchMode: KGV2SearchMode.Regex, - } - : match - ? { - searchStr: match, - searchMode: KGV2SearchMode.Semantic, - } - : { - regex: { pattern: ".*" }, - searchMode: KGV2SearchMode.Regex, - }; - - const response = await sdk.knowledgeGraphApi.searchDocumentsV2({ - kGV2DocumentSearchRequest: { - documentType: KGV2DocumentType.TagValue, - nDocuments: limit, - metadataPostProcessing: { - groupByKey: "originalContent.key", - maxGroupCount: 5, - }, - ...searchParams, - }, - }); - - // Project KG documents into the REST TagValuesResponse envelope. - // V1 only emits Correlation tags, so kind is hard-coded. - const tagValuePairs = response.documents.flatMap((d) => { - const name = d.metadata?.tagKey as string | undefined; - const value = d.metadata?.tagValue as string | undefined; - if (name === undefined || value === undefined) { - return []; - } - return [{ name, value, kind: TagKind.Correlation }]; - }); - - // KG search returns at most `limit` documents and does not surface a - // separate population count, so totalCount mirrors the page length. - return { - tagValuePairs, - meta: { totalCount: tagValuePairs.length }, - }; + throw new Error("Knowledge Graph API is not available on this Observe instance."); } From 733d249a2ab7cfa654fab4e64ffeb15a4087e477 Mon Sep 17 00:00:00 2001 From: Joe Darling Date: Thu, 25 Jun 2026 08:12:51 +0000 Subject: [PATCH 02/13] fix(monitor): restore CI compatibility and fix type errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore skill/KG source files to proper generated imports (our stubs caused CI typecheck failures when codegen regenerated correct types) - Extract createApiConfiguration helper to src/rest/api-config.ts so monitor REST wrappers can use MonitorApi directly without pulling in ObserveRestSDK (which imports SkillsApi/V2KnowledgeGraphApi absent from the local dev spec) - Fix MonitorV2RuleKind enum values in create/update commands (string literals → enum constants) - Fix MonitorV2CronSchedule.interval reference in view (field is cronConfig in current schema) - Fix test Parameters[N] indices (off-by-one: commands with a positional arg use [2] not [3]; commands without use [1] not [2]) - Add noUncheckedIndexedAccess-safe ! assertions on mock.calls[0] - Add required outputStage field to MultiStageQuery test fixtures Co-Authored-By: Claude Sonnet 4.6 --- src/commands/monitor/create.test.ts | 12 ++--- src/commands/monitor/create.ts | 2 +- src/commands/monitor/delete.test.ts | 4 +- src/commands/monitor/disable.test.ts | 6 +-- src/commands/monitor/enable.test.ts | 6 +-- src/commands/monitor/list.test.ts | 6 +-- src/commands/monitor/update.test.ts | 12 ++--- src/commands/monitor/update.ts | 2 +- src/commands/monitor/view.test.ts | 4 +- src/commands/monitor/view.ts | 2 +- src/commands/skill/list.ts | 10 ++-- src/commands/skill/view.ts | 3 +- src/lib/kg-search.ts | 8 +-- src/rest/api-config.ts | 11 +++++ src/rest/client.ts | 21 ++++---- src/rest/monitor/create-monitor.ts | 8 +-- src/rest/monitor/delete-monitor.ts | 7 +-- src/rest/monitor/get-monitor.ts | 8 +-- src/rest/monitor/list-monitors.ts | 8 +-- src/rest/monitor/update-monitor.ts | 8 +-- src/rest/skill/get-skill.ts | 25 ++++++++-- src/rest/skill/list-skills.ts | 15 ++++-- src/rest/tag-key/list-tag-keys.ts | 71 ++++++++++++++++++++++++++- src/rest/tag-value/list-tag-values.ts | 63 ++++++++++++++++++++++-- 24 files changed, 238 insertions(+), 84 deletions(-) create mode 100644 src/rest/api-config.ts diff --git a/src/commands/monitor/create.test.ts b/src/commands/monitor/create.test.ts index 53fc91b..86dd6cd 100644 --- a/src/commands/monitor/create.test.ts +++ b/src/commands/monitor/create.test.ts @@ -25,7 +25,7 @@ const loadConfigFn = mock( ); const STUB_DEFINITION: MonitorV2Definition = { - inputQuery: { stages: [] } as MonitorV2Definition["inputQuery"], + inputQuery: { outputStage: "main", stages: [] } as MonitorV2Definition["inputQuery"], rules: [], }; @@ -61,7 +61,7 @@ const deps = { createMonitor: createMonitorFn, getMonitor: getMonitorFn, readFile: readFileFn, -} as Parameters<(typeof import("./create"))["create"]>[2]; +} as Parameters<(typeof import("./create"))["create"]>[1]; beforeAll(async () => { previousNoColor = process.env.NO_COLOR; @@ -139,7 +139,7 @@ describe("monitor create — API forwarding", () => { deps, ); expect(createMonitorFn).toHaveBeenCalledTimes(1); - const call = createMonitorFn.mock.calls[0][0] as { monitorV2: { name: string; ruleKind: string; definition: unknown } }; + 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); @@ -174,7 +174,7 @@ describe("monitor create — API forwarding", () => { }, deps, ); - const call = createMonitorFn.mock.calls[0][0] as { monitorV2: { actionRules: unknown[] } }; + const call = createMonitorFn.mock.calls[0]![0] as unknown as { monitorV2: { actionRules: unknown[] } }; expect(call.monitorV2.actionRules).toEqual(actionRules); }); @@ -189,7 +189,7 @@ describe("monitor create — API forwarding", () => { }, deps, ); - const call = createMonitorFn.mock.calls[0][0] as { monitorV2: object }; + const call = createMonitorFn.mock.calls[0]![0] as unknown as { monitorV2: object }; expect(call.monitorV2).not.toHaveProperty("actionRules"); }); }); @@ -238,7 +238,7 @@ describe("monitor create — output", () => { deps, ); expect(getMonitorFn).toHaveBeenCalledTimes(1); - expect(getMonitorFn.mock.calls[0][0]).toMatchObject({ id: 99001 }); + 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" }); }); diff --git a/src/commands/monitor/create.ts b/src/commands/monitor/create.ts index f790a3a..4215487 100644 --- a/src/commands/monitor/create.ts +++ b/src/commands/monitor/create.ts @@ -107,7 +107,7 @@ export const createCommand = buildCommand({ }, ruleKind: { kind: "enum", - values: ["Count", "Threshold", "Promote"], + values: [MonitorV2RuleKind.Count, MonitorV2RuleKind.Threshold, MonitorV2RuleKind.Promote], brief: "Alert rule kind (Count, Threshold, Promote)", optional: false, }, diff --git a/src/commands/monitor/delete.test.ts b/src/commands/monitor/delete.test.ts index 2ae2a2f..35d9b0c 100644 --- a/src/commands/monitor/delete.test.ts +++ b/src/commands/monitor/delete.test.ts @@ -33,7 +33,7 @@ let previousForceColor: string | undefined; const deps = { loadConfig: loadConfigFn, deleteMonitor: deleteMonitorFn, -} as Parameters<(typeof import("./delete"))["deleteMonitorCommand"]>[3]; +} as Parameters<(typeof import("./delete"))["deleteMonitorCommand"]>[2]; beforeAll(async () => { previousNoColor = process.env.NO_COLOR; @@ -140,7 +140,7 @@ describe("monitor delete — API forwarding", () => { const { context } = createMockContext(); await deleteMonitorCommand.call(context, {}, TEST_MONITOR_ID, deps); expect(deleteMonitorFn).toHaveBeenCalledTimes(1); - expect(deleteMonitorFn.mock.calls[0][0]).toMatchObject({ + expect(deleteMonitorFn.mock.calls[0]![0]).toMatchObject({ id: Number(TEST_MONITOR_ID), }); }); diff --git a/src/commands/monitor/disable.test.ts b/src/commands/monitor/disable.test.ts index b9d91ff..c070b2d 100644 --- a/src/commands/monitor/disable.test.ts +++ b/src/commands/monitor/disable.test.ts @@ -52,7 +52,7 @@ const deps = { loadConfig: loadConfigFn, updateMonitor: updateMonitorFn, getMonitor: getMonitorFn, -} as Parameters<(typeof import("./disable"))["disable"]>[3]; +} as Parameters<(typeof import("./disable"))["disable"]>[2]; beforeAll(async () => { previousNoColor = process.env.NO_COLOR; @@ -165,7 +165,7 @@ describe("monitor disable — API forwarding", () => { const { context } = createMockContext(); await disable.call(context, {}, TEST_MONITOR_ID, deps); expect(updateMonitorFn).toHaveBeenCalledTimes(1); - expect(updateMonitorFn.mock.calls[0][0]).toMatchObject({ + expect(updateMonitorFn.mock.calls[0]![0]).toMatchObject({ id: Number(TEST_MONITOR_ID), disabled: true, }); @@ -181,7 +181,7 @@ describe("monitor disable — API forwarding", () => { 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) }); + expect(getMonitorFn.mock.calls[0]![0]).toMatchObject({ id: Number(TEST_MONITOR_ID) }); }); }); diff --git a/src/commands/monitor/enable.test.ts b/src/commands/monitor/enable.test.ts index 7e699af..ca11032 100644 --- a/src/commands/monitor/enable.test.ts +++ b/src/commands/monitor/enable.test.ts @@ -52,7 +52,7 @@ const deps = { loadConfig: loadConfigFn, updateMonitor: updateMonitorFn, getMonitor: getMonitorFn, -} as Parameters<(typeof import("./enable"))["enable"]>[3]; +} as Parameters<(typeof import("./enable"))["enable"]>[2]; beforeAll(async () => { previousNoColor = process.env.NO_COLOR; @@ -165,7 +165,7 @@ describe("monitor enable — API forwarding", () => { const { context } = createMockContext(); await enable.call(context, {}, TEST_MONITOR_ID, deps); expect(updateMonitorFn).toHaveBeenCalledTimes(1); - expect(updateMonitorFn.mock.calls[0][0]).toMatchObject({ + expect(updateMonitorFn.mock.calls[0]![0]).toMatchObject({ id: Number(TEST_MONITOR_ID), disabled: false, }); @@ -181,7 +181,7 @@ describe("monitor enable — API forwarding", () => { 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) }); + expect(getMonitorFn.mock.calls[0]![0]).toMatchObject({ id: Number(TEST_MONITOR_ID) }); }); }); diff --git a/src/commands/monitor/list.test.ts b/src/commands/monitor/list.test.ts index 767998e..5278192 100644 --- a/src/commands/monitor/list.test.ts +++ b/src/commands/monitor/list.test.ts @@ -54,7 +54,7 @@ let previousForceColor: string | undefined; const deps = { loadConfig: loadConfigFn, listMonitors: listMonitorsFn, -} as Parameters<(typeof import("./list"))["list"]>[2]; +} as Parameters<(typeof import("./list"))["list"]>[1]; beforeAll(async () => { previousNoColor = process.env.NO_COLOR; @@ -122,14 +122,14 @@ describe("monitor list — API forwarding", () => { 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" }); + 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(); + expect(listMonitorsFn.mock.calls[0]![0].nameSubstring).toBeUndefined(); }); }); diff --git a/src/commands/monitor/update.test.ts b/src/commands/monitor/update.test.ts index 315a705..43fc2e7 100644 --- a/src/commands/monitor/update.test.ts +++ b/src/commands/monitor/update.test.ts @@ -27,7 +27,7 @@ const loadConfigFn = mock( ); const STUB_DEFINITION: MonitorV2Definition = { - inputQuery: { stages: [] } as MonitorV2Definition["inputQuery"], + inputQuery: { outputStage: "main", stages: [] } as MonitorV2Definition["inputQuery"], rules: [], }; @@ -62,7 +62,7 @@ const deps = { updateMonitor: updateMonitorFn, getMonitor: getMonitorFn, readFile: readFileFn, -} as Parameters<(typeof import("./update"))["update"]>[3]; +} as Parameters<(typeof import("./update"))["update"]>[2]; beforeAll(async () => { previousNoColor = process.env.NO_COLOR; @@ -180,7 +180,7 @@ describe("monitor update — API forwarding", () => { const { context } = createMockContext(); await update.call(context, { name: "New Name" }, TEST_MONITOR_ID, deps); expect(updateMonitorFn).toHaveBeenCalledTimes(1); - const call = updateMonitorFn.mock.calls[0][0] as { id: number; name: string }; + const call = updateMonitorFn.mock.calls[0]![0] as unknown as { id: number; name: string }; expect(call.id).toBe(Number(TEST_MONITOR_ID)); expect(call.name).toBe("New Name"); }); @@ -188,7 +188,7 @@ describe("monitor update — API forwarding", () => { test("only includes explicitly provided fields in the patch", async () => { const { context } = createMockContext(); await update.call(context, { name: "Only Name" }, TEST_MONITOR_ID, deps); - const call = updateMonitorFn.mock.calls[0][0] as object; + const call = updateMonitorFn.mock.calls[0]![0] as unknown as object; expect(call).not.toHaveProperty("description"); expect(call).not.toHaveProperty("ruleKind"); expect(call).not.toHaveProperty("definition"); @@ -203,7 +203,7 @@ describe("monitor update — API forwarding", () => { deps, ); expect(readFileFn).toHaveBeenCalledWith("/path/to/def.json"); - const call = updateMonitorFn.mock.calls[0][0] as { definition: unknown }; + const call = updateMonitorFn.mock.calls[0]![0] as unknown as { definition: unknown }; expect(call.definition).toMatchObject(STUB_DEFINITION); }); @@ -217,7 +217,7 @@ describe("monitor update — API forwarding", () => { const { context } = createMockContext(); await update.call(context, { name: "foo", json: true }, TEST_MONITOR_ID, deps); expect(getMonitorFn).toHaveBeenCalledTimes(1); - expect(getMonitorFn.mock.calls[0][0]).toMatchObject({ + expect(getMonitorFn.mock.calls[0]![0]).toMatchObject({ id: Number(TEST_MONITOR_ID), }); }); diff --git a/src/commands/monitor/update.ts b/src/commands/monitor/update.ts index bd01e1d..93284ef 100644 --- a/src/commands/monitor/update.ts +++ b/src/commands/monitor/update.ts @@ -137,7 +137,7 @@ export const updateCommand = buildCommand({ }, ruleKind: { kind: "enum", - values: ["Count", "Threshold", "Promote"], + values: [MonitorV2RuleKind.Count, MonitorV2RuleKind.Threshold, MonitorV2RuleKind.Promote], brief: "New alert rule kind (Count, Threshold, Promote)", optional: true, }, diff --git a/src/commands/monitor/view.test.ts b/src/commands/monitor/view.test.ts index 36fb1b6..20e0ac9 100644 --- a/src/commands/monitor/view.test.ts +++ b/src/commands/monitor/view.test.ts @@ -43,7 +43,7 @@ let previousForceColor: string | undefined; const deps = { loadConfig: loadConfigFn, getMonitor: getMonitorFn, -} as Parameters<(typeof import("./view"))["view"]>[3]; +} as Parameters<(typeof import("./view"))["view"]>[2]; beforeAll(async () => { previousNoColor = process.env.NO_COLOR; @@ -167,7 +167,7 @@ describe("monitor view — API forwarding", () => { const { context } = createMockContext(); await view.call(context, { json: true }, "42", deps); expect(getMonitorFn).toHaveBeenCalledTimes(1); - expect(getMonitorFn.mock.calls[0][0]).toMatchObject({ id: 42 }); + expect(getMonitorFn.mock.calls[0]![0]).toMatchObject({ id: 42 }); }); }); diff --git a/src/commands/monitor/view.ts b/src/commands/monitor/view.ts index d994412..d01afb4 100644 --- a/src/commands/monitor/view.ts +++ b/src/commands/monitor/view.ts @@ -106,7 +106,7 @@ export async function view( transform: monitor.effectiveScheduling.transform ? "transform-driven" : undefined, - scheduled: monitor.effectiveScheduling.scheduled?.interval ?? undefined, + scheduled: monitor.effectiveScheduling.scheduled?.cronConfig ?? undefined, } : undefined, }; diff --git a/src/commands/skill/list.ts b/src/commands/skill/list.ts index 126bb36..4fc112e 100644 --- a/src/commands/skill/list.ts +++ b/src/commands/skill/list.ts @@ -2,11 +2,11 @@ import { buildCommand } from "@stricli/core"; import chalk from "chalk"; import type { LocalContext } from "../../context"; import { listSkills } from "../../rest/skill/list-skills"; -// Stub: Skill types are not available in the generated SDK for this tenant. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type SkillResource = any; -const SkillVisibility = { Listed: "Listed" as const, Unlisted: "Unlisted" as const }; -const ListSkillsVisibilityParameter = { Listed: "Listed" as const, Unlisted: "Unlisted" as const }; +import { + type SkillResource, + SkillVisibility, + ListSkillsVisibilityParameter, +} from "../../rest/generated"; import { loadConfig } from "../../lib/config"; import { formatApiError } from "../../lib/format-error"; import { muteStatusWriter } from "../../lib/writer"; diff --git a/src/commands/skill/view.ts b/src/commands/skill/view.ts index 9f1d400..cca628e 100644 --- a/src/commands/skill/view.ts +++ b/src/commands/skill/view.ts @@ -2,8 +2,7 @@ import { buildCommand } from "@stricli/core"; import chalk from "chalk"; import type { LocalContext } from "../../context"; import { getSkill } from "../../rest/skill/get-skill"; -// Stub: SkillVisibility is not available in the generated SDK for this tenant. -const SkillVisibility = { Listed: "Listed" as const, Unlisted: "Unlisted" as const }; +import { SkillVisibility } from "../../rest/generated"; import { loadConfig } from "../../lib/config"; import { formatApiError } from "../../lib/format-error"; import { muteStatusWriter } from "../../lib/writer"; diff --git a/src/lib/kg-search.ts b/src/lib/kg-search.ts index c5a7108..6e8f815 100644 --- a/src/lib/kg-search.ts +++ b/src/lib/kg-search.ts @@ -7,15 +7,9 @@ */ import { ObserveRestSDK } from "../rest/client"; +import { KGV2DocumentType } from "../rest/generated"; import type { Config } from "./config"; -// Stub: KGV2DocumentType is not available in the generated SDK for this tenant. -const KGV2DocumentType = { - TagKey: "TagKey" as const, - TagValue: "TagValue" as const, - Metric: "Metric" as const, -}; - /** * KG tag-value document ids strip surrounding quotes and replace dots with * underscores (e.g. `service.name` -> `service_name`). Mirrors the 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 708cd56..a825de6 100644 --- a/src/rest/client.ts +++ b/src/rest/client.ts @@ -1,33 +1,30 @@ 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 index 330cfdf..29b7a39 100644 --- a/src/rest/monitor/create-monitor.ts +++ b/src/rest/monitor/create-monitor.ts @@ -1,11 +1,11 @@ import type { Config } from "../../lib/config"; -import type { MonitorApiCreateMonitorRequest, MonitorV2 } from "../generated"; -import { ObserveRestSDK } from "../client"; +import { MonitorApi, type MonitorApiCreateMonitorRequest, type MonitorV2 } from "../generated"; +import { createApiConfiguration } from "../api-config"; export async function createMonitor({ config, ...params }: { config: Config } & MonitorApiCreateMonitorRequest): Promise { - const sdk = new ObserveRestSDK(config); - return await sdk.monitorApi.createMonitor(params); + const api = new MonitorApi(createApiConfiguration(config)); + return await api.createMonitor(params); } diff --git a/src/rest/monitor/delete-monitor.ts b/src/rest/monitor/delete-monitor.ts index f0d6aa5..0feb2e8 100644 --- a/src/rest/monitor/delete-monitor.ts +++ b/src/rest/monitor/delete-monitor.ts @@ -1,5 +1,6 @@ import type { Config } from "../../lib/config"; -import { ObserveRestSDK } from "../client"; +import { MonitorApi } from "../generated"; +import { createApiConfiguration } from "../api-config"; export async function deleteMonitor({ config, @@ -8,6 +9,6 @@ export async function deleteMonitor({ config: Config; id: number; }): Promise { - const sdk = new ObserveRestSDK(config); - await sdk.monitorApi.deleteMonitor({ id }); + const api = new MonitorApi(createApiConfiguration(config)); + await api.deleteMonitor({ id }); } diff --git a/src/rest/monitor/get-monitor.ts b/src/rest/monitor/get-monitor.ts index b87c26e..8b9b981 100644 --- a/src/rest/monitor/get-monitor.ts +++ b/src/rest/monitor/get-monitor.ts @@ -1,7 +1,7 @@ import type { Config } from "../../lib/config"; -import type { MonitorV2 } from "../generated"; +import { MonitorApi, type MonitorV2 } from "../generated"; import { ResponseError } from "../generated/runtime"; -import { ObserveRestSDK } from "../client"; +import { createApiConfiguration } from "../api-config"; export async function getMonitor({ config, @@ -10,9 +10,9 @@ export async function getMonitor({ config: Config; id: number; }): Promise { - const sdk = new ObserveRestSDK(config); + const api = new MonitorApi(createApiConfiguration(config)); try { - return await sdk.monitorApi.getMonitor({ id }); + return await api.getMonitor({ id }); } catch (error) { if (error instanceof ResponseError && error.response.status === 404) { return null; diff --git a/src/rest/monitor/list-monitors.ts b/src/rest/monitor/list-monitors.ts index c6589a5..d9e6668 100644 --- a/src/rest/monitor/list-monitors.ts +++ b/src/rest/monitor/list-monitors.ts @@ -1,11 +1,11 @@ import type { Config } from "../../lib/config"; -import type { MonitorApiListMonitorsRequest } from "../generated"; -import { ObserveRestSDK } from "../client"; +import { MonitorApi, type MonitorApiListMonitorsRequest } from "../generated"; +import { createApiConfiguration } from "../api-config"; export async function listMonitors({ config, ...params }: { config: Config } & MonitorApiListMonitorsRequest) { - const sdk = new ObserveRestSDK(config); - return await sdk.monitorApi.listMonitors(params); + const api = new MonitorApi(createApiConfiguration(config)); + return await api.listMonitors(params); } diff --git a/src/rest/monitor/update-monitor.ts b/src/rest/monitor/update-monitor.ts index 2fa4674..fa7be4b 100644 --- a/src/rest/monitor/update-monitor.ts +++ b/src/rest/monitor/update-monitor.ts @@ -1,12 +1,12 @@ import type { Config } from "../../lib/config"; -import type { MonitorV2PatchRequest } from "../generated"; -import { ObserveRestSDK } from "../client"; +import { MonitorApi, type MonitorV2PatchRequest } from "../generated"; +import { createApiConfiguration } from "../api-config"; 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 }); + const api = new MonitorApi(createApiConfiguration(config)); + await api.updateMonitor({ id, monitorV2PatchRequest: patch }); } diff --git a/src/rest/skill/get-skill.ts b/src/rest/skill/get-skill.ts index 5256582..493283f 100644 --- a/src/rest/skill/get-skill.ts +++ b/src/rest/skill/get-skill.ts @@ -1,6 +1,25 @@ import type { Config } from "../../lib/config"; +import type { SkillResource } from "../generated"; +import { ResponseError } from "../generated"; +import { ObserveRestSDK } from "../client"; -export async function getSkill(params: { config: Config; skillId: string }): Promise { - void params; - throw new Error("Skills API is not available on this Observe instance."); +export async function getSkill({ + config, + skillId, +}: { + config: Config; + skillId: string; +}): Promise { + const sdk = new ObserveRestSDK(config); + try { + return await sdk.skillsApi.getSkill({ + id: skillId, + expand: true, + }); + } catch (error) { + if (error instanceof ResponseError && error.response.status === 404) { + return null; + } + throw error; + } } diff --git a/src/rest/skill/list-skills.ts b/src/rest/skill/list-skills.ts index 079cf61..f8189be 100644 --- a/src/rest/skill/list-skills.ts +++ b/src/rest/skill/list-skills.ts @@ -1,6 +1,15 @@ import type { Config } from "../../lib/config"; +import type { SkillsApiListSkillsRequest } from "../generated"; +import { ObserveRestSDK } from "../client"; -export async function listSkills(params: { config: Config; [key: string]: unknown }): Promise { - void params; - throw new Error("Skills API is not available on this Observe instance."); +export async function listSkills({ + config, + ...params +}: { config: Config } & Omit) { + const sdk = new ObserveRestSDK(config); + const response = await sdk.skillsApi.listSkills({ + ...params, + expand: false, + }); + return response; } diff --git a/src/rest/tag-key/list-tag-keys.ts b/src/rest/tag-key/list-tag-keys.ts index d05c466..10ba6e4 100644 --- a/src/rest/tag-key/list-tag-keys.ts +++ b/src/rest/tag-key/list-tag-keys.ts @@ -1,12 +1,79 @@ import type { Config } from "../../lib/config"; +import { + KGV2DocumentType, + KGV2SearchMode, + type KGV2DocumentSearchRequest, +} from "../generated"; +import { ObserveRestSDK } from "../client"; import type { TagKeysResponse } from "../types/tag-keys"; -export async function listTagKeys(_params: { +function extractTagValues(originalContent: unknown): string[] { + if ( + typeof originalContent === "object" && + originalContent !== null && + "values" in originalContent && + Array.isArray(originalContent.values) + ) { + return originalContent.values as string[]; + } + return []; +} + +export async function listTagKeys({ + config, + match, + mode = "semantic", + limit, + valueLimit, +}: { config: Config; match?: string; mode?: "semantic" | "regex"; limit: number; valueLimit?: number; }): Promise { - throw new Error("Knowledge Graph API is not available on this Observe instance."); + const sdk = new ObserveRestSDK(config); + + const searchParams: Partial = + match && mode === "regex" + ? { + regex: { pattern: match }, + searchMode: KGV2SearchMode.Regex, + } + : match + ? { + searchStr: match, + searchMode: KGV2SearchMode.Semantic, + } + : { + regex: { pattern: ".*" }, + searchMode: KGV2SearchMode.Regex, + }; + + const response = await sdk.knowledgeGraphApi.searchDocumentsV2({ + kGV2DocumentSearchRequest: { + documentType: KGV2DocumentType.TagKey, + nDocuments: limit, + ...searchParams, + }, + }); + + // Project KG documents into the REST TagKeysResponse envelope. + const tagKeys = response.documents.flatMap((d) => { + const name = d.metadata?.tagKey as string | undefined; + if (name === undefined) { + return []; + } + const allValues = extractTagValues(d.metadata?.originalContent); + const values = + typeof valueLimit === "number" + ? allValues.slice(0, valueLimit) + : allValues; + return [{ name, values }]; + }); + + return { + tagKeys, + meta: { totalCount: tagKeys.length }, + }; } diff --git a/src/rest/tag-value/list-tag-values.ts b/src/rest/tag-value/list-tag-values.ts index f6a5bdc..806824d 100644 --- a/src/rest/tag-value/list-tag-values.ts +++ b/src/rest/tag-value/list-tag-values.ts @@ -1,11 +1,68 @@ import type { Config } from "../../lib/config"; -import type { TagValuesResponse } from "../types/tag-values"; +import { + KGV2DocumentType, + KGV2SearchMode, + type KGV2DocumentSearchRequest, +} from "../generated"; +import { ObserveRestSDK } from "../client"; +import { TagKind, type TagValuesResponse } from "../types/tag-values"; -export async function listTagValues(_params: { +export async function listTagValues({ + config, + match, + mode = "semantic", + limit, +}: { config: Config; match?: string; mode?: "semantic" | "regex"; limit: number; }): Promise { - throw new Error("Knowledge Graph API is not available on this Observe instance."); + const sdk = new ObserveRestSDK(config); + + const searchParams: Partial = + match && mode === "regex" + ? { + regex: { pattern: match }, + searchMode: KGV2SearchMode.Regex, + } + : match + ? { + searchStr: match, + searchMode: KGV2SearchMode.Semantic, + } + : { + regex: { pattern: ".*" }, + searchMode: KGV2SearchMode.Regex, + }; + + const response = await sdk.knowledgeGraphApi.searchDocumentsV2({ + kGV2DocumentSearchRequest: { + documentType: KGV2DocumentType.TagValue, + nDocuments: limit, + metadataPostProcessing: { + groupByKey: "originalContent.key", + maxGroupCount: 5, + }, + ...searchParams, + }, + }); + + // Project KG documents into the REST TagValuesResponse envelope. + // V1 only emits Correlation tags, so kind is hard-coded. + const tagValuePairs = response.documents.flatMap((d) => { + const name = d.metadata?.tagKey as string | undefined; + const value = d.metadata?.tagValue as string | undefined; + if (name === undefined || value === undefined) { + return []; + } + return [{ name, value, kind: TagKind.Correlation }]; + }); + + // KG search returns at most `limit` documents and does not surface a + // separate population count, so totalCount mirrors the page length. + return { + tagValuePairs, + meta: { totalCount: tagValuePairs.length }, + }; } From 0447540ed64733a55e58948b2d6ce9616c2e6cc7 Mon Sep 17 00:00:00 2001 From: Joe Darling Date: Thu, 25 Jun 2026 09:01:09 +0000 Subject: [PATCH 03/13] fix(kg): restore KGV2DocumentType import in search-metrics-kg Co-Authored-By: Claude Sonnet 4.6 --- src/kg/search-metrics-kg.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/kg/search-metrics-kg.ts b/src/kg/search-metrics-kg.ts index fb767a0..ae96ad3 100644 --- a/src/kg/search-metrics-kg.ts +++ b/src/kg/search-metrics-kg.ts @@ -27,8 +27,7 @@ import { type RelatedEntities, type RelatedMetric, } from "../lib/kg-search"; -// Stub: KGV2DocumentType is not available in the generated SDK for this tenant. -const KGV2DocumentType = { Metric: "Metric" as const }; +import { KGV2DocumentType } from "../rest/generated"; import { MetricState } from "../gql/generated/graphql"; import type { GqlMetricMatch } from "../gql/metric/list-metrics"; From 2275e50f4026cb37dc8a91499fad8a0c6e5e3eaa Mon Sep 17 00:00:00 2001 From: Joe Darling Date: Thu, 25 Jun 2026 09:11:43 +0000 Subject: [PATCH 04/13] fix(monitor): resolve all eslint errors for CI compatibility Co-Authored-By: Claude Sonnet 4.6 --- src/commands/monitor/create.test.ts | 2 +- src/commands/monitor/create.ts | 5 ++--- src/commands/monitor/delete.ts | 7 +------ src/commands/monitor/disable.ts | 2 +- src/commands/monitor/enable.ts | 2 +- src/commands/monitor/list.ts | 7 ++++--- src/commands/monitor/update.test.ts | 2 +- src/commands/monitor/update.ts | 7 +------ src/commands/monitor/view.ts | 2 +- src/lib/parsers.ts | 2 ++ 10 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/commands/monitor/create.test.ts b/src/commands/monitor/create.test.ts index 86dd6cd..b46f9bd 100644 --- a/src/commands/monitor/create.test.ts +++ b/src/commands/monitor/create.test.ts @@ -25,7 +25,7 @@ const loadConfigFn = mock( ); const STUB_DEFINITION: MonitorV2Definition = { - inputQuery: { outputStage: "main", stages: [] } as MonitorV2Definition["inputQuery"], + inputQuery: { outputStage: "main", stages: [] }, rules: [], }; diff --git a/src/commands/monitor/create.ts b/src/commands/monitor/create.ts index 4215487..2d287ab 100644 --- a/src/commands/monitor/create.ts +++ b/src/commands/monitor/create.ts @@ -72,7 +72,7 @@ export async function create( let createdId: number; try { - createdId = parseMonitorId(String(created.id ?? "")); + createdId = parseMonitorId(created.id); } catch { throw new Error(`Create API returned unexpected monitor ID: "${created.id}"`); } @@ -94,8 +94,7 @@ export async function create( } export const createCommand = buildCommand({ - loader: async () => - create as (this: LocalContext, flags: CreateMonitorFlags) => Promise, + loader: async () => create, parameters: { positional: { kind: "tuple", parameters: [] }, flags: { diff --git a/src/commands/monitor/delete.ts b/src/commands/monitor/delete.ts index c2bc030..ad46b04 100644 --- a/src/commands/monitor/delete.ts +++ b/src/commands/monitor/delete.ts @@ -46,12 +46,7 @@ export async function deleteMonitorCommand( } export const deleteCommand = buildCommand({ - loader: async () => - deleteMonitorCommand as ( - this: LocalContext, - flags: Record, - monitorId: string, - ) => Promise, + loader: async () => deleteMonitorCommand, parameters: { positional: { kind: "tuple", diff --git a/src/commands/monitor/disable.ts b/src/commands/monitor/disable.ts index 0f9e830..a7734cb 100644 --- a/src/commands/monitor/disable.ts +++ b/src/commands/monitor/disable.ts @@ -64,7 +64,7 @@ export async function disable( } export const disableCommand = buildCommand({ - loader: async () => disable as (this: LocalContext, flags: DisableMonitorFlags, monitorId: string) => Promise, + loader: async () => disable, parameters: { positional: { kind: "tuple", diff --git a/src/commands/monitor/enable.ts b/src/commands/monitor/enable.ts index 64a9cd1..5adb581 100644 --- a/src/commands/monitor/enable.ts +++ b/src/commands/monitor/enable.ts @@ -64,7 +64,7 @@ export async function enable( } export const enableCommand = buildCommand({ - loader: async () => enable as (this: LocalContext, flags: EnableMonitorFlags, monitorId: string) => Promise, + loader: async () => enable, parameters: { positional: { kind: "tuple", diff --git a/src/commands/monitor/list.ts b/src/commands/monitor/list.ts index ec32ab0..bb58c09 100644 --- a/src/commands/monitor/list.ts +++ b/src/commands/monitor/list.ts @@ -90,7 +90,7 @@ const FIELD_COLUMNS = { }), ruleKind: col.accessor((row) => row.ruleKind, { header: "KIND", - format: (value) => ruleKindColor(value as MonitorV2RuleKind | undefined), + format: (value) => ruleKindColor(value), }), disabled: col.accessor((row) => row.disabled ?? false, { header: "DISABLED", @@ -131,7 +131,8 @@ export async function list( }); if (flags.kind) { - monitors = monitors.filter((m) => flags.kind!.includes(m.ruleKind as MonitorV2RuleKind)); + const filterKinds = flags.kind; + monitors = monitors.filter((m) => m.ruleKind != null && filterKinds.includes(m.ruleKind)); } if (flags.disabled != null) { @@ -198,7 +199,7 @@ function parseFields(value: string): FieldName[] { } export const listCommand = buildCommand({ - loader: async () => list as (this: LocalContext, flags: ListMonitorsFlags) => Promise, + loader: async () => list, parameters: { positional: { kind: "tuple", diff --git a/src/commands/monitor/update.test.ts b/src/commands/monitor/update.test.ts index 43fc2e7..d7cfcb3 100644 --- a/src/commands/monitor/update.test.ts +++ b/src/commands/monitor/update.test.ts @@ -27,7 +27,7 @@ const loadConfigFn = mock( ); const STUB_DEFINITION: MonitorV2Definition = { - inputQuery: { outputStage: "main", stages: [] } as MonitorV2Definition["inputQuery"], + inputQuery: { outputStage: "main", stages: [] }, rules: [], }; diff --git a/src/commands/monitor/update.ts b/src/commands/monitor/update.ts index 93284ef..88fbd6b 100644 --- a/src/commands/monitor/update.ts +++ b/src/commands/monitor/update.ts @@ -111,12 +111,7 @@ export async function update( } export const updateCommand = buildCommand({ - loader: async () => - update as ( - this: LocalContext, - flags: UpdateMonitorFlags, - monitorId: string, - ) => Promise, + loader: async () => update, parameters: { positional: { kind: "tuple", diff --git a/src/commands/monitor/view.ts b/src/commands/monitor/view.ts index d01afb4..1649ae9 100644 --- a/src/commands/monitor/view.ts +++ b/src/commands/monitor/view.ts @@ -119,7 +119,7 @@ export async function view( } export const viewCommand = buildCommand({ - loader: async () => view as (this: LocalContext, flags: ViewMonitorFlags, monitorId: string) => Promise, + loader: async () => view, parameters: { positional: { kind: "tuple", diff --git a/src/lib/parsers.ts b/src/lib/parsers.ts index 35c952c..f45d5ac 100644 --- a/src/lib/parsers.ts +++ b/src/lib/parsers.ts @@ -17,12 +17,14 @@ export function parseMonitorId(value: string): number { 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 }, ); } } From 699a8162b16ce99bc5c04b626be82c04086f318c Mon Sep 17 00:00:00 2001 From: Joe Darling Date: Thu, 25 Jun 2026 16:13:12 +0000 Subject: [PATCH 05/13] style(monitor): run prettier on all new monitor files Co-Authored-By: Claude Sonnet 4.6 --- src/commands/monitor/create.test.ts | 16 ++++++++--- src/commands/monitor/create.ts | 10 +++++-- src/commands/monitor/delete.test.ts | 7 ++++- src/commands/monitor/delete.ts | 4 ++- src/commands/monitor/disable.test.ts | 11 ++++++-- src/commands/monitor/disable.ts | 4 ++- src/commands/monitor/enable.test.ts | 4 ++- src/commands/monitor/enable.ts | 4 ++- src/commands/monitor/list.test.ts | 40 +++++++++++++++++++++------- src/commands/monitor/list.ts | 29 ++++++++++++-------- src/commands/monitor/update.test.ts | 34 ++++++++++++++++++----- src/commands/monitor/update.ts | 10 +++++-- src/commands/monitor/view.test.ts | 10 +++++-- src/commands/monitor/view.ts | 7 +++-- src/lib/parsers.test.ts | 4 ++- src/lib/parsers.ts | 7 ++++- src/rest/monitor/create-monitor.ts | 6 ++++- 17 files changed, 159 insertions(+), 48 deletions(-) diff --git a/src/commands/monitor/create.test.ts b/src/commands/monitor/create.test.ts index b46f9bd..7350e56 100644 --- a/src/commands/monitor/create.test.ts +++ b/src/commands/monitor/create.test.ts @@ -49,7 +49,9 @@ const getMonitorFn = mock( Promise.resolve(monitorStub()), ); -const readFileFn = mock((_path: string): string => JSON.stringify(STUB_DEFINITION)); +const readFileFn = mock((_path: string): string => + JSON.stringify(STUB_DEFINITION), +); let create: (typeof import("./create"))["create"]; @@ -139,7 +141,9 @@ describe("monitor create — API forwarding", () => { deps, ); expect(createMonitorFn).toHaveBeenCalledTimes(1); - const call = createMonitorFn.mock.calls[0]![0] as unknown as { monitorV2: { name: string; ruleKind: string; definition: unknown } }; + 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); @@ -174,7 +178,9 @@ describe("monitor create — API forwarding", () => { }, deps, ); - const call = createMonitorFn.mock.calls[0]![0] as unknown as { monitorV2: { actionRules: unknown[] } }; + const call = createMonitorFn.mock.calls[0]![0] as unknown as { + monitorV2: { actionRules: unknown[] }; + }; expect(call.monitorV2.actionRules).toEqual(actionRules); }); @@ -189,7 +195,9 @@ describe("monitor create — API forwarding", () => { }, deps, ); - const call = createMonitorFn.mock.calls[0]![0] as unknown as { monitorV2: object }; + const call = createMonitorFn.mock.calls[0]![0] as unknown as { + monitorV2: object; + }; expect(call.monitorV2).not.toHaveProperty("actionRules"); }); }); diff --git a/src/commands/monitor/create.ts b/src/commands/monitor/create.ts index 2d287ab..dceeacc 100644 --- a/src/commands/monitor/create.ts +++ b/src/commands/monitor/create.ts @@ -74,7 +74,9 @@ export async function create( try { createdId = parseMonitorId(created.id); } catch { - throw new Error(`Create API returned unexpected monitor ID: "${created.id}"`); + throw new Error( + `Create API returned unexpected monitor ID: "${created.id}"`, + ); } if (flags.json) { @@ -106,7 +108,11 @@ export const createCommand = buildCommand({ }, ruleKind: { kind: "enum", - values: [MonitorV2RuleKind.Count, MonitorV2RuleKind.Threshold, MonitorV2RuleKind.Promote], + values: [ + MonitorV2RuleKind.Count, + MonitorV2RuleKind.Threshold, + MonitorV2RuleKind.Promote, + ], brief: "Alert rule kind (Count, Threshold, Promote)", optional: false, }, diff --git a/src/commands/monitor/delete.test.ts b/src/commands/monitor/delete.test.ts index 35d9b0c..3a560b4 100644 --- a/src/commands/monitor/delete.test.ts +++ b/src/commands/monitor/delete.test.ts @@ -97,7 +97,12 @@ describe("monitor delete — ID validation", () => { 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); + 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"); diff --git a/src/commands/monitor/delete.ts b/src/commands/monitor/delete.ts index ad46b04..a11f453 100644 --- a/src/commands/monitor/delete.ts +++ b/src/commands/monitor/delete.ts @@ -26,7 +26,9 @@ export async function deleteMonitorCommand( try { id = parseMonitorId(monitorId); } catch { - writer.error(`Invalid monitor ID: "${monitorId}". Must be a positive integer.`); + writer.error( + `Invalid monitor ID: "${monitorId}". Must be a positive integer.`, + ); process.exit(1); return; } diff --git a/src/commands/monitor/disable.test.ts b/src/commands/monitor/disable.test.ts index c070b2d..2a9b5b1 100644 --- a/src/commands/monitor/disable.test.ts +++ b/src/commands/monitor/disable.test.ts @@ -119,7 +119,12 @@ describe("monitor disable — ID validation", () => { 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); + 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"); @@ -181,7 +186,9 @@ describe("monitor disable — API forwarding", () => { 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) }); + expect(getMonitorFn.mock.calls[0]![0]).toMatchObject({ + id: Number(TEST_MONITOR_ID), + }); }); }); diff --git a/src/commands/monitor/disable.ts b/src/commands/monitor/disable.ts index a7734cb..58352c6 100644 --- a/src/commands/monitor/disable.ts +++ b/src/commands/monitor/disable.ts @@ -35,7 +35,9 @@ export async function disable( try { id = parseMonitorId(monitorId); } catch { - writer.error(`Invalid monitor ID: "${monitorId}". Must be a positive integer.`); + writer.error( + `Invalid monitor ID: "${monitorId}". Must be a positive integer.`, + ); process.exit(1); return; } diff --git a/src/commands/monitor/enable.test.ts b/src/commands/monitor/enable.test.ts index ca11032..cfe97e5 100644 --- a/src/commands/monitor/enable.test.ts +++ b/src/commands/monitor/enable.test.ts @@ -181,7 +181,9 @@ describe("monitor enable — API forwarding", () => { 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) }); + expect(getMonitorFn.mock.calls[0]![0]).toMatchObject({ + id: Number(TEST_MONITOR_ID), + }); }); }); diff --git a/src/commands/monitor/enable.ts b/src/commands/monitor/enable.ts index 5adb581..d2fe602 100644 --- a/src/commands/monitor/enable.ts +++ b/src/commands/monitor/enable.ts @@ -35,7 +35,9 @@ export async function enable( try { id = parseMonitorId(monitorId); } catch { - writer.error(`Invalid monitor ID: "${monitorId}". Must be a positive integer.`); + writer.error( + `Invalid monitor ID: "${monitorId}". Must be a positive integer.`, + ); process.exit(1); return; } diff --git a/src/commands/monitor/list.test.ts b/src/commands/monitor/list.test.ts index 5278192..325da34 100644 --- a/src/commands/monitor/list.test.ts +++ b/src/commands/monitor/list.test.ts @@ -37,8 +37,13 @@ function monitorTerseStub( 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 }), + monitorTerseStub("2", "Beta Monitor", { + ruleKind: MonitorV2RuleKind.Threshold, + disabled: true, + }), + monitorTerseStub("3", "Gamma Monitor", { + ruleKind: MonitorV2RuleKind.Promote, + }), ]; const listMonitorsFn = mock( @@ -122,7 +127,9 @@ describe("monitor list — API forwarding", () => { 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" }); + expect(listMonitorsFn.mock.calls[0]![0]).toMatchObject({ + nameSubstring: "alpha", + }); }); test("calls listMonitors without nameSubstring when --match is absent", async () => { @@ -138,22 +145,37 @@ describe("monitor list — kind filter", () => { test("--kind Count returns only Count monitors", async () => { const { context, stdout } = createMockContext(); - await list.call(context, { kind: [MonitorV2RuleKind.Count], json: true }, deps); + 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); + 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 }, + { + 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); + 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, + ); }); }); diff --git a/src/commands/monitor/list.ts b/src/commands/monitor/list.ts index bb58c09..3f9cd19 100644 --- a/src/commands/monitor/list.ts +++ b/src/commands/monitor/list.ts @@ -2,10 +2,7 @@ import { buildCommand } from "@stricli/core"; import chalk from "chalk"; import type { LocalContext } from "../../context"; import { listMonitors } from "../../rest/monitor/list-monitors"; -import { - type MonitorV2Terse, - MonitorV2RuleKind, -} from "../../rest/generated"; +import { type MonitorV2Terse, MonitorV2RuleKind } from "../../rest/generated"; import { loadConfig } from "../../lib/config"; import { formatApiError } from "../../lib/format-error"; import { muteStatusWriter } from "../../lib/writer"; @@ -61,7 +58,10 @@ const RULE_KIND_ORDER: Record = { [MonitorV2RuleKind.Threshold]: 2, }; -function sortMonitors(monitors: MonitorV2Terse[], sort: SortField): MonitorV2Terse[] { +function sortMonitors( + monitors: MonitorV2Terse[], + sort: SortField, +): MonitorV2Terse[] { return [...monitors].sort((a, b) => { switch (sort) { case "id": @@ -69,7 +69,10 @@ function sortMonitors(monitors: MonitorV2Terse[], sort: SortField): MonitorV2Ter case "name": return (a.name ?? "").localeCompare(b.name ?? ""); case "kind": - return (RULE_KIND_ORDER[a.ruleKind ?? ""] ?? 99) - (RULE_KIND_ORDER[b.ruleKind ?? ""] ?? 99); + return ( + (RULE_KIND_ORDER[a.ruleKind ?? ""] ?? 99) - + (RULE_KIND_ORDER[b.ruleKind ?? ""] ?? 99) + ); case "disabled": return Number(a.disabled ?? false) - Number(b.disabled ?? false); } @@ -94,8 +97,7 @@ const FIELD_COLUMNS = { }), disabled: col.accessor((row) => row.disabled ?? false, { header: "DISABLED", - format: (value) => - value ? chalk.yellow("Yes") : chalk.dim("No"), + format: (value) => (value ? chalk.yellow("Yes") : chalk.dim("No")), }), } satisfies Record>; @@ -132,11 +134,15 @@ export async function list( if (flags.kind) { const filterKinds = flags.kind; - monitors = monitors.filter((m) => m.ruleKind != null && filterKinds.includes(m.ruleKind)); + monitors = monitors.filter( + (m) => m.ruleKind != null && filterKinds.includes(m.ruleKind), + ); } if (flags.disabled != null) { - monitors = monitors.filter((m) => (m.disabled ?? false) === flags.disabled); + monitors = monitors.filter( + (m) => (m.disabled ?? false) === flags.disabled, + ); } if (flags.sort) { @@ -220,7 +226,8 @@ export const listCommand = buildCommand({ }, disabled: { kind: "boolean", - brief: "Filter by disabled status (--disabled shows only disabled, --no-disabled shows only enabled)", + brief: + "Filter by disabled status (--disabled shows only disabled, --no-disabled shows only enabled)", optional: true, }, sort: { diff --git a/src/commands/monitor/update.test.ts b/src/commands/monitor/update.test.ts index d7cfcb3..76cc869 100644 --- a/src/commands/monitor/update.test.ts +++ b/src/commands/monitor/update.test.ts @@ -50,7 +50,9 @@ const getMonitorFn = mock( Promise.resolve(monitorStub()), ); -const readFileFn = mock((_path: string): string => JSON.stringify(STUB_DEFINITION)); +const readFileFn = mock((_path: string): string => + JSON.stringify(STUB_DEFINITION), +); let update: (typeof import("./update"))["update"]; @@ -180,7 +182,10 @@ describe("monitor update — API forwarding", () => { const { context } = createMockContext(); await update.call(context, { name: "New Name" }, TEST_MONITOR_ID, deps); expect(updateMonitorFn).toHaveBeenCalledTimes(1); - const call = updateMonitorFn.mock.calls[0]![0] as unknown as { id: number; name: string }; + const call = updateMonitorFn.mock.calls[0]![0] as unknown as { + id: number; + name: string; + }; expect(call.id).toBe(Number(TEST_MONITOR_ID)); expect(call.name).toBe("New Name"); }); @@ -203,7 +208,9 @@ describe("monitor update — API forwarding", () => { deps, ); expect(readFileFn).toHaveBeenCalledWith("/path/to/def.json"); - const call = updateMonitorFn.mock.calls[0]![0] as unknown as { definition: unknown }; + const call = updateMonitorFn.mock.calls[0]![0] as unknown as { + definition: unknown; + }; expect(call.definition).toMatchObject(STUB_DEFINITION); }); @@ -215,7 +222,12 @@ describe("monitor update — API forwarding", () => { test("calls getMonitor with numeric ID when --json is set", async () => { const { context } = createMockContext(); - await update.call(context, { name: "foo", json: true }, TEST_MONITOR_ID, deps); + await update.call( + context, + { name: "foo", json: true }, + TEST_MONITOR_ID, + deps, + ); expect(getMonitorFn).toHaveBeenCalledTimes(1); expect(getMonitorFn.mock.calls[0]![0]).toMatchObject({ id: Number(TEST_MONITOR_ID), @@ -258,7 +270,12 @@ describe("monitor update — ID validation", () => { test("ID exceeding MAX_SAFE_INTEGER exits with code 1", async () => { const { context, stderr, getExitCode } = createMockContext(); try { - await update.call(context, { name: "foo" }, String(Number.MAX_SAFE_INTEGER + 1), deps); + await update.call( + context, + { name: "foo" }, + String(Number.MAX_SAFE_INTEGER + 1), + deps, + ); throw new Error("expected process.exit"); } catch (error) { expect((error as Error).message).toBe("process.exit"); @@ -348,7 +365,12 @@ describe("monitor update — error handling", () => { getMonitorFn.mockImplementationOnce(() => Promise.resolve(null)); const { context, stderr, getExitCode } = createMockContext(); try { - await update.call(context, { name: "foo", json: true }, TEST_MONITOR_ID, deps); + await update.call( + context, + { name: "foo", json: true }, + TEST_MONITOR_ID, + deps, + ); throw new Error("expected process.exit"); } catch (error) { expect((error as Error).message).toBe("process.exit"); diff --git a/src/commands/monitor/update.ts b/src/commands/monitor/update.ts index 88fbd6b..52c2ef5 100644 --- a/src/commands/monitor/update.ts +++ b/src/commands/monitor/update.ts @@ -50,7 +50,9 @@ export async function update( try { id = parseMonitorId(monitorId); } catch { - writer.error(`Invalid monitor ID: "${monitorId}". Must be a positive integer.`); + writer.error( + `Invalid monitor ID: "${monitorId}". Must be a positive integer.`, + ); process.exit(1); return; } @@ -132,7 +134,11 @@ export const updateCommand = buildCommand({ }, ruleKind: { kind: "enum", - values: [MonitorV2RuleKind.Count, MonitorV2RuleKind.Threshold, MonitorV2RuleKind.Promote], + values: [ + MonitorV2RuleKind.Count, + MonitorV2RuleKind.Threshold, + MonitorV2RuleKind.Promote, + ], brief: "New alert rule kind (Count, Threshold, Promote)", optional: true, }, diff --git a/src/commands/monitor/view.test.ts b/src/commands/monitor/view.test.ts index 20e0ac9..455e8b7 100644 --- a/src/commands/monitor/view.test.ts +++ b/src/commands/monitor/view.test.ts @@ -20,7 +20,10 @@ const loadConfigFn = mock( }), ); -function monitorStub(id: string, overrides: Partial = {}): MonitorV2 { +function monitorStub( + id: string, + overrides: Partial = {}, +): MonitorV2 { return { id, name: "Test Monitor", @@ -177,7 +180,10 @@ describe("monitor view — output", () => { test("JSON output matches MonitorV2 shape", async () => { getMonitorFn.mockImplementationOnce(() => Promise.resolve( - monitorStub("42", { name: "My Monitor", ruleKind: MonitorV2RuleKind.Threshold }), + monitorStub("42", { + name: "My Monitor", + ruleKind: MonitorV2RuleKind.Threshold, + }), ), ); const { context, stdout } = createMockContext(); diff --git a/src/commands/monitor/view.ts b/src/commands/monitor/view.ts index 1649ae9..e3d7aaa 100644 --- a/src/commands/monitor/view.ts +++ b/src/commands/monitor/view.ts @@ -56,7 +56,9 @@ export async function view( try { id = parseMonitorId(monitorId); } catch { - writer.error(`Invalid monitor ID: "${monitorId}". Must be a positive integer.`); + writer.error( + `Invalid monitor ID: "${monitorId}". Must be a positive integer.`, + ); process.exit(1); return; } @@ -106,7 +108,8 @@ export async function view( transform: monitor.effectiveScheduling.transform ? "transform-driven" : undefined, - scheduled: monitor.effectiveScheduling.scheduled?.cronConfig ?? undefined, + scheduled: + monitor.effectiveScheduling.scheduled?.cronConfig ?? undefined, } : undefined, }; diff --git a/src/lib/parsers.test.ts b/src/lib/parsers.test.ts index e9012d0..26f3993 100644 --- a/src/lib/parsers.test.ts +++ b/src/lib/parsers.test.ts @@ -19,7 +19,9 @@ describe("parseMonitorId", () => { 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); + expect(parseMonitorId(String(Number.MAX_SAFE_INTEGER))).toBe( + Number.MAX_SAFE_INTEGER, + ); }); test("rejects zero", () => { diff --git a/src/lib/parsers.ts b/src/lib/parsers.ts index f45d5ac..8a068e1 100644 --- a/src/lib/parsers.ts +++ b/src/lib/parsers.ts @@ -11,7 +11,12 @@ export function parseMonitorId(value: string): number { 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) { + 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; diff --git a/src/rest/monitor/create-monitor.ts b/src/rest/monitor/create-monitor.ts index 29b7a39..d76d14b 100644 --- a/src/rest/monitor/create-monitor.ts +++ b/src/rest/monitor/create-monitor.ts @@ -1,5 +1,9 @@ import type { Config } from "../../lib/config"; -import { MonitorApi, type MonitorApiCreateMonitorRequest, type MonitorV2 } from "../generated"; +import { + MonitorApi, + type MonitorApiCreateMonitorRequest, + type MonitorV2, +} from "../generated"; import { createApiConfiguration } from "../api-config"; export async function createMonitor({ From 36df5a8ae469d4480bf84e989e75cbe654a67823 Mon Sep 17 00:00:00 2001 From: Joe Darling Date: Fri, 26 Jun 2026 19:13:43 +0000 Subject: [PATCH 06/13] feat(monitor): address PR review feedback - delete: require --force flag to prevent accidental deletions - create: add fullDescription with MonitorV2Definition schema link and minimal JSON example in help text - list: add client-side pagination (--limit, --offset) with default limit of 100, consistent with alert list and metric list Co-Authored-By: Claude Sonnet 4.6 --- src/commands/monitor/create.ts | 14 +++++++ src/commands/monitor/delete.test.ts | 57 +++++++++++++++++++++++++++-- src/commands/monitor/delete.ts | 20 +++++++++- src/commands/monitor/list.test.ts | 43 ++++++++++++++++++++++ src/commands/monitor/list.ts | 41 ++++++++++++++++++++- 5 files changed, 168 insertions(+), 7 deletions(-) diff --git a/src/commands/monitor/create.ts b/src/commands/monitor/create.ts index dceeacc..8047196 100644 --- a/src/commands/monitor/create.ts +++ b/src/commands/monitor/create.ts @@ -138,5 +138,19 @@ export const createCommand = buildCommand({ }, docs: { brief: "Create a new monitor", + fullDescription: + "Creates a new MonitorV2 monitor.\n\n" + + "--definition-file schema (MonitorV2Definition as JSON):\n" + + "Full schema reference: https://developer.observeinc.com/#model/monitorv2definition\n\n" + + "Minimal example:\n" + + "{\n" + + ' "inputQuery": {\n' + + ' "outputStage": "main",\n' + + ' "stages": [{ "stageID": "main", "pipeline": "filter true" }]\n' + + " },\n" + + ' "rules": []\n' + + "}\n\n" + + "--action-rules-file schema (Array as JSON):\n" + + "Full schema reference: https://developer.observeinc.com/#model/monitorv2actionrule\n", }, }); diff --git a/src/commands/monitor/delete.test.ts b/src/commands/monitor/delete.test.ts index 3a560b4..9d7a00e 100644 --- a/src/commands/monitor/delete.test.ts +++ b/src/commands/monitor/delete.test.ts @@ -138,12 +138,46 @@ describe("monitor delete — ID validation", () => { }); }); +describe("monitor delete — force guard", () => { + beforeEach(() => deleteMonitorFn.mockClear()); + + test("without --force 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 --force proceeds to delete", async () => { + const { context, stdout } = createMockContext(); + await deleteMonitorCommand.call( + context, + { force: true }, + TEST_MONITOR_ID, + deps, + ); + expect(deleteMonitorFn).toHaveBeenCalledTimes(1); + expect(stdout.join("")).toContain("deleted"); + }); +}); + describe("monitor delete — API forwarding", () => { beforeEach(() => deleteMonitorFn.mockClear()); test("passes the correct numeric ID to deleteMonitor", async () => { const { context } = createMockContext(); - await deleteMonitorCommand.call(context, {}, TEST_MONITOR_ID, deps); + await deleteMonitorCommand.call( + context, + { force: true }, + TEST_MONITOR_ID, + deps, + ); expect(deleteMonitorFn).toHaveBeenCalledTimes(1); expect(deleteMonitorFn.mock.calls[0]![0]).toMatchObject({ id: Number(TEST_MONITOR_ID), @@ -156,7 +190,12 @@ describe("monitor delete — output", () => { test("prints success message containing the monitor ID", async () => { const { context, stdout } = createMockContext(); - await deleteMonitorCommand.call(context, {}, TEST_MONITOR_ID, deps); + await deleteMonitorCommand.call( + context, + { force: true }, + TEST_MONITOR_ID, + deps, + ); expect(stdout.join("")).toContain(TEST_MONITOR_ID); expect(stdout.join("")).toContain("deleted"); }); @@ -174,7 +213,12 @@ describe("monitor delete — error handling", () => { }); const { context, stderr, getExitCode } = createMockContext(); try { - await deleteMonitorCommand.call(context, {}, TEST_MONITOR_ID, deps); + await deleteMonitorCommand.call( + context, + { force: true }, + TEST_MONITOR_ID, + deps, + ); throw new Error("expected process.exit"); } catch (error) { expect((error as Error).message).toBe("process.exit"); @@ -189,7 +233,12 @@ describe("monitor delete — error handling", () => { ); const { context, stderr, getExitCode } = createMockContext(); try { - await deleteMonitorCommand.call(context, {}, TEST_MONITOR_ID, deps); + await deleteMonitorCommand.call( + context, + { force: true }, + TEST_MONITOR_ID, + deps, + ); throw new Error("expected process.exit"); } catch (error) { expect((error as Error).message).toBe("process.exit"); diff --git a/src/commands/monitor/delete.ts b/src/commands/monitor/delete.ts index a11f453..efc3f66 100644 --- a/src/commands/monitor/delete.ts +++ b/src/commands/monitor/delete.ts @@ -5,6 +5,10 @@ import { loadConfig } from "../../lib/config"; import { formatApiError } from "../../lib/format-error"; import { parseMonitorId } from "../../lib/parsers"; +interface DeleteMonitorFlags { + force?: boolean; +} + export interface DeleteMonitorDeps { loadConfig?: typeof loadConfig; deleteMonitor?: typeof deleteMonitor; @@ -12,7 +16,7 @@ export interface DeleteMonitorDeps { export async function deleteMonitorCommand( this: LocalContext, - _flags: Record, + flags: DeleteMonitorFlags, monitorId: string, deps: DeleteMonitorDeps = {}, ): Promise { @@ -33,6 +37,12 @@ export async function deleteMonitorCommand( return; } + if (!flags.force) { + writer.error(`Deleting a monitor is irreversible. Use --force to confirm.`); + process.exit(1); + return; + } + try { const config = loadConfigImpl(); @@ -54,7 +64,13 @@ export const deleteCommand = buildCommand({ kind: "tuple", parameters: [{ brief: "Monitor ID", parse: String }], }, - flags: {}, + flags: { + force: { + kind: "boolean", + brief: "Confirm deletion (required — deletion is irreversible)", + optional: true, + }, + }, aliases: {}, }, docs: { diff --git a/src/commands/monitor/list.test.ts b/src/commands/monitor/list.test.ts index 325da34..2e7b055 100644 --- a/src/commands/monitor/list.test.ts +++ b/src/commands/monitor/list.test.ts @@ -242,6 +242,49 @@ describe("monitor list — output", () => { }); }); +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(); diff --git a/src/commands/monitor/list.ts b/src/commands/monitor/list.ts index 3f9cd19..26da629 100644 --- a/src/commands/monitor/list.ts +++ b/src/commands/monitor/list.ts @@ -12,6 +12,7 @@ import { 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"; @@ -24,6 +25,20 @@ interface ListMonitorsFlags { 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 = [ @@ -149,6 +164,11 @@ export async function list( 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") { @@ -166,10 +186,17 @@ export async function list( return; } - writer.write(chalk.green(`Found ${monitors.length} monitor(s):\n`)); + 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); @@ -253,6 +280,18 @@ export const listCommand = buildCommand({ 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", From 0a79c2a9614cebdd501809dc673bc80bd112fec5 Mon Sep 17 00:00:00 2001 From: Joe Darling Date: Fri, 26 Jun 2026 19:49:10 +0000 Subject: [PATCH 07/13] feat(monitor): address PR review feedback round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename --rule-kind → --kind in create and update for consistency with list - Extract shared ruleKindColor to monitor-utils.ts; fix Count color blue → green - Add --definition inline JSON flag to create and update (mutex with --definition-file) - Show definition section in monitor view human-readable output - Update index.ts brief: "View" → "Manage observe monitors" Co-Authored-By: Claude Sonnet 4.6 --- src/commands/monitor/create.test.ts | 30 +++++++++++----------- src/commands/monitor/create.ts | 37 ++++++++++++++++++++++----- src/commands/monitor/index.ts | 2 +- src/commands/monitor/list.ts | 15 +---------- src/commands/monitor/monitor-utils.ts | 16 ++++++++++++ src/commands/monitor/update.ts | 36 ++++++++++++++++++++------ src/commands/monitor/view.ts | 20 ++++----------- 7 files changed, 96 insertions(+), 60 deletions(-) create mode 100644 src/commands/monitor/monitor-utils.ts diff --git a/src/commands/monitor/create.test.ts b/src/commands/monitor/create.test.ts index 7350e56..92ffd9a 100644 --- a/src/commands/monitor/create.test.ts +++ b/src/commands/monitor/create.test.ts @@ -129,13 +129,13 @@ describe("monitor create — API forwarding", () => { loadConfigFn.mockClear(); }); - test("passes name, ruleKind, and parsed definition to createMonitor", async () => { + test("passes name, kind, and parsed definition to createMonitor", async () => { const { context } = createMockContext(); await create.call( context, { name: "My Monitor", - ruleKind: MonitorV2RuleKind.Count, + kind: MonitorV2RuleKind.Count, definitionFile: "/fake/definition.json", }, deps, @@ -155,7 +155,7 @@ describe("monitor create — API forwarding", () => { context, { name: "My Monitor", - ruleKind: MonitorV2RuleKind.Threshold, + kind: MonitorV2RuleKind.Threshold, definitionFile: "/path/to/def.json", }, deps, @@ -172,7 +172,7 @@ describe("monitor create — API forwarding", () => { context, { name: "My Monitor", - ruleKind: MonitorV2RuleKind.Count, + kind: MonitorV2RuleKind.Count, definitionFile: "/def.json", actionRulesFile: "/rules.json", }, @@ -190,7 +190,7 @@ describe("monitor create — API forwarding", () => { context, { name: "My Monitor", - ruleKind: MonitorV2RuleKind.Count, + kind: MonitorV2RuleKind.Count, definitionFile: "/def.json", }, deps, @@ -218,7 +218,7 @@ describe("monitor create — output", () => { context, { name: "My Monitor", - ruleKind: MonitorV2RuleKind.Count, + kind: MonitorV2RuleKind.Count, definitionFile: "/def.json", }, deps, @@ -239,7 +239,7 @@ describe("monitor create — output", () => { context, { name: "My Monitor", - ruleKind: MonitorV2RuleKind.Count, + kind: MonitorV2RuleKind.Count, definitionFile: "/def.json", json: true, }, @@ -270,7 +270,7 @@ describe("monitor create — error handling", () => { context, { name: "My Monitor", - ruleKind: MonitorV2RuleKind.Count, + kind: MonitorV2RuleKind.Count, definitionFile: "/def.json", }, deps, @@ -293,7 +293,7 @@ describe("monitor create — error handling", () => { context, { name: "My Monitor", - ruleKind: MonitorV2RuleKind.Count, + kind: MonitorV2RuleKind.Count, definitionFile: "/def.json", }, deps, @@ -316,7 +316,7 @@ describe("monitor create — error handling", () => { context, { name: "My Monitor", - ruleKind: MonitorV2RuleKind.Count, + kind: MonitorV2RuleKind.Count, definitionFile: "/def.json", }, deps, @@ -337,7 +337,7 @@ describe("monitor create — error handling", () => { context, { name: "My Monitor", - ruleKind: MonitorV2RuleKind.Count, + kind: MonitorV2RuleKind.Count, definitionFile: "/def.json", }, deps, @@ -361,7 +361,7 @@ describe("monitor create — error handling", () => { context, { name: "My Monitor", - ruleKind: MonitorV2RuleKind.Count, + kind: MonitorV2RuleKind.Count, definitionFile: "/def.json", actionRulesFile: "/rules.json", }, @@ -384,7 +384,7 @@ describe("monitor create — error handling", () => { context, { name: "My Monitor", - ruleKind: MonitorV2RuleKind.Count, + kind: MonitorV2RuleKind.Count, definitionFile: "/def.json", actionRulesFile: "/rules.json", }, @@ -408,7 +408,7 @@ describe("monitor create — error handling", () => { context, { name: "My Monitor", - ruleKind: MonitorV2RuleKind.Count, + kind: MonitorV2RuleKind.Count, definitionFile: "/def.json", }, deps, @@ -432,7 +432,7 @@ describe("monitor create — error handling", () => { context, { name: "My Monitor", - ruleKind: MonitorV2RuleKind.Count, + kind: MonitorV2RuleKind.Count, definitionFile: "/def.json", json: true, }, diff --git a/src/commands/monitor/create.ts b/src/commands/monitor/create.ts index 8047196..23542e2 100644 --- a/src/commands/monitor/create.ts +++ b/src/commands/monitor/create.ts @@ -16,8 +16,9 @@ import { parseMonitorId, parseJsonFile } from "../../lib/parsers"; interface CreateMonitorFlags { name: string; - ruleKind: MonitorV2RuleKind; - definitionFile: string; + kind: MonitorV2RuleKind; + definition?: string; + definitionFile?: string; actionRulesFile?: string; json?: boolean; } @@ -43,14 +44,29 @@ export async function create( const { process, writer: _writer } = this; const writer = muteStatusWriter(_writer, { muted: flags.json === true }); + if (flags.definition != null && flags.definitionFile != null) { + writer.error("--definition and --definition-file are mutually exclusive."); + process.exit(1); + return; + } + + if (flags.definition == null && flags.definitionFile == null) { + writer.error("One of --definition or --definition-file is required."); + process.exit(1); + return; + } + try { const config = loadConfigImpl(); writer.info("Creating monitor..."); + const rawDefinition = + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + flags.definition ?? readFileImpl(resolve(flags.definitionFile!)); const definition = parseJsonFile( - readFileImpl(resolve(flags.definitionFile)), - "--definition-file", + rawDefinition, + "--definition / --definition-file", ); const actionRules = flags.actionRulesFile @@ -64,7 +80,7 @@ export async function create( config, monitorV2: { name: flags.name, - ruleKind: flags.ruleKind, + ruleKind: flags.kind, definition, ...(actionRules != null && { actionRules }), }, @@ -106,7 +122,7 @@ export const createCommand = buildCommand({ brief: "Monitor name", optional: false, }, - ruleKind: { + kind: { kind: "enum", values: [ MonitorV2RuleKind.Count, @@ -116,11 +132,18 @@ export const createCommand = buildCommand({ brief: "Alert rule kind (Count, Threshold, Promote)", optional: false, }, + definition: { + kind: "parsed", + parse: String, + brief: + "MonitorV2Definition as inline JSON (alternative to --definition-file)", + optional: true, + }, definitionFile: { kind: "parsed", parse: String, brief: "Path to JSON file containing the MonitorV2Definition", - optional: false, + optional: true, }, actionRulesFile: { kind: "parsed", diff --git a/src/commands/monitor/index.ts b/src/commands/monitor/index.ts index 2d70881..460a8b7 100644 --- a/src/commands/monitor/index.ts +++ b/src/commands/monitor/index.ts @@ -18,7 +18,7 @@ export const monitorRoutes = buildRouteMap({ disable: disableCommand, }, docs: { - brief: "View observe monitors", + brief: "Manage observe monitors", fullDescription: [ "View and manage monitors in Observe", "", diff --git a/src/commands/monitor/list.ts b/src/commands/monitor/list.ts index 26da629..b4cdf56 100644 --- a/src/commands/monitor/list.ts +++ b/src/commands/monitor/list.ts @@ -3,6 +3,7 @@ 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"; @@ -53,20 +54,6 @@ type FieldName = (typeof AVAILABLE_FIELDS)[number]; const DEFAULT_FIELDS: FieldName[] = ["id", "name", "ruleKind", "disabled"]; -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.blue(kind); - case MonitorV2RuleKind.Promote: - return chalk.magenta(kind); - default: - return chalk.dim(kind); - } -} - const RULE_KIND_ORDER: Record = { [MonitorV2RuleKind.Count]: 0, [MonitorV2RuleKind.Promote]: 1, 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.ts b/src/commands/monitor/update.ts index 52c2ef5..9e16c69 100644 --- a/src/commands/monitor/update.ts +++ b/src/commands/monitor/update.ts @@ -18,7 +18,8 @@ import { parseMonitorId, parseJsonFile } from "../../lib/parsers"; interface UpdateMonitorFlags { name?: string; description?: string; - ruleKind?: MonitorV2RuleKind; + kind?: MonitorV2RuleKind; + definition?: string; definitionFile?: string; actionRulesFile?: string; json?: boolean; @@ -57,16 +58,23 @@ export async function update( return; } + if (flags.definition != null && flags.definitionFile != null) { + writer.error("--definition and --definition-file are mutually exclusive."); + process.exit(1); + return; + } + const hasUpdate = flags.name != null || flags.description != null || - flags.ruleKind != null || + flags.kind != null || + flags.definition != null || flags.definitionFile != null || flags.actionRulesFile != null; if (!hasUpdate) { writer.error( - "At least one update flag is required (--name, --description, --rule-kind, --definition-file, --action-rules-file).", + "At least one update flag is required (--name, --description, --kind, --definition-file, --action-rules-file).", ); process.exit(1); return; @@ -80,11 +88,16 @@ export async function update( const patch: MonitorV2PatchRequest = {}; if (flags.name != null) patch.name = flags.name; if (flags.description != null) patch.description = flags.description; - if (flags.ruleKind != null) patch.ruleKind = flags.ruleKind; - if (flags.definitionFile != null) { + if (flags.kind != null) patch.ruleKind = flags.kind; + const rawDefinition = + flags.definition ?? + (flags.definitionFile + ? readFileImpl(resolve(flags.definitionFile)) + : null); + if (rawDefinition != null) { patch.definition = parseJsonFile( - readFileImpl(resolve(flags.definitionFile)), - "--definition-file", + rawDefinition, + "--definition / --definition-file", ); } if (flags.actionRulesFile != null) { @@ -132,7 +145,7 @@ export const updateCommand = buildCommand({ brief: "New monitor description", optional: true, }, - ruleKind: { + kind: { kind: "enum", values: [ MonitorV2RuleKind.Count, @@ -142,6 +155,13 @@ export const updateCommand = buildCommand({ brief: "New alert rule kind (Count, Threshold, Promote)", optional: true, }, + definition: { + kind: "parsed", + parse: String, + brief: + "MonitorV2Definition as inline JSON (alternative to --definition-file)", + optional: true, + }, definitionFile: { kind: "parsed", parse: String, diff --git a/src/commands/monitor/view.ts b/src/commands/monitor/view.ts index e3d7aaa..d411767 100644 --- a/src/commands/monitor/view.ts +++ b/src/commands/monitor/view.ts @@ -2,13 +2,13 @@ import { buildCommand } from "@stricli/core"; import chalk from "chalk"; import type { LocalContext } from "../../context"; import { getMonitor } from "../../rest/monitor/get-monitor"; -import { MonitorV2RuleKind } from "../../rest/generated"; 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"; @@ -17,20 +17,6 @@ interface ViewMonitorFlags { json?: boolean; } -function ruleKindColor(kind: MonitorV2RuleKind | undefined): string { - if (!kind) return "-"; - switch (kind) { - case MonitorV2RuleKind.Threshold: - return chalk.cyan(kind); - case MonitorV2RuleKind.Count: - return chalk.blue(kind); - case MonitorV2RuleKind.Promote: - return chalk.magenta(kind); - default: - return chalk.dim(kind); - } -} - export interface ViewMonitorDeps { loadConfig?: typeof loadConfig; getMonitor?: typeof getMonitor; @@ -115,6 +101,10 @@ export async function view( }; renderObject(viewData, (text) => writer.write(text)); + + writer.write(""); + writer.write(chalk.bold("Definition:")); + writer.write(JSON.stringify(monitor.definition, null, 2)); } catch (error) { writer.error(`Error: ${await formatApiError(error)}`); process.exit(1); From 3cf40ec364fb55f729618f4cda07b8333e7d7ec8 Mon Sep 17 00:00:00 2001 From: Joe Darling Date: Mon, 29 Jun 2026 17:33:40 +0000 Subject: [PATCH 08/13] fix(monitor): add --definition to update no-flags error message Co-Authored-By: Claude Sonnet 4.6 --- src/commands/monitor/update.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/monitor/update.ts b/src/commands/monitor/update.ts index 9e16c69..26e797b 100644 --- a/src/commands/monitor/update.ts +++ b/src/commands/monitor/update.ts @@ -74,7 +74,7 @@ export async function update( if (!hasUpdate) { writer.error( - "At least one update flag is required (--name, --description, --kind, --definition-file, --action-rules-file).", + "At least one update flag is required (--name, --description, --kind, --definition, --definition-file, --action-rules-file).", ); process.exit(1); return; From 3c5680acd9ad7f09f99b799c2325d052745404a2 Mon Sep 17 00:00:00 2001 From: Joe Darling Date: Mon, 29 Jun 2026 23:36:44 +0000 Subject: [PATCH 09/13] feat(monitor): use ObserveRestSDK in rest files and add --file to update - Refactor all monitor rest helpers to use ObserveRestSDK instead of directly instantiating MonitorApi with createApiConfiguration, matching the pattern used by all other rest files - Add --file flag to monitor update supporting the edit flow: observe monitor view --json > monitor.json # edit monitor.json observe monitor update --file monitor.json --file is mutually exclusive with all other update flags Co-Authored-By: Claude Sonnet 4.6 --- src/commands/monitor/update.ts | 116 +++++++++++++++++++++-------- src/rest/monitor/create-monitor.ts | 12 +-- src/rest/monitor/delete-monitor.ts | 7 +- src/rest/monitor/get-monitor.ts | 9 +-- src/rest/monitor/list-monitors.ts | 8 +- src/rest/monitor/update-monitor.ts | 8 +- 6 files changed, 106 insertions(+), 54 deletions(-) diff --git a/src/commands/monitor/update.ts b/src/commands/monitor/update.ts index cc3a9c3..4eaebc8 100644 --- a/src/commands/monitor/update.ts +++ b/src/commands/monitor/update.ts @@ -16,6 +16,7 @@ import { muteStatusWriter } from "../../lib/writer"; import { parseMonitorId, parseJsonFile } from "../../lib/parsers"; interface UpdateMonitorFlags { + file?: string; name?: string; description?: string; kind?: MonitorV2RuleKind; @@ -32,6 +33,15 @@ export interface UpdateMonitorDeps { readFile?: (path: string) => string; } +const FILE_EXCLUSIVE_FLAGS = [ + "--name", + "--description", + "--kind", + "--definition", + "--definition-file", + "--action-rules-file", +]; + export async function update( this: LocalContext, flags: UpdateMonitorFlags, @@ -58,13 +68,7 @@ export async function update( return; } - if (flags.definition != null && flags.definitionFile != null) { - writer.error("--definition and --definition-file are mutually exclusive."); - process.exit(1); - return; - } - - const hasUpdate = + const hasFieldFlags = flags.name != null || flags.description != null || flags.kind != null || @@ -72,9 +76,23 @@ export async function update( flags.definitionFile != null || flags.actionRulesFile != null; - if (!hasUpdate) { + if (flags.file != null && hasFieldFlags) { + writer.error( + `--file is mutually exclusive with ${FILE_EXCLUSIVE_FLAGS.join(", ")}.`, + ); + process.exit(1); + return; + } + + if (flags.definition != null && flags.definitionFile != null) { + writer.error("--definition and --definition-file are mutually exclusive."); + process.exit(1); + return; + } + + if (flags.file == null && !hasFieldFlags) { writer.error( - "At least one update flag is required (--name, --description, --kind, --definition, --definition-file, --action-rules-file).", + "At least one update flag is required (--file, --name, --description, --kind, --definition, --definition-file, --action-rules-file).", ); process.exit(1); return; @@ -85,26 +103,49 @@ export async function update( writer.info("Updating monitor..."); - const patch: MonitorV2PatchRequest = {}; - if (flags.name != null) patch.name = flags.name; - if (flags.description != null) patch.description = flags.description; - if (flags.kind != null) patch.ruleKind = flags.kind; - const rawDefinition = - flags.definition ?? - (flags.definitionFile - ? readFileImpl(resolve(flags.definitionFile)) - : null); - if (rawDefinition != null) { - patch.definition = parseJsonFile( - rawDefinition, - "--definition / --definition-file", - ); - } - if (flags.actionRulesFile != null) { - patch.actionRules = parseJsonFile( - readFileImpl(resolve(flags.actionRulesFile)), - "--action-rules-file", - ); + let patch: MonitorV2PatchRequest; + + if (flags.file != null) { + const raw = readFileImpl(resolve(flags.file)); + const parsed = parseJsonFile>(raw, "--file"); + patch = { + ...(parsed.name != null && { name: parsed.name as string }), + ...(parsed.description != null && { + description: parsed.description as string, + }), + ...(parsed.ruleKind != null && { + ruleKind: parsed.ruleKind as MonitorV2RuleKind, + }), + ...(parsed.definition != null && { + definition: parsed.definition as MonitorV2Definition, + }), + ...(parsed.actionRules != null && { + actionRules: parsed.actionRules as MonitorV2ActionRule[], + }), + ...(parsed.disabled != null && { disabled: parsed.disabled as boolean }), + }; + } else { + patch = {}; + if (flags.name != null) patch.name = flags.name; + if (flags.description != null) patch.description = flags.description; + if (flags.kind != null) patch.ruleKind = flags.kind; + const rawDefinition = + flags.definition ?? + (flags.definitionFile + ? readFileImpl(resolve(flags.definitionFile)) + : null); + if (rawDefinition != null) { + patch.definition = parseJsonFile( + rawDefinition, + "--definition / --definition-file", + ); + } + if (flags.actionRulesFile != null) { + patch.actionRules = parseJsonFile( + readFileImpl(resolve(flags.actionRulesFile)), + "--action-rules-file", + ); + } } await updateMonitorImpl({ config, id, ...patch }); @@ -134,6 +175,13 @@ export const updateCommand = defineCommand({ 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`). Mutually exclusive with all other flags.", + optional: true, + }, name: { kind: "parsed", parse: String, @@ -185,5 +233,15 @@ export const updateCommand = defineCommand({ }, docs: { brief: "Update a monitor", + fullDescription: [ + "Update a monitor's name, description, rule kind, definition, or action rules.", + "", + "Edit flow (--file):", + " observe monitor view --json > monitor.json", + " # edit monitor.json", + " observe monitor update --file monitor.json", + "", + "--file is mutually exclusive with all other flags.", + ].join("\n"), }, }); diff --git a/src/rest/monitor/create-monitor.ts b/src/rest/monitor/create-monitor.ts index d76d14b..2c41880 100644 --- a/src/rest/monitor/create-monitor.ts +++ b/src/rest/monitor/create-monitor.ts @@ -1,15 +1,11 @@ import type { Config } from "../../lib/config"; -import { - MonitorApi, - type MonitorApiCreateMonitorRequest, - type MonitorV2, -} from "../generated"; -import { createApiConfiguration } from "../api-config"; +import { ObserveRestSDK } from "../client"; +import type { MonitorApiCreateMonitorRequest, MonitorV2 } from "../generated"; export async function createMonitor({ config, ...params }: { config: Config } & MonitorApiCreateMonitorRequest): Promise { - const api = new MonitorApi(createApiConfiguration(config)); - return await api.createMonitor(params); + 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 index 0feb2e8..f0d6aa5 100644 --- a/src/rest/monitor/delete-monitor.ts +++ b/src/rest/monitor/delete-monitor.ts @@ -1,6 +1,5 @@ import type { Config } from "../../lib/config"; -import { MonitorApi } from "../generated"; -import { createApiConfiguration } from "../api-config"; +import { ObserveRestSDK } from "../client"; export async function deleteMonitor({ config, @@ -9,6 +8,6 @@ export async function deleteMonitor({ config: Config; id: number; }): Promise { - const api = new MonitorApi(createApiConfiguration(config)); - await api.deleteMonitor({ id }); + 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 index 8b9b981..beb11c9 100644 --- a/src/rest/monitor/get-monitor.ts +++ b/src/rest/monitor/get-monitor.ts @@ -1,7 +1,6 @@ import type { Config } from "../../lib/config"; -import { MonitorApi, type MonitorV2 } from "../generated"; -import { ResponseError } from "../generated/runtime"; -import { createApiConfiguration } from "../api-config"; +import { ObserveRestSDK } from "../client"; +import { type MonitorV2, ResponseError } from "../generated"; export async function getMonitor({ config, @@ -10,9 +9,9 @@ export async function getMonitor({ config: Config; id: number; }): Promise { - const api = new MonitorApi(createApiConfiguration(config)); + const sdk = new ObserveRestSDK(config); try { - return await api.getMonitor({ id }); + return await sdk.monitorApi.getMonitor({ id }); } catch (error) { if (error instanceof ResponseError && error.response.status === 404) { return null; diff --git a/src/rest/monitor/list-monitors.ts b/src/rest/monitor/list-monitors.ts index d9e6668..32cb63c 100644 --- a/src/rest/monitor/list-monitors.ts +++ b/src/rest/monitor/list-monitors.ts @@ -1,11 +1,11 @@ import type { Config } from "../../lib/config"; -import { MonitorApi, type MonitorApiListMonitorsRequest } from "../generated"; -import { createApiConfiguration } from "../api-config"; +import { ObserveRestSDK } from "../client"; +import type { MonitorApiListMonitorsRequest } from "../generated"; export async function listMonitors({ config, ...params }: { config: Config } & MonitorApiListMonitorsRequest) { - const api = new MonitorApi(createApiConfiguration(config)); - return await api.listMonitors(params); + 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 index fa7be4b..b8db73e 100644 --- a/src/rest/monitor/update-monitor.ts +++ b/src/rest/monitor/update-monitor.ts @@ -1,12 +1,12 @@ import type { Config } from "../../lib/config"; -import { MonitorApi, type MonitorV2PatchRequest } from "../generated"; -import { createApiConfiguration } from "../api-config"; +import { ObserveRestSDK } from "../client"; +import type { MonitorV2PatchRequest } from "../generated"; export async function updateMonitor({ config, id, ...patch }: { config: Config; id: number } & MonitorV2PatchRequest): Promise { - const api = new MonitorApi(createApiConfiguration(config)); - await api.updateMonitor({ id, monitorV2PatchRequest: patch }); + const sdk = new ObserveRestSDK(config); + await sdk.monitorApi.updateMonitor({ id, monitorV2PatchRequest: patch }); } From b8336734f04fc1406b69c280c30abb6ffd28afbc Mon Sep 17 00:00:00 2001 From: Joe Darling Date: Tue, 30 Jun 2026 15:29:36 +0000 Subject: [PATCH 10/13] fix(monitor): fix lint errors in delete.ts Remove unnecessary optional chain and fix conditional operator to satisfy @typescript-eslint/no-unnecessary-condition. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/monitor/delete.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/monitor/delete.ts b/src/commands/monitor/delete.ts index 0dba782..6c4a07e 100644 --- a/src/commands/monitor/delete.ts +++ b/src/commands/monitor/delete.ts @@ -69,7 +69,7 @@ export async function deleteMonitorCommand( if (!flags.yes) { // When no confirmFn is injected (real usage), require a TTY. - if (!confirmFn && !(process.stdin as NodeJS.ReadStream)?.isTTY) { + 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.", ); @@ -85,7 +85,7 @@ export async function deleteMonitorCommand( } const confirm = confirmFn ?? makeDefaultConfirm(process); - const confirmed = await confirm(monitor.name ?? monitorId); + const confirmed = await confirm(monitor.name); if (!confirmed) { writer.error("Deletion cancelled."); process.exit(1); From 9d1d1d119be80a8c39cf99b838999f07a171885b Mon Sep 17 00:00:00 2001 From: Joe Darling Date: Tue, 30 Jun 2026 15:31:49 +0000 Subject: [PATCH 11/13] fix(monitor): fix prettier formatting in monitor command files Co-Authored-By: Claude Sonnet 4.6 --- src/commands/monitor/delete.test.ts | 10 ++++------ src/commands/monitor/delete.ts | 5 ++++- src/commands/monitor/update.ts | 4 +++- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/commands/monitor/delete.test.ts b/src/commands/monitor/delete.test.ts index d21bf10..a2fbda9 100644 --- a/src/commands/monitor/delete.test.ts +++ b/src/commands/monitor/delete.test.ts @@ -167,9 +167,8 @@ describe("monitor delete — yes guard", () => { }); 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 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, { @@ -182,9 +181,8 @@ describe("monitor delete — yes guard", () => { }); 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 getMonitorFn = mock((_params: { config: Config; id: number }) => + Promise.resolve({ id: Number(TEST_MONITOR_ID), name: "Test Monitor" }), ); const { context, stderr, getExitCode } = createMockContext(); try { diff --git a/src/commands/monitor/delete.ts b/src/commands/monitor/delete.ts index 6c4a07e..ef50cfb 100644 --- a/src/commands/monitor/delete.ts +++ b/src/commands/monitor/delete.ts @@ -69,7 +69,10 @@ export async function deleteMonitorCommand( if (!flags.yes) { // When no confirmFn is injected (real usage), require a TTY. - if (!confirmFn && !(process.stdin as NodeJS.ReadStream | undefined)?.isTTY) { + 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.", ); diff --git a/src/commands/monitor/update.ts b/src/commands/monitor/update.ts index 4eaebc8..a65c6b5 100644 --- a/src/commands/monitor/update.ts +++ b/src/commands/monitor/update.ts @@ -122,7 +122,9 @@ export async function update( ...(parsed.actionRules != null && { actionRules: parsed.actionRules as MonitorV2ActionRule[], }), - ...(parsed.disabled != null && { disabled: parsed.disabled as boolean }), + ...(parsed.disabled != null && { + disabled: parsed.disabled as boolean, + }), }; } else { patch = {}; From 80f76374bb9c5b8e7f785cbad43c633f52536cab Mon Sep 17 00:00:00 2001 From: Joe Darling Date: Wed, 1 Jul 2026 21:49:40 +0000 Subject: [PATCH 12/13] feat(monitor): simplify create/update to --file only Remove individual field flags (--name, --kind, --definition, etc.) from both create and update in favour of a single --file flag, matching the pattern used by other CLIs. Also fix update to use !== undefined instead of != null when building the patch, preserving explicit null values per JSON merge-patch semantics. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/monitor/create.test.ts | 259 ++++++++++------------------ src/commands/monitor/create.ts | 123 +++++-------- src/commands/monitor/update.test.ts | 145 +++++++++------- src/commands/monitor/update.ts | 166 ++++-------------- 4 files changed, 247 insertions(+), 446 deletions(-) diff --git a/src/commands/monitor/create.test.ts b/src/commands/monitor/create.test.ts index 92ffd9a..8474402 100644 --- a/src/commands/monitor/create.test.ts +++ b/src/commands/monitor/create.test.ts @@ -29,10 +29,16 @@ const STUB_DEFINITION: MonitorV2Definition = { 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: "New Monitor", + name: "My Monitor", ruleKind: MonitorV2RuleKind.Count, definition: STUB_DEFINITION, ...overrides, @@ -49,9 +55,7 @@ const getMonitorFn = mock( Promise.resolve(monitorStub()), ); -const readFileFn = mock((_path: string): string => - JSON.stringify(STUB_DEFINITION), -); +const readFileFn = mock((_path: string): string => STUB_CREATE_FILE); let create: (typeof import("./create"))["create"]; @@ -129,17 +133,9 @@ describe("monitor create — API forwarding", () => { loadConfigFn.mockClear(); }); - test("passes name, kind, and parsed definition to createMonitor", async () => { + test("passes name, ruleKind, and definition from --file to createMonitor", async () => { const { context } = createMockContext(); - await create.call( - context, - { - name: "My Monitor", - kind: MonitorV2RuleKind.Count, - definitionFile: "/fake/definition.json", - }, - deps, - ); + 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 }; @@ -149,52 +145,33 @@ describe("monitor create — API forwarding", () => { expect(call.monitorV2.definition).toMatchObject(STUB_DEFINITION); }); - test("reads definition from definitionFile path", async () => { + test("reads file from the provided path", async () => { const { context } = createMockContext(); - await create.call( - context, - { - name: "My Monitor", - kind: MonitorV2RuleKind.Threshold, - definitionFile: "/path/to/def.json", - }, - deps, - ); - expect(readFileFn).toHaveBeenCalledWith("/path/to/def.json"); + await create.call(context, { file: "/path/to/monitor.json" }, deps); + expect(readFileFn).toHaveBeenCalledWith("/path/to/monitor.json"); }); - test("includes actionRules when --action-rules-file is provided", async () => { + test("includes actionRules when present in --file", async () => { const actionRules = [{ actionId: "act-1" }]; - readFileFn.mockImplementationOnce(() => JSON.stringify(STUB_DEFINITION)); - readFileFn.mockImplementationOnce(() => JSON.stringify(actionRules)); - const { context } = createMockContext(); - await create.call( - context, - { + readFileFn.mockImplementationOnce(() => + JSON.stringify({ name: "My Monitor", - kind: MonitorV2RuleKind.Count, - definitionFile: "/def.json", - actionRulesFile: "/rules.json", - }, - deps, + 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 --action-rules-file is not provided", async () => { + test("omits actionRules when not present in --file", async () => { const { context } = createMockContext(); - await create.call( - context, - { - name: "My Monitor", - kind: MonitorV2RuleKind.Count, - definitionFile: "/def.json", - }, - deps, - ); + await create.call(context, { file: "/monitor.json" }, deps); const call = createMonitorFn.mock.calls[0]![0] as unknown as { monitorV2: object; }; @@ -214,15 +191,7 @@ describe("monitor create — output", () => { Promise.resolve(monitorStub({ id: "99001" })), ); const { context, stdout } = createMockContext(); - await create.call( - context, - { - name: "My Monitor", - kind: MonitorV2RuleKind.Count, - definitionFile: "/def.json", - }, - deps, - ); + await create.call(context, { file: "/monitor.json" }, deps); expect(stdout.join("")).toContain("99001"); expect(stdout.join("")).toContain("created"); }); @@ -235,16 +204,7 @@ describe("monitor create — output", () => { Promise.resolve(monitorStub({ id: "99001", name: "My Monitor" })), ); const { context, stdout } = createMockContext(); - await create.call( - context, - { - name: "My Monitor", - kind: MonitorV2RuleKind.Count, - definitionFile: "/def.json", - json: true, - }, - deps, - ); + 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; @@ -252,121 +212,92 @@ describe("monitor create — output", () => { }); }); -describe("monitor create — error handling", () => { +describe("monitor create — file validation", () => { 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")), + 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, - { - name: "My Monitor", - kind: MonitorV2RuleKind.Count, - definitionFile: "/def.json", - }, - deps, - ); + 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"); + expect(stderr.join("")).toContain("name"); + expect(createMonitorFn).not.toHaveBeenCalled(); }); - test("loadConfig error exits with code 1 and prints to stderr", async () => { - loadConfigFn.mockImplementationOnce((): never => { - throw new Error("no config file found"); - }); + 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, - { - name: "My Monitor", - kind: MonitorV2RuleKind.Count, - definitionFile: "/def.json", - }, - deps, - ); + 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"); + expect(stderr.join("")).toContain("ruleKind"); + expect(createMonitorFn).not.toHaveBeenCalled(); }); - test("readFile throwing for --definition-file exits with code 1", async () => { - readFileFn.mockImplementationOnce((): never => { - throw new Error("ENOENT: no such file or directory, open '/def.json'"); - }); + 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, - { - name: "My Monitor", - kind: MonitorV2RuleKind.Count, - definitionFile: "/def.json", - }, - deps, - ); + 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"); + expect(stderr.join("")).toContain("definition"); + expect(createMonitorFn).not.toHaveBeenCalled(); }); - test("invalid JSON in --definition-file exits with code 1 and mentions the flag", async () => { + 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, - { - name: "My Monitor", - kind: MonitorV2RuleKind.Count, - definitionFile: "/def.json", - }, - deps, - ); + 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-file"); + expect(stderr.join("")).toContain("--file"); }); +}); - test("readFile throwing for --action-rules-file exits with code 1", async () => { - readFileFn.mockImplementationOnce(() => JSON.stringify(STUB_DEFINITION)); - readFileFn.mockImplementationOnce((): never => { - throw new Error("ENOENT: no such file or directory, open '/rules.json'"); - }); +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, - { - name: "My Monitor", - kind: MonitorV2RuleKind.Count, - definitionFile: "/def.json", - actionRulesFile: "/rules.json", - }, - deps, - ); + await create.call(context, { file: "/monitor.json" }, deps); throw new Error("expected process.exit"); } catch (error) { expect((error as Error).message).toBe("process.exit"); @@ -375,27 +306,36 @@ describe("monitor create — error handling", () => { expect(stderr.join("")).toContain("Error"); }); - test("invalid JSON in --action-rules-file exits with code 1 and mentions the flag", async () => { - readFileFn.mockImplementationOnce(() => JSON.stringify(STUB_DEFINITION)); - readFileFn.mockImplementationOnce(() => "not json at all"); + 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, - { - name: "My Monitor", - kind: MonitorV2RuleKind.Count, - definitionFile: "/def.json", - actionRulesFile: "/rules.json", - }, - deps, + 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("--action-rules-file"); + expect(stderr.join("")).toContain("Error"); }); test("createMonitor returning invalid id exits with code 1", async () => { @@ -404,15 +344,7 @@ describe("monitor create — error handling", () => { ); const { context, stderr, getExitCode } = createMockContext(); try { - await create.call( - context, - { - name: "My Monitor", - kind: MonitorV2RuleKind.Count, - definitionFile: "/def.json", - }, - deps, - ); + await create.call(context, { file: "/monitor.json" }, deps); throw new Error("expected process.exit"); } catch (error) { expect((error as Error).message).toBe("process.exit"); @@ -428,16 +360,7 @@ describe("monitor create — error handling", () => { getMonitorFn.mockImplementationOnce(() => Promise.resolve(null)); const { context, stderr, getExitCode } = createMockContext(); try { - await create.call( - context, - { - name: "My Monitor", - kind: MonitorV2RuleKind.Count, - definitionFile: "/def.json", - json: true, - }, - deps, - ); + 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"); diff --git a/src/commands/monitor/create.ts b/src/commands/monitor/create.ts index 28ac6ae..2dd7e88 100644 --- a/src/commands/monitor/create.ts +++ b/src/commands/monitor/create.ts @@ -15,11 +15,7 @@ import { muteStatusWriter } from "../../lib/writer"; import { parseMonitorId, parseJsonFile } from "../../lib/parsers"; interface CreateMonitorFlags { - name: string; - kind: MonitorV2RuleKind; - definition?: string; - definitionFile?: string; - actionRulesFile?: string; + file: string; json?: boolean; } @@ -44,45 +40,41 @@ export async function create( const { process, writer: _writer } = this; const writer = muteStatusWriter(_writer, { muted: flags.json === true }); - if (flags.definition != null && flags.definitionFile != null) { - writer.error("--definition and --definition-file are mutually exclusive."); - process.exit(1); - return; - } - - if (flags.definition == null && flags.definitionFile == null) { - writer.error("One of --definition or --definition-file is required."); - process.exit(1); - return; - } - try { const config = loadConfigImpl(); writer.info("Creating monitor..."); - const rawDefinition = - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - flags.definition ?? readFileImpl(resolve(flags.definitionFile!)); - const definition = parseJsonFile( - rawDefinition, - "--definition / --definition-file", - ); + const raw = readFileImpl(resolve(flags.file)); + const parsed = parseJsonFile>(raw, "--file"); - const actionRules = flags.actionRulesFile - ? parseJsonFile( - readFileImpl(resolve(flags.actionRulesFile)), - "--action-rules-file", - ) - : undefined; + 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: flags.name, - ruleKind: flags.kind, - definition, - ...(actionRules != null && { actionRules }), + name: parsed.name, + ruleKind: parsed.ruleKind as MonitorV2RuleKind, + definition: parsed.definition as MonitorV2Definition, + ...(parsed.actionRules != null && { + actionRules: parsed.actionRules as MonitorV2ActionRule[], + }), }, }); @@ -117,41 +109,12 @@ export const createCommand = defineCommand({ parameters: { positional: { kind: "tuple", parameters: [] }, flags: { - name: { + file: { kind: "parsed", parse: String, - brief: "Monitor name", + brief: "Path to JSON file containing the monitor to create", optional: false, }, - kind: { - kind: "enum", - values: [ - MonitorV2RuleKind.Count, - MonitorV2RuleKind.Threshold, - MonitorV2RuleKind.Promote, - ], - brief: "Alert rule kind (Count, Threshold, Promote)", - optional: false, - }, - definition: { - kind: "parsed", - parse: String, - brief: - "MonitorV2Definition as inline JSON (alternative to --definition-file)", - optional: true, - }, - definitionFile: { - kind: "parsed", - parse: String, - brief: "Path to JSON file containing the MonitorV2Definition", - optional: true, - }, - actionRulesFile: { - kind: "parsed", - parse: String, - brief: "Path to JSON file containing Array", - optional: true, - }, json: { kind: "boolean", brief: "Output the created monitor as JSON", @@ -162,19 +125,21 @@ export const createCommand = defineCommand({ }, docs: { brief: "Create a new monitor", - fullDescription: - "Creates a new MonitorV2 monitor.\n\n" + - "--definition-file schema (MonitorV2Definition as JSON):\n" + - "Full schema reference: https://developer.observeinc.com/#model/monitorv2definition\n\n" + - "Minimal example:\n" + - "{\n" + - ' "inputQuery": {\n' + - ' "outputStage": "main",\n' + - ' "stages": [{ "stageID": "main", "pipeline": "filter true" }]\n' + - " },\n" + - ' "rules": []\n' + - "}\n\n" + - "--action-rules-file schema (Array as JSON):\n" + - "Full schema reference: https://developer.observeinc.com/#model/monitorv2actionrule\n", + 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/update.test.ts b/src/commands/monitor/update.test.ts index 76cc869..3b7fe83 100644 --- a/src/commands/monitor/update.test.ts +++ b/src/commands/monitor/update.test.ts @@ -41,6 +41,8 @@ function monitorStub(overrides: Partial = {}): MonitorV2 { }; } +const STUB_FILE = JSON.stringify(monitorStub()); + const updateMonitorFn = mock( (_params: { config: Config; id: number }): Promise => Promise.resolve(), ); @@ -50,9 +52,7 @@ const getMonitorFn = mock( Promise.resolve(monitorStub()), ); -const readFileFn = mock((_path: string): string => - JSON.stringify(STUB_DEFINITION), -); +const readFileFn = mock((_path: string): string => STUB_FILE); let update: (typeof import("./update"))["update"]; @@ -131,7 +131,7 @@ describe("monitor update — ID validation", () => { test("non-integer ID exits with code 1 and does not call updateMonitor", async () => { const { context, stderr, getExitCode } = createMockContext(); try { - await update.call(context, { name: "foo" }, "abc", deps); + 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"); @@ -144,7 +144,7 @@ describe("monitor update — ID validation", () => { test("float ID exits with code 1", async () => { const { context, stderr, getExitCode } = createMockContext(); try { - await update.call(context, { name: "foo" }, "1.5", deps); + 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"); @@ -152,21 +152,22 @@ describe("monitor update — ID validation", () => { expect(getExitCode()).toBe(1); expect(stderr.join("")).toContain("Invalid monitor ID"); }); -}); -describe("monitor update — no-op validation", () => { - beforeEach(() => updateMonitorFn.mockClear()); - - test("exits with code 1 when no update flags are provided", async () => { + test("ID exceeding MAX_SAFE_INTEGER exits with code 1", async () => { const { context, stderr, getExitCode } = createMockContext(); try { - await update.call(context, {}, TEST_MONITOR_ID, deps); + 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("At least one update flag"); + expect(stderr.join("")).toContain("Invalid monitor ID"); expect(updateMonitorFn).not.toHaveBeenCalled(); }); }); @@ -178,45 +179,72 @@ describe("monitor update — API forwarding", () => { readFileFn.mockClear(); }); - test("passes numeric ID and name to updateMonitor", async () => { + test("passes numeric ID and patchable fields from --file to updateMonitor", async () => { const { context } = createMockContext(); - await update.call(context, { name: "New Name" }, TEST_MONITOR_ID, deps); + 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("New Name"); + expect(call.name).toBe("Joe - Test Monitor"); + expect(call.ruleKind).toBe(MonitorV2RuleKind.Count); }); - test("only includes explicitly provided fields in the patch", async () => { + 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, { name: "Only Name" }, TEST_MONITOR_ID, deps); - const call = updateMonitorFn.mock.calls[0]![0] as unknown as object; - expect(call).not.toHaveProperty("description"); - expect(call).not.toHaveProperty("ruleKind"); - expect(call).not.toHaveProperty("definition"); + 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("reads and forwards definition from --definition-file", async () => { + 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, - { definitionFile: "/path/to/def.json" }, + { file: "/monitor.json" }, TEST_MONITOR_ID, deps, ); - expect(readFileFn).toHaveBeenCalledWith("/path/to/def.json"); - const call = updateMonitorFn.mock.calls[0]![0] as unknown as { - definition: unknown; - }; - expect(call.definition).toMatchObject(STUB_DEFINITION); + 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, { name: "foo" }, TEST_MONITOR_ID, deps); + await update.call( + context, + { file: "/monitor.json" }, + TEST_MONITOR_ID, + deps, + ); expect(getMonitorFn).not.toHaveBeenCalled(); }); @@ -224,7 +252,7 @@ describe("monitor update — API forwarding", () => { const { context } = createMockContext(); await update.call( context, - { name: "foo", json: true }, + { file: "/monitor.json", json: true }, TEST_MONITOR_ID, deps, ); @@ -243,7 +271,12 @@ describe("monitor update — output", () => { test("prints success message with monitor ID", async () => { const { context, stdout } = createMockContext(); - await update.call(context, { name: "foo" }, TEST_MONITOR_ID, deps); + await update.call( + context, + { file: "/monitor.json" }, + TEST_MONITOR_ID, + deps, + ); expect(stdout.join("")).toContain(TEST_MONITOR_ID); expect(stdout.join("")).toContain("updated"); }); @@ -255,7 +288,7 @@ describe("monitor update — output", () => { const { context, stdout } = createMockContext(); await update.call( context, - { name: "Updated Name", json: true }, + { file: "/monitor.json", json: true }, TEST_MONITOR_ID, deps, ); @@ -264,28 +297,6 @@ describe("monitor update — output", () => { }); }); -describe("monitor update — ID validation", () => { - beforeEach(() => updateMonitorFn.mockClear()); - - test("ID exceeding MAX_SAFE_INTEGER exits with code 1", async () => { - const { context, stderr, getExitCode } = createMockContext(); - try { - await update.call( - context, - { name: "foo" }, - 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 — error handling", () => { beforeEach(() => { updateMonitorFn.mockClear(); @@ -299,7 +310,12 @@ describe("monitor update — error handling", () => { ); const { context, stderr, getExitCode } = createMockContext(); try { - await update.call(context, { name: "foo" }, TEST_MONITOR_ID, deps); + 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"); @@ -314,7 +330,12 @@ describe("monitor update — error handling", () => { }); const { context, stderr, getExitCode } = createMockContext(); try { - await update.call(context, { name: "foo" }, TEST_MONITOR_ID, deps); + 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"); @@ -323,7 +344,7 @@ describe("monitor update — error handling", () => { expect(stderr.join("")).toContain("Error"); }); - test("readFile throwing for --definition-file exits with code 1", async () => { + test("readFile throwing exits with code 1", async () => { readFileFn.mockImplementationOnce((): never => { throw new Error("ENOENT: no such file or directory"); }); @@ -331,7 +352,7 @@ describe("monitor update — error handling", () => { try { await update.call( context, - { definitionFile: "/missing.json" }, + { file: "/monitor.json" }, TEST_MONITOR_ID, deps, ); @@ -343,13 +364,13 @@ describe("monitor update — error handling", () => { expect(stderr.join("")).toContain("Error"); }); - test("invalid JSON in --definition-file exits with code 1 and mentions the flag", async () => { + 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, - { definitionFile: "/bad.json" }, + { file: "/monitor.json" }, TEST_MONITOR_ID, deps, ); @@ -358,7 +379,7 @@ describe("monitor update — error handling", () => { expect((error as Error).message).toBe("process.exit"); } expect(getExitCode()).toBe(1); - expect(stderr.join("")).toContain("--definition-file"); + expect(stderr.join("")).toContain("--file"); }); test("getMonitor returning null after update exits with code 1", async () => { @@ -367,7 +388,7 @@ describe("monitor update — error handling", () => { try { await update.call( context, - { name: "foo", json: true }, + { file: "/monitor.json", json: true }, TEST_MONITOR_ID, deps, ); diff --git a/src/commands/monitor/update.ts b/src/commands/monitor/update.ts index a65c6b5..82d9c36 100644 --- a/src/commands/monitor/update.ts +++ b/src/commands/monitor/update.ts @@ -16,13 +16,7 @@ import { muteStatusWriter } from "../../lib/writer"; import { parseMonitorId, parseJsonFile } from "../../lib/parsers"; interface UpdateMonitorFlags { - file?: string; - name?: string; - description?: string; - kind?: MonitorV2RuleKind; - definition?: string; - definitionFile?: string; - actionRulesFile?: string; + file: string; json?: boolean; } @@ -33,15 +27,6 @@ export interface UpdateMonitorDeps { readFile?: (path: string) => string; } -const FILE_EXCLUSIVE_FLAGS = [ - "--name", - "--description", - "--kind", - "--definition", - "--definition-file", - "--action-rules-file", -]; - export async function update( this: LocalContext, flags: UpdateMonitorFlags, @@ -68,87 +53,34 @@ export async function update( return; } - const hasFieldFlags = - flags.name != null || - flags.description != null || - flags.kind != null || - flags.definition != null || - flags.definitionFile != null || - flags.actionRulesFile != null; - - if (flags.file != null && hasFieldFlags) { - writer.error( - `--file is mutually exclusive with ${FILE_EXCLUSIVE_FLAGS.join(", ")}.`, - ); - process.exit(1); - return; - } - - if (flags.definition != null && flags.definitionFile != null) { - writer.error("--definition and --definition-file are mutually exclusive."); - process.exit(1); - return; - } - - if (flags.file == null && !hasFieldFlags) { - writer.error( - "At least one update flag is required (--file, --name, --description, --kind, --definition, --definition-file, --action-rules-file).", - ); - process.exit(1); - return; - } - try { const config = loadConfigImpl(); writer.info("Updating monitor..."); - let patch: MonitorV2PatchRequest; - - if (flags.file != null) { - const raw = readFileImpl(resolve(flags.file)); - const parsed = parseJsonFile>(raw, "--file"); - patch = { - ...(parsed.name != null && { name: parsed.name as string }), - ...(parsed.description != null && { - description: parsed.description as string, - }), - ...(parsed.ruleKind != null && { - ruleKind: parsed.ruleKind as MonitorV2RuleKind, - }), - ...(parsed.definition != null && { - definition: parsed.definition as MonitorV2Definition, - }), - ...(parsed.actionRules != null && { - actionRules: parsed.actionRules as MonitorV2ActionRule[], - }), - ...(parsed.disabled != null && { - disabled: parsed.disabled as boolean, - }), + 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; }; - } else { - patch = {}; - if (flags.name != null) patch.name = flags.name; - if (flags.description != null) patch.description = flags.description; - if (flags.kind != null) patch.ruleKind = flags.kind; - const rawDefinition = - flags.definition ?? - (flags.definitionFile - ? readFileImpl(resolve(flags.definitionFile)) - : null); - if (rawDefinition != null) { - patch.definition = parseJsonFile( - rawDefinition, - "--definition / --definition-file", - ); - } - if (flags.actionRulesFile != null) { - patch.actionRules = parseJsonFile( - readFileImpl(resolve(flags.actionRulesFile)), - "--action-rules-file", - ); - } - } + + const patch = Object.fromEntries( + Object.entries({ + name, + description, + ruleKind, + definition, + actionRules, + disabled, + }).filter(([, v]) => v !== undefined), + ) as MonitorV2PatchRequest; await updateMonitorImpl({ config, id, ...patch }); @@ -181,49 +113,8 @@ export const updateCommand = defineCommand({ kind: "parsed", parse: String, brief: - "Path to a full monitor JSON file (e.g. from `monitor view --json`). Mutually exclusive with all other flags.", - optional: true, - }, - name: { - kind: "parsed", - parse: String, - brief: "New monitor name", - optional: true, - }, - description: { - kind: "parsed", - parse: String, - brief: "New monitor description", - optional: true, - }, - kind: { - kind: "enum", - values: [ - MonitorV2RuleKind.Count, - MonitorV2RuleKind.Threshold, - MonitorV2RuleKind.Promote, - ], - brief: "New alert rule kind (Count, Threshold, Promote)", - optional: true, - }, - definition: { - kind: "parsed", - parse: String, - brief: - "MonitorV2Definition as inline JSON (alternative to --definition-file)", - optional: true, - }, - definitionFile: { - kind: "parsed", - parse: String, - brief: "Path to JSON file containing the new MonitorV2Definition", - optional: true, - }, - actionRulesFile: { - kind: "parsed", - parse: String, - brief: "Path to JSON file containing new Array", - optional: true, + "Path to a full monitor JSON file (e.g. from `monitor view --json`)", + optional: false, }, json: { kind: "boolean", @@ -236,14 +127,15 @@ export const updateCommand = defineCommand({ docs: { brief: "Update a monitor", fullDescription: [ - "Update a monitor's name, description, rule kind, definition, or action rules.", + "Update a monitor from a JSON file.", "", - "Edit flow (--file):", + "Edit flow:", " observe monitor view --json > monitor.json", " # edit monitor.json", " observe monitor update --file monitor.json", "", - "--file is mutually exclusive with all other flags.", + "Patchable fields: name, description, ruleKind, definition, actionRules, disabled.", + "Read-only fields (id, effectiveScheduling) are ignored.", ].join("\n"), }, }); From 0a1cb45ca0f502ca9edd3befb2ae6e45c1d6950d Mon Sep 17 00:00:00 2001 From: Joe Darling Date: Wed, 1 Jul 2026 23:08:25 +0000 Subject: [PATCH 13/13] docs(skill): update monitor create/update examples to --file interface Co-Authored-By: Claude Sonnet 4.6 --- skills/observe-cli/SKILL.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/skills/observe-cli/SKILL.md b/skills/observe-cli/SKILL.md index 7d50143..1a6d48b 100644 --- a/skills/observe-cli/SKILL.md +++ b/skills/observe-cli/SKILL.md @@ -290,28 +290,30 @@ observe monitor list --match "checkout" --json # filter by name observe monitor view --json # full monitor definition -observe monitor create --name "My Monitor" \ - --rule-kind Count \ - --definition-file ./definition.json --json # create; --json returns the created object +observe monitor create --file ./monitor.json --json # create from JSON file; --json returns the created object -observe monitor update --name "New Name" --json # rename; --json returns the updated object -observe monitor update --definition-file ./def.json --json +# 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 +observe monitor delete # permanent removal; prompts for confirmation +observe monitor delete --yes # skip confirmation (non-interactive / scripting) ``` Notes: -- `--definition-file` accepts a path to a JSON file containing a - `MonitorV2Definition` object (OPAL `inputQuery` + alert `rules`). -- `--action-rules-file` (create/update) accepts a path to a JSON file - containing an array of `MonitorV2ActionRule` objects; omit to leave - action rules unchanged. +- `--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 `update --disabled` for clarity. + 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.