neonctl 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { writer } from '../writer.js';
2
2
  import { branchCreateRequest, branchCreateRequestEndpointOptions, } from '../parameters.gen.js';
3
3
  import { commandFailHandler } from '../utils.js';
4
4
  import { retryOnLock } from '../api.js';
5
- import { branchIdFromProps } from '../enrichers.js';
5
+ import { branchIdFromProps, fillSingleProject } from '../enrichers.js';
6
6
  const BRANCH_FIELDS = ['id', 'name', 'created_at', 'updated_at'];
7
7
  export const command = 'branches';
8
8
  export const describe = 'Manage branches';
@@ -15,9 +15,9 @@ export const builder = (argv) => argv
15
15
  'project.id': {
16
16
  describe: 'Project ID',
17
17
  type: 'string',
18
- demandOption: true,
19
18
  },
20
19
  })
20
+ .middleware(fillSingleProject)
21
21
  .command('list', 'List branches', (yargs) => yargs, async (args) => await list(args))
22
22
  .command('create', 'Create a branch', (yargs) => yargs.options({
23
23
  ...branchCreateRequest,
@@ -1,24 +1,27 @@
1
1
  import { EndpointType } from '@neondatabase/api-client';
2
- import { branchIdFromProps } from '../enrichers.js';
3
- export const command = 'connection-string <branch>';
2
+ import { branchIdFromProps, fillSingleProject } from '../enrichers.js';
3
+ export const command = 'connection-string [branch]';
4
4
  export const aliases = ['cs'];
5
5
  export const describe = 'Get connection string';
6
6
  export const builder = (argv) => {
7
- return argv.usage('usage: $0 connection-string <branch> [options]').options({
7
+ return argv
8
+ .usage('usage: $0 connection-string [branch] [options]')
9
+ .positional('branch', {
10
+ describe: 'Branch name or id. If ommited will use the primary branch',
11
+ type: 'string',
12
+ })
13
+ .options({
8
14
  'project.id': {
9
15
  type: 'string',
10
16
  describe: 'Project ID',
11
- demandOption: true,
12
17
  },
13
18
  'role.name': {
14
19
  type: 'string',
15
20
  describe: 'Role name',
16
- demandOption: true,
17
21
  },
18
22
  'database.name': {
19
23
  type: 'string',
20
24
  describe: 'Database name',
21
- demandOption: true,
22
25
  },
23
26
  pooled: {
24
27
  type: 'boolean',
@@ -30,22 +33,50 @@ export const builder = (argv) => {
30
33
  describe: 'Use connection string for Prisma setup',
31
34
  default: false,
32
35
  },
33
- });
36
+ })
37
+ .middleware(fillSingleProject);
34
38
  };
35
39
  export const handler = async (props) => {
40
+ const projectId = props.project.id;
36
41
  const branchId = await branchIdFromProps(props);
37
- const { data: { endpoints }, } = await props.apiClient.listProjectBranchEndpoints(props.project.id, branchId);
42
+ const { data: { endpoints }, } = await props.apiClient.listProjectBranchEndpoints(projectId, branchId);
38
43
  const endpoint = endpoints.find((e) => e.type === EndpointType.ReadWrite);
39
44
  if (!endpoint) {
40
45
  throw new Error(`No endpoint found for the branch: ${branchId}`);
41
46
  }
42
- const { data: password } = await props.apiClient.getProjectBranchRolePassword(props.project.id, endpoint.branch_id, props.role.name);
47
+ const role = props.role?.name ||
48
+ (await props.apiClient
49
+ .listProjectBranchRoles(projectId, branchId)
50
+ .then(({ data }) => {
51
+ if (data.roles.length === 0) {
52
+ throw new Error(`No roles found for the branch: ${branchId}`);
53
+ }
54
+ if (data.roles.length === 1) {
55
+ return data.roles[0].name;
56
+ }
57
+ throw new Error(`Multiple roles found for the branch, please provide one with the --role.name option: ${data.roles
58
+ .map((r) => r.name)
59
+ .join(', ')}`);
60
+ }));
61
+ const database = props.database?.name ||
62
+ (await props.apiClient
63
+ .listProjectBranchDatabases(projectId, branchId)
64
+ .then(({ data }) => {
65
+ if (data.databases.length === 0) {
66
+ throw new Error(`No databases found for the branch: ${branchId}`);
67
+ }
68
+ if (data.databases.length === 1) {
69
+ return data.databases[0].name;
70
+ }
71
+ throw new Error(`Multiple databases found for the branch, please provide one with the --database.name option: ${data.databases}`);
72
+ }));
73
+ const { data: password } = await props.apiClient.getProjectBranchRolePassword(props.project.id, endpoint.branch_id, role);
43
74
  const host = props.pooled
44
75
  ? endpoint.host.replace(endpoint.id, `${endpoint.id}-pooler`)
45
76
  : endpoint.host;
46
77
  const connectionString = new URL(`postgres://${host}`);
47
- connectionString.pathname = props.database.name;
48
- connectionString.username = props.role.name;
78
+ connectionString.pathname = database;
79
+ connectionString.username = role;
49
80
  connectionString.password = password.password;
50
81
  if (props.prisma) {
51
82
  connectionString.searchParams.set('connect_timeout', '30');
@@ -69,4 +69,12 @@ describe('connection_string', () => {
69
69
  snapshot: true,
70
70
  },
71
71
  });
72
+ testCliCommand({
73
+ name: 'connection_string without any args should pass',
74
+ args: ['connection-string'],
75
+ mockDir: 'single_project',
76
+ expected: {
77
+ snapshot: true,
78
+ },
79
+ });
72
80
  });
@@ -1,5 +1,5 @@
1
1
  import { retryOnLock } from '../api.js';
2
- import { branchIdFromProps } from '../enrichers.js';
2
+ import { branchIdFromProps, fillSingleProject } from '../enrichers.js';
3
3
  import { databaseCreateRequest } from '../parameters.gen.js';
4
4
  import { commandFailHandler } from '../utils.js';
5
5
  import { writer } from '../writer.js';
@@ -15,14 +15,13 @@ export const builder = (argv) => argv
15
15
  'project.id': {
16
16
  describe: 'Project ID',
17
17
  type: 'string',
18
- demandOption: true,
19
18
  },
20
19
  branch: {
21
20
  describe: 'Branch ID or name',
22
21
  type: 'string',
23
- demandOption: true,
24
22
  },
25
23
  })
24
+ .middleware(fillSingleProject)
26
25
  .command('list', 'List databases', (yargs) => yargs, async (args) => await list(args))
27
26
  .command('create', 'Create a database', (yargs) => yargs.options(databaseCreateRequest), async (args) => await create(args))
28
27
  .command('delete <database>', 'Delete a database', (yargs) => yargs, async (args) => await deleteDb(args));
@@ -1,3 +1,4 @@
1
+ import { fillSingleProject } from '../enrichers.js';
1
2
  import { commandFailHandler } from '../utils.js';
2
3
  import { writer } from '../writer.js';
3
4
  const OPERATIONS_FIELDS = ['id', 'action', 'status', 'created_at'];
@@ -12,9 +13,9 @@ export const builder = (argv) => argv
12
13
  'project.id': {
13
14
  describe: 'Project ID',
14
15
  type: 'string',
15
- demandOption: true,
16
16
  },
17
17
  })
