Skip to content

Commit da3cf9a

Browse files
committed
refactor: derive snapshot types from OpenAPI UpdateRequest schemas
Replace all hand-written *Snapshot interfaces with types derived from the generated OpenAPI Update*Request schemas via Required<Schemas[...]>. This guarantees that when the API contract changes (field added, removed, or renamed), the TypeScript compiler immediately errors in the snapshot functions, preventing silent drift between YAML engine and API. Key changes: - Remove dead alwaysChanged plumbing from HandlerDef / defineHandler - Replace 14 custom snapshot interfaces with Required<UpdateXRequest> type aliases (tag, environment, notificationPolicy, webhook, resourceGroup, monitor) or documented custom types for special cases (secret: hash-based, alertChannel: hash-based, dependency: split API) - Remove 7 old normalization helpers, replace with 4 API-type-aligned helpers (sortAssertions, apiAssertionsToSnapshot, apiIncidentPolicyToSnapshot, apiTagsToSnapshot) - Export toCreateAssertionRequest and toIncidentPolicy from transform.ts - Centralize as-any casts into api-client.ts helper functions - Add typed RefEntry<K> generics to resolver.ts - Add RefTypeDtoMap to types.ts for compile-time DTO mapping - Fix webhook snapshot to also track enabled field - Fix resourceGroup snapshot field name (defaultAlertChannelIds → defaultAlertChannels) to match API contract - Make toDesiredSnapshot / toCurrentSnapshot required (not optional) Made-with: Cursor
1 parent 38d66d9 commit da3cf9a

39 files changed

Lines changed: 18258 additions & 1021 deletions

docs/openapi/monitoring-api.json

Lines changed: 16672 additions & 1 deletion
Large diffs are not rendered by default.

src/commands/alert-channels/test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {Command, Args} from '@oclif/core'
22
import {globalFlags, buildClient} from '../../lib/base-command.js'
3-
import {typedPost} from '../../lib/typed-api.js'
3+
import {checkedFetch} from '../../lib/api-client.js'
44

