Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/e2e/__tests__/help.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe('help', () => {
incidents Create and manage status page incidents.
login Login to your Checkly account or create a new one.
logout Log out and clear any local credentials.
members List account members and pending invites.
rca Trigger and retrieve root cause analyses.
rules Generate a rules file to use with AI IDEs and Copilots.
runtimes List all supported runtimes and dependencies.
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"import": {
"description": "Import existing resources from your Checkly account to your project."
},
"members": {
"description": "List and manage members in your Checkly account."
},
"rca": {
"description": "Trigger and retrieve root cause analyses."
},
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ai-context/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const MANAGE_REFERENCES = [
},
{
id: 'manage-account-members',
description: 'List account members and pending invites (`account members`)',
description: 'List account members and pending invites (`members`), update member roles, and delete members',
},
] as const

Expand Down
34 changes: 26 additions & 8 deletions packages/cli/src/ai-context/references/manage-account-members.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
# Account Members

List active account members and pending or expired account invites.
List active account members and pending or expired account invites. Update active member roles and delete active members.

## Usage

```bash
npx checkly account members
npx checkly account members --output json
npx checkly account members --search alice
npx checkly account members --type invite --status pending
npx checkly account members --role admin
npx checkly account members --limit 25
npx checkly account members --hide-id
npx checkly members
npx checkly members --output json
npx checkly members --search alice
npx checkly members --type invite --status pending
npx checkly members --role admin
npx checkly members --limit 25
npx checkly members --hide-id
npx checkly members update alice@example.com --role read_run
npx checkly members update <user-id> --role admin --id --force
npx checkly members delete alice@example.com --force
```

Flags:
Expand All @@ -24,6 +27,21 @@ Flags:
- `-o, --output <format>` — `table` (default), `json`, or `md`.
- `--hide-id` — hide member and invite IDs in table output.

Role update:
- `checkly members update <member> --role <role>` updates an active account member role.
- `<member>` can be an email address or user ID. Values containing `@` are resolved as exact member emails by default; other values are treated as user IDs.
- Use `--email` or `--id` to force how `<member>` is interpreted.
- Valid update roles are `admin`, `read_write`, `read_run`, and `read_only` (case-insensitive).
- `owner` cannot be set through this command.
- This mutation requires confirmation. In non-interactive mode, rerun with `--force` after reviewing the confirmation preview.

Delete member:
- `checkly members delete <member>` removes an active account member.
- `<member>` can be an email address or user ID. Values containing `@` are resolved as exact member emails by default; other values are treated as user IDs.
- Use `--email` or `--id` to force how `<member>` is interpreted.
- This destructive mutation requires confirmation. In non-interactive mode, rerun with `--force` after reviewing the confirmation preview.
- Pending invite cancellation is not handled by this command.

## JSON response shape

```json
Expand Down
275 changes: 275 additions & 0 deletions packages/cli/src/commands/__tests__/account-members-mutations.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('../../helpers/cli-mode', () => ({
detectCliMode: vi.fn(() => 'agent'),
}))

vi.mock('../../rest/api', () => ({
accountMembers: { getAll: vi.fn(), updateRole: vi.fn(), delete: vi.fn() },
validateAuthentication: vi.fn().mockResolvedValue({ name: 'Test Account' }),
}))

vi.mock('prompts', () => ({
default: vi.fn(() => Promise.resolve({ confirm: true })),
}))

import { detectCliMode } from '../../helpers/cli-mode.js'
import * as api from '../../rest/api.js'
import { AuthCommand } from '../authCommand.js'
import MembersUpdate, { normalizeAccountMemberUpdateRole } from '../members/update.js'
import MembersDelete from '../members/delete.js'

const updatedMember = {
type: 'member' as const,
accountId: '11111111-1111-1111-1111-111111111111',
userId: '22222222-2222-2222-2222-222222222222',
name: 'Ada Admin',
email: 'ada@example.com',
role: 'ADMIN' as const,
status: 'ACTIVE' as const,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
isSupportMembership: false,
ssoEnabled: false,
mfaEnabled: true,
}

function createCommandContext (Command: typeof AuthCommand, parsed: unknown) {
const logged: string[] = []
let exitCodeValue: number | undefined
return {
parse: vi.fn().mockResolvedValue(parsed),
error: vi.fn((message: string) => {
throw new Error(message)
}),
log: vi.fn((msg?: string) => {
if (msg) logged.push(msg)
}),
exit: vi.fn((code: number) => {
exitCodeValue = code
throw new Error(`EXIT_${code}`)
}),
confirmOrAbort: AuthCommand.prototype.confirmOrAbort,
style: {
outputFormat: undefined,
shortSuccess: vi.fn(),
longError: vi.fn(),
},
constructor: Command,
logged,
get exitCodeValue () {
return exitCodeValue
},
}
}

describe('account members mutation commands', () => {
beforeEach(() => {
vi.clearAllMocks()
process.exitCode = undefined
vi.mocked(api.accountMembers.getAll).mockResolvedValue({
data: { members: [updatedMember], length: 1, nextId: null },
} as any)
vi.mocked(api.accountMembers.updateRole).mockResolvedValue({ data: updatedMember } as any)
vi.mocked(api.accountMembers.delete).mockResolvedValue({} as any)
})

it('normalizes update role values and excludes owner', () => {
expect(normalizeAccountMemberUpdateRole('admin')).toBe('ADMIN')
expect(normalizeAccountMemberUpdateRole('Read_Run')).toBe('READ_RUN')
expect(normalizeAccountMemberUpdateRole(' read_only ')).toBe('READ_ONLY')
expect(normalizeAccountMemberUpdateRole('owner')).toBeUndefined()
expect(normalizeAccountMemberUpdateRole('superadmin')).toBeUndefined()
})

it('members update exits 2 in agent mode without --force', async () => {
vi.mocked(detectCliMode).mockReturnValue('agent')
const ctx = createCommandContext(MembersUpdate, {
args: { member: updatedMember.userId },
flags: {
'role': 'admin',
'email': false,
'id': false,
'output': 'table',
'force': false,
'dry-run': false,
},
})

await expect(
MembersUpdate.prototype.run.call(ctx as any),
).rejects.toThrow('EXIT_2')

const output = JSON.parse(ctx.logged[0])
expect(output.status).toBe('confirmation_required')
expect(output.command).toBe('members update')
expect(output.confirmCommand).toContain('--force')
expect(output.confirmCommand).toContain(updatedMember.userId)
expect(api.accountMembers.updateRole).not.toHaveBeenCalled()
})

it('members update executes with --force in agent mode', async () => {
vi.mocked(detectCliMode).mockReturnValue('agent')
const ctx = createCommandContext(MembersUpdate, {
args: { member: updatedMember.userId },
flags: {
'role': 'read_run',
'email': false,
'id': false,
'output': 'json',
'force': true,
'dry-run': false,
},
})

await MembersUpdate.prototype.run.call(ctx as any)

expect(api.accountMembers.updateRole).toHaveBeenCalledWith(updatedMember.userId, 'READ_RUN')
expect(JSON.parse(ctx.logged[0])).toMatchObject({
userId: updatedMember.userId,
email: updatedMember.email,
})
})

it('members update resolves an email before updating', async () => {
vi.mocked(detectCliMode).mockReturnValue('agent')
const ctx = createCommandContext(MembersUpdate, {
args: { member: updatedMember.email.toUpperCase() },
flags: {
'role': 'read_only',
'email': false,
'id': false,
'output': 'json',
'force': true,
'dry-run': false,
},
})

await MembersUpdate.prototype.run.call(ctx as any)

expect(api.accountMembers.getAll).toHaveBeenCalledWith({
search: updatedMember.email.toUpperCase(),
type: 'member',
status: 'ACTIVE',
})
expect(api.accountMembers.updateRole).toHaveBeenCalledWith(updatedMember.userId, 'READ_ONLY')
})

it('members update can force email-looking values to be treated as IDs', async () => {
vi.mocked(detectCliMode).mockReturnValue('agent')
const ctx = createCommandContext(MembersUpdate, {
args: { member: 'user@example.com' },
flags: {
'role': 'read_only',
'email': false,
'id': true,
'output': 'json',
'force': true,
'dry-run': false,
},
})

await MembersUpdate.prototype.run.call(ctx as any)

expect(api.accountMembers.getAll).not.toHaveBeenCalled()
expect(api.accountMembers.updateRole).toHaveBeenCalledWith('user@example.com', 'READ_ONLY')
})

it('members update rejects owner role locally', async () => {
const ctx = createCommandContext(MembersUpdate, {
args: { member: updatedMember.userId },
flags: {
'role': 'owner',
'email': false,
'id': false,
'output': 'table',
'force': true,
'dry-run': false,
},
})

await expect(
MembersUpdate.prototype.run.call(ctx as any),
).rejects.toThrow('Invalid --role "owner"')

expect(api.accountMembers.updateRole).not.toHaveBeenCalled()
})

it('members delete exits 2 in agent mode without --force', async () => {
vi.mocked(detectCliMode).mockReturnValue('agent')
const ctx = createCommandContext(MembersDelete, {
args: { member: updatedMember.userId },
flags: {
'email': false,
'id': false,
'force': false,
'dry-run': false,
},
})

await expect(
MembersDelete.prototype.run.call(ctx as any),
).rejects.toThrow('EXIT_2')

const output = JSON.parse(ctx.logged[0])
expect(output.status).toBe('confirmation_required')
expect(output.command).toBe('members delete')
expect(output.classification.destructive).toBe(true)
expect(output.confirmCommand).toContain('--force')
expect(output.confirmCommand).toContain(updatedMember.userId)
expect(api.accountMembers.delete).not.toHaveBeenCalled()
})

it('members delete executes with --force in agent mode', async () => {
vi.mocked(detectCliMode).mockReturnValue('agent')
const ctx = createCommandContext(MembersDelete, {
args: { member: updatedMember.userId },
flags: {
'email': false,
'id': false,
'force': true,
'dry-run': false,
},
})

await MembersDelete.prototype.run.call(ctx as any)

expect(api.accountMembers.delete).toHaveBeenCalledWith(updatedMember.userId)
})

it('members delete resolves an email before showing the confirmation preview', async () => {
vi.mocked(detectCliMode).mockReturnValue('agent')
const ctx = createCommandContext(MembersDelete, {
args: { member: updatedMember.email },
flags: {
'email': false,
'id': false,
'force': false,
'dry-run': false,
},
})

await expect(
MembersDelete.prototype.run.call(ctx as any),
).rejects.toThrow('EXIT_2')

const output = JSON.parse(ctx.logged[0])
expect(api.accountMembers.getAll).toHaveBeenCalledWith({
search: updatedMember.email,
type: 'member',
status: 'ACTIVE',
})
expect(output.changes[0]).toContain(updatedMember.email)
expect(output.changes[0]).toContain(updatedMember.userId)
expect(api.accountMembers.delete).not.toHaveBeenCalled()
})

it('has correct metadata', () => {
expect(MembersUpdate.readOnly).toBe(false)
expect(MembersUpdate.destructive).toBe(false)
expect(MembersUpdate.idempotent).toBe(true)
expect(MembersDelete.readOnly).toBe(false)
expect(MembersDelete.destructive).toBe(true)
expect(MembersDelete.idempotent).toBe(true)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
normalizeAccountMemberRole,
normalizeAccountMemberStatus,
normalizeAccountMemberType,
} from '../account/members.js'
} from '../members.js'

describe('account members flag normalization', () => {
it('normalizes type values case-insensitively', () => {
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/commands/__tests__/command-metadata.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@ import PwTest from '../pw-test.js'
import SyncPlaywright from '../sync-playwright.js'
import SkillsInstall from '../skills/install.js'
import AccountPlan from '../account/plan.js'
import AccountMembers from '../account/members.js'
import Members from '../members.js'
import MembersUpdate from '../members/update.js'
import MembersDelete from '../members/delete.js'
import Api from '../api.js'
import TestSessionsGet from '../test-sessions/get.js'

const commands: Array<[string, typeof BaseCommand]> = [
['api', Api],
['account members', AccountMembers],
['members', Members],
['members update', MembersUpdate],
['members delete', MembersDelete],
['account plan', AccountPlan],
['checks list', ChecksList],
['checks get', ChecksGet],
Expand Down
Loading
Loading