18
+ .middleware(fillSingleProject)
18
19
  .command('list', 'List operations', (yargs) => yargs, async (args) => await list(args));
19
20
  export const handler = (args) => {
20
21
  return args;
package/commands/roles.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { retryOnLock } from '../api.js';
2
- import { branchIdFromProps } from '../enrichers.js';
2
+ import { branchIdFromProps, fillSingleProject } from '../enrichers.js';
3
3
  import { roleCreateRequest } from '../parameters.gen.js';
4
4
  import { commandFailHandler } from '../utils.js';
5
5
  import { writer } from '../writer.js';
@@ -15,14 +15,13 @@ export const builder = (argv) => argv
15
15
  'project.id': {
16
16
  describe: 'Project ID',
17
17
  type: 'string',
18
- demandOption: true,
19
18
  },
20
19
  branch: {
21
20
  describe: 'Branch ID or name',
22
21
  type: 'string',
23
- demandOption: true,
24
22
  },
25
23
  })
24
+ .middleware(fillSingleProject)
26
25
  .command('list', 'List roles', (yargs) => yargs, async (args) => await list(args))
27
26
  .command('create', 'Create a role', (yargs) => yargs.options(roleCreateRequest), async (args) => await create(args))
28
27
  .command('delete <role>', 'Delete a role', (yargs) => yargs, async (args) => await deleteRole(args));
package/enrichers.js CHANGED
@@ -6,14 +6,43 @@ export const branchIdResolve = async ({ branch, apiClient, projectId, }) => {
6
6
  const { data } = await apiClient.listProjectBranches(projectId);
7
7
  const branchData = data.branches.find((b) => b.name === branch);
8
8
  if (!branchData) {
9
- throw new Error(`Branch ${branch} not found`);
9
+ throw new Error(`Branch ${branch} not found.\nAvailable branches: ${data.branches
10
+ .map((b) => b.name)
11
+ .join(', ')}`);
10
12
  }
11
13
  return branchData.id;
12
14
  };
