diff --git a/bun.lockb b/bun.lockb index 04d3138..11b024e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/mocks/single_org/organizations/1/vpc/region/azure-test/vpc-endpoints/vpc-test/POST.js b/mocks/single_org/organizations/1/vpc/region/azure-test/vpc-endpoints/vpc-test/POST.js new file mode 100644 index 0000000..4663334 --- /dev/null +++ b/mocks/single_org/organizations/1/vpc/region/azure-test/vpc-endpoints/vpc-test/POST.js @@ -0,0 +1,8 @@ +import { expect } from 'vitest'; + +export default function (req, res) { + expect(req.body).toMatchObject({ + label: 'newlabel', + }); + res.status(200).send({}); +} diff --git a/mocks/single_org/organizations/1/vpc/region/test/vpc-endpoints/GET.json b/mocks/single_org/organizations/1/vpc/region/test/vpc-endpoints/GET.json new file mode 100644 index 0000000..05c112a --- /dev/null +++ b/mocks/single_org/organizations/1/vpc/region/test/vpc-endpoints/GET.json @@ -0,0 +1,8 @@ +{ + "endpoints": [ + { + "vpc_endpoint_id": "vpc-test", + "label": "test-label" + } + ] +} diff --git a/mocks/single_org/organizations/1/vpc/region/test/vpc-endpoints/vpc-test/DELETE.js b/mocks/single_org/organizations/1/vpc/region/test/vpc-endpoints/vpc-test/DELETE.js new file mode 100644 index 0000000..fdc4115 --- /dev/null +++ b/mocks/single_org/organizations/1/vpc/region/test/vpc-endpoints/vpc-test/DELETE.js @@ -0,0 +1,5 @@ +import { expect } from 'vitest'; + +export default function (req, res) { + res.status(200).send({}); +} diff --git a/mocks/single_org/organizations/1/vpc/region/test/vpc-endpoints/vpc-test/GET.json b/mocks/single_org/organizations/1/vpc/region/test/vpc-endpoints/vpc-test/GET.json new file mode 100644 index 0000000..168fd08 --- /dev/null +++ b/mocks/single_org/organizations/1/vpc/region/test/vpc-endpoints/vpc-test/GET.json @@ -0,0 +1,4 @@ +{ + "vpc_endpoint_id": "vpc-test", + "state": "new" +} diff --git a/mocks/single_org/organizations/1/vpc/region/test/vpc-endpoints/vpc-test/POST.js b/mocks/single_org/organizations/1/vpc/region/test/vpc-endpoints/vpc-test/POST.js new file mode 100644 index 0000000..4663334 --- /dev/null +++ b/mocks/single_org/organizations/1/vpc/region/test/vpc-endpoints/vpc-test/POST.js @@ -0,0 +1,8 @@ +import { expect } from 'vitest'; + +export default function (req, res) { + expect(req.body).toMatchObject({ + label: 'newlabel', + }); + res.status(200).send({}); +} diff --git a/mocks/single_org/projects/GET.json b/mocks/single_org/projects/GET.json new file mode 100644 index 0000000..9baebd0 --- /dev/null +++ b/mocks/single_org/projects/GET.json @@ -0,0 +1,10 @@ +{ + "projects": [ + { + "id": "test-project-123456", + "name": "test-project-123456", + "created_at": "2019-01-01T00:00:00.000Z", + "updated_at": "2019-01-01T00:00:00.000Z" + } + ] +} diff --git a/mocks/single_org/projects/test-project-123456/GET.json b/mocks/single_org/projects/test-project-123456/GET.json new file mode 100644 index 0000000..0a4e5eb --- /dev/null +++ b/mocks/single_org/projects/test-project-123456/GET.json @@ -0,0 +1,14 @@ +{ + "project": { + "id": "test-project-123456", + "name": "test-project-123456", + "created_at": "2019-01-01T00:00:00.000Z", + "updated_at": "2019-01-01T00:00:00.000Z", + "settings": { + "allowed_ips": { + "ips": ["192.168.1.1"], + "protected_branches_only": false + } + } + } +} diff --git a/mocks/single_org/projects/test-project-123456/branches/GET.json b/mocks/single_org/projects/test-project-123456/branches/GET.json new file mode 100644 index 0000000..ce79f91 --- /dev/null +++ b/mocks/single_org/projects/test-project-123456/branches/GET.json @@ -0,0 +1,11 @@ +{ + "branches": [ + { + "id": "br-main-branch-123456", + "name": "main", + "default": true, + "created_at": "2021-01-01T00:00:00.000Z", + "updated_at": "2021-01-01T00:00:00.000Z" + } + ] +} diff --git a/mocks/single_org/projects/test-project-123456/branches/br-main-branch-123456/databases/GET.json b/mocks/single_org/projects/test-project-123456/branches/br-main-branch-123456/databases/GET.json new file mode 100644 index 0000000..85b31e2 --- /dev/null +++ b/mocks/single_org/projects/test-project-123456/branches/br-main-branch-123456/databases/GET.json @@ -0,0 +1,3 @@ +{ + "databases": [{ "name": "db1", "owner_name": "user1" }] +} diff --git a/mocks/single_org/projects/test-project-123456/branches/br-main-branch-123456/endpoints/GET.json b/mocks/single_org/projects/test-project-123456/branches/br-main-branch-123456/endpoints/GET.json new file mode 100644 index 0000000..8453297 --- /dev/null +++ b/mocks/single_org/projects/test-project-123456/branches/br-main-branch-123456/endpoints/GET.json @@ -0,0 +1,10 @@ +{ + "endpoints": [ + { + "id": "ep-test-endpoint-123456", + "created_at": "2019-01-01T00:00:00Z", + "type": "read_write", + "branch_id": "br-main-branch-123456" + } + ] +} diff --git a/mocks/single_org/projects/test-project-123456/branches/br-main-branch-123456/roles/GET.json b/mocks/single_org/projects/test-project-123456/branches/br-main-branch-123456/roles/GET.json new file mode 100644 index 0000000..3962c3d --- /dev/null +++ b/mocks/single_org/projects/test-project-123456/branches/br-main-branch-123456/roles/GET.json @@ -0,0 +1,3 @@ +{ + "roles": [{ "name": "test_role", "created_at": "2016-01-01T00:00:00Z" }] +} diff --git a/mocks/single_org/projects/test-project-123456/branches/br-main-branch-123456/roles/test_role/reveal_password/GET.json b/mocks/single_org/projects/test-project-123456/branches/br-main-branch-123456/roles/test_role/reveal_password/GET.json new file mode 100644 index 0000000..f29e488 --- /dev/null +++ b/mocks/single_org/projects/test-project-123456/branches/br-main-branch-123456/roles/test_role/reveal_password/GET.json @@ -0,0 +1,3 @@ +{ + "password": "password" +} diff --git a/mocks/single_org/projects/test-project-123456/vpc-endpoints/GET.json b/mocks/single_org/projects/test-project-123456/vpc-endpoints/GET.json new file mode 100644 index 0000000..6badc54 --- /dev/null +++ b/mocks/single_org/projects/test-project-123456/vpc-endpoints/GET.json @@ -0,0 +1,8 @@ +{ + "endpoints": [ + { + "vpc_endpoint_id": "vpc-test", + "label": "test-label-project" + } + ] +} diff --git a/mocks/single_org/projects/test-project-123456/vpc-endpoints/vpc-test/DELETE.js b/mocks/single_org/projects/test-project-123456/vpc-endpoints/vpc-test/DELETE.js new file mode 100644 index 0000000..fdc4115 --- /dev/null +++ b/mocks/single_org/projects/test-project-123456/vpc-endpoints/vpc-test/DELETE.js @@ -0,0 +1,5 @@ +import { expect } from 'vitest'; + +export default function (req, res) { + res.status(200).send({}); +} diff --git a/mocks/single_org/projects/test-project-123456/vpc-endpoints/vpc-test/POST.js b/mocks/single_org/projects/test-project-123456/vpc-endpoints/vpc-test/POST.js new file mode 100644 index 0000000..4663334 --- /dev/null +++ b/mocks/single_org/projects/test-project-123456/vpc-endpoints/vpc-test/POST.js @@ -0,0 +1,8 @@ +import { expect } from 'vitest'; + +export default function (req, res) { + expect(req.body).toMatchObject({ + label: 'newlabel', + }); + res.status(200).send({}); +} diff --git a/mocks/single_org/users/me/GET.json b/mocks/single_org/users/me/GET.json new file mode 100644 index 0000000..9fe875d --- /dev/null +++ b/mocks/single_org/users/me/GET.json @@ -0,0 +1,5 @@ +{ + "id": "1", + "name": "John Doe", + "email": "john@example.com" +} diff --git a/mocks/single_org/users/me/organizations/GET.json b/mocks/single_org/users/me/organizations/GET.json new file mode 100644 index 0000000..4455c7e --- /dev/null +++ b/mocks/single_org/users/me/organizations/GET.json @@ -0,0 +1,8 @@ +{ + "organizations": [ + { + "name": "Organization 1", + "id": 1 + } + ] +} diff --git a/package-lock.json b/package-lock.json index 16079a4..264e4cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.4.1", "license": "MIT", "dependencies": { - "@neondatabase/api-client": "1.11.1", + "@neondatabase/api-client": "1.11.2", "@segment/analytics-node": "^1.0.0-beta.26", "axios": "^1.4.0", "axios-debug-log": "^1.0.0", diff --git a/package.json b/package.json index 29a60a8..a576fe0 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "vitest": "^1.6.0" }, "dependencies": { - "@neondatabase/api-client": "1.11.1", + "@neondatabase/api-client": "1.11.2", "@segment/analytics-node": "^1.0.0-beta.26", "axios": "^1.4.0", "axios-debug-log": "^1.0.0", diff --git a/src/commands/__snapshots__/vpc_endpoints.test.ts.snap b/src/commands/__snapshots__/vpc_endpoints.test.ts.snap new file mode 100644 index 0000000..160712a --- /dev/null +++ b/src/commands/__snapshots__/vpc_endpoints.test.ts.snap @@ -0,0 +1,56 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`vpc-endpoints > delete org VPC endpoint 1`] = ` +"{} +" +`; + +exports[`vpc-endpoints > delete project VPC endpoint restrictions 1`] = ` +"{} +" +`; + +exports[`vpc-endpoints > get org VPC endpoint status 1`] = ` +"vpc_endpoint_id: vpc-test +state: new +" +`; + +exports[`vpc-endpoints > list org VPC endpoints 1`] = ` +"- vpc_endpoint_id: vpc-test + label: test-label +" +`; + +exports[`vpc-endpoints > list org VPC endpoints implicit org 1`] = ` +"- vpc_endpoint_id: vpc-test + label: test-label +" +`; + +exports[`vpc-endpoints > list project VPC endpoint restrictions 1`] = ` +"- vpc_endpoint_id: vpc-test + label: test-label-project +" +`; + +exports[`vpc-endpoints > list project VPC endpoint restrictions with implicit project 1`] = ` +"- vpc_endpoint_id: vpc-test + label: test-label-project +" +`; + +exports[`vpc-endpoints > set org VPC endpoint in azure 1`] = ` +"{} +" +`; + +exports[`vpc-endpoints > set project VPC endpoint restrictions 1`] = ` +"{} +" +`; + +exports[`vpc-endpoints > update org VPC endpoint 1`] = ` +"{} +" +`; diff --git a/src/commands/index.ts b/src/commands/index.ts index 3b0ec5d..f1a2ae4 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,6 +1,7 @@ import * as auth from './auth.js'; import * as projects from './projects.js'; import * as ipAllow from './ip_allow.js'; +import * as vpcEndpoints from './vpc_endpoints.js'; import * as users from './user.js'; import * as orgs from './orgs.js'; import * as branches from './branches.js'; @@ -17,6 +18,7 @@ export default [ orgs, projects, ipAllow, + vpcEndpoints, branches, databases, roles, diff --git a/src/commands/projects.ts b/src/commands/projects.ts index f21b7ca..40b876e 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -20,7 +20,7 @@ export const PROJECT_FIELDS = [ 'created_at', ] as const; -const REGIONS = [ +export const REGIONS = [ 'aws-us-west-2', 'aws-ap-southeast-1', 'aws-ap-southeast-2', diff --git a/src/commands/vpc_endpoints.test.ts b/src/commands/vpc_endpoints.test.ts new file mode 100644 index 0000000..cfedce6 --- /dev/null +++ b/src/commands/vpc_endpoints.test.ts @@ -0,0 +1,105 @@ +import { describe } from 'vitest'; +import { test } from '../test_utils/fixtures'; + +describe('vpc-endpoints', () => { + test('list org VPC endpoints', async ({ testCliCommand }) => { + await testCliCommand( + ['vpc', 'endpoint', 'list', '--org-id', '1', '--region-id', 'test'], + { mockDir: 'single_org' }, + ); + }); + + test('list org VPC endpoints implicit org', async ({ testCliCommand }) => { + await testCliCommand(['vpc', 'endpoint', 'list', '--region-id', 'test'], { + mockDir: 'single_org', + }); + }); + + test('update org VPC endpoint', async ({ testCliCommand }) => { + await testCliCommand( + [ + 'vpc', + 'endpoint', + 'update', + 'vpc-test', + '--label', + 'newlabel', + '--region-id', + 'test', + ], + { mockDir: 'single_org' }, + ); + }); + + test('delete org VPC endpoint', async ({ testCliCommand }) => { + await testCliCommand( + ['vpc', 'endpoint', 'remove', 'vpc-test', '--region-id', 'test'], + { mockDir: 'single_org' }, + ); + }); + + test('get org VPC endpoint status', async ({ testCliCommand }) => { + await testCliCommand( + ['vpc', 'endpoint', 'status', 'vpc-test', '--region-id', 'test'], + { mockDir: 'single_org' }, + ); + }); + + test('set org VPC endpoint in azure', async ({ testCliCommand }) => { + await testCliCommand( + [ + 'vpc', + 'endpoint', + 'update', + 'vpc-test', + '--label', + 'newlabel', + '--region-id', + 'azure-test', + ], + { + mockDir: 'single_org', + stderr: + 'INFO: VPC endpoint configuration is not supported for Azure regions', + }, + ); + }); + + test('list project VPC endpoint restrictions', async ({ testCliCommand }) => { + await testCliCommand( + [ + 'vpc', + 'project', + 'list', + '--region-id', + 'test', + '--project-id', + 'test-project-123456', + ], + { mockDir: 'single_org' }, + ); + }); + + test('list project VPC endpoint restrictions with implicit project', async ({ + testCliCommand, + }) => { + await testCliCommand(['vpc', 'project', 'list', '--region-id', 'test'], { + mockDir: 'single_org', + }); + }); + + test('set project VPC endpoint restrictions', async ({ testCliCommand }) => { + await testCliCommand( + ['vpc', 'project', 'update', 'vpc-test', '--label', 'newlabel'], + { mockDir: 'single_org' }, + ); + }); + + test('delete project VPC endpoint restrictions', async ({ + testCliCommand, + }) => { + await testCliCommand(['vpc', 'project', 'remove', 'vpc-test'], { + mockDir: 'single_org', + }); + }); +}); diff --git a/src/commands/vpc_endpoints.ts b/src/commands/vpc_endpoints.ts new file mode 100644 index 0000000..a8e9351 --- /dev/null +++ b/src/commands/vpc_endpoints.ts @@ -0,0 +1,231 @@ +import { VPCEndpointAssignment } from '@neondatabase/api-client'; +import yargs from 'yargs'; +import { + CommonProps, + ProjectScopeProps, + OrgScopeProps, + IdOrNameProps, +} from '../types'; +import { writer } from '../writer.js'; +import { fillSingleProject, fillSingleOrg } from '../utils/enrichers.js'; +import { REGIONS } from './projects.js'; +import { log } from '../log.js'; + +const VPC_ENDPOINT_FIELDS = ['vpc_endpoint_id', 'label'] as const; + +const VPC_ENDPOINT_DETAILS_FIELDS = [ + 'vpc_endpoint_id', + 'label', + 'state', + 'num_restricted_projects', + 'example_restricted_projects', +] as const; + +export const command = 'vpc'; +export const describe = 'Manage VPC endpoints and project VPC restrictions'; +export const builder = (argv: yargs.Argv) => { + return argv + .usage('$0 vpc [options]') + .command( + 'endpoint', + 'Manage VPC endpoints.\n' + + 'See: https://neon.tech/docs/guides/neon-private-networking\n' + + 'After adding an endpoint to an organization, client connections will be accepted\n' + + 'from the corresponding VPC for all projects in the organization, unless overridden\n' + + 'by a project-level VPC endpoint restriction.', + (yargs) => { + return yargs + .options({ + 'org-id': { + describe: 'Organization ID', + type: 'string', + }, + 'region-id': { + describe: `The region ID. Possible values: ${REGIONS.join(', ')}`, + type: 'string', + demandOption: true, + }, + }) + .middleware(fillSingleOrg as any) + .command( + 'list', + 'List configured VPC endpoints for this organization.', + (yargs) => yargs, + async (args) => { + await listOrg(args as any); + }, + ) + .command({ + command: 'assign ', + aliases: ['update ', 'add '], + describe: + 'Add or update a VPC endpoint for this organization.\n' + + 'Note: Azure regions are not yet supported.', + builder: (yargs) => + yargs.options({ + label: { + describe: + 'An optional descriptive label for the VPC endpoint', + type: 'string', + }, + }), + handler: async (args) => { + await assignOrg(args as any); + }, + }) + .command( + 'remove ', + 'Remove a VPC endpoint from this organization.', + (yargs) => yargs, + async (args) => { + await removeOrg(args as any); + }, + ) + .command( + 'status ', + 'Get the status of a VPC endpoint for this organization.', + (yargs) => yargs, + async (args) => { + await statusOrg(args as any); + }, + ); + }, + ) + .command( + 'project', + 'Manage project-level VPC endpoint restrictions.\n' + + 'By default, connections are accepted from any VPC configured at the organization level.\n' + + 'A project-level VPC endpoint restriction can be used to restrict connections to a specific VPC.', + (yargs) => { + return yargs + .options({ + 'project-id': { + describe: 'Project ID', + type: 'string', + }, + }) + .middleware(fillSingleProject as any) + .command( + 'list', + 'List VPC endpoint restrictions for this project.', + (yargs) => yargs, + async (args) => { + await listProject(args as any); + }, + ) + .command({ + command: 'restrict ', + aliases: ['update '], + describe: + 'Configure or update a VPC endpoint restriction for this project.', + builder: (yargs) => + yargs.options({ + label: { + describe: + 'An optional descriptive label for the VPC endpoint restriction', + type: 'string', + }, + }), + handler: async (args) => { + await assignProject(args as any); + }, + }) + .command( + 'remove ', + 'Remove a VPC endpoint restriction from this project.', + (yargs) => yargs, + async (args) => { + await removeProject(args as any); + }, + ); + }, + ); +}; + +const listOrg = async ( + props: CommonProps & OrgScopeProps & { regionId: string }, +) => { + const { data } = await props.apiClient.listOrganizationVpcEndpoints( + props.orgId, + props.regionId, + ); + writer(props).end(data.endpoints, { + fields: VPC_ENDPOINT_FIELDS, + }); +}; + +const assignOrg = async ( + props: CommonProps & + OrgScopeProps & + IdOrNameProps & { regionId: string; label?: string }, +) => { + const vpcEndpointAssignment: VPCEndpointAssignment = { + label: props.label || '', + }; + const { data } = await props.apiClient.assignOrganizationVpcEndpoint( + props.orgId, + props.regionId, + props.id, + vpcEndpointAssignment, + ); + writer(props).end(data, { fields: [] }); + + if (props.regionId.startsWith('azure')) { + log.info('VPC endpoint configuration is not supported for Azure regions'); + } +}; + +const removeOrg = async ( + props: CommonProps & OrgScopeProps & IdOrNameProps & { regionId: string }, +) => { + const { data } = await props.apiClient.deleteOrganizationVpcEndpoint( + props.orgId, + props.regionId, + props.id, + ); + writer(props).end(data, { fields: [] }); +}; + +const statusOrg = async ( + props: CommonProps & OrgScopeProps & IdOrNameProps & { regionId: string }, +) => { + const { data } = await props.apiClient.getOrganizationVpcEndpointDetails( + props.orgId, + props.regionId, + props.id, + ); + writer(props).end(data, { fields: VPC_ENDPOINT_DETAILS_FIELDS }); +}; + +const listProject = async (props: CommonProps & ProjectScopeProps) => { + const { data } = await props.apiClient.listProjectVpcEndpoints( + props.projectId, + ); + writer(props).end(data.endpoints, { + fields: VPC_ENDPOINT_FIELDS, + }); +}; + +const assignProject = async ( + props: CommonProps & ProjectScopeProps & IdOrNameProps & { label?: string }, +) => { + const vpcEndpointAssignment: VPCEndpointAssignment = { + label: props.label || '', + }; + const { data } = await props.apiClient.assignProjectVpcEndpoint( + props.projectId, + props.id, + vpcEndpointAssignment, + ); + writer(props).end(data, { fields: [] }); +}; + +const removeProject = async ( + props: CommonProps & ProjectScopeProps & IdOrNameProps, +) => { + const { data } = await props.apiClient.deleteProjectVpcEndpoint( + props.projectId, + props.id, + ); + writer(props).end(data, { fields: [] }); +}; diff --git a/src/parameters.gen.ts b/src/parameters.gen.ts index 64b4bec..dbcda6f 100644 --- a/src/parameters.gen.ts +++ b/src/parameters.gen.ts @@ -58,12 +58,12 @@ export const projectCreateRequest = { }, 'project.settings.block_public_connections': { type: "boolean", - description: "When set, connections from the public internet\nare disallowed. This supersedes the AllowedIPs list.\n(IN DEVELOPMENT - NOT AVAILABLE YET)\n", + description: "When set, connections from the public internet\nare disallowed. This supersedes the AllowedIPs list.\nThis parameter is under active development and its semantics may change in the future.\n", demandOption: false, }, 'project.settings.block_vpc_connections': { type: "boolean", - description: "When set, connections using VPC endpoints\nare disallowed.\n(IN DEVELOPMENT - NOT AVAILABLE YET)\n", + description: "When set, connections using VPC endpoints are disallowed.\nThis parameter is under active development and its semantics may change in the future.\n", demandOption: false, }, 'project.name': { @@ -181,12 +181,12 @@ export const projectUpdateRequest = { }, 'project.settings.block_public_connections': { type: "boolean", - description: "When set, connections from the public internet\nare disallowed. This supersedes the AllowedIPs list.\n(IN DEVELOPMENT - NOT AVAILABLE YET)\n", + description: "When set, connections from the public internet\nare disallowed. This supersedes the AllowedIPs list.\nThis parameter is under active development and its semantics may change in the future.\n", demandOption: false, }, 'project.settings.block_vpc_connections': { type: "boolean", - description: "When set, connections using VPC endpoints\nare disallowed.\n(IN DEVELOPMENT - NOT AVAILABLE YET)\n", + description: "When set, connections using VPC endpoints are disallowed.\nThis parameter is under active development and its semantics may change in the future.\n", demandOption: false, }, 'project.name': { diff --git a/src/types.ts b/src/types.ts index 0602bf9..d2e10e0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,10 @@ export type ProjectScopeProps = CommonProps & { projectId: string; }; +export type OrgScopeProps = CommonProps & { + orgId: string; +}; + export type IdOrNameProps = { id: string; }; diff --git a/src/utils/enrichers.ts b/src/utils/enrichers.ts index 274e177..c1ebb28 100644 --- a/src/utils/enrichers.ts +++ b/src/utils/enrichers.ts @@ -1,4 +1,9 @@ -import { BranchScopeProps, CommonProps, ProjectScopeProps } from '../types.js'; +import { + BranchScopeProps, + CommonProps, + ProjectScopeProps, + OrgScopeProps, +} from '../types.js'; import { looksLikeBranchId } from './formats.js'; export const branchIdResolve = async ({ @@ -55,9 +60,7 @@ export const branchIdFromProps = async (props: BranchScopeProps) => { throw new Error('No default branch found'); }; -export const fillSingleProject = async ( - props: CommonProps & Partial>, -) => { +export const fillSingleProject = async (props: ProjectScopeProps) => { if (props.projectId) { return props; } @@ -75,3 +78,19 @@ export const fillSingleProject = async ( projectId: data.projects[0].id, }; }; + +export const fillSingleOrg = async (props: OrgScopeProps) => { + if (props.orgId) { + return props; + } + const { data } = await props.apiClient.getCurrentUserOrganizations(); + if (data.organizations.length === 0) { + throw new Error('No organizations found'); + } + if (data.organizations.length > 1) { + throw new Error( + `Multiple organizations found, please provide one with the --org-id option`, + ); + } + return { ...props, orgId: data.organizations[0].id }; +};