Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions .changeset/pr-762.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- auto-generated -->
---
'@sanity/cli': minor
---

feat(organizations): add commands for listing, creating, updating, and deleting organizations
1 change: 1 addition & 0 deletions packages/@sanity/cli/oclif.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default {
mcp: {description: 'Configure Sanity MCP server for AI editors'},
media: {description: 'Manage media assets and aspect definitions'},
openapi: {description: 'Manage OpenAPI specifications'},
organizations: {description: 'Manage your organizations'},
projects: {description: 'Manage Sanity projects'},
schemas: {description: 'Manage and validate schemas'},
telemetry: {description: 'Manage telemetry consent'},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {describe, expect, test} from 'vitest'

import {validateOrganizationSlug} from '../validateOrganizationSlug.js'

describe('validateOrganizationSlug', () => {
test.each([['acme'], ['acme-corp'], ['my-org-123'], ['a'], ['abc123']])(
'returns true for valid slug: "%s"',
(slug) => {
expect(validateOrganizationSlug(slug)).toBe(true)
},
)

test.each([
['', 'Organization slug cannot be empty'],
[' ', 'Organization slug cannot be empty'],
])('returns error for empty or whitespace: "%s"', (slug, expected) => {
expect(validateOrganizationSlug(slug)).toBe(expected)
})

test.each([
['Acme', 'Organization slug must be lowercase'],
['ACME', 'Organization slug must be lowercase'],
])('returns error for uppercase: "%s"', (slug, expected) => {
expect(validateOrganizationSlug(slug)).toBe(expected)
})

test.each([
['acme corp', 'Organization slug cannot contain spaces'],
['acme\tcorp', 'Organization slug cannot contain spaces'],
])('returns error for spaces: "%s"', (slug, expected) => {
expect(validateOrganizationSlug(slug)).toBe(expected)
})

test.each([
['my_org', 'Organization slug may only contain lowercase letters, numbers, and dashes'],
['acme!', 'Organization slug may only contain lowercase letters, numbers, and dashes'],
['acmé', 'Organization slug may only contain lowercase letters, numbers, and dashes'],
])('returns error for invalid characters: "%s"', (slug, expected) => {
expect(validateOrganizationSlug(slug)).toBe(expected)
})

test.each([
['-acme', 'Organization slug cannot start or end with a dash'],
['acme-', 'Organization slug cannot start or end with a dash'],
])('returns error for leading or trailing dash: "%s"', (slug, expected) => {
expect(validateOrganizationSlug(slug)).toBe(expected)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function validateOrganizationSlug(input: string): string | true {
if (!input || input.trim() === '') {
return 'Organization slug cannot be empty'
}
if (input !== input.toLowerCase()) {
return 'Organization slug must be lowercase'
}
if (/\s/.test(input)) {
return 'Organization slug cannot contain spaces'
}
if (!/^[a-z0-9-]+$/.test(input)) {
return 'Organization slug may only contain lowercase letters, numbers, and dashes'
}
if (input.startsWith('-') || input.endsWith('-')) {
return 'Organization slug cannot start or end with a dash'
}
return true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import {testCommand} from '@sanity/cli-test'
import {afterEach, describe, expect, test, vi} from 'vitest'

import {CreateOrganizationCommand} from '../create.js'

const mockRequest = vi.hoisted(() => vi.fn())
const mockInput = vi.hoisted(() => vi.fn())

vi.mock('@sanity/cli-core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@sanity/cli-core')>()
return {
...actual,
getGlobalCliClient: vi.fn().mockResolvedValue({
request: mockRequest,
}),
}
})

vi.mock('@sanity/cli-core/ux', async () => {
const actual = await vi.importActual<typeof import('@sanity/cli-core/ux')>('@sanity/cli-core/ux')
return {
...actual,
input: mockInput,
spinner: vi.fn().mockReturnValue({
fail: vi.fn(),
start: vi.fn().mockReturnThis(),
succeed: vi.fn(),
}),
}
})

const createdOrg = {
createdAt: '2026-01-01T00:00:00Z',
createdByUserId: 'user-123',
defaultRoleName: null,
features: [],
id: 'org-new',
members: [],
name: 'My Org',
slug: null,
telemetryConsentStatus: 'allowed',
updatedAt: '2026-01-01T00:00:00Z',
}

describe('organizations create', () => {
afterEach(() => {
vi.clearAllMocks()
})

test('creates organization with --name flag', async () => {
mockRequest.mockResolvedValue(createdOrg)

const {error, stdout} = await testCommand(CreateOrganizationCommand, ['--name', 'My Org'])

if (error) throw error
expect(stdout).toContain('org-new')
expect(stdout).toContain('My Org')
expect(mockRequest).toHaveBeenCalledWith({
body: {name: 'My Org'},
method: 'post',
uri: '/organizations',
})
})

test('creates organization with --name flag and --default-role flag', async () => {
mockRequest.mockResolvedValue({...createdOrg, defaultRoleName: 'viewer'})

const {error, stdout} = await testCommand(CreateOrganizationCommand, [
'--name',
'My Org',
'--default-role',
'viewer',
])

if (error) throw error
expect(stdout).toContain('org-new')
expect(mockRequest).toHaveBeenCalledWith({
body: {defaultRoleName: 'viewer', name: 'My Org'},
method: 'post',
uri: '/organizations',
})
})

test('trims name before sending', async () => {
mockRequest.mockResolvedValue(createdOrg)

const {error} = await testCommand(CreateOrganizationCommand, ['--name', ' My Org '])

if (error) throw error
expect(mockRequest).toHaveBeenCalledWith({
body: {name: 'My Org'},
method: 'post',
uri: '/organizations',
})
})

test('errors when --default-role flag is empty', async () => {
const {error} = await testCommand(CreateOrganizationCommand, [
'--name',
'My Org',
'--default-role',
' ',
])

expect(error).toBeInstanceOf(Error)
expect(error?.message).toContain('Default role cannot be empty')
expect(error?.oclif?.exit).toBe(1)
expect(mockRequest).not.toHaveBeenCalled()
})

test('prompts for name when arg is not provided', async () => {
mockInput.mockResolvedValue('Prompted Org')
mockRequest.mockResolvedValue({...createdOrg, name: 'Prompted Org'})

const {error, stdout} = await testCommand(CreateOrganizationCommand, [], {
mocks: {isInteractive: true},
})

if (error) throw error
expect(mockInput).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Organization name:',
validate: expect.any(Function),
}),
)
expect(stdout).toContain('org-new')
})

test('errors when --name is missing in a non-interactive environment', async () => {
const {error} = await testCommand(CreateOrganizationCommand, [], {
mocks: {isInteractive: false},
})

expect(error).toBeInstanceOf(Error)
expect(error?.message).toContain('Organization name is required')
expect(error?.message).toContain('--name')
expect(error?.oclif?.exit).toBe(1)
expect(mockInput).not.toHaveBeenCalled()
expect(mockRequest).not.toHaveBeenCalled()
})

test('errors when --name flag is empty', async () => {
const {error} = await testCommand(CreateOrganizationCommand, ['--name', ''])

expect(error).toBeInstanceOf(Error)
expect(error?.message).toContain('Organization name cannot be empty')
expect(error?.oclif?.exit).toBe(1)
})

test('errors when --name flag exceeds 100 characters', async () => {
const longName = 'a'.repeat(101)
const {error} = await testCommand(CreateOrganizationCommand, ['--name', longName])

expect(error).toBeInstanceOf(Error)
expect(error?.message).toContain('Organization name cannot be longer than 100 characters')
expect(error?.oclif?.exit).toBe(1)
})

test('errors when API call fails', async () => {
mockRequest.mockRejectedValue(new Error('Server error'))

const {error} = await testCommand(CreateOrganizationCommand, ['--name', 'My Org'])

expect(error).toBeInstanceOf(Error)
expect(error?.message).toContain('Failed to create organization')
expect(error?.oclif?.exit).toBe(1)
})
})
Loading
Loading