diff --git a/README.md b/README.md index 147564d..af9ea63 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,9 @@ Every resource supports `list`, `get`, `create`, `update`, and `delete` subcomma | `resource-groups` | list, get, create, update, delete | `/api/v1/resource-groups` | | `webhooks` | list, get, create, update, delete, test | `/api/v1/webhooks` | | `api-keys` | list, get, create, delete, revoke | `/api/v1/api-keys` | -| `dependencies` | list, get, track, delete | `/api/v1/service-subscriptions` | -| `data services` | status, uptime | `/api/v1/services` | +| `dependencies` | list, get, track, update, delete | `/api/v1/service-subscriptions` | +| `services` | list, get, status, summary, components, categories, uptime, incidents, maintenances | `/api/v1/services`, `/api/v1/categories` | +| `data services` | status, uptime (aliases of `services status` / `services uptime`) | `/api/v1/services` | ### Global Flags diff --git a/docs/openapi/monitoring-api.json b/docs/openapi/monitoring-api.json index 22b4050..444dc1b 100644 --- a/docs/openapi/monitoring-api.json +++ b/docs/openapi/monitoring-api.json @@ -13520,6 +13520,15 @@ "type": "boolean" } }, + { + "name": "search", + "in": "query", + "description": "Case-insensitive substring match on service name or slug", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "cursor", "in": "query", diff --git a/package.json b/package.json index 0c46f7b..3c89534 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,9 @@ "secrets": { "description": "Manage workspace secrets used in monitor configurations and headers" }, + "services": { + "description": "Browse the status-data service catalog and query service health" + }, "skills": { "description": "Generate and install Cursor / Claude agent skill packages for DevHelm" }, diff --git a/src/commands/data/services/status.ts b/src/commands/data/services/status.ts index 63bdd28..07f54d1 100644 --- a/src/commands/data/services/status.ts +++ b/src/commands/data/services/status.ts @@ -1,17 +1,4 @@ -import {Command, Args} from '@oclif/core' -import {globalFlags, buildClient, display} from '../../../lib/base-command.js' -import {checkedFetch, unwrapData} from '../../../lib/api-client.js' - -export default class DataServicesStatus extends Command { - static description = 'Get the current status of a service' - static examples = ['<%= config.bin %> data services status aws-ec2'] - static args = {slug: Args.string({description: 'Service slug', required: true})} - static flags = {...globalFlags} - - async run() { - const {args, flags} = await this.parse(DataServicesStatus) - const client = buildClient(flags) - const resp = await checkedFetch(client.GET('/api/v1/services/{slugOrId}', {params: {path: {slugOrId: args.slug}}})) - display(this, unwrapData(resp), flags.output) - } -} +// Back-compat shim: `data services status` is the legacy spelling of +// `services status`. Re-exporting the class registers the same command +// under the old id so existing scripts keep working. +export {default} from '../../services/status.js' diff --git a/src/commands/data/services/uptime.ts b/src/commands/data/services/uptime.ts index 7a3e3c0..44a7b4b 100644 --- a/src/commands/data/services/uptime.ts +++ b/src/commands/data/services/uptime.ts @@ -1,26 +1,4 @@ -import {Command, Args, Flags} from '@oclif/core' -import {globalFlags, buildClient, display} from '../../../lib/base-command.js' -import {apiGet, unwrapData} from '../../../lib/api-client.js' - -export default class DataServicesUptime extends Command { - static description = 'Get uptime data for a service' - static examples = [ - '<%= config.bin %> data services uptime aws-ec2', - '<%= config.bin %> data services uptime aws-ec2 --period 30d', - ] - static args = {slug: Args.string({description: 'Service slug', required: true})} - static flags = { - ...globalFlags, - period: Flags.string({description: 'Time period (7d, 30d, 90d)', default: '30d'}), - granularity: Flags.string({description: 'Data granularity (hourly, daily)'}), - } - - async run() { - const {args, flags} = await this.parse(DataServicesUptime) - const client = buildClient(flags) - const query: Record = {period: flags.period} - if (flags.granularity) query.granularity = flags.granularity - const resp = await apiGet(client, `/api/v1/services/${args.slug}/uptime`, {query}) - display(this, unwrapData(resp), flags.output) - } -} +// Back-compat shim: `data services uptime` is the legacy spelling of +// `services uptime`. Re-exporting the class registers the same command +// under the old id so existing scripts keep working. +export {default} from '../../services/uptime.js' diff --git a/src/commands/dependencies/track.ts b/src/commands/dependencies/track.ts index 86ffaed..6551456 100644 --- a/src/commands/dependencies/track.ts +++ b/src/commands/dependencies/track.ts @@ -1,18 +1,43 @@ -import {Command, Args} from '@oclif/core' +import {Command, Args, Flags} from '@oclif/core' +import type {components} from '../../lib/api.generated.js' import {globalFlags, buildClient} from '../../lib/base-command.js' import {checkedFetch} from '../../lib/api-client.js' +import {ALERT_SENSITIVITIES} from '../../lib/spec-facts.generated.js' +import {uuidFlag} from '../../lib/validators.js' + +type ServiceSubscribeRequest = components['schemas']['ServiceSubscribeRequest'] export default class DependenciesTrack extends Command { static description = 'Start tracking a service as a dependency' - static examples = ['<%= config.bin %> dependencies track aws-ec2'] + static examples = [ + '<%= config.bin %> dependencies track aws-ec2', + '<%= config.bin %> dependencies track stripe --alert-sensitivity INCIDENTS_ONLY', + ] static args = {slug: Args.string({description: 'Service slug from the catalog', required: true})} - static flags = {...globalFlags} + static flags = { + ...globalFlags, + component: uuidFlag({ + description: 'Component ID to subscribe to (omit for a whole-service subscription)', + }), + 'alert-sensitivity': Flags.string({ + description: 'Alert sensitivity (default: AWARENESS — track silently, never alert)', + options: [...ALERT_SENSITIVITIES], + }), + } async run() { const {args, flags} = await this.parse(DependenciesTrack) const client = buildClient(flags) + const body: ServiceSubscribeRequest = {} + if (flags.component) body.componentId = flags.component + if (flags['alert-sensitivity']) body.alertSensitivity = flags['alert-sensitivity'] const resp = await checkedFetch( - client.POST('/api/v1/service-subscriptions/{slug}', {params: {path: {slug: args.slug}}}), + client.POST('/api/v1/service-subscriptions/{slug}', { + params: {path: {slug: args.slug}}, + // Omit the body entirely when no flag was set, preserving the + // pre-flag behavior of an empty POST. + ...(Object.keys(body).length > 0 ? {body} : {}), + }), ) this.log(`Now tracking '${resp?.data?.name ?? args.slug}' as a dependency.`) } diff --git a/src/commands/dependencies/update.ts b/src/commands/dependencies/update.ts new file mode 100644 index 0000000..6438222 --- /dev/null +++ b/src/commands/dependencies/update.ts @@ -0,0 +1,35 @@ +import {Command, Flags} from '@oclif/core' +import {globalFlags, buildClient, display} from '../../lib/base-command.js' +import {checkedFetch, unwrapData} from '../../lib/api-client.js' +import {ALERT_SENSITIVITIES} from '../../lib/spec-facts.generated.js' +import {uuidArg} from '../../lib/validators.js' + +export default class DependenciesUpdate extends Command { + static description = 'Update the alert sensitivity of a tracked dependency' + static examples = [ + '<%= config.bin %> dependencies update --alert-sensitivity INCIDENTS_ONLY', + ] + static args = { + subscriptionId: uuidArg({description: 'dependency subscriptionId', required: true}), + } + static flags = { + ...globalFlags, + 'alert-sensitivity': Flags.string({ + description: 'New alert sensitivity for the subscription', + options: [...ALERT_SENSITIVITIES], + required: true, + }), + } + + async run() { + const {args, flags} = await this.parse(DependenciesUpdate) + const client = buildClient(flags) + const resp = await checkedFetch( + client.PATCH('/api/v1/service-subscriptions/{id}/alert-sensitivity', { + params: {path: {id: args.subscriptionId}}, + body: {alertSensitivity: flags['alert-sensitivity']}, + }), + ) + display(this, unwrapData(resp), flags.output) + } +} diff --git a/src/commands/services/categories.ts b/src/commands/services/categories.ts new file mode 100644 index 0000000..b40b3e7 --- /dev/null +++ b/src/commands/services/categories.ts @@ -0,0 +1,31 @@ +import {Command} from '@oclif/core' +import type {z} from 'zod' +import {apiGetPage} from '../../lib/api-client.js' +import type {components} from '../../lib/api.generated.js' +import {schemas as apiSchemas} from '../../lib/api-zod.generated.js' +import {globalFlags, buildClient, display} from '../../lib/base-command.js' +import type {ColumnDef} from '../../lib/output.js' + +type CategoryDto = components['schemas']['CategoryDto'] + +const COLUMNS: ColumnDef[] = [ + {header: 'CATEGORY', get: (r) => r.category ?? ''}, + {header: 'SERVICES', get: (r) => (r.serviceCount == null ? '' : String(r.serviceCount))}, +] + +export default class ServicesCategories extends Command { + static description = 'List service catalog categories with service counts' + static examples = ['<%= config.bin %> services categories'] + static flags = {...globalFlags} + + async run() { + const {flags} = await this.parse(ServicesCategories) + const client = buildClient(flags) + const result = await apiGetPage( + client, + '/api/v1/categories', + apiSchemas.CategoryDto as z.ZodType, + ) + display(this, result.data, flags.output, COLUMNS as ColumnDef[]) + } +} diff --git a/src/commands/services/components.ts b/src/commands/services/components.ts new file mode 100644 index 0000000..0a1a5fd --- /dev/null +++ b/src/commands/services/components.ts @@ -0,0 +1,34 @@ +import {Command, Args} from '@oclif/core' +import type {z} from 'zod' +import {apiGetPage} from '../../lib/api-client.js' +import type {components} from '../../lib/api.generated.js' +import {schemas as apiSchemas} from '../../lib/api-zod.generated.js' +import {globalFlags, buildClient, display} from '../../lib/base-command.js' +import type {ColumnDef} from '../../lib/output.js' + +type ServiceComponentDto = components['schemas']['ServiceComponentDto'] + +const COLUMNS: ColumnDef[] = [ + {header: 'ID', get: (r) => r.id ?? ''}, + {header: 'NAME', get: (r) => r.name ?? ''}, + {header: 'STATUS', get: (r) => r.status ?? ''}, + {header: 'GROUP', get: (r) => r.groupId ?? ''}, +] + +export default class ServicesComponents extends Command { + static description = 'List the components of a service' + static examples = ['<%= config.bin %> services components aws-ec2'] + static args = {slug: Args.string({description: 'Service slug or ID', required: true})} + static flags = {...globalFlags} + + async run() { + const {args, flags} = await this.parse(ServicesComponents) + const client = buildClient(flags) + const result = await apiGetPage( + client, + `/api/v1/services/${args.slug}/components`, + apiSchemas.ServiceComponentDto as z.ZodType, + ) + display(this, result.data, flags.output, COLUMNS as ColumnDef[]) + } +} diff --git a/src/commands/services/get.ts b/src/commands/services/get.ts new file mode 100644 index 0000000..7fe0d60 --- /dev/null +++ b/src/commands/services/get.ts @@ -0,0 +1,19 @@ +import {Command, Args} from '@oclif/core' +import {globalFlags, buildClient, display} from '../../lib/base-command.js' +import {checkedFetch, unwrapData} from '../../lib/api-client.js' + +export default class ServicesGet extends Command { + static description = 'Get full catalog details for a service' + static examples = ['<%= config.bin %> services get stripe'] + static args = {slug: Args.string({description: 'Service slug or ID', required: true})} + static flags = {...globalFlags} + + async run() { + const {args, flags} = await this.parse(ServicesGet) + const client = buildClient(flags) + const resp = await checkedFetch( + client.GET('/api/v1/services/{slugOrId}', {params: {path: {slugOrId: args.slug}}}), + ) + display(this, unwrapData(resp), flags.output) + } +} diff --git a/src/commands/services/incidents.ts b/src/commands/services/incidents.ts new file mode 100644 index 0000000..00ee35c --- /dev/null +++ b/src/commands/services/incidents.ts @@ -0,0 +1,67 @@ +import {Command, Args, Flags} from '@oclif/core' +import type {z} from 'zod' +import {apiGetPage, checkedFetch, unwrapData} from '../../lib/api-client.js' +import type {components} from '../../lib/api.generated.js' +import {schemas as apiSchemas} from '../../lib/api-zod.generated.js' +import {globalFlags, buildClient, display} from '../../lib/base-command.js' +import type {ColumnDef} from '../../lib/output.js' + +type ServiceIncidentDto = components['schemas']['ServiceIncidentDto'] + +const COLUMNS: ColumnDef[] = [ + {header: 'ID', get: (r) => r.id ?? ''}, + {header: 'TITLE', get: (r) => r.title ?? ''}, + {header: 'IMPACT', get: (r) => r.impact ?? ''}, + {header: 'STATUS', get: (r) => r.status ?? ''}, + {header: 'STARTED', get: (r) => r.startedAt ?? ''}, + {header: 'RESOLVED', get: (r) => r.resolvedAt ?? ''}, +] + +export default class ServicesIncidents extends Command { + static description = + 'List incidents for a service (or across all services), or show one incident in full detail' + static examples = [ + '<%= config.bin %> services incidents stripe', + '<%= config.bin %> services incidents stripe --status active', + '<%= config.bin %> services incidents --from 2026-06-01T00:00:00Z', + '<%= config.bin %> services incidents stripe 22222222-2222-2222-2222-222222222222', + ] + static args = { + slug: Args.string({description: 'Service slug or ID (omit to list incidents across all services)'}), + incidentId: Args.string({description: 'Incident ID — show full detail for this incident'}), + } + static flags = { + ...globalFlags, + status: Flags.string({description: 'Filter by incident status', options: ['active', 'resolved']}), + from: Flags.string({description: 'ISO-8601 lower bound on incident start time'}), + } + + async run() { + const {args, flags} = await this.parse(ServicesIncidents) + const client = buildClient(flags) + + if (args.slug && args.incidentId) { + const resp = await checkedFetch( + client.GET('/api/v1/services/{slugOrId}/incidents/{incidentId}', { + params: {path: {slugOrId: args.slug, incidentId: args.incidentId}}, + }), + ) + display(this, unwrapData(resp), flags.output) + return + } + + const path = args.slug + ? `/api/v1/services/${args.slug}/incidents` + : '/api/v1/services/incidents' + const query: Record = {} + if (flags.status) query.status = flags.status + if (flags.from) query.from = flags.from + const result = await apiGetPage( + client, + path, + apiSchemas.ServiceIncidentDto as z.ZodType, + {query}, + ) + display(this, result.data, flags.output, COLUMNS as ColumnDef[]) + } +} diff --git a/src/commands/services/list.ts b/src/commands/services/list.ts new file mode 100644 index 0000000..a5743d0 --- /dev/null +++ b/src/commands/services/list.ts @@ -0,0 +1,54 @@ +import {Command, Flags} from '@oclif/core' +import type {z} from 'zod' +import {apiGetCursorPage} from '../../lib/api-client.js' +import type {components} from '../../lib/api.generated.js' +import {schemas as apiSchemas} from '../../lib/api-zod.generated.js' +import {globalFlags, buildClient, display} from '../../lib/base-command.js' +import type {ColumnDef} from '../../lib/output.js' + +type ServiceCatalogDto = components['schemas']['ServiceCatalogDto'] + +const COLUMNS: ColumnDef[] = [ + {header: 'NAME', get: (r) => r.name ?? ''}, + {header: 'SLUG', get: (r) => r.slug ?? ''}, + {header: 'CATEGORY', get: (r) => r.category ?? ''}, + {header: 'STATUS', get: (r) => r.overallStatus ?? ''}, + {header: 'UPTIME 30D', get: (r) => (r.uptime30d == null ? '' : String(r.uptime30d))}, +] + +export default class ServicesList extends Command { + static description = 'Browse the status-data service catalog (Stripe, GitHub, AWS, ...)' + static examples = [ + '<%= config.bin %> services list', + '<%= config.bin %> services list --category cloud --status operational', + '<%= config.bin %> services list --search stripe', + ] + static flags = { + ...globalFlags, + category: Flags.string({description: 'Filter by category slug'}), + search: Flags.string({description: 'Case-insensitive substring match on service name or slug'}), + status: Flags.string({description: 'Filter by current overall status'}), + limit: Flags.integer({description: 'Page size (1–100)', default: 20}), + cursor: Flags.string({description: 'Opaque cursor from a previous response'}), + } + + async run() { + const {flags} = await this.parse(ServicesList) + const client = buildClient(flags) + const query: Record = {limit: flags.limit} + if (flags.category) query.category = flags.category + if (flags.search) query.search = flags.search + if (flags.status) query.status = flags.status + if (flags.cursor) query.cursor = flags.cursor + const page = await apiGetCursorPage( + client, + '/api/v1/services', + apiSchemas.ServiceCatalogDto as z.ZodType, + {query}, + ) + display(this, page.data, flags.output, COLUMNS as ColumnDef[]) + if (page.hasMore && page.nextCursor && flags.output === 'table') { + this.log(`More results available — re-run with --cursor ${page.nextCursor}`) + } + } +} diff --git a/src/commands/services/maintenances.ts b/src/commands/services/maintenances.ts new file mode 100644 index 0000000..fcf64c4 --- /dev/null +++ b/src/commands/services/maintenances.ts @@ -0,0 +1,36 @@ +import {Command, Args} from '@oclif/core' +import type {z} from 'zod' +import {apiGetPage} from '../../lib/api-client.js' +import type {components} from '../../lib/api.generated.js' +import {schemas as apiSchemas} from '../../lib/api-zod.generated.js' +import {globalFlags, buildClient, display} from '../../lib/base-command.js' +import type {ColumnDef} from '../../lib/output.js' + +type ScheduledMaintenanceDto = components['schemas']['ScheduledMaintenanceDto'] + +const COLUMNS: ColumnDef[] = [ + {header: 'ID', get: (r) => r.id ?? ''}, + {header: 'TITLE', get: (r) => r.title ?? ''}, + {header: 'IMPACT', get: (r) => r.impact ?? ''}, + {header: 'STATUS', get: (r) => r.status ?? ''}, + {header: 'SCHEDULED FOR', get: (r) => r.scheduledFor ?? ''}, + {header: 'SCHEDULED UNTIL', get: (r) => r.scheduledUntil ?? ''}, +] + +export default class ServicesMaintenances extends Command { + static description = 'List scheduled maintenances for a service' + static examples = ['<%= config.bin %> services maintenances aws-ec2'] + static args = {slug: Args.string({description: 'Service slug or ID', required: true})} + static flags = {...globalFlags} + + async run() { + const {args, flags} = await this.parse(ServicesMaintenances) + const client = buildClient(flags) + const result = await apiGetPage( + client, + `/api/v1/services/${args.slug}/maintenances`, + apiSchemas.ScheduledMaintenanceDto as z.ZodType, + ) + display(this, result.data, flags.output, COLUMNS as ColumnDef[]) + } +} diff --git a/src/commands/services/status.ts b/src/commands/services/status.ts new file mode 100644 index 0000000..892ae0c --- /dev/null +++ b/src/commands/services/status.ts @@ -0,0 +1,19 @@ +import {Command, Args} from '@oclif/core' +import {globalFlags, buildClient, display} from '../../lib/base-command.js' +import {checkedFetch, unwrapData} from '../../lib/api-client.js' + +export default class ServicesStatus extends Command { + static description = 'Get the current live status of a service (lightweight snapshot)' + static examples = ['<%= config.bin %> services status aws-ec2'] + static args = {slug: Args.string({description: 'Service slug or ID', required: true})} + static flags = {...globalFlags} + + async run() { + const {args, flags} = await this.parse(ServicesStatus) + const client = buildClient(flags) + const resp = await checkedFetch( + client.GET('/api/v1/services/{slugOrId}/live-status', {params: {path: {slugOrId: args.slug}}}), + ) + display(this, unwrapData(resp), flags.output) + } +} diff --git a/src/commands/services/summary.ts b/src/commands/services/summary.ts new file mode 100644 index 0000000..2d046ff --- /dev/null +++ b/src/commands/services/summary.ts @@ -0,0 +1,16 @@ +import {Command} from '@oclif/core' +import {globalFlags, buildClient, display} from '../../lib/base-command.js' +import {checkedFetch, unwrapData} from '../../lib/api-client.js' + +export default class ServicesSummary extends Command { + static description = 'Show a global status summary across all catalog services' + static examples = ['<%= config.bin %> services summary'] + static flags = {...globalFlags} + + async run() { + const {flags} = await this.parse(ServicesSummary) + const client = buildClient(flags) + const resp = await checkedFetch(client.GET('/api/v1/services/summary')) + display(this, unwrapData(resp), flags.output) + } +} diff --git a/src/commands/services/uptime.ts b/src/commands/services/uptime.ts new file mode 100644 index 0000000..91e501b --- /dev/null +++ b/src/commands/services/uptime.ts @@ -0,0 +1,71 @@ +import {Command, Args, Flags} from '@oclif/core' +import type {z} from 'zod' +import {apiGet, apiGetPage, unwrapData} from '../../lib/api-client.js' +import type {components} from '../../lib/api.generated.js' +import {schemas as apiSchemas} from '../../lib/api-zod.generated.js' +import {globalFlags, buildClient, display} from '../../lib/base-command.js' +import type {ColumnDef} from '../../lib/output.js' +import {uuidFlag} from '../../lib/validators.js' + +type ComponentUptimeDayDto = components['schemas']['ComponentUptimeDayDto'] + +const COMPONENT_COLUMNS: ColumnDef[] = [ + {header: 'DATE', get: (r) => r.date ?? ''}, + {header: 'UPTIME %', get: (r) => (r.uptimePercentage == null ? '' : String(r.uptimePercentage))}, + {header: 'DEGRADED (S)', get: (r) => String(r.degradedSeconds ?? '')}, + {header: 'PARTIAL OUTAGE (S)', get: (r) => String(r.partialOutageSeconds ?? '')}, + {header: 'MAJOR OUTAGE (S)', get: (r) => String(r.majorOutageSeconds ?? '')}, +] + +export default class ServicesUptime extends Command { + static description = 'Get uptime data for a service, or for one of its components' + static examples = [ + '<%= config.bin %> services uptime aws-ec2', + '<%= config.bin %> services uptime aws-ec2 --period 30d', + '<%= config.bin %> services uptime aws-ec2 --component 11111111-1111-1111-1111-111111111111', + ] + static args = {slug: Args.string({description: 'Service slug or ID', required: true})} + static flags = { + ...globalFlags, + period: Flags.string({description: 'Time period (7d, 30d, 90d)', default: '30d'}), + granularity: Flags.string({ + description: 'Data granularity (hourly, daily) — service-level only', + exclusive: ['component'], + }), + component: uuidFlag({ + description: 'Component ID — fetch per-day uptime for a single component', + }), + from: Flags.string({ + description: 'ISO-8601 lower bound (component-level only)', + dependsOn: ['component'], + }), + to: Flags.string({ + description: 'ISO-8601 upper bound (component-level only)', + dependsOn: ['component'], + }), + } + + async run() { + const {args, flags} = await this.parse(ServicesUptime) + const client = buildClient(flags) + + if (flags.component) { + const query: Record = {period: flags.period} + if (flags.from) query.from = flags.from + if (flags.to) query.to = flags.to + const result = await apiGetPage( + client, + `/api/v1/services/${args.slug}/components/${flags.component}/uptime`, + apiSchemas.ComponentUptimeDayDto as z.ZodType, + {query}, + ) + display(this, result.data, flags.output, COMPONENT_COLUMNS as ColumnDef[]) + return + } + + const query: Record = {period: flags.period} + if (flags.granularity) query.granularity = flags.granularity + const resp = await apiGet(client, `/api/v1/services/${args.slug}/uptime`, {query}) + display(this, unwrapData(resp), flags.output) + } +} diff --git a/src/lib/api.generated.ts b/src/lib/api.generated.ts index 49262d1..91dc81f 100644 --- a/src/lib/api.generated.ts +++ b/src/lib/api.generated.ts @@ -18850,6 +18850,8 @@ export interface operations { status?: string; /** @description Filter by published status for pSEO pages */ published?: boolean; + /** @description Case-insensitive substring match on service name or slug */ + search?: string; /** @description Opaque cursor from a previous response */ cursor?: string; /** @description Page size (1–100, default 20) */ diff --git a/test/commands/dependencies.test.ts b/test/commands/dependencies.test.ts new file mode 100644 index 0000000..f1296b3 --- /dev/null +++ b/test/commands/dependencies.test.ts @@ -0,0 +1,68 @@ +import {expect, test, describe} from 'vitest' +import {execSync} from 'node:child_process' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const repoRoot = join(__dirname, '..', '..') + +function run(argv: string): string { + // Some help / --help invocations exit 0; flag-validation errors exit + // non-zero. Capture stderr too so the assertions can match either. + try { + return execSync(`node bin/dev.js ${argv}`, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + } catch (err) { + // execSync throws on non-zero exit; normalize to stdout+stderr. + const e = err as {stdout?: string; stderr?: string; status?: number} + return `${e.stdout ?? ''}${e.stderr ?? ''}` + } +} + +describe('dependencies topic', () => { + test('topic --help lists every subcommand including update', () => { + const out = run('dependencies --help') + for (const cmd of ['list', 'get', 'track', 'delete', 'update']) { + expect(out).toContain(`dependencies ${cmd}`) + } + }) + + test('track --help exposes --component and --alert-sensitivity with spec values', () => { + const out = run('dependencies track --help') + expect(out).toContain('--component') + expect(out).toContain('--alert-sensitivity') + expect(out).toContain('ALL|AWARENESS|INCIDENTS_ONLY|MAJOR_ONLY') + }) + + test('track rejects an invalid --alert-sensitivity value locally', () => { + const out = run('dependencies track stripe --alert-sensitivity LOUD') + expect(out).toContain( + 'Expected --alert-sensitivity=LOUD to be one of: ALL, AWARENESS, INCIDENTS_ONLY, MAJOR_ONLY', + ) + }) + + test('track rejects a malformed --component UUID locally', () => { + const out = run('dependencies track stripe --component not-a-uuid') + expect(out).toContain('Invalid UUID format') + }) + + test('update --help requires --alert-sensitivity', () => { + const out = run('dependencies update --help') + expect(out).toContain('SUBSCRIPTIONID') + expect(out).toContain('(required)') + expect(out).toContain('ALL|AWARENESS|INCIDENTS_ONLY|MAJOR_ONLY') + }) + + test('update errors without --alert-sensitivity', () => { + const out = run('dependencies update 11111111-1111-1111-1111-111111111111') + expect(out).toContain('Missing required flag alert-sensitivity') + }) + + test('update rejects a malformed subscriptionId locally', () => { + const out = run('dependencies update not-a-uuid --alert-sensitivity ALL') + expect(out).toContain('Invalid UUID format') + }) +}) diff --git a/test/commands/services.test.ts b/test/commands/services.test.ts new file mode 100644 index 0000000..01dcad5 --- /dev/null +++ b/test/commands/services.test.ts @@ -0,0 +1,122 @@ +import {expect, test, describe} from 'vitest' +import {execSync} from 'node:child_process' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const repoRoot = join(__dirname, '..', '..') + +function run(argv: string): string { + // Some help / --help invocations exit 0; flag-validation errors exit + // non-zero. Capture stderr too so the assertions can match either. + try { + return execSync(`node bin/dev.js ${argv}`, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + } catch (err) { + // execSync throws on non-zero exit; normalize to stdout+stderr. + const e = err as {stdout?: string; stderr?: string; status?: number} + return `${e.stdout ?? ''}${e.stderr ?? ''}` + } +} + +describe('services topic', () => { + test('topic --help lists every subcommand', () => { + const out = run('services --help') + for (const cmd of [ + 'list', + 'get', + 'status', + 'summary', + 'components', + 'categories', + 'uptime', + 'incidents', + 'maintenances', + ]) { + expect(out).toContain(`services ${cmd}`) + } + expect(out).toContain('Browse the status-data service catalog') + }) + + test('list --help advertises catalog filters and cursor pagination', () => { + const out = run('services list --help') + for (const flag of ['--category', '--search', '--status', '--limit', '--cursor']) { + expect(out).toContain(flag) + } + expect(out).toContain('[default: 20]') + }) + + test('get --help requires a slug arg', () => { + const out = run('services get --help') + expect(out).toContain('SLUG') + expect(out).toContain('Service slug or ID') + }) + + test('status --help describes the lightweight live snapshot', () => { + const out = run('services status --help') + expect(out).toContain('live status') + }) + + test('summary --help describes the global status rollup', () => { + const out = run('services summary --help') + expect(out).toContain('global status summary') + }) + + test('uptime --help exposes --period, --granularity, and component-level flags', () => { + const out = run('services uptime --help') + expect(out).toContain('--period') + expect(out).toContain('[default: 30d]') + expect(out).toContain('--granularity') + expect(out).toContain('--component') + expect(out).toContain('--from') + expect(out).toContain('--to') + }) + + test('uptime rejects combining --granularity with --component', () => { + const out = run( + 'services uptime aws-ec2 --component 11111111-1111-1111-1111-111111111111 --granularity daily', + ) + expect(out).toContain('cannot also be provided when using --granularity') + }) + + test('uptime rejects --from without --component', () => { + const out = run('services uptime aws-ec2 --from 2026-06-01T00:00:00Z') + expect(out).toContain('All of the following must be provided when using --from: --component') + }) + + test('uptime rejects a malformed --component UUID locally', () => { + const out = run('services uptime aws-ec2 --component not-a-uuid') + expect(out).toContain('Invalid UUID format') + }) + + test('incidents --help takes optional slug and incidentId plus --status and --from', () => { + const out = run('services incidents --help') + expect(out).toContain('[SLUG]') + expect(out).toContain('[INCIDENTID]') + expect(out).toContain('full detail') + expect(out).toContain('--status') + expect(out).toContain('active|resolved') + expect(out).toContain('--from') + }) + + test('incidents rejects an unknown --status value locally', () => { + const out = run('services incidents stripe --status nonsense') + expect(out).toContain('Expected --status=nonsense to be one of: active, resolved') + }) +}) + +describe('data services back-compat shims', () => { + test('data services status still resolves', () => { + const out = run('data services status --help') + expect(out).toContain('live status') + }) + + test('data services uptime still resolves with the same flags', () => { + const out = run('data services uptime --help') + expect(out).toContain('--period') + expect(out).toContain('--granularity') + }) +})