55
export default class AlertChannelsTest extends Command {
66
static description = 'Send a test notification to an alert channel'
@@ -11,7 +11,7 @@ export default class AlertChannelsTest extends Command {
1111
async run() {
1212
const {args, flags} = await this.parse(AlertChannelsTest)
1313
const client = buildClient(flags)
14-
const resp = await typedPost<{data?: {success?: boolean}}>(client, `/api/v1/alert-channels/${args.id}/test`)
14+
const resp = await checkedFetch(client.POST('/api/v1/alert-channels/{id}/test', {params: {path: {id: args.id}}}))
1515
this.log(resp.data?.success ? 'Test notification sent successfully.' : 'Test notification failed.')
1616
}
1717
}

src/commands/api-keys/revoke.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {Command, Args} from '@oclif/core'
22
import {globalFlags, buildClient} from '../../lib/base-command.js'
3-
import {typedPost} from '../../lib/typed-api.js'
3+
import {checkedFetch} from '../../lib/api-client.js'
44

55
export default class ApiKeysRevoke extends Command {
66
static description = 'Revoke an API key'
@@ -11,7 +11,7 @@ export default class ApiKeysRevoke extends Command {
1111
async run() {
1212
const {args, flags} = await this.parse(ApiKeysRevoke)
1313
const client = buildClient(flags)
14-
await typedPost(client, `/api/v1/api-keys/${args.id}/revoke`)
14+
await checkedFetch(client.POST('/api/v1/api-keys/{id}/revoke', {params: {path: {id: Number(args.id)}}}))
1515
this.log(`API key '${args.id}' revoked.`)
1616
}
1717
}

src/commands/auth/login.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {Command, Flags} from '@oclif/core'
22
import {globalFlags} from '../../lib/base-command.js'
3-
import {createApiClient} from '../../lib/api-client.js'
4-
import {typedGet} from '../../lib/typed-api.js'
3+
import {createApiClient, checkedFetch, apiGet} from '../../lib/api-client.js'
54
import {saveContext, resolveApiUrl} from '../../lib/auth.js'
65
import * as readline from 'node:readline'
76

@@ -26,9 +25,7 @@ export default class AuthLogin extends Command {
2625
const client = createApiClient({baseUrl: apiUrl, token})
2726

2827
try {
29-
const resp = await typedGet<{data?: {organization?: {name?: string; id?: number}; key?: {name?: string}; plan?: {tier?: string}}}>(
30-
client, '/api/v1/auth/me',
31-
)
28+
const resp = await checkedFetch(client.GET('/api/v1/auth/me'))
3229
const me = resp.data
3330

3431
saveContext({name: flags.name, apiUrl, token}, true)
@@ -45,7 +42,7 @@ export default class AuthLogin extends Command {
4542
}
4643

4744
try {
48-
await typedGet(client, '/api/v1/dashboard/overview')
45+
await apiGet(client, '/api/v1/dashboard/overview')
4946
saveContext({name: flags.name, apiUrl, token}, true)
5047
this.log('')
5148
this.log(` Authenticated successfully.`)

src/commands/auth/me.ts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,8 @@
11
import {Command} from '@oclif/core'
22
import {globalFlags, buildClient} from '../../lib/base-command.js'
3-
import {typedGet} from '../../lib/typed-api.js'
3+
import {checkedFetch} from '../../lib/api-client.js'
44
import {formatOutput, OutputFormat} from '../../lib/output.js'
55

6-
interface AuthMeResponse {
7-
data?: {
8-
key?: {id?: string; name?: string; createdAt?: string; expiresAt?: string; lastUsedAt?: string}
9-
organization?: {id?: number; name?: string}
10-
plan?: {
11-
tier?: string
12-
subscriptionStatus?: string
13-
trialActive?: boolean
14-
trialExpiresAt?: string
15-
usage?: Record<string, number>
16-
entitlements?: Record<string, {value: number}>
17-
}
18-
rateLimits?: {requestsPerMinute?: number; remaining?: number; windowMs?: number}
19-
}
20-
}
21-
226
export default class AuthMe extends Command {
237
static description = 'Show current API key identity, organization, plan, and rate limits'
248
static examples = ['<%= config.bin %> auth me', '<%= config.bin %> auth me --output json']
@@ -27,7 +11,7 @@ export default class AuthMe extends Command {
2711
async run() {
2812
const {flags} = await this.parse(AuthMe)
2913
const client = buildClient(flags)
30-
const resp = await typedGet<AuthMeResponse>(client, '/api/v1/auth/me')
14+
const resp = await checkedFetch(client.GET('/api/v1/auth/me'))
3115
const me = resp.data
3216

3317
const format = flags.output as OutputFormat

src/commands/data/services/status.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {Command, Args} from '@oclif/core'
22
import {globalFlags, buildClient, display} from '../../../lib/base-command.js'
3-
import {typedGet} from '../../../lib/typed-api.js'
3+
import {checkedFetch} from '../../../lib/api-client.js'
44

55
export default class DataServicesStatus extends Command {
66
static description = 'Get the current status of a service'
@@ -11,7 +11,7 @@ export default class DataServicesStatus extends Command {
1111
async run() {
1212
const {args, flags} = await this.parse(DataServicesStatus)
1313
const client = buildClient(flags)
14-
const resp = await typedGet<{data?: unknown}>(client, `/api/v1/services/${args.slug}`)
14+
const resp = await checkedFetch(client.GET('/api/v1/services/{slugOrId}', {params: {path: {slugOrId: args.slug}}}))
1515
display(this, resp.data ?? resp, flags.output)
1616
}
1717
}

src/commands/data/services/uptime.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {Command, Args, Flags} from '@oclif/core'
22
import {globalFlags, buildClient, display} from '../../../lib/base-command.js'
3-
import {typedGet} from '../../../lib/typed-api.js'
3+
import {apiGet} from '../../../lib/api-client.js'
44

55
export default class DataServicesUptime extends Command {
66
static description = 'Get uptime data for a service'
@@ -18,9 +18,9 @@ export default class DataServicesUptime extends Command {
1818
async run() {
1919
const {args, flags} = await this.parse(DataServicesUptime)
2020
const client = buildClient(flags)
21-
const query: Record<string, unknown> = {period: flags.period}
21+
const query: Record<string, string> = {period: flags.period}
2222
if (flags.granularity) query.granularity = flags.granularity
23-
const resp = await typedGet<{data?: unknown}>(client, `/api/v1/services/${args.slug}/uptime`, query)
23+
const resp = await apiGet<{data?: unknown}>(client, `/api/v1/services/${args.slug}/uptime`, {query})
2424
display(this, resp.data ?? resp, flags.output)
2525
}
2626
}

src/commands/dependencies/track.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {Command, Args} from '@oclif/core'
22
import {globalFlags, buildClient} from '../../lib/base-command.js'
3-
import {typedPost} from '../../lib/typed-api.js'
3+
import {checkedFetch} from '../../lib/api-client.js'
44

55
export default class DependenciesTrack extends Command {
66
static description = 'Start tracking a service as a dependency'
@@ -11,7 +11,7 @@ export default class DependenciesTrack extends Command {
1111
async run() {
1212
const {args, flags} = await this.parse(DependenciesTrack)
1313
const client = buildClient(flags)
14-
const resp = await typedPost<{data?: {serviceName?: string}}>(client, `/api/v1/service-subscriptions/${args.slug}`)
15-
this.log(`Now tracking '${resp.data?.serviceName}' as a dependency.`)
14+
const resp = await checkedFetch(client.POST('/api/v1/service-subscriptions/{slug}', {params: {path: {slug: args.slug}}}))
15+
this.log(`Now tracking '${resp.data?.name}' as a dependency.`)
1616
}
1717
}

src/commands/deploy.ts

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import {hostname} from 'node:os'
12
import {Command, Flags} from '@oclif/core'
2-
import {createApiClient} from '../lib/api-client.js'
3+
import {createApiClient, apiPost, apiDelete} from '../lib/api-client.js'
34
import {resolveToken, resolveApiUrl} from '../lib/auth.js'
45
import {loadConfig, validate, fetchAllRefs, diff, formatPlan, apply, writeState, buildState} from '../lib/yaml/index.js'
56
import {checkEntitlements, formatEntitlementWarnings} from '../lib/yaml/entitlements.js'
@@ -35,6 +36,14 @@ export default class Deploy extends Command {
3536
description: 'Show what would change without applying (same as "devhelm plan")',
3637
default: false,
3738
}),
39+
'force-unlock': Flags.boolean({
40+
description: 'Force-break an existing deploy lock before acquiring',
41+
default: false,
42+
}),
43+
'no-lock': Flags.boolean({
44+
description: 'Skip deploy locking (not recommended for team use)',
45+
default: false,
46+
}),
3847
'api-url': Flags.string({description: 'Override API base URL'}),
3948
'api-token': Flags.string({description: 'Override API token'}),
4049
verbose: Flags.boolean({char: 'v', description: 'Show verbose output', default: false}),
@@ -111,27 +120,76 @@ export default class Deploy extends Command {
111120
}
112121
}
113122

114-
this.log('Applying changes...')
115-
const applyResult = await apply(changeset, refs, client)
116-
117-
for (const s of applyResult.succeeded) {
118-
const icon = s.action === 'delete' ? '-' : s.action === 'update' ? '~' : '+'
119-
this.log(` ${icon} ${s.resourceType} "${s.refKey}" — ${s.action}d`)
123+
let lockId: string | undefined
124+
if (!flags['no-lock']) {
125+
lockId = await this.acquireLock(client, flags['force-unlock'])
120126
}
121127

122-
if (applyResult.failed.length > 0) {
123-
this.log('')
124-
for (const f of applyResult.failed) {
125-
this.log(` ✗ ${f.resourceType} "${f.refKey}" — ${f.action} failed: ${f.error}`)
128+
try {
129+
this.log('Applying changes...')
130+
const applyResult = await apply(changeset, refs, client)
131+
132+
for (const s of applyResult.succeeded) {
133+
const icon = s.action === 'delete' ? '-' : s.action === 'update' ? '~' : '+'
134+
this.log(` ${icon} ${s.resourceType} "${s.refKey}" — ${s.action}d`)
126135
}
127-
}
128136

129-
writeState(buildState(applyResult.stateEntries))
137+
if (applyResult.failed.length > 0) {
138+
this.log('')
139+
for (const f of applyResult.failed) {
140+
this.log(` ✗ ${f.resourceType} "${f.refKey}" — ${f.action} failed: ${f.error}`)
141+
}
142+
}
130143

131-
this.log(`\nDone: ${applyResult.succeeded.length} succeeded, ${applyResult.failed.length} failed.`)
144+
writeState(buildState(applyResult.stateEntries))
132145

133-
if (applyResult.failed.length > 0) {
134-
this.exit(2)
146+
this.log(`\nDone: ${applyResult.succeeded.length} succeeded, ${applyResult.failed.length} failed.`)
147+
148+
if (applyResult.failed.length > 0) {
149+
this.exit(2)
150+
}
151+
} finally {
152+
if (lockId) {
153+
await this.releaseLock(client, lockId)
154+
}
135155
}
136156
}
157+
158+
private async acquireLock(client: ReturnType<typeof createApiClient>, forceUnlock: boolean): Promise<string | undefined> {
159+
if (forceUnlock) {
160+
try {
161+
await apiDelete(client, '/api/v1/deploy/lock/force')
162+
} catch {
163+
// Force-unlock is best-effort; the lock may not exist
164+
}
165+
}
166+
167+
try {
168+
const resp = await apiPost<{data?: {id?: string}}>(
169+
client, '/api/v1/deploy/lock',
170+
{lockedBy: `${process.env.USER ?? 'cli'}@${hostname()}`, ttlMinutes: 10},
171+
)
172+
const lockId = resp.data?.id
173+
if (!lockId) {
174+
this.warn('Deploy lock acquired but no lock ID returned. Proceeding without lock protection.')
175+
}
176+
return lockId
177+
} catch (err) {
178+
const msg = err instanceof Error ? err.message : String(err)
179+
if (msg.includes('409') || msg.includes('Conflict') || msg.includes('lock held')) {
180+
this.warn(`Deploy lock conflict: ${msg}`)
181+
this.warn('Use --force-unlock to break the existing lock, or --no-lock to skip locking.')
182+
this.exit(3)
183+
}
184+
this.warn(`Failed to acquire deploy lock: ${msg}`)
185+
this.warn('Use --no-lock to skip locking if the lock service is unavailable.')
186+
this.exit(3)
187+
}
188+
}
189+
190+
private async releaseLock(client: ReturnType<typeof createApiClient>, lockId: string): Promise<void> {
191+
try {
192+
await apiDelete(client, `/api/v1/deploy/lock/${lockId}`)
193+
} catch { /* best-effort release */ }
194+
}
137195
}

src/commands/incidents/delete.ts

Lines changed: 0 additions & 4 deletions
This file was deleted.

0 commit comments

Comments
 (0)