13
- export const branchIdFromProps = async (props) => branchIdResolve({
14
- branch: 'branch' in props && typeof props.branch === 'string'
15
+ export const branchIdFromProps = async (props) => {
16
+ const branch = 'branch' in props && typeof props.branch === 'string'
15
17
  ? props.branch
16
- : props.id,
17
- apiClient: props.apiClient,
18
- projectId: props.project.id,
19
- });
18
+ : props.id;
19
+ if (branch) {
20
+ return await branchIdResolve({
21
+ branch,
22
+ apiClient: props.apiClient,
23
+ projectId: props.project.id,
24
+ });
25
+ }
26
+ const { data } = await props.apiClient.listProjectBranches(props.project.id);
27
+ const primaryBranch = data.branches.find((b) => b.primary);
28
+ if (primaryBranch) {
29
+ return primaryBranch.id;
30
+ }
31
+ throw new Error('No primary branch found');
32
+ };
33
+ export const fillSingleProject = async (props) => {
34
+ if (props.project) {
35
+ return props;
36
+ }
37
+ const { data } = await props.apiClient.listProjects({});
38
+ if (data.projects.length === 0) {
39
+ throw new Error('No projects found');
40
+ }
41
+ if (data.projects.length > 1) {
42
+ throw new Error(`Multiple projects found, please provide one with the --project.id option`);
43
+ }
44
+ return {
45
+ ...props,
46
+ project: { id: data.projects[0].id },
47
+ };
48
+ };
package/env.js CHANGED
@@ -1,3 +1,6 @@
1
1
  export const isCi = () => {
2
2
  return process.env.CI !== 'false' && Boolean(process.env.CI);
3
3
  };
4
+ export const isDebug = () => {
5
+ return Boolean(process.env.DEBUG);
6
+ };
package/index.js CHANGED
@@ -81,7 +81,10 @@ builder = builder
81
81
  .completion()
82
82
  .fail(async (msg, err) => {
83
83
  if (isAxiosError(err)) {
84
- if (err.response?.status === 401) {
84
+ if (err.code === 'ECONNABORTED') {
85
+ log.error('Request timed out');
86
+ }
87
+ else if (err.response?.status === 401) {
85
88
  log.error('Authentication failed, please run `neonctl auth`');
86
89
  }
87
90
  else {
@@ -91,6 +94,7 @@ builder = builder
91
94
  else {
92
95
  log.error(msg || err?.message);
93
96
  }
97
+ err.stack && log.degug('Stack: %s', err.stack);
94
98
  process.exit(1);
95
99
  });
96
100
  (async () => {
package/log.js CHANGED
@@ -1,5 +1,11 @@
1
1
  import { format } from 'node:util';
2
+ import { isDebug } from './env.js';
2
3
  export const log = {
4
+ degug: (...args) => {
5
+ if (isDebug()) {
6
+ process.stderr.write(`DEBUG: ${format(...args)}\n`);
7
+ }
8
+ },
3
9
  info: (...args) => {
4
10
  process.stderr.write(`INFO: ${format(...args)}\n`);
5
11
  },
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "url": "git@github.com:neondatabase/neonctl.git"
6
6
  },
7
7
  "type": "module",
8
- "version": "1.13.0",
8
+ "version": "1.14.0",
9
9
  "description": "CLI tool for NeonDB Cloud management",
10
10
  "main": "index.js",
11
11
  "author": "NeonDB",
package/test_utils.js CHANGED
@@ -4,21 +4,21 @@ import emocks from 'emocks';
4
4
  import express from 'express';
5
5
  import { fork } from 'node:child_process';
6
6
  import { join } from 'node:path';
7
- const runMockServer = async () => new Promise((resolve) => {
7
+ const runMockServer = async (mockDir) => new Promise((resolve) => {
8
8
  const app = express();
9
9
  app.use(express.json());
10
- app.use('/', emocks(join(process.cwd(), 'mocks')));
10
+ app.use('/', emocks(join(process.cwd(), 'mocks', mockDir)));
11
11
  const server = app.listen(0);
12
12
  server.on('listening', () => {
13
13
  console.log(`Mock server listening at ${server.address().port}`);
14
14
  });
15
15
  resolve(server);
16
16
  });
17
- export const testCliCommand = ({ args, name, expected, }) => {
17
+ export const testCliCommand = ({ args, name, expected, mockDir = 'main', }) => {
18
18
  let server;
19
19
  describe(name, () => {
20
20
  beforeAll(async () => {
21
- server = await runMockServer();
21
+ server = await runMockServer(mockDir);
22
22
  });
23
23
  afterAll(async () => {
24
24
  return new Promise((resolve) => {
@@ -53,6 +53,9 @@ export const testCliCommand = ({ args, name, expected, }) => {
53
53
  });
54
54
  cp.on('close', (code) => {
55
55
  try {
56
+ if (code !== 0 && error) {
57
+ console.error(error);
58
+ }
56
59
  expect(code).toBe(0);
57
60
  if (code === 0 && expected) {
58
61
  if (expected.snapshot) {