diff --git a/docs/openapi/monitoring-api.json b/docs/openapi/monitoring-api.json index 78593df..3cde442 100644 --- a/docs/openapi/monitoring-api.json +++ b/docs/openapi/monitoring-api.json @@ -10128,8 +10128,7 @@ "$ref": "#/components/schemas/TestNotificationPolicyRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -12921,6 +12920,7 @@ "Status Data" ], "summary": "Get a single service by slug or UUID with current status, components, and recent incidents", + "description": "When ``summary=true``, the inline ``components`` list is trimmed to groups + showcase leaves + currently-impacted leaves + ungrouped leaves, and a ``componentsSummary`` block is added with the trimmed counts. Powers SSR for vendors with hundreds of components (Snowflake, Cloudflare, DigitalOcean) without OOM-ing the renderer. Default false for full back-compat.", "operationId": "getService", "parameters": [ { @@ -12930,6 +12930,16 @@ "schema": { "type": "string" } + }, + { + "name": "summary", + "in": "query", + "description": "Return a curated subset of components (groups + showcase + impacted + ungrouped) and a componentsSummary block; default false", + "required": false, + "schema": { + "type": "boolean", + "default": false + } } ], "responses": { @@ -13032,6 +13042,7 @@ "Status Data" ], "summary": "List active components for a service with current status and inline uptime", + "description": "When ``groupId`` is supplied, only direct children of that group are returned — used by the pSEO renderer to lazy-load the leaves under a group that summary mode trimmed. Without ``groupId`` the response includes every active component for the service.", "operationId": "getComponents", "parameters": [ { @@ -13041,6 +13052,16 @@ "schema": { "type": "string" } + }, + { + "name": "groupId", + "in": "query", + "description": "Restrict result to direct children of this group component id", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -21894,6 +21915,35 @@ }, "description": "A single component position" }, + "ComponentsSummaryDto": { + "required": [ + "groupComponentCounts", + "includedCount", + "totalCount" + ], + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "description": "Total active components for this service across all groups", + "format": "int32" + }, + "includedCount": { + "type": "integer", + "description": "Number of components actually returned in the inline ``components`` list", + "format": "int32" + }, + "groupComponentCounts": { + "type": "object", + "additionalProperties": { + "type": "integer", + "description": "Per-group active leaf count, keyed by group component id (UUID stringified). Empty when the service has no groups; lets the UI render \"show all N\" affordances without a second round trip", + "format": "int32" + }, + "description": "Per-group active leaf count, keyed by group component id (UUID stringified). Empty when the service has no groups; lets the UI render \"show all N\" affordances without a second round trip" + } + } + }, "ComponentStatusDto": { "required": [ "id", @@ -22239,8 +22289,7 @@ "CreateEnvironmentRequest": { "required": [ "name", - "slug", - "isDefault" + "slug" ], "type": "object", "properties": { @@ -22269,7 +22318,8 @@ }, "isDefault": { "type": "boolean", - "description": "Whether this is the default environment for new monitors" + "description": "Whether this is the default environment for new monitors (default: false)", + "nullable": true } } }, @@ -22992,11 +23042,10 @@ "subscribedEvents": { "minItems": 1, "type": "array", - "description": "Event types to deliver, e.g. monitor.created, incident.resolved", + "description": "Event types to deliver", "items": { "minLength": 1, "type": "string", - "description": "Event types to deliver, e.g. monitor.created, incident.resolved", "enum": [ "monitor.created", "monitor.updated", @@ -23895,6 +23944,7 @@ }, "ErrorResponse": { "required": [ + "code", "message", "status", "timestamp" @@ -23907,6 +23957,11 @@ "format": "int32", "example": 404 }, + "code": { + "type": "string", + "description": "Coarse machine-readable error category (e.g. NOT_FOUND, RATE_LIMITED); stable per status", + "example": "NOT_FOUND" + }, "message": { "type": "string", "description": "Human-readable error message; safe to surface to end users", @@ -23917,13 +23972,21 @@ "description": "Server time when the error was produced (epoch milliseconds)", "format": "int64", "example": 1737302400000 + }, + "requestId": { + "type": "string", + "description": "Opaque per-request id; same value as the X-Request-Id response header. Use in support tickets.", + "nullable": true, + "example": "5b6f7a8c-1234-4d5e-9f0a-1b2c3d4e5f6a" } }, "description": "Uniform error envelope returned for every non-2xx response", "example": { "status": 404, + "code": "NOT_FOUND", "message": "Monitor not found", - "timestamp": 1737302400000 + "timestamp": 1737302400000, + "requestId": "5b6f7a8c-1234-4d5e-9f0a-1b2c3d4e5f6a" } }, "EscalationChain": { @@ -25443,7 +25506,7 @@ "properties": { "type": { "type": "string", - "description": "Rule type, e.g. severity_gte, monitor_id_in, region_in", + "description": "Rule type used to evaluate incidents and status events", "enum": [ "severity_gte", "monitor_id_in", @@ -25452,7 +25515,8 @@ "monitor_type_in", "service_id_in", "resource_group_id_in", - "component_name_in" + "component_name_in", + "monitor_tag_in" ] }, "value": { @@ -28172,6 +28236,14 @@ "$ref": "#/components/schemas/ServiceComponentDto" } }, + "componentsSummary": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/ComponentsSummaryDto" + } + ] + }, "uptime": { "nullable": true, "allOf": [ @@ -32541,7 +32613,6 @@ "nullable": true, "items": { "type": "string", - "description": "Replace subscribed events; null preserves current", "enum": [ "monitor.created", "monitor.updated", @@ -32952,4 +33023,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/commands/alert-channels/test.ts b/src/commands/alert-channels/test.ts index b4278eb..69df524 100644 --- a/src/commands/alert-channels/test.ts +++ b/src/commands/alert-channels/test.ts @@ -12,9 +12,9 @@ export default class AlertChannelsTest extends Command { async run() { const {args, flags} = await this.parse(AlertChannelsTest) const client = buildClient(flags) - const resp = (await checkedFetch( + const resp = await checkedFetch( client.POST('/api/v1/alert-channels/{id}/test', {params: {path: {id: args.id}}}), - )) as {data?: {success?: boolean}} - this.log(resp.data?.success ? 'Test notification sent successfully.' : 'Test notification failed.') + ) + this.log(resp?.data?.success ? 'Test notification sent successfully.' : 'Test notification failed.') } } diff --git a/src/commands/auth/context/delete.ts b/src/commands/auth/context/delete.ts index d721768..1f72842 100644 --- a/src/commands/auth/context/delete.ts +++ b/src/commands/auth/context/delete.ts @@ -1,6 +1,7 @@ import {Command, Args} from '@oclif/core' import {globalFlags} from '../../../lib/base-command.js' import {removeContext} from '../../../lib/auth.js' +import {EXIT_CODES} from '../../../lib/errors.js' export default class AuthContextDelete extends Command { static description = 'Delete an auth context' @@ -11,7 +12,9 @@ export default class AuthContextDelete extends Command { async run() { const {args} = await this.parse(AuthContextDelete) const ok = removeContext(args.name) - if (!ok) { this.error(`Context '${args.name}' not found.`, {exit: 1}) } + if (!ok) { + this.error(`Context '${args.name}' not found.`, {exit: EXIT_CODES.VALIDATION}) + } this.log(`Context '${args.name}' deleted.`) } } diff --git a/src/commands/auth/context/use.ts b/src/commands/auth/context/use.ts index 6ebd1b5..6ca86ab 100644 --- a/src/commands/auth/context/use.ts +++ b/src/commands/auth/context/use.ts @@ -1,6 +1,7 @@ import {Command, Args} from '@oclif/core' import {globalFlags} from '../../../lib/base-command.js' import {setCurrentContext} from '../../../lib/auth.js' +import {EXIT_CODES} from '../../../lib/errors.js' export default class AuthContextUse extends Command { static description = 'Switch to a different auth context' @@ -11,7 +12,9 @@ export default class AuthContextUse extends Command { async run() { const {args} = await this.parse(AuthContextUse) const ok = setCurrentContext(args.name) - if (!ok) { this.error(`Context '${args.name}' not found.`, {exit: 1}) } + if (!ok) { + this.error(`Context '${args.name}' not found.`, {exit: EXIT_CODES.VALIDATION}) + } this.log(`Switched to context '${args.name}'.`) } } diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 17448fd..5f19af4 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -2,6 +2,7 @@ import {Command, Flags} from '@oclif/core' import {globalFlags} from '../../lib/base-command.js' import {createApiClient, checkedFetch, apiGet} from '../../lib/api-client.js' import {saveContext, resolveApiUrl} from '../../lib/auth.js' +import {EXIT_CODES} from '../../lib/errors.js' import * as readline from 'node:readline' export default class AuthLogin extends Command { @@ -25,18 +26,12 @@ export default class AuthLogin extends Command { const client = createApiClient({baseUrl: apiUrl, token}) try { - const resp = (await checkedFetch(client.GET('/api/v1/auth/me'))) as { - data?: { - organization?: {name?: string; id?: string | number} - key?: {name?: string} - plan?: {tier?: string} - } - } - if (!resp.data) { + const resp = await checkedFetch(client.GET('/api/v1/auth/me')) + const me = resp?.data + if (!me) { throw new Error('Empty response') } - const me = resp.data saveContext({name: flags.name, apiUrl, token}, true) this.log('') this.log(` Authenticated successfully.`) @@ -57,7 +52,10 @@ export default class AuthLogin extends Command { this.log(` Authenticated successfully.`) this.log(` Context '${flags.name}' saved to ~/.devhelm/contexts.json`) } catch { - this.error('Invalid token. Authentication failed.', {exit: 2}) + // Token rejected by the API. Surface as DevhelmAuthError-equivalent + // (exit 11 — same as any other 4xx) so scripts can branch on the + // canonical API error code instead of a bespoke "auth login" exit. + this.error('Invalid token. Authentication failed.', {exit: EXIT_CODES.API}) } } diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index 057e65a..c1afab2 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -1,6 +1,7 @@ import {Command} from '@oclif/core' import {globalFlags} from '../../lib/base-command.js' import {resolveToken} from '../../lib/auth.js' +import {EXIT_CODES} from '../../lib/errors.js' export default class AuthToken extends Command { static description = 'Print the current API token' @@ -10,7 +11,9 @@ export default class AuthToken extends Command { async run() { const {flags} = await this.parse(AuthToken) const token = flags['api-token'] || resolveToken() - if (!token) { this.error('No token found. Run `devhelm auth login` first.', {exit: 2}) } + if (!token) { + this.error('No token found. Run `devhelm auth login` first.', {exit: EXIT_CODES.VALIDATION}) + } this.log(token) } } diff --git a/src/commands/dependencies/track.ts b/src/commands/dependencies/track.ts index 1b2330d..86ffaed 100644 --- a/src/commands/dependencies/track.ts +++ b/src/commands/dependencies/track.ts @@ -11,9 +11,9 @@ export default class DependenciesTrack extends Command { async run() { const {args, flags} = await this.parse(DependenciesTrack) const client = buildClient(flags) - const resp = (await checkedFetch( + const resp = await checkedFetch( client.POST('/api/v1/service-subscriptions/{slug}', {params: {path: {slug: args.slug}}}), - )) as {data?: {name?: string}} - this.log(`Now tracking '${resp.data?.name}' as a dependency.`) + ) + this.log(`Now tracking '${resp?.data?.name ?? args.slug}' as a dependency.`) } } diff --git a/src/commands/deploy/force-unlock.ts b/src/commands/deploy/force-unlock.ts index 3cda20c..500f459 100644 --- a/src/commands/deploy/force-unlock.ts +++ b/src/commands/deploy/force-unlock.ts @@ -1,6 +1,7 @@ import {Command, Flags} from '@oclif/core' import {createApiClient, apiDelete} from '../../lib/api-client.js' import {resolveToken, resolveApiUrl} from '../../lib/auth.js' +import {DevhelmApiError, EXIT_CODES} from '../../lib/errors.js' import {urlFlag} from '../../lib/validators.js' export default class DeployForceUnlock extends Command { @@ -40,7 +41,10 @@ export default class DeployForceUnlock extends Command { const token = flags['api-token'] ?? resolveToken() if (!token) { - this.error('No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', {exit: 1}) + this.error( + 'No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', + {exit: EXIT_CODES.VALIDATION}, + ) } const client = createApiClient({ @@ -53,12 +57,15 @@ export default class DeployForceUnlock extends Command { await apiDelete(client, '/api/v1/deploy/lock/force') this.log('Deploy lock released.') } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - if (msg.includes('404') || msg.includes('Not Found')) { + // Branch on the typed error rather than substring-matching the message: + // the canonical DevhelmApiError carries a real HTTP status, so 404 + // ("no lock to release") is unambiguous and we exit successfully. + if (err instanceof DevhelmApiError && err.status === 404) { this.log('No active deploy lock found.') return } - this.error(`Failed to release lock: ${msg}`, {exit: 1}) + const msg = err instanceof Error ? err.message : String(err) + this.error(`Failed to release lock: ${msg}`, {exit: EXIT_CODES.API}) } } } diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index 899ef8f..88bffee 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -1,8 +1,8 @@ import {hostname} from 'node:os' import {Command, Flags} from '@oclif/core' -import {createApiClient, apiPost, apiDelete} from '../../lib/api-client.js' +import {checkedFetch, createApiClient, apiDelete} from '../../lib/api-client.js' import {resolveToken, resolveApiUrl} from '../../lib/auth.js' -import {EXIT_CODES} from '../../lib/errors.js' +import {DevhelmApiError, EXIT_CODES} from '../../lib/errors.js' import {urlFlag} from '../../lib/validators.js' import {loadConfig, validate, validatePlanRefs, fetchAllRefs, registerYamlPendingRefs, diff, formatPlan, changesetToJson, apply, writeState, buildStateV2, readState, emptyState, processMovedBlocks, resourceAddress, StateFileCorruptError} from '../../lib/yaml/index.js' import {checkEntitlements, formatEntitlementWarnings} from '../../lib/yaml/entitlements.js' @@ -82,7 +82,7 @@ export default class Deploy extends Command { try { config = loadConfig(flags.file) } catch (err) { - this.error(err instanceof Error ? err.message : String(err), {exit: 1}) + this.error(err instanceof Error ? err.message : String(err), {exit: EXIT_CODES.VALIDATION}) } const result = validate(config) @@ -91,12 +91,15 @@ export default class Deploy extends Command { for (const e of result.errors) { this.log(` ✗ ${e.path}: ${e.message}`) } - this.error('Fix validation errors before deploying', {exit: 4}) + this.error('Fix validation errors before deploying', {exit: EXIT_CODES.VALIDATION}) } const token = flags['api-token'] ?? resolveToken() if (!token) { - this.error('No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', {exit: 1}) + this.error( + 'No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', + {exit: EXIT_CODES.VALIDATION}, + ) } const client = createApiClient({ @@ -110,7 +113,7 @@ export default class Deploy extends Command { currentState = readState() ?? emptyState() } catch (err) { if (err instanceof StateFileCorruptError) { - this.error(err.message, {exit: 1}) + this.error(err.message, {exit: EXIT_CODES.VALIDATION}) } throw err } @@ -146,7 +149,7 @@ export default class Deploy extends Command { for (const e of planResult.errors) { this.log(` ✗ ${e.path}: ${e.message}`) } - this.error('Fix validation errors before deploying', {exit: 4}) + this.error('Fix validation errors before deploying', {exit: EXIT_CODES.VALIDATION}) } const changeset = diff( @@ -277,18 +280,23 @@ export default class Deploy extends Command { while (true) { try { - const resp = (await apiPost( - client, '/api/v1/deploy/lock', - {lockedBy: `${process.env.USER ?? 'cli'}@${hostname()}`, ttlMinutes: DEFAULT_LOCK_TTL}, - )) as {data?: {id?: string}} - const lockId = resp.data?.id + const resp = await checkedFetch( + client.POST('/api/v1/deploy/lock', { + body: {lockedBy: `${process.env.USER ?? 'cli'}@${hostname()}`, ttlMinutes: DEFAULT_LOCK_TTL}, + }), + ) + const lockId = resp?.data?.id if (!lockId) { this.warn('Deploy lock acquired but no lock ID returned. Proceeding without lock protection.') } return lockId } catch (err) { const msg = err instanceof Error ? err.message : String(err) - const isConflict = msg.includes('409') || msg.includes('Conflict') || msg.includes('lock held') + // Branch on the typed error rather than substring-matching, which + // otherwise misclassifies any message containing "Conflict" as a + // lock contention. Server returns 409 when another session holds + // the lock. + const isConflict = err instanceof DevhelmApiError && err.status === 409 if (isConflict && Date.now() < deadline) { lastError = msg diff --git a/src/commands/import.ts b/src/commands/import.ts index b87d0c9..29e30dc 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -9,6 +9,7 @@ import {fetchPaginated} from '../lib/typed-api.js' import {readState, writeState, emptyState, upsertStateEntry, resourceAddress, StateFileCorruptError} from '../lib/yaml/state.js' import type {ChildStateEntry} from '../lib/yaml/state.js' import type {HandledResourceType} from '../lib/yaml/types.js' +import {EXIT_CODES} from '../lib/errors.js' type Schemas = components['schemas'] @@ -47,14 +48,20 @@ export default class Import extends Command { const {args, flags} = await this.parse(Import) if (!VALID_TYPES.includes(args.type as typeof VALID_TYPES[number])) { - this.error(`Unknown resource type "${args.type}". Valid types: ${VALID_TYPES.join(', ')}`, {exit: 1}) + this.error( + `Unknown resource type "${args.type}". Valid types: ${VALID_TYPES.join(', ')}`, + {exit: EXIT_CODES.VALIDATION}, + ) } const resourceType = args.type as HandledResourceType const token = flags['api-token'] ?? resolveToken() if (!token) { - this.error('No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', {exit: 1}) + this.error( + 'No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', + {exit: EXIT_CODES.VALIDATION}, + ) } const client = createApiClient({ @@ -65,7 +72,7 @@ export default class Import extends Command { const handler = allHandlers().find((h) => h.resourceType === resourceType) if (!handler) { - this.error(`No handler for resource type "${resourceType}"`, {exit: 1}) + this.error(`No handler for resource type "${resourceType}"`, {exit: EXIT_CODES.VALIDATION}) } this.log(`Fetching ${resourceType} resources...`) @@ -79,7 +86,7 @@ export default class Import extends Command { (available.length > 0 ? `Available: ${available.join(', ')}` : 'No resources of this type exist.'), - {exit: 1}, + {exit: EXIT_CODES.VALIDATION}, ) } @@ -88,7 +95,7 @@ export default class Import extends Command { state = readState() ?? emptyState() } catch (err) { if (err instanceof StateFileCorruptError) { - this.error(err.message, {exit: 1}) + this.error(err.message, {exit: EXIT_CODES.VALIDATION}) } throw err } diff --git a/src/commands/incidents/resolve.ts b/src/commands/incidents/resolve.ts index c58ef66..396f941 100644 --- a/src/commands/incidents/resolve.ts +++ b/src/commands/incidents/resolve.ts @@ -16,9 +16,9 @@ export default class IncidentsResolve extends Command { const {args, flags} = await this.parse(IncidentsResolve) const client = buildClient(flags) const body = flags.message ? {body: flags.message} : undefined - const resp = (await checkedFetch( + const resp = await checkedFetch( client.POST('/api/v1/incidents/{id}/resolve', {params: {path: {id: args.id}}, body}), - )) as {data?: {incident?: {title?: string}}} - this.log(`Incident '${resp.data?.incident?.title}' resolved.`) + ) + this.log(`Incident '${resp?.data?.incident?.title ?? args.id}' resolved.`) } } diff --git a/src/commands/init.ts b/src/commands/init.ts index 98a60f4..d4ca69d 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,5 +1,6 @@ import {Command, Flags} from '@oclif/core' import {existsSync, writeFileSync} from 'node:fs' +import {EXIT_CODES} from '../lib/errors.js' const TEMPLATE = `# devhelm.yml — DevHelm monitoring-as-code configuration # Docs: https://docs.devhelm.io/cli/configuration @@ -127,13 +128,21 @@ export default class Init extends Command { const {flags} = await this.parse(Init) if (existsSync(flags.path) && !flags.force) { - this.error(`${flags.path} already exists. Use --force to overwrite.`, {exit: 1}) + this.error( + `${flags.path} already exists. Use --force to overwrite.`, + {exit: EXIT_CODES.VALIDATION}, + ) } try { writeFileSync(flags.path, TEMPLATE) } catch (err) { - this.error(`Failed to write ${flags.path}: ${err instanceof Error ? err.message : String(err)}`, {exit: 1}) + // Filesystem failure (read-only FS, no perms, disk full) — leave at the + // generic exit code; this isn't a config validation issue. + this.error( + `Failed to write ${flags.path}: ${err instanceof Error ? err.message : String(err)}`, + {exit: EXIT_CODES.GENERAL}, + ) } this.log(`Created ${flags.path}`) this.log('Edit the file, then run "devhelm validate" to check it.') diff --git a/src/commands/monitors/pause.ts b/src/commands/monitors/pause.ts index c050838..dabecb9 100644 --- a/src/commands/monitors/pause.ts +++ b/src/commands/monitors/pause.ts @@ -12,9 +12,9 @@ export default class MonitorsPause extends Command { async run() { const {args, flags} = await this.parse(MonitorsPause) const client = buildClient(flags) - const resp = (await checkedFetch( + const resp = await checkedFetch( client.POST('/api/v1/monitors/{id}/pause', {params: {path: {id: args.id}}}), - )) as {data?: {name?: string}} - this.log(`Monitor '${resp.data?.name}' paused.`) + ) + this.log(`Monitor '${resp?.data?.name ?? args.id}' paused.`) } } diff --git a/src/commands/monitors/resume.ts b/src/commands/monitors/resume.ts index 62dafd0..dc25d2a 100644 --- a/src/commands/monitors/resume.ts +++ b/src/commands/monitors/resume.ts @@ -12,9 +12,9 @@ export default class MonitorsResume extends Command { async run() { const {args, flags} = await this.parse(MonitorsResume) const client = buildClient(flags) - const resp = (await checkedFetch( + const resp = await checkedFetch( client.POST('/api/v1/monitors/{id}/resume', {params: {path: {id: args.id}}}), - )) as {data?: {name?: string}} - this.log(`Monitor '${resp.data?.name}' resumed.`) + ) + this.log(`Monitor '${resp?.data?.name ?? args.id}' resumed.`) } } diff --git a/src/commands/plan.ts b/src/commands/plan.ts index feabd51..bb7524d 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -55,7 +55,7 @@ export default class Plan extends Command { try { config = loadConfig(flags.file) } catch (err) { - this.error(err instanceof Error ? err.message : String(err), {exit: 1}) + this.error(err instanceof Error ? err.message : String(err), {exit: EXIT_CODES.VALIDATION}) } const result = validate(config) @@ -64,12 +64,15 @@ export default class Plan extends Command { for (const e of result.errors) { this.log(` ✗ ${e.path}: ${e.message}`) } - this.error('Fix validation errors first', {exit: 4}) + this.error('Fix validation errors first', {exit: EXIT_CODES.VALIDATION}) } const token = flags['api-token'] ?? resolveToken() if (!token) { - this.error('No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', {exit: 1}) + this.error( + 'No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', + {exit: EXIT_CODES.VALIDATION}, + ) } const client = createApiClient({ @@ -83,7 +86,7 @@ export default class Plan extends Command { currentState = readState() ?? emptyState() } catch (err) { if (err instanceof StateFileCorruptError) { - this.error(err.message, {exit: 1}) + this.error(err.message, {exit: EXIT_CODES.VALIDATION}) } throw err } @@ -120,7 +123,7 @@ export default class Plan extends Command { for (const e of planResult.errors) { this.log(` ✗ ${e.path}: ${e.message}`) } - this.error('Fix validation errors first', {exit: 4}) + this.error('Fix validation errors first', {exit: EXIT_CODES.VALIDATION}) } const changeset = diff( diff --git a/src/commands/state/pull.ts b/src/commands/state/pull.ts index cdd1891..42a0ac9 100644 --- a/src/commands/state/pull.ts +++ b/src/commands/state/pull.ts @@ -2,6 +2,7 @@ import {Command, Flags} from '@oclif/core' import type {components} from '../../lib/api.generated.js' import {createApiClient} from '../../lib/api-client.js' import {resolveToken, resolveApiUrl} from '../../lib/auth.js' +import {EXIT_CODES} from '../../lib/errors.js' import {urlFlag} from '../../lib/validators.js' import {fetchAllRefs} from '../../lib/yaml/resolver.js' import {allHandlers} from '../../lib/yaml/handlers.js' @@ -31,7 +32,10 @@ export default class StatePull extends Command { const token = flags['api-token'] ?? resolveToken() if (!token) { - this.error('No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', {exit: 1}) + this.error( + 'No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', + {exit: EXIT_CODES.VALIDATION}, + ) } const client = createApiClient({ diff --git a/src/commands/state/rm.ts b/src/commands/state/rm.ts index c52da4c..7101617 100644 --- a/src/commands/state/rm.ts +++ b/src/commands/state/rm.ts @@ -1,4 +1,5 @@ import {Command, Args} from '@oclif/core' +import {EXIT_CODES} from '../../lib/errors.js' import {readState, writeState, removeStateEntry, StateFileCorruptError} from '../../lib/yaml/state.js' export default class StateRm extends Command { @@ -23,18 +24,18 @@ export default class StateRm extends Command { state = readState() } catch (err) { if (err instanceof StateFileCorruptError) { - this.error(err.message, {exit: 1}) + this.error(err.message, {exit: EXIT_CODES.VALIDATION}) } throw err } if (!state) { - this.error('No state file found.', {exit: 1}) + this.error('No state file found.', {exit: EXIT_CODES.VALIDATION}) } const removed = removeStateEntry(state, args.address) if (!removed) { - this.error(`Address "${args.address}" not found in state.`, {exit: 1}) + this.error(`Address "${args.address}" not found in state.`, {exit: EXIT_CODES.VALIDATION}) } writeState(state) diff --git a/src/commands/state/show.ts b/src/commands/state/show.ts index a1d0568..6d884b4 100644 --- a/src/commands/state/show.ts +++ b/src/commands/state/show.ts @@ -1,4 +1,5 @@ import {Command, Flags} from '@oclif/core' +import {EXIT_CODES} from '../../lib/errors.js' import {readState, StateFileCorruptError} from '../../lib/yaml/state.js' export default class StateShow extends Command { @@ -20,7 +21,7 @@ export default class StateShow extends Command { state = readState() } catch (err) { if (err instanceof StateFileCorruptError) { - this.error(err.message, {exit: 1}) + this.error(err.message, {exit: EXIT_CODES.VALIDATION}) } throw err } diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 31488ce..3288c57 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,4 +1,5 @@ import {Command, Args, Flags} from '@oclif/core' +import {EXIT_CODES} from '../lib/errors.js' import {parseConfigFile, validate} from '../lib/yaml/index.js' export default class Validate extends Command { @@ -43,9 +44,9 @@ export default class Validate extends Command { const msg = err instanceof Error ? err.message : String(err) if (isJson) { this.log(JSON.stringify({valid: false, errors: [{path: '', message: msg}], warnings: []}, null, 2)) - this.exit(1) + this.exit(EXIT_CODES.VALIDATION) } - this.error(msg, {exit: 1}) + this.error(msg, {exit: EXIT_CODES.VALIDATION}) } const result = validate(config) @@ -59,7 +60,7 @@ export default class Validate extends Command { errors: result.errors, warnings: result.warnings, }, null, 2)) - if (hasErrors || strictFail) this.exit(4) + if (hasErrors || strictFail) this.exit(EXIT_CODES.VALIDATION) return } @@ -77,7 +78,7 @@ export default class Validate extends Command { this.log(` ✗ ${e.path}: ${e.message}`) } this.log('') - this.exit(4) + this.exit(EXIT_CODES.VALIDATION) } if (strictFail) { @@ -86,7 +87,7 @@ export default class Validate extends Command { this.log(` ✗ ${w.path}: ${w.message}`) } this.log('') - this.exit(4) + this.exit(EXIT_CODES.VALIDATION) } const sections: string[] = [] diff --git a/src/commands/webhooks/test.ts b/src/commands/webhooks/test.ts index 730542d..b5123d6 100644 --- a/src/commands/webhooks/test.ts +++ b/src/commands/webhooks/test.ts @@ -12,9 +12,9 @@ export default class WebhooksTest extends Command { async run() { const {args, flags} = await this.parse(WebhooksTest) const client = buildClient(flags) - const resp = (await checkedFetch( + const resp = await checkedFetch( client.POST('/api/v1/webhooks/{id}/test', {params: {path: {id: args.id}}}), - )) as {data?: {success?: boolean}} - this.log(resp.data?.success ? 'Test event delivered.' : 'Test delivery failed.') + ) + this.log(resp?.data?.success ? 'Test event delivered.' : 'Test delivery failed.') } } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 4723192..55daf4c 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -2,43 +2,65 @@ import createClient, {type Middleware} from 'openapi-fetch' import type {PathsWithMethod} from 'openapi-typescript-helpers' import type {ZodType} from 'zod' import type {paths, components} from './api.generated.js' -import {AuthError, DevhelmError, EXIT_CODES} from './errors.js' +import { + DevhelmApiError, + DevhelmAuthError, + DevhelmNotFoundError, + DevhelmTransportError, + type DevhelmApiErrorOptions, +} from './errors.js' import {parseSingle, parsePage, parseCursorPage, type Page, type CursorPage as ValidatedCursorPage} from './response-validation.js' export type {paths, components} -export class ApiRequestError extends Error { - constructor( - public status: number, - public statusText: string, - public body: string, - ) { - const parsed = ApiRequestError.parseBody(body) - super(parsed) - this.name = 'ApiRequestError' - } - - private static parseBody(body: string): string { - try { - const json = JSON.parse(body) as Record - const msg = json.message ?? json.error - return typeof msg === 'string' ? msg : body - } catch { - return body || 'Unknown API error' +/** + * Build a `DevhelmApiError` (or appropriate subclass) from the openapi-fetch + * `error` body and the raw `Response`. Pulls `code` and `requestId` from the + * canonical `ErrorResponse` envelope; the `X-Request-Id` response header + * always wins so we still get the id even when the body is non-conforming. + */ +function errorFromResponse(rawError: unknown, response: Response): DevhelmApiError { + const headerRequestId = response.headers.get('x-request-id') ?? undefined + + let code: string | undefined + let bodyRequestId: string | undefined + let detail: string | undefined + let message = response.statusText || `HTTP ${response.status}` + + if (rawError && typeof rawError === 'object') { + const body = rawError as Record + if (typeof body.message === 'string' && body.message.length > 0) { + message = body.message + detail = body.message + } else if (typeof body.error === 'string' && body.error.length > 0) { + message = body.error + detail = body.error } - } - - toTypedError(): DevhelmError { - if (this.status === 401 || this.status === 403) { - return new AuthError(`Authentication failed: ${this.message}`) + if (typeof body.code === 'string' && body.code.length > 0) code = body.code + if (typeof body.requestId === 'string' && body.requestId.length > 0) { + bodyRequestId = body.requestId + } else if (typeof body.request_id === 'string' && body.request_id.length > 0) { + bodyRequestId = body.request_id } + } else if (typeof rawError === 'string' && rawError.length > 0) { + message = rawError + detail = rawError + } - if (this.status === 404) { - return new DevhelmError(this.message, EXIT_CODES.NOT_FOUND) - } + const opts: DevhelmApiErrorOptions = { + code, + requestId: headerRequestId ?? bodyRequestId, + detail, + rawBody: rawError, + } - return new DevhelmError(this.message, EXIT_CODES.API) + if (response.status === 401 || response.status === 403) { + return new DevhelmAuthError(message, response.status, opts) } + if (response.status === 404) { + return new DevhelmNotFoundError(message, response.status, opts) + } + return new DevhelmApiError(message, response.status, opts) } // Backward-compatible wrapper types matching the API response shapes @@ -91,24 +113,40 @@ export function createApiClient(opts: { export type ApiClient = ReturnType /** - * Unwrap an openapi-fetch response: returns the raw JSON body as `unknown` - * on success, throws a typed DevhelmError on failure (AuthError for 401/403, - * NOT_FOUND for 404, API for others). + * Unwrap an openapi-fetch response. On a 2xx, returns whatever + * openapi-fetch decoded into `data` — typed as `TData` because the + * generic flows from the input promise's `data?: TData` field. On a + * non-2xx response, throws a typed `DevhelmApiError` (or + * `DevhelmAuthError` / `DevhelmNotFoundError` for 401–403 / 404). When + * the request never produced a response (DNS, timeout, TLS, connection + * reset) it throws `DevhelmTransportError`. + * + * Why the generic matters: without it, callers — especially the YAML + * apply handlers — were forced to wrap every result in + * `castEnvelope<{id?: string}>(...)` to recover the typed shape that + * openapi-fetch already inferred from the spec. Those casts were P5 + * violations that hid spec drift behind silent type-only assertions. + * With the generic, the typed shape flows straight through and the + * handlers can read `.data?.id` directly. * - * Callers MUST validate the returned `unknown` through a Zod schema (use - * `apiGetSingle` / `apiGetPage` / `apiGetCursorPage` from this module, or - * roll their own with `parseSingle` / `parsePage` from - * `response-validation.ts`). The previous `data as T` cast was a P5 - * violation that hid spec drift behind silent type-only assertions. + * For loosely-typed paths (the dynamic-path helpers below) the input + * type is `unknown`, so the public surface still degrades safely to + * `unknown` and callers can apply a Zod schema via the `apiGetSingle` + * family of helpers. */ -export async function checkedFetch( - promise: Promise<{data?: unknown; error?: unknown; response: Response}>, -): Promise { - const {data, error, response} = await promise +export async function checkedFetch( + promise: Promise<{data?: TData; error?: unknown; response: Response}>, +): Promise { + let result: {data?: TData; error?: unknown; response: Response} + try { + result = await promise + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause) + throw new DevhelmTransportError(message, {cause}) + } + const {data, error, response} = result if (error || !response.ok) { - const body = typeof error === 'object' && error !== null ? JSON.stringify(error) : String(error ?? 'Unknown error') - const apiError = new ApiRequestError(response.status, response.statusText, body) - throw apiError.toTypedError() + throw errorFromResponse(error, response) } return data } diff --git a/src/lib/api-zod.generated.ts b/src/lib/api-zod.generated.ts index 7fed1fe..29b7703 100644 --- a/src/lib/api-zod.generated.ts +++ b/src/lib/api-zod.generated.ts @@ -12,8 +12,10 @@ const pageable = z const ErrorResponse = z .object({ status: z.number().int(), + code: z.string(), message: z.string(), timestamp: z.number().int(), + requestId: z.string().nullish(), }) .strict(); const DiscordChannelConfig = z @@ -126,7 +128,7 @@ const CreateEnvironmentRequest = z .max(100) .regex(/^[a-z0-9][a-z0-9_-]*$/), variables: z.record(z.string().nullable()).nullish(), - isDefault: z.boolean(), + isDefault: z.boolean().nullish(), }) .strict(); const UpdateEnvironmentRequest = z @@ -805,6 +807,7 @@ const MatchRule = z "service_id_in", "resource_group_id_in", "component_name_in", + "monitor_tag_in", ]), value: z.string().nullish(), monitorIds: z.array(z.string().uuid()).nullish(), @@ -1612,6 +1615,13 @@ const ComponentImpact = z majorOutageSeconds: z.number().int(), }) .strict(); +const ComponentsSummaryDto = z + .object({ + totalCount: z.number().int(), + includedCount: z.number().int(), + groupComponentCounts: z.record(z.number().int()), + }) + .strict(); const ComponentStatusDto = z .object({ id: z.string(), name: z.string(), status: z.string() }) .strict(); @@ -2432,6 +2442,7 @@ const ServiceDetailDto = z currentStatus: ServiceStatusDto.nullish(), recentIncidents: z.array(ServiceIncidentDto), components: z.array(ServiceComponentDto), + componentsSummary: ComponentsSummaryDto.nullish(), uptime: ComponentUptimeSummaryDto.nullish(), activeMaintenances: z.array(ScheduledMaintenanceDto), dataCompleteness: z.string(), @@ -3372,6 +3383,7 @@ export const schemas = { CheckResultDetailsDto, CheckResultDto, ComponentImpact, + ComponentsSummaryDto, ComponentStatusDto, ComponentUptimeSummaryDto, CursorPageCheckResultDto, diff --git a/src/lib/api.generated.ts b/src/lib/api.generated.ts index 4a2e376..553233d 100644 --- a/src/lib/api.generated.ts +++ b/src/lib/api.generated.ts @@ -1467,7 +1467,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Get a single service by slug or UUID with current status, components, and recent incidents */ + /** + * Get a single service by slug or UUID with current status, components, and recent incidents + * @description When ``summary=true``, the inline ``components`` list is trimmed to groups + showcase leaves + currently-impacted leaves + ungrouped leaves, and a ``componentsSummary`` block is added with the trimmed counts. Powers SSR for vendors with hundreds of components (Snowflake, Cloudflare, DigitalOcean) without OOM-ing the renderer. Default false for full back-compat. + */ get: operations["getService"]; put?: never; post?: never; @@ -1484,7 +1487,10 @@ export interface paths { path?: never; cookie?: never; }; - /** List active components for a service with current status and inline uptime */ + /** + * List active components for a service with current status and inline uptime + * @description When ``groupId`` is supplied, only direct children of that group are returned — used by the pSEO renderer to lazy-load the leaves under a group that summary mode trimmed. Without ``groupId`` the response includes every active component for the service. + */ get: operations["getComponents"]; put?: never; post?: never; @@ -2852,6 +2858,22 @@ export interface components { */ groupId?: string | null; }; + ComponentsSummaryDto: { + /** + * Format: int32 + * @description Total active components for this service across all groups + */ + totalCount: number; + /** + * Format: int32 + * @description Number of components actually returned in the inline ``components`` list + */ + includedCount: number; + /** @description Per-group active leaf count, keyed by group component id (UUID stringified). Empty when the service has no groups; lets the UI render "show all N" affordances without a second round trip */ + groupComponentCounts: { + [key: string]: number; + }; + }; /** @description Current status of each active component */ ComponentStatusDto: { /** @description Component UUID */ @@ -2969,8 +2991,8 @@ export interface components { variables?: { [key: string]: string | null; } | null; - /** @description Whether this is the default environment for new monitors */ - isDefault: boolean; + /** @description Whether this is the default environment for new monitors (default: false) */ + isDefault?: boolean | null; }; /** @description Invite a new member to the organization by email */ CreateInviteRequest: { @@ -3264,7 +3286,7 @@ export interface components { url: string; /** @description Optional human-readable description */ description?: string | null; - /** @description Event types to deliver, e.g. monitor.created, incident.resolved */ + /** @description Event types to deliver */ subscribedEvents: ("monitor.created" | "monitor.updated" | "monitor.deleted" | "incident.created" | "incident.resolved" | "incident.reopened" | "service.status_changed" | "service.component_changed" | "service.incident_created" | "service.incident_updated" | "service.incident_resolved")[]; }; /** @description Create a new workspace within the organization */ @@ -3650,8 +3672,10 @@ export interface components { * @description Uniform error envelope returned for every non-2xx response * @example { * "status": 404, + * "code": "NOT_FOUND", * "message": "Monitor not found", - * "timestamp": 1737302400000 + * "timestamp": 1737302400000, + * "requestId": "5b6f7a8c-1234-4d5e-9f0a-1b2c3d4e5f6a" * } */ ErrorResponse: { @@ -3661,6 +3685,11 @@ export interface components { * @example 404 */ status: number; + /** + * @description Coarse machine-readable error category (e.g. NOT_FOUND, RATE_LIMITED); stable per status + * @example NOT_FOUND + */ + code: string; /** * @description Human-readable error message; safe to surface to end users * @example Monitor not found @@ -3672,6 +3701,11 @@ export interface components { * @example 1737302400000 */ timestamp: number; + /** + * @description Opaque per-request id; same value as the X-Request-Id response header. Use in support tickets. + * @example 5b6f7a8c-1234-4d5e-9f0a-1b2c3d4e5f6a + */ + requestId?: string | null; }; /** @description Escalation chain defining which channels to notify; null preserves current */ EscalationChain: { @@ -4410,10 +4444,10 @@ export interface components { /** @description Match rules to evaluate (all must pass; omit or empty for catch-all) */ MatchRule: { /** - * @description Rule type, e.g. severity_gte, monitor_id_in, region_in + * @description Rule type used to evaluate incidents and status events * @enum {string} */ - type: "severity_gte" | "monitor_id_in" | "region_in" | "incident_status" | "monitor_type_in" | "service_id_in" | "resource_group_id_in" | "component_name_in"; + type: "severity_gte" | "monitor_id_in" | "region_in" | "incident_status" | "monitor_type_in" | "service_id_in" | "resource_group_id_in" | "component_name_in" | "monitor_tag_in"; /** @description Comparison value for single-value rules like severity_gte */ value?: string | null; /** @description Monitor UUIDs to match for monitor_id_in rules */ @@ -5571,6 +5605,7 @@ export interface components { currentStatus?: components["schemas"]["ServiceStatusDto"] | null; recentIncidents: components["schemas"]["ServiceIncidentDto"][]; components: components["schemas"]["ServiceComponentDto"][]; + componentsSummary?: components["schemas"]["ComponentsSummaryDto"] | null; uptime?: components["schemas"]["ComponentUptimeSummaryDto"] | null; activeMaintenances: components["schemas"]["ScheduledMaintenanceDto"][]; dataCompleteness: string; @@ -15513,7 +15548,7 @@ export interface operations { }; cookie?: never; }; - requestBody: { + requestBody?: { content: { "application/json": components["schemas"]["TestNotificationPolicyRequest"]; }; @@ -17876,7 +17911,10 @@ export interface operations { }; getService: { parameters: { - query?: never; + query?: { + /** @description Return a curated subset of components (groups + showcase + impacted + ungrouped) and a componentsSummary block; default false */ + summary?: boolean; + }; header?: never; path: { slugOrId: string; @@ -17970,7 +18008,10 @@ export interface operations { }; getComponents: { parameters: { - query?: never; + query?: { + /** @description Restrict result to direct children of this group component id */ + groupId?: string; + }; header?: never; path: { slugOrId: string; diff --git a/src/lib/base-command.ts b/src/lib/base-command.ts index d81a850..ff33610 100644 --- a/src/lib/base-command.ts +++ b/src/lib/base-command.ts @@ -1,7 +1,7 @@ import {Command, Flags} from '@oclif/core' import {createApiClient, type ApiClient} from './api-client.js' import {resolveToken, resolveApiUrl} from './auth.js' -import {AuthError} from './errors.js' +import {DevhelmValidationError} from './errors.js' import {formatOutput, OutputFormat, ColumnDef} from './output.js' import {urlFlag} from './validators.js' @@ -23,7 +23,11 @@ export function buildClient(flags: { verbose?: boolean }): ApiClient { const token = flags['api-token'] || resolveToken() - if (!token) throw new AuthError() + if (!token) { + throw new DevhelmValidationError( + 'Not authenticated. Run `devhelm auth login` first, or set DEVHELM_API_TOKEN.', + ) + } const baseUrl = flags['api-url'] || resolveApiUrl() diff --git a/src/lib/descriptions.generated.ts b/src/lib/descriptions.generated.ts index a0bd774..890378a 100644 --- a/src/lib/descriptions.generated.ts +++ b/src/lib/descriptions.generated.ts @@ -54,7 +54,7 @@ export const fieldDescriptions: Record> = "name": "Human-readable environment name", "slug": "URL-safe identifier (lowercase alphanumeric, hyphens, underscores)", "variables": "Initial key-value variable pairs for this environment", - "isDefault": "Whether this is the default environment for new monitors" + "isDefault": "Whether this is the default environment for new monitors (default: false)" }, "UpdateEnvironmentRequest": { "name": "New environment name; null preserves current", @@ -107,7 +107,7 @@ export const fieldDescriptions: Record> = "CreateWebhookEndpointRequest": { "url": "HTTPS endpoint that receives webhook event payloads", "description": "Optional human-readable description", - "subscribedEvents": "Event types to deliver, e.g. monitor.created, incident.resolved" + "subscribedEvents": "Event types to deliver" }, "UpdateWebhookEndpointRequest": { "url": "New webhook URL; null preserves current", @@ -206,7 +206,7 @@ export const fieldDescriptions: Record> = "repeatIntervalSeconds": "Repeat notification interval in seconds until acknowledged" }, "MatchRule": { - "type": "Rule type, e.g. severity_gte, monitor_id_in, region_in", + "type": "Rule type used to evaluate incidents and status events", "value": "Comparison value for single-value rules like severity_gte", "monitorIds": "Monitor UUIDs to match for monitor_id_in rules", "regions": "Region codes to match for region_in rules", diff --git a/src/lib/errors.ts b/src/lib/errors.ts index deb66ac..93f24ae 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,14 +1,32 @@ +/** + * Error taxonomy for the DevHelm CLI (P4 — see + * `mono/cowork/design/040-codegen-policies.md`). + * + * DevhelmValidationError → exit 4 — local schema mismatch (request/response + * validation, YAML parse, missing config) + * DevhelmApiError → exit 11 — HTTP 4xx/5xx from the API. Carries + * `status`, `code`, `requestId` + * DevhelmTransportError → exit 12 — network failure (DNS, timeout, TLS) + * + * Sub-types may extend any of the three roots; their exit code defaults to + * the root's. CLI-only operational sentinels (CHANGES_PENDING, + * PARTIAL_FAILURE) live alongside but are NOT error classes — they are + * normal program-flow signals from `plan` / `deploy`. + */ + export const EXIT_CODES = { SUCCESS: 0, GENERAL: 1, - AUTH: 2, - API: 3, VALIDATION: 4, - NOT_FOUND: 5, CHANGES_PENDING: 10, - PARTIAL_FAILURE: 11, + API: 11, + TRANSPORT: 12, + PARTIAL_FAILURE: 13, } as const +export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES] + +/** Umbrella class — every typed CLI error inherits from this. */ export class DevhelmError extends Error { constructor( message: string, @@ -19,23 +37,83 @@ export class DevhelmError extends Error { } } -export class AuthError extends DevhelmError { - constructor(message = 'Not authenticated. Run `devhelm auth login` first.') { - super(message, EXIT_CODES.AUTH) - this.name = 'AuthError' +/** + * Local validation failed before, or instead of, an HTTP exchange. Examples: + * malformed YAML, a Zod-validated request body that doesn't match the spec, + * or a missing API token (precondition for talking to the API at all). + */ +export class DevhelmValidationError extends DevhelmError { + constructor(message: string) { + super(message, EXIT_CODES.VALIDATION) + this.name = 'DevhelmValidationError' } } -export class ValidationError extends DevhelmError { - constructor(message: string) { - super(message, EXIT_CODES.VALIDATION) - this.name = 'ValidationError' +export interface DevhelmApiErrorOptions { + /** Coarse machine-readable category from `ErrorResponse.code`. */ + code?: string + /** + * Per-request id from the API's `X-Request-Id` response header + * (mirrored in `ErrorResponse.requestId`). Always include this when + * filing a support ticket. + */ + requestId?: string + /** Human-readable detail extracted from the response body, if any. */ + detail?: string + /** Raw parsed body for debugging non-conforming responses. */ + rawBody?: unknown +} + +/** + * The API returned a non-2xx response. Always carries the HTTP status code; + * `code` and `requestId` are populated when the server returns the canonical + * `ErrorResponse` envelope (the standard for every DevHelm API endpoint). + */ +export class DevhelmApiError extends DevhelmError { + readonly status: number + readonly code: string | undefined + readonly requestId: string | undefined + readonly detail: string | undefined + readonly rawBody: unknown + + constructor(message: string, status: number, options?: DevhelmApiErrorOptions) { + super(message, EXIT_CODES.API) + this.name = 'DevhelmApiError' + this.status = status + this.code = options?.code + this.requestId = options?.requestId + this.detail = options?.detail + this.rawBody = options?.rawBody + } +} + +/** 401 or 403 from the API. Surfaced separately for ergonomics. */ +export class DevhelmAuthError extends DevhelmApiError { + constructor(message: string, status: number, options?: DevhelmApiErrorOptions) { + super(message, status, options) + this.name = 'DevhelmAuthError' + } +} + +/** 404 from the API. */ +export class DevhelmNotFoundError extends DevhelmApiError { + constructor(message: string, status: number, options?: DevhelmApiErrorOptions) { + super(message, status, options) + this.name = 'DevhelmNotFoundError' } } -export class NotFoundError extends DevhelmError { - constructor(resource: string, id: string) { - super(`${resource} '${id}' not found.`, EXIT_CODES.NOT_FOUND) - this.name = 'NotFoundError' +/** + * The HTTP request never produced a server response — DNS failure, connection + * refused, TLS handshake failure, request/read timeout, etc. Wraps the + * underlying fetch/network error on `cause`. + */ +export class DevhelmTransportError extends DevhelmError { + constructor(message: string, options?: {cause?: unknown}) { + super(message, EXIT_CODES.TRANSPORT) + this.name = 'DevhelmTransportError' + if (options?.cause !== undefined) { + ;(this as {cause?: unknown}).cause = options.cause + } } } diff --git a/src/lib/response-validation.ts b/src/lib/response-validation.ts index 241b4b2..1c13aa3 100644 --- a/src/lib/response-validation.ts +++ b/src/lib/response-validation.ts @@ -16,7 +16,7 @@ * `parseSingle` / `parsePage` / `parseCursorPage` directly. */ import {z, type ZodType, type ZodIssue, type ZodError} from 'zod' -import {ValidationError} from './errors.js' +import {DevhelmValidationError} from './errors.js' function formatZodIssue(issue: ZodIssue): string { const path = issue.path.length > 0 ? issue.path.join('.') : '' @@ -25,7 +25,7 @@ function formatZodIssue(issue: ZodIssue): string { function throwAsValidation(error: ZodError, contextLabel: string): never { const summary = error.issues.map(formatZodIssue).join('; ') - throw new ValidationError(`${contextLabel} (${summary})`) + throw new DevhelmValidationError(`${contextLabel} (${summary})`) } export function parse(schema: ZodType, data: unknown, contextLabel: string): T { diff --git a/src/lib/spec-facts.generated.ts b/src/lib/spec-facts.generated.ts index 00fc763..9dd7cd3 100644 --- a/src/lib/spec-facts.generated.ts +++ b/src/lib/spec-facts.generated.ts @@ -58,7 +58,7 @@ export type AuthTypes = (typeof AUTH_TYPES)[number] export const MANAGED_BY = ['DASHBOARD', 'CLI', 'TERRAFORM'] as const export type ManagedBy = (typeof MANAGED_BY)[number] -export const MATCH_RULE_TYPES = ['severity_gte', 'monitor_id_in', 'region_in', 'incident_status', 'monitor_type_in', 'service_id_in', 'resource_group_id_in', 'component_name_in'] as const +export const MATCH_RULE_TYPES = ['severity_gte', 'monitor_id_in', 'region_in', 'incident_status', 'monitor_type_in', 'service_id_in', 'resource_group_id_in', 'component_name_in', 'monitor_tag_in'] as const export type MatchRuleTypes = (typeof MATCH_RULE_TYPES)[number] export const WEBHOOK_EVENT_TYPES = ['monitor.created', 'monitor.updated', 'monitor.deleted', 'incident.created', 'incident.resolved', 'incident.reopened', 'service.status_changed', 'service.component_changed', 'service.incident_created', 'service.incident_updated', 'service.incident_resolved'] as const diff --git a/src/lib/yaml/child-reconciler.ts b/src/lib/yaml/child-reconciler.ts index 73be3b2..58dee37 100644 --- a/src/lib/yaml/child-reconciler.ts +++ b/src/lib/yaml/child-reconciler.ts @@ -50,8 +50,14 @@ export interface ChildCollectionDef { /** Delete a child */ applyDelete(parentId: string, childId: string): Promise - /** Optional: batch reorder after individual mutations */ - applyReorder?(parentId: string, orderedIds: string[]): Promise + /** + * Optional: batch reorder after individual mutations. Receives both the + * ordered API IDs and the corresponding desired YAML so handlers can + * preserve other per-row attributes (e.g. component groupId) instead of + * losing them when the server treats the reorder payload as a full + * upsert. + */ + applyReorder?(parentId: string, ordered: Array<{id: string; yaml: TYaml; index: number}>): Promise } export interface ChildChange { @@ -259,15 +265,15 @@ export async function applyChildDiff( // incomplete, so a partial reorder could move surviving children to wrong // positions. Re-run handles ordering once everything is in place. if (diffResult.reorder && def.applyReorder && errors.length === 0) { - const orderedIds: string[] = [] - for (const yaml of desiredYaml) { + const ordered: Array<{id: string; yaml: TYaml; index: number}> = [] + for (const [index, yaml] of desiredYaml.entries()) { const key = def.identityKey(yaml) const id = newIds.get(key) ?? existingByKey[key] - if (id) orderedIds.push(id) + if (id) ordered.push({id, yaml, index}) } - if (orderedIds.length > 0) { + if (ordered.length > 0) { try { - await def.applyReorder(parentId, orderedIds) + await def.applyReorder(parentId, ordered) } catch (err) { errors.push(`reorder ${def.name}: ${errorMessage(err)}`) } diff --git a/src/lib/yaml/handlers.ts b/src/lib/yaml/handlers.ts index fd6f6fc..36938a6 100644 --- a/src/lib/yaml/handlers.ts +++ b/src/lib/yaml/handlers.ts @@ -53,15 +53,19 @@ import {checkedFetch, apiPatch} from '../api-client.js' import {apiPost, apiPut, apiDelete as apiDeleteRaw} from '../api-client.js' /** - * Narrow the `unknown` body returned by `checkedFetch` to a `{data?: ...}` - * envelope when the caller only needs the inner id/key off the apply-create - * response. The runtime shape is whatever the server sent — these handlers - * stash the id in the YAML state file and surface a clear error later if - * it's missing. Switch individual call-sites to `apiPostSingle` + a Zod - * schema as response DTOs become Zod-generated. + * Coerce a freshly-created resource id to a string. The OpenAPI spec + * types most ids as `string` (UUID), but the notification-policy create + * response returns `number`, so the typed `.data.id` we read off + * `checkedFetch` can be either. Stashing it in the YAML state file is + * always string-shaped, hence the conversion here. + * + * Returns `undefined` when the response was 2xx but had no id at all — + * the applier records that as a failure ("Create succeeded but API + * returned no resource ID") so we don't lose the partial outcome. */ -function castEnvelope(resp: unknown): {data?: T} { - return resp as {data?: T} +function idToString(id: string | number | null | undefined): string | undefined { + if (id === null || id === undefined) return undefined + return typeof id === 'string' ? id : String(id) } import type {ChildCollectionDef} from './child-reconciler.js' import {diffChildren, applyChildDiff} from './child-reconciler.js' @@ -326,8 +330,8 @@ const tagHandler = defineHandler({ fetchAll: (client) => fetchPaginated(client, '/api/v1/tags'), async applyCreate(yaml, _refs, client) { - const resp = castEnvelope<{id?: string}>(await checkedFetch(client.POST('/api/v1/tags', {body: toCreateTagRequest(yaml)}))) - return resp.data?.id ?? undefined + const resp = await checkedFetch(client.POST('/api/v1/tags', {body: toCreateTagRequest(yaml)})) + return idToString(resp?.data?.id) }, async applyUpdate(yaml, id, _refs, client) { const body = toCreateTagRequest(yaml) as Schemas['UpdateTagRequest'] @@ -364,8 +368,8 @@ const environmentHandler = defineHandler fetchPaginated(client, '/api/v1/environments'), async applyCreate(yaml, _refs, client) { - const resp = castEnvelope<{id?: string}>(await checkedFetch(client.POST('/api/v1/environments', {body: toCreateEnvironmentRequest(yaml)}))) - return resp.data?.id ?? undefined + const resp = await checkedFetch(client.POST('/api/v1/environments', {body: toCreateEnvironmentRequest(yaml)})) + return idToString(resp?.data?.id) }, async applyUpdate(yaml, id, refs, client) { // The environment API uses slug as the path key and does not support @@ -425,8 +429,8 @@ const secretHandler = defineHandler fetchPaginated(client, '/api/v1/secrets'), async applyCreate(yaml, _refs, client) { - const resp = castEnvelope<{id?: string}>(await checkedFetch(client.POST('/api/v1/secrets', {body: toCreateSecretRequest(yaml)}))) - return resp.data?.id ?? undefined + const resp = await checkedFetch(client.POST('/api/v1/secrets', {body: toCreateSecretRequest(yaml)})) + return idToString(resp?.data?.id) }, async applyUpdate(yaml, _id, _refs, client) { await checkedFetch(client.PUT('/api/v1/secrets/{key}', {params: {path: {key: yaml.key}}, body: {value: yaml.value}})) @@ -471,8 +475,8 @@ const alertChannelHandler = defineHandler fetchPaginated(client, '/api/v1/alert-channels'), async applyCreate(yaml, _refs, client) { - const resp = castEnvelope<{id?: string}>(await checkedFetch(client.POST('/api/v1/alert-channels', {body: toCreateAlertChannelRequest(yaml)}))) - return resp.data?.id ?? undefined + const resp = await checkedFetch(client.POST('/api/v1/alert-channels', {body: toCreateAlertChannelRequest(yaml)})) + return idToString(resp?.data?.id) }, async applyUpdate(yaml, id, _refs, client) { await checkedFetch(client.PUT('/api/v1/alert-channels/{id}', {params: {path: {id}}, body: toCreateAlertChannelRequest(yaml)})) @@ -520,8 +524,8 @@ const notificationPolicyHandler = defineHandler fetchPaginated(client, '/api/v1/notification-policies'), async applyCreate(yaml, refs, client) { - const resp = castEnvelope<{id?: string | number}>(await checkedFetch(client.POST('/api/v1/notification-policies', {body: toCreateNotificationPolicyRequest(yaml, refs)}))) - return resp.data?.id != null ? String(resp.data.id) : undefined + const resp = await checkedFetch(client.POST('/api/v1/notification-policies', {body: toCreateNotificationPolicyRequest(yaml, refs)})) + return idToString(resp?.data?.id) }, async applyUpdate(yaml, id, refs, client) { const body = toCreateNotificationPolicyRequest(yaml, refs) as Schemas['UpdateNotificationPolicyRequest'] @@ -567,8 +571,8 @@ const webhookHandler = defineHandler fetchPaginated(client, '/api/v1/webhooks'), async applyCreate(yaml, _refs, client) { - const resp = castEnvelope<{id?: string}>(await checkedFetch(client.POST('/api/v1/webhooks', {body: toCreateWebhookRequest(yaml)}))) - return resp.data?.id ?? undefined + const resp = await checkedFetch(client.POST('/api/v1/webhooks', {body: toCreateWebhookRequest(yaml)})) + return idToString(resp?.data?.id) }, async applyUpdate(yaml, id, _refs, client) { const body = { @@ -641,8 +645,8 @@ const resourceGroupHandler = defineHandler fetchPaginated(client, '/api/v1/resource-groups'), async applyCreate(yaml, refs, client) { - const resp = castEnvelope<{id?: string}>(await checkedFetch(client.POST('/api/v1/resource-groups', {body: toCreateResourceGroupRequest(yaml, refs)}))) - return resp.data?.id ?? undefined + const resp = await checkedFetch(client.POST('/api/v1/resource-groups', {body: toCreateResourceGroupRequest(yaml, refs)})) + return idToString(resp?.data?.id) }, async applyUpdate(yaml, id, refs, client) { await checkedFetch(client.PUT('/api/v1/resource-groups/{id}', {params: {path: {id}}, body: toCreateResourceGroupRequest(yaml, refs)})) @@ -730,8 +734,8 @@ const monitorHandler = defineHandler fetchPaginated(client, '/api/v1/monitors'), async applyCreate(yaml, refs, client) { - const resp = castEnvelope<{id?: string}>(await checkedFetch(client.POST('/api/v1/monitors', {body: toCreateMonitorRequest(yaml, refs)}))) - return resp.data?.id ?? undefined + const resp = await checkedFetch(client.POST('/api/v1/monitors', {body: toCreateMonitorRequest(yaml, refs)})) + return idToString(resp?.data?.id) }, async applyUpdate(yaml, id, refs, client) { // YAML `auth: null` / `environment: null` signals an explicit clear, @@ -819,14 +823,14 @@ const dependencyHandler = defineHandler fetchPaginated(client, '/api/v1/service-subscriptions'), async applyCreate(yaml, _refs, client) { - const resp = castEnvelope<{subscriptionId?: string}>(await checkedFetch(client.POST('/api/v1/service-subscriptions/{slug}', { + const resp = await checkedFetch(client.POST('/api/v1/service-subscriptions/{slug}', { params: {path: {slug: yaml.service}}, body: { alertSensitivity: yaml.alertSensitivity ?? null, componentId: yaml.component ?? null, }, - }))) - return resp.data?.subscriptionId ?? undefined + })) + return idToString(resp?.data?.subscriptionId) }, async applyUpdate(yaml, id, _refs, client) { if (yaml.alertSensitivity !== undefined) { @@ -1037,8 +1041,16 @@ function makeComponentCollectionDef( async applyDelete(parentId, childId) { await apiDeleteRaw(client, `/api/v1/status-pages/${parentId}/components/${childId}`) }, - async applyReorder(parentId, orderedIds) { - const positions = orderedIds.map((id, i) => ({componentId: id, displayOrder: i, groupId: null})) + async applyReorder(parentId, ordered) { + // The reorder endpoint replaces both displayOrder AND groupId on every + // listed component. If we sent groupId: null for everything, we'd + // silently move every grouped component back to "ungrouped" on every + // deploy that triggered a reorder. Resolve groupId from the YAML's + // group name so reorder preserves group assignments. + const positions = ordered.map(({id, yaml, index}) => { + const groupId = yaml.group ? (groupNameToId.get(yaml.group) ?? null) : null + return {componentId: id, displayOrder: index, groupId} + }) await apiPut(client, `/api/v1/status-pages/${parentId}/components/reorder`, {positions}) }, } diff --git a/src/lib/yaml/zod-schemas.ts b/src/lib/yaml/zod-schemas.ts index cf94290..0055cf2 100644 --- a/src/lib/yaml/zod-schemas.ts +++ b/src/lib/yaml/zod-schemas.ts @@ -482,9 +482,33 @@ export type DevhelmConfigZ = z.infer // ── Error formatting ───────────────────────────────────────────────── +/** + * Render a Zod issue path in the same notation `validator.ts` emits + * (`monitors[0].config.url`) so users never see two competing path + * styles for what is structurally the same location. Without this, the + * structural Zod layer reports `monitors.0.config.url` and the + * cross-resource validator reports `monitors[0].config.url` — the + * inconsistency is visible whenever both layers fire on one file. + * + * Numeric segments → `[N]`; string segments → `.field` (with the + * leading `.` suppressed when it would precede `[`). The first segment + * never gets a leading dot. + */ +export function formatZodPath(path: ReadonlyArray): string { + if (path.length === 0) return '(root)' + let out = '' + for (const seg of path) { + if (typeof seg === 'number') { + out += `[${seg}]` + } else if (out === '') { + out = seg + } else { + out += `.${seg}` + } + } + return out +} + export function formatZodErrors(error: z.ZodError): string[] { - return error.issues.map((issue) => { - const path = issue.path.length > 0 ? issue.path.join('.') : '(root)' - return `${path}: ${issue.message}` - }) + return error.issues.map((issue) => `${formatZodPath(issue.path)}: ${issue.message}`) } diff --git a/test/yaml/child-reconciler.test.ts b/test/yaml/child-reconciler.test.ts index 9ef0d55..814990b 100644 --- a/test/yaml/child-reconciler.test.ts +++ b/test/yaml/child-reconciler.test.ts @@ -192,7 +192,10 @@ describe('child-reconciler', () => { existingByKey: {A: 'id-1', B: 'id-2'}, } await applyChildDiff(def, 'p-1', [{name: 'B'}, {name: 'A'}], diffResult, current) - expect(reorderFn).toHaveBeenCalledWith('p-1', ['id-2', 'id-1']) + expect(reorderFn).toHaveBeenCalledWith('p-1', [ + {id: 'id-2', yaml: {name: 'B'}, index: 0}, + {id: 'id-1', yaml: {name: 'A'}, index: 1}, + ]) }) it('builds complete child state for state file', async () => { diff --git a/test/yaml/zod-schemas.test.ts b/test/yaml/zod-schemas.test.ts index 8cc6c6a..9de38cd 100644 --- a/test/yaml/zod-schemas.test.ts +++ b/test/yaml/zod-schemas.test.ts @@ -1,5 +1,5 @@ import {describe, it, expect} from 'vitest' -import {DevhelmConfigSchema, formatZodErrors, _ZOD_ENUMS} from '../../src/lib/yaml/zod-schemas.js' +import {DevhelmConfigSchema, formatZodErrors, formatZodPath, _ZOD_ENUMS} from '../../src/lib/yaml/zod-schemas.js' import * as schema from '../../src/lib/yaml/schema.js' describe('DevhelmConfigSchema', () => { @@ -592,4 +592,41 @@ describe('formatZodErrors', () => { expect(messages[0]).toMatch(/^\(root\):/) } }) + + // Path format must match validator.ts (`monitors[0].config.url`) so users + // never see two competing notations for what is structurally the same + // location. Without this, the structural Zod layer reported + // `monitors.0.config.url` while the cross-resource validator reported + // `monitors[0].config.url` — visible whenever both layers fire on one file. + it('renders array indices as [N] for parity with validator.ts', () => { + const result = DevhelmConfigSchema.safeParse({ + monitors: [{name: 'X', type: 'HTTP', config: {}}], + }) + expect(result.success).toBe(false) + if (!result.success) { + const messages = formatZodErrors(result.error) + const joined = messages.join('\n') + expect(joined).toContain('monitors[0]') + expect(joined).not.toMatch(/monitors\.0\b/) + } + }) +}) + +describe('formatZodPath', () => { + it('returns (root) for an empty path', () => { + expect(formatZodPath([])).toBe('(root)') + }) + + it('uses dotted notation for object fields', () => { + expect(formatZodPath(['monitors', 'config', 'url'])).toBe('monitors.config.url') + }) + + it('uses [N] notation for numeric indices', () => { + expect(formatZodPath(['monitors', 0, 'config', 'url'])).toBe('monitors[0].config.url') + }) + + it('does not insert a dot before [N]', () => { + expect(formatZodPath([0])).toBe('[0]') + expect(formatZodPath(['arr', 1, 2, 'k'])).toBe('arr[1][2].k') + }) })