diff --git a/packages/@sanity/cli/oclif.config.js b/packages/@sanity/cli/oclif.config.js index 7c2245123..a416564af 100644 --- a/packages/@sanity/cli/oclif.config.js +++ b/packages/@sanity/cli/oclif.config.js @@ -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'}, diff --git a/packages/@sanity/cli/src/actions/organizations/__tests__/validateOrganizationSlug.test.ts b/packages/@sanity/cli/src/actions/organizations/__tests__/validateOrganizationSlug.test.ts new file mode 100644 index 000000000..7d94d3651 --- /dev/null +++ b/packages/@sanity/cli/src/actions/organizations/__tests__/validateOrganizationSlug.test.ts @@ -0,0 +1,33 @@ +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) + }) +}) diff --git a/packages/@sanity/cli/src/actions/organizations/validateOrganizationSlug.ts b/packages/@sanity/cli/src/actions/organizations/validateOrganizationSlug.ts new file mode 100644 index 000000000..c6d07cfcf --- /dev/null +++ b/packages/@sanity/cli/src/actions/organizations/validateOrganizationSlug.ts @@ -0,0 +1,12 @@ +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' + } + return true +} diff --git a/packages/@sanity/cli/src/commands/organizations/__tests__/create.test.ts b/packages/@sanity/cli/src/commands/organizations/__tests__/create.test.ts new file mode 100644 index 000000000..aa96aa169 --- /dev/null +++ b/packages/@sanity/cli/src/commands/organizations/__tests__/create.test.ts @@ -0,0 +1,116 @@ +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() + return { + ...actual, + getGlobalCliClient: vi.fn().mockResolvedValue({ + request: mockRequest, + }), + } +}) + +vi.mock('@sanity/cli-core/ux', async () => { + const actual = await vi.importActual('@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') + }) + + 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') + }) + + 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, []) + + 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 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) + }) +}) diff --git a/packages/@sanity/cli/src/commands/organizations/__tests__/delete.test.ts b/packages/@sanity/cli/src/commands/organizations/__tests__/delete.test.ts new file mode 100644 index 000000000..d903ac72f --- /dev/null +++ b/packages/@sanity/cli/src/commands/organizations/__tests__/delete.test.ts @@ -0,0 +1,153 @@ +import {input} from '@sanity/cli-core/ux' +import {testCommand} from '@sanity/cli-test' +import {afterEach, describe, expect, test, vi} from 'vitest' + +import {DeleteOrganizationCommand} from '../delete.js' + +const mockRequest = vi.hoisted(() => vi.fn()) + +vi.mock('@sanity/cli-core', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getGlobalCliClient: vi.fn().mockResolvedValue({ + request: mockRequest, + }), + } +}) + +vi.mock('@sanity/cli-core/ux', async () => { + const actual = await vi.importActual('@sanity/cli-core/ux') + return { + ...actual, + input: vi.fn(), + spinner: vi + .fn() + .mockReturnValue({fail: vi.fn(), start: vi.fn().mockReturnThis(), succeed: vi.fn()}), + } +}) + +const mockInput = vi.mocked(input) + +const org = { + createdAt: '2024-01-01T00:00:00Z', + defaultRoleName: null, + id: 'org-aaa', + name: 'Acme Corp', + slug: 'acme-corp', + updatedAt: '2026-03-18T00:00:00Z', +} + +describe('organizations delete', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test('deletes organization after typing org name', async () => { + mockRequest.mockResolvedValueOnce(org).mockResolvedValueOnce({deleted: true}) + mockInput.mockResolvedValue(org.name) + + const {error, stdout} = await testCommand(DeleteOrganizationCommand, ['org-aaa']) + + if (error) throw error + expect(mockInput).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Type the name of the organization'), + validate: expect.any(Function), + }), + ) + expect(stdout).toContain('Organization deleted') + }) + + test('skips confirmation with --force flag', async () => { + mockRequest.mockResolvedValue({deleted: true}) + + const {error, stderr} = await testCommand(DeleteOrganizationCommand, ['org-aaa', '--force']) + + if (error) throw error + expect(mockInput).not.toHaveBeenCalled() + expect(stderr).toContain(`--force' used: skipping confirmation`) + }) + + test('errors when user cancels the input prompt', async () => { + mockRequest.mockResolvedValueOnce(org) + mockInput.mockRejectedValue(new Error('User cancelled')) + + const {error} = await testCommand(DeleteOrganizationCommand, ['org-aaa']) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toBe('User cancelled') + expect(error?.oclif?.exit).toBe(1) + }) + + test('requires organizationId argument', async () => { + const {error} = await testCommand(DeleteOrganizationCommand, []) + + expect(error).toBeInstanceOf(Error) + }) + + test('shows user-friendly error when org is not found during fetch', async () => { + const apiError = Object.assign(new Error('Not found'), {statusCode: 404}) + mockRequest.mockRejectedValue(apiError) + + const {error} = await testCommand(DeleteOrganizationCommand, ['org-aaa']) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('Organization "org-aaa" not found') + expect(error?.oclif?.exit).toBe(1) + }) + + test('errors when org retrieval fails', async () => { + mockRequest.mockRejectedValue(new Error('Network error')) + + const {error} = await testCommand(DeleteOrganizationCommand, ['org-aaa']) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('Organization retrieval failed') + expect(error?.oclif?.exit).toBe(1) + }) + + test('shows user-friendly error on 404 during delete', async () => { + mockRequest.mockResolvedValueOnce(org) + mockInput.mockResolvedValue(org.name) + const apiError = Object.assign(new Error('Not found'), {statusCode: 404}) + mockRequest.mockRejectedValueOnce(apiError) + + const {error} = await testCommand(DeleteOrganizationCommand, ['org-aaa']) + + expect(mockInput).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Type the name of the organization'), + validate: expect.any(Function), + }), + ) + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('Organization "org-aaa" not found') + expect(error?.oclif?.exit).toBe(1) + }) + + test('errors when delete API call fails', async () => { + mockRequest.mockResolvedValueOnce(org) + mockInput.mockResolvedValue(org.name) + const apiError = Object.assign(new Error('Organization has projects'), {statusCode: 409}) + mockRequest.mockRejectedValueOnce(apiError) + + const {error} = await testCommand(DeleteOrganizationCommand, ['org-aaa']) + + expect(mockInput).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Type the name of the organization'), + validate: expect.any(Function), + }), + ) + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('Failed to delete organization') + expect(error?.oclif?.exit).toBe(1) + }) + + test('errors when --force is used without organizationId', async () => { + const {error} = await testCommand(DeleteOrganizationCommand, ['--force']) + + expect(error).toBeInstanceOf(Error) + }) +}) diff --git a/packages/@sanity/cli/src/commands/organizations/__tests__/get.test.ts b/packages/@sanity/cli/src/commands/organizations/__tests__/get.test.ts new file mode 100644 index 000000000..e6bc7a14a --- /dev/null +++ b/packages/@sanity/cli/src/commands/organizations/__tests__/get.test.ts @@ -0,0 +1,70 @@ +import {testCommand} from '@sanity/cli-test' +import {afterEach, describe, expect, test, vi} from 'vitest' + +import {GetOrganizationCommand} from '../get.js' + +const mockRequest = vi.hoisted(() => vi.fn()) + +vi.mock('@sanity/cli-core', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getGlobalCliClient: vi.fn().mockResolvedValue({ + request: mockRequest, + }), + } +}) + +const organization = { + createdAt: '2024-01-15T10:00:00Z', + defaultRoleName: 'viewer', + id: 'org-aaa', + name: 'Acme Corp', + slug: 'acme', + updatedAt: '2024-06-01T12:00:00Z', +} + +describe('organizations get', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test('displays organization details', async () => { + mockRequest.mockResolvedValue(organization) + + const {error, stdout} = await testCommand(GetOrganizationCommand, ['org-aaa']) + + if (error) throw error + expect(stdout).toContain('org-aaa') + expect(stdout).toContain('Acme Corp') + expect(stdout).toContain('acme') + expect(stdout).toContain('viewer') + }) + + test('requires organizationId argument', async () => { + const {error} = await testCommand(GetOrganizationCommand, []) + + expect(error).toBeInstanceOf(Error) + }) + + test('errors when organization not found', async () => { + const notFound = Object.assign(new Error('Not found'), {statusCode: 404}) + mockRequest.mockRejectedValue(notFound) + + const {error} = await testCommand(GetOrganizationCommand, ['org-missing']) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('org-missing') + expect(error?.oclif?.exit).toBe(1) + }) + + test('errors on generic API failure', async () => { + mockRequest.mockRejectedValue(new Error('Network error')) + + const {error} = await testCommand(GetOrganizationCommand, ['org-aaa']) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('Failed to get organization') + expect(error?.oclif?.exit).toBe(1) + }) +}) diff --git a/packages/@sanity/cli/src/commands/organizations/__tests__/list.test.ts b/packages/@sanity/cli/src/commands/organizations/__tests__/list.test.ts new file mode 100644 index 000000000..89a00ca54 --- /dev/null +++ b/packages/@sanity/cli/src/commands/organizations/__tests__/list.test.ts @@ -0,0 +1,64 @@ +import {testCommand} from '@sanity/cli-test' +import {afterEach, describe, expect, test, vi} from 'vitest' + +import {ListOrganizationsCommand} from '../list.js' + +const mockList = vi.hoisted(() => vi.fn()) + +vi.mock('@sanity/cli-core', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getGlobalCliClient: vi.fn().mockResolvedValue({ + request: mockList, + }), + } +}) + +const organizations = [ + {id: 'org-aaa', name: 'Acme Corp', slug: 'acme'}, + {id: 'org-bbb', name: 'Globex', slug: null}, +] + +describe('organizations list', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test('lists organizations in a table', async () => { + mockList.mockResolvedValue(organizations) + + const {error, stdout} = await testCommand(ListOrganizationsCommand, []) + + if (error) throw error + expect(stdout).toContain('org-aaa') + expect(stdout).toContain('Acme Corp') + expect(stdout).toContain('acme') + expect(stdout).toContain('org-bbb') + expect(stdout).toContain('Globex') + // The null slug should render as '-' + const lines = stdout.split('\n') + const globexLine = lines.find((l) => l.includes('Globex')) + expect(globexLine).toBeDefined() + expect(globexLine).toContain('-') + }) + + test('shows empty message when no organizations', async () => { + mockList.mockResolvedValue([]) + + const {error, stdout} = await testCommand(ListOrganizationsCommand, []) + + if (error) throw error + expect(stdout).toContain('No organizations found') + }) + + test('errors when API call fails', async () => { + mockList.mockRejectedValue(new Error('Network error')) + + const {error} = await testCommand(ListOrganizationsCommand, []) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('Failed to list organizations') + expect(error?.oclif?.exit).toBe(1) + }) +}) diff --git a/packages/@sanity/cli/src/commands/organizations/__tests__/update.test.ts b/packages/@sanity/cli/src/commands/organizations/__tests__/update.test.ts new file mode 100644 index 000000000..c0b1d4718 --- /dev/null +++ b/packages/@sanity/cli/src/commands/organizations/__tests__/update.test.ts @@ -0,0 +1,140 @@ +import {testCommand} from '@sanity/cli-test' +import {afterEach, describe, expect, test, vi} from 'vitest' + +import {UpdateOrganizationCommand} from '../update.js' + +const mockRequest = vi.hoisted(() => vi.fn()) + +vi.mock('@sanity/cli-core', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getGlobalCliClient: vi.fn().mockResolvedValue({ + request: mockRequest, + }), + } +}) + +vi.mock('@sanity/cli-core/ux', async () => { + const actual = await vi.importActual('@sanity/cli-core/ux') + return { + ...actual, + spinner: vi + .fn() + .mockReturnValue({fail: vi.fn(), start: vi.fn().mockReturnThis(), succeed: vi.fn()}), + } +}) + +const updatedOrg = { + createdAt: '2024-01-01T00:00:00Z', + defaultRoleName: null, + id: 'org-aaa', + name: 'New Name', + slug: 'new-slug', + updatedAt: '2026-03-18T00:00:00Z', +} + +describe('organizations update', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + test('updates organization name', async () => { + mockRequest.mockResolvedValue(updatedOrg) + + const {error, stdout} = await testCommand(UpdateOrganizationCommand, [ + 'org-aaa', + '--name', + 'New Name', + ]) + + if (error) throw error + expect(stdout).toContain('Organization updated') + }) + + test('updates organization slug', async () => { + mockRequest.mockResolvedValue(updatedOrg) + + const {error, stdout} = await testCommand(UpdateOrganizationCommand, [ + 'org-aaa', + '--slug', + 'new-slug', + ]) + + if (error) throw error + expect(stdout).toContain('Organization updated') + }) + + test('updates multiple fields at once', async () => { + mockRequest.mockResolvedValue(updatedOrg) + + const {error, stdout} = await testCommand(UpdateOrganizationCommand, [ + 'org-aaa', + '--name', + 'New Name', + '--slug', + 'new-slug', + '--default-role', + 'viewer', + ]) + + if (error) throw error + expect(stdout).toContain('Organization updated') + }) + + test('errors when no flags are provided', async () => { + const {error} = await testCommand(UpdateOrganizationCommand, ['org-aaa']) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('At least one of the following must be provided') + expect(error?.oclif?.exit).toBe(2) + }) + + test('requires organizationId argument', async () => { + const {error} = await testCommand(UpdateOrganizationCommand, ['--name', 'Foo']) + + expect(error).toBeInstanceOf(Error) + }) + + test('validates name flag', async () => { + const {error} = await testCommand(UpdateOrganizationCommand, ['org-aaa', '--name', ' ']) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('Organization name cannot be empty') + expect(error?.oclif?.exit).toBe(1) + }) + + test('shows user-friendly error on 404', async () => { + const apiError = Object.assign(new Error('Not found'), {statusCode: 404}) + mockRequest.mockRejectedValue(apiError) + + const {error} = await testCommand(UpdateOrganizationCommand, ['org-aaa', '--name', 'New Name']) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('Organization "org-aaa" not found') + expect(error?.oclif?.exit).toBe(1) + }) + + test('validates slug flag', async () => { + const {error} = await testCommand(UpdateOrganizationCommand, [ + 'org-aaa', + '--slug', + 'Invalid Slug!', + ]) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('must be lowercase') + expect(error?.oclif?.exit).toBe(1) + }) + + test('surfaces API error (e.g. slug requires authSAML feature)', async () => { + const apiError = Object.assign(new Error('Slug requires SAML'), {statusCode: 403}) + mockRequest.mockRejectedValue(apiError) + + const {error} = await testCommand(UpdateOrganizationCommand, ['org-aaa', '--slug', 'my-slug']) + + expect(error).toBeInstanceOf(Error) + expect(error?.message).toContain('Failed to update organization') + expect(error?.oclif?.exit).toBe(1) + }) +}) diff --git a/packages/@sanity/cli/src/commands/organizations/create.ts b/packages/@sanity/cli/src/commands/organizations/create.ts new file mode 100644 index 000000000..dd8039965 --- /dev/null +++ b/packages/@sanity/cli/src/commands/organizations/create.ts @@ -0,0 +1,73 @@ +import {Flags} from '@oclif/core' +import {type FlagInput} from '@oclif/core/interfaces' +import {SanityCommand, subdebug} from '@sanity/cli-core' +import {spinner} from '@sanity/cli-core/ux' + +import {validateOrganizationName} from '../../actions/organizations/validateOrganizationName.js' +import {promptForOrganizationName} from '../../prompts/promptForOrganizationName.js' +import {createOrganization} from '../../services/organizations.js' +import {getErrorMessage} from '../../util/getErrorMessage.js' +import {organizationAliases} from '../../util/organizationAliases.js' + +const createOrgDebug = subdebug('organizations:create') + +export class CreateOrganizationCommand extends SanityCommand { + static override description = 'Create a new organization' + + static override examples = [ + { + command: '<%= config.bin %> <%= command.id %>', + description: 'Interactively create an organization', + }, + { + command: '<%= config.bin %> <%= command.id %> --name "Acme Corp"', + description: 'Create an organization named "Acme Corp"', + }, + { + command: '<%= config.bin %> <%= command.id %> --name "Acme Corp" --default-role member', + description: 'Create an organization with a default member role', + }, + ] + + static override flags = { + 'default-role': Flags.string({ + description: 'Default role assigned to new members', + required: false, + }), + name: Flags.string({ + description: 'Organization name', + required: false, + }), + } satisfies FlagInput + + static override hiddenAliases = organizationAliases('create') + + public async run(): Promise { + const {'default-role': defaultRole, name: nameFlag} = this.flags + + let name: string + if (nameFlag === undefined) { + name = await promptForOrganizationName() + } else { + const validation = validateOrganizationName(nameFlag) + if (validation !== true) { + this.error(validation, {exit: 1}) + } + name = nameFlag + } + + const spin = spinner('Creating organization').start() + try { + const org = await createOrganization(name, defaultRole) + spin.succeed('Organization created') + this.log(`ID: ${org.id}`) + this.log(`Name: ${org.name}`) + } catch (error) { + spin.fail() + createOrgDebug('Error creating organization', error) + this.error(`Failed to create organization: ${getErrorMessage(error)}`, { + exit: 1, + }) + } + } +} diff --git a/packages/@sanity/cli/src/commands/organizations/delete.ts b/packages/@sanity/cli/src/commands/organizations/delete.ts new file mode 100644 index 000000000..c57810d87 --- /dev/null +++ b/packages/@sanity/cli/src/commands/organizations/delete.ts @@ -0,0 +1,109 @@ +import {styleText} from 'node:util' + +import {Args, Flags} from '@oclif/core' +import {type FlagInput} from '@oclif/core/interfaces' +import {SanityCommand, subdebug} from '@sanity/cli-core' +import {input, logSymbols, spinner} from '@sanity/cli-core/ux' + +import {deleteOrganization, getOrganization} from '../../services/organizations.js' +import {hasStatusCode} from '../../util/apiError.js' +import {getErrorMessage} from '../../util/getErrorMessage.js' +import {organizationAliases} from '../../util/organizationAliases.js' + +const deleteOrgDebug = subdebug('organizations:delete') + +export class DeleteOrganizationCommand extends SanityCommand { + static override args = { + organizationId: Args.string({ + description: 'Organization ID to delete', + required: true, + }), + } + + static override description = 'Delete an organization' + + static override examples = [ + { + command: '<%= config.bin %> <%= command.id %> org-abc123', + description: 'Delete an organization (prompts for confirmation)', + }, + { + command: '<%= config.bin %> <%= command.id %> org-abc123 --force', + description: 'Delete an organization without confirmation', + }, + ] + + static override flags = { + force: Flags.boolean({ + description: 'Do not prompt for delete confirmation - forcefully delete', + required: false, + }), + } satisfies FlagInput + + static override hiddenAliases = organizationAliases('delete') + + public async run(): Promise { + const {organizationId} = this.args + const {force} = this.flags + + if (force) { + this.warn(`'--force' used: skipping confirmation, deleting organization "${organizationId}"`) + } else { + await this.confirmDeletion(organizationId) + } + + const spin = spinner('Deleting organization').start() + try { + await deleteOrganization(organizationId) + spin.succeed() + this.log('Organization deleted') + } catch (error) { + spin.fail() + deleteOrgDebug('Error deleting organization', error) + if (hasStatusCode(error) && error.statusCode === 404) { + this.error(`Organization "${organizationId}" not found`, {exit: 1}) + } + this.error(`Failed to delete organization: ${getErrorMessage(error)}`, {exit: 1}) + } + } + + private async confirmDeletion(organizationId: string): Promise { + let orgName: string + try { + const org = await getOrganization(organizationId) + orgName = org.name + } catch (error) { + const err = error instanceof Error ? error : new Error(`${error}`) + deleteOrgDebug(`Error getting organization ${organizationId}`, err) + if (hasStatusCode(error) && error.statusCode === 404) { + this.error(`Organization "${organizationId}" not found`, {exit: 1}) + } + this.error(`Organization retrieval failed: ${err.message}`, {exit: 1}) + } + + this.log( + styleText( + 'yellow', + `${logSymbols.warning} Deleting organization "${styleText(['bold', 'underline'], orgName)}"\n`, + ), + ) + + try { + await input({ + message: + 'Are you ABSOLUTELY sure you want to delete this organization?\n Type the name of the organization to confirm delete:', + validate: (value) => { + const trimmed = value.trim().toLowerCase() + return ( + trimmed === orgName.toLowerCase() || + 'Incorrect organization name. Ctrl + C to cancel delete.' + ) + }, + }) + } catch (error) { + const err = error instanceof Error ? error : new Error(`${error}`) + deleteOrgDebug(`User cancelled`, err) + this.error(`User cancelled`, {exit: 1}) + } + } +} diff --git a/packages/@sanity/cli/src/commands/organizations/get.ts b/packages/@sanity/cli/src/commands/organizations/get.ts new file mode 100644 index 000000000..7314771ea --- /dev/null +++ b/packages/@sanity/cli/src/commands/organizations/get.ts @@ -0,0 +1,51 @@ +import {Args} from '@oclif/core' +import {SanityCommand, subdebug} from '@sanity/cli-core' + +import {getOrganization} from '../../services/organizations.js' +import {hasStatusCode} from '../../util/apiError.js' +import {getErrorMessage} from '../../util/getErrorMessage.js' +import {organizationAliases} from '../../util/organizationAliases.js' + +const getOrgDebug = subdebug('organizations:get') + +export class GetOrganizationCommand extends SanityCommand { + static override args = { + organizationId: Args.string({ + description: 'Organization ID', + required: true, + }), + } + + static override description = 'Get details of an organization' + + static override examples = [ + { + command: '<%= config.bin %> <%= command.id %> org-abc123', + description: 'Get details of a specific organization', + }, + ] + + static override hiddenAliases = organizationAliases('get') + + public async run(): Promise { + const {organizationId} = this.args + + let org + try { + org = await getOrganization(organizationId) + } catch (error) { + getOrgDebug('Error getting organization', error) + if (hasStatusCode(error) && error.statusCode === 404) { + this.error(`Organization "${organizationId}" not found`, {exit: 1}) + } + this.error(`Failed to get organization: ${getErrorMessage(error)}`, {exit: 1}) + } + + this.log(`ID: ${org.id}`) + this.log(`Name: ${org.name}`) + this.log(`Slug: ${org.slug ?? '-'}`) + this.log(`Default role: ${org.defaultRoleName ?? '-'}`) + this.log(`Created: ${org.createdAt}`) + this.log(`Updated: ${org.updatedAt}`) + } +} diff --git a/packages/@sanity/cli/src/commands/organizations/list.ts b/packages/@sanity/cli/src/commands/organizations/list.ts new file mode 100644 index 000000000..dcc801a96 --- /dev/null +++ b/packages/@sanity/cli/src/commands/organizations/list.ts @@ -0,0 +1,50 @@ +import {SanityCommand, subdebug} from '@sanity/cli-core' +import {Table} from 'console-table-printer' + +import {listOrganizations} from '../../services/organizations.js' +import {getErrorMessage} from '../../util/getErrorMessage.js' +import {organizationAliases} from '../../util/organizationAliases.js' + +const listOrgsDebug = subdebug('organizations:list') + +export class ListOrganizationsCommand extends SanityCommand { + static override description = 'List organizations you are a member of' + + static override examples = [ + { + command: '<%= config.bin %> <%= command.id %>', + description: 'List all your organizations', + }, + ] + + static override hiddenAliases = organizationAliases('list') + + public async run(): Promise { + let organizations + try { + organizations = await listOrganizations() + } catch (error) { + listOrgsDebug('Error listing organizations', error) + this.error(`Failed to list organizations: ${getErrorMessage(error)}`, {exit: 1}) + } + + if (organizations.length === 0) { + this.log('No organizations found') + return + } + + const table = new Table({ + columns: [ + {alignment: 'left', name: 'id', title: 'ID'}, + {alignment: 'left', name: 'name', title: 'Name'}, + {alignment: 'left', name: 'slug', title: 'Slug'}, + ], + }) + + for (const {id, name, slug} of organizations) { + table.addRow({id, name, slug: slug ?? '-'}) + } + + table.printTable() + } +} diff --git a/packages/@sanity/cli/src/commands/organizations/update.ts b/packages/@sanity/cli/src/commands/organizations/update.ts new file mode 100644 index 000000000..421838cea --- /dev/null +++ b/packages/@sanity/cli/src/commands/organizations/update.ts @@ -0,0 +1,97 @@ +import {Args, Flags} from '@oclif/core' +import {type FlagInput} from '@oclif/core/interfaces' +import {SanityCommand, subdebug} from '@sanity/cli-core' +import {spinner} from '@sanity/cli-core/ux' + +import {validateOrganizationName} from '../../actions/organizations/validateOrganizationName.js' +import {validateOrganizationSlug} from '../../actions/organizations/validateOrganizationSlug.js' +import {type OrganizationUpdateParams, updateOrganization} from '../../services/organizations.js' +import {hasStatusCode} from '../../util/apiError.js' +import {getErrorMessage} from '../../util/getErrorMessage.js' +import {organizationAliases} from '../../util/organizationAliases.js' + +const updateOrgDebug = subdebug('organizations:update') + +const UPDATE_FLAGS = ['name', 'slug', 'default-role'] as const + +export class UpdateOrganizationCommand extends SanityCommand { + static override args = { + organizationId: Args.string({ + description: 'Organization ID', + required: true, + }), + } + + static override description = 'Update an organization' + + static override examples = [ + { + command: '<%= config.bin %> <%= command.id %> org-abc123 --name "New Name"', + description: 'Rename an organization', + }, + { + command: '<%= config.bin %> <%= command.id %> org-abc123 --slug new-slug', + description: 'Set the organization slug (requires authSAML feature)', + }, + { + command: '<%= config.bin %> <%= command.id %> org-abc123 --default-role viewer', + description: 'Change the default member role', + }, + ] + + static override flags = { + 'default-role': Flags.string({ + atLeastOne: [...UPDATE_FLAGS], + description: 'New default role for new members', + required: false, + }), + name: Flags.string({ + atLeastOne: [...UPDATE_FLAGS], + description: 'New organization name', + required: false, + }), + slug: Flags.string({ + atLeastOne: [...UPDATE_FLAGS], + description: 'New URL slug (requires authSAML feature on the organization)', + required: false, + }), + } satisfies FlagInput + + static override hiddenAliases = organizationAliases('update') + + public async run(): Promise { + const {organizationId} = this.args + const {'default-role': defaultRole, name, slug} = this.flags + + const params: OrganizationUpdateParams = {} + if (name !== undefined) { + const validation = validateOrganizationName(name) + if (validation !== true) { + this.error(validation, {exit: 1}) + } + params.name = name + } + if (slug !== undefined) { + const slugValidation = validateOrganizationSlug(slug) + if (slugValidation !== true) { + this.error(slugValidation, {exit: 1}) + } + params.slug = slug + } + if (defaultRole !== undefined) params.defaultRoleName = defaultRole + + const spin = spinner('Updating organization').start() + try { + await updateOrganization(organizationId, params) + spin.succeed() + this.log('Organization updated') + } catch (error) { + spin.fail() + updateOrgDebug('Error updating organization', error) + if (hasStatusCode(error) && error.statusCode === 404) { + this.error(`Organization "${organizationId}" not found`, {exit: 1}) + } + this.error(`Failed to update organization: ${getErrorMessage(error)}`, {exit: 1}) + } + } +} diff --git a/packages/@sanity/cli/src/prompts/promptForOrganizationName.ts b/packages/@sanity/cli/src/prompts/promptForOrganizationName.ts index 231c7cd13..2c88a7e21 100644 --- a/packages/@sanity/cli/src/prompts/promptForOrganizationName.ts +++ b/packages/@sanity/cli/src/prompts/promptForOrganizationName.ts @@ -3,7 +3,7 @@ import {input} from '@sanity/cli-core/ux' import {validateOrganizationName} from '../actions/organizations/validateOrganizationName.js' -export async function promptForOrganizationName(user: SanityOrgUser): Promise { +export async function promptForOrganizationName(user?: SanityOrgUser): Promise { return input({ default: user?.name, message: 'Organization name:', diff --git a/packages/@sanity/cli/src/services/organizations.ts b/packages/@sanity/cli/src/services/organizations.ts index 658d5b61f..a60bd5a39 100644 --- a/packages/@sanity/cli/src/services/organizations.ts +++ b/packages/@sanity/cli/src/services/organizations.ts @@ -6,7 +6,7 @@ export const ORGANIZATIONS_API_VERSION = 'v2025-05-14' export interface ProjectOrganization { id: string name: string - slug: string + slug: string | null } export interface OrganizationCreateResponse { @@ -19,6 +19,22 @@ export interface OrganizationCreateResponse { slug: string | null } +interface Organization extends ProjectOrganization { + createdAt: string + defaultRoleName: string | null + updatedAt: string +} + +export interface OrganizationUpdateParams { + defaultRoleName?: string + name?: string + slug?: string +} + +interface OrganizationDeleteResponse { + deleted: boolean +} + export interface OrganizationWithGrant { hasAttachGrant: boolean organization: ProjectOrganization @@ -54,14 +70,17 @@ export async function listOrganizations( /** * Create a new organization */ -export async function createOrganization(name: string): Promise { +export async function createOrganization( + name: string, + defaultRoleName?: string, +): Promise { const client = await getGlobalCliClient({ apiVersion: ORGANIZATIONS_API_VERSION, requireUser: true, }) return client.request({ - body: {name}, + body: {name, ...(defaultRoleName ? {defaultRoleName} : {})}, method: 'post', uri: '/organizations', }) @@ -82,3 +101,54 @@ export async function getOrganizationGrants( uri: `/organizations/${organizationId}/grants`, }) } + +/** + * Get a single organization by ID + */ +export async function getOrganization(organizationId: string): Promise { + const client = await getGlobalCliClient({ + apiVersion: ORGANIZATIONS_API_VERSION, + requireUser: true, + }) + + return client.request({ + query: {includeFeatures: 'false', includeMembers: 'false'}, + uri: `/organizations/${organizationId}`, + }) +} + +/** + * Update an organization + */ +export async function updateOrganization( + organizationId: string, + params: OrganizationUpdateParams, +): Promise { + const client = await getGlobalCliClient({ + apiVersion: ORGANIZATIONS_API_VERSION, + requireUser: true, + }) + + return client.request({ + body: params, + method: 'patch', + uri: `/organizations/${organizationId}`, + }) +} + +/** + * Delete an organization + */ +export async function deleteOrganization( + organizationId: string, +): Promise { + const client = await getGlobalCliClient({ + apiVersion: ORGANIZATIONS_API_VERSION, + requireUser: true, + }) + + return client.request({ + method: 'delete', + uri: `/organizations/${organizationId}`, + }) +} diff --git a/packages/@sanity/cli/src/topicAliases.ts b/packages/@sanity/cli/src/topicAliases.ts index 841a6ce58..61f2ed874 100644 --- a/packages/@sanity/cli/src/topicAliases.ts +++ b/packages/@sanity/cli/src/topicAliases.ts @@ -23,6 +23,7 @@ export const topicAliases: Record = { documents: ['document'], functions: ['function'], hooks: ['hook'], + organizations: ['organization', 'organisations', 'organisation', 'org', 'orgs'], projects: ['project'], schemas: ['schema'], tokens: ['token'], diff --git a/packages/@sanity/cli/src/util/apiError.ts b/packages/@sanity/cli/src/util/apiError.ts new file mode 100644 index 000000000..32265324c --- /dev/null +++ b/packages/@sanity/cli/src/util/apiError.ts @@ -0,0 +1,11 @@ +/** + * Type guard for API errors that carry an HTTP status code. + */ +export function hasStatusCode(err: unknown): err is {message: string; statusCode: number} { + return ( + typeof err === 'object' && + err !== null && + 'statusCode' in err && + typeof (err as Record).statusCode === 'number' + ) +} diff --git a/packages/@sanity/cli/src/util/organizationAliases.ts b/packages/@sanity/cli/src/util/organizationAliases.ts new file mode 100644 index 000000000..fee6eeaea --- /dev/null +++ b/packages/@sanity/cli/src/util/organizationAliases.ts @@ -0,0 +1,5 @@ +const ORGANIZATION_PREFIXES = ['organization', 'organisations', 'organisation', 'org', 'orgs'] + +export function organizationAliases(action: string): string[] { + return ORGANIZATION_PREFIXES.map((prefix) => `${prefix}:${action}`) +}