neonctl 1.19.1 → 1.21.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.
package/README.md CHANGED
@@ -12,17 +12,17 @@ Before installing, ensure that you have met the following prerequisites:
12
12
 
13
13
  - Node.js 16.0 or higher. To check if you already have Node.js, run the following command:
14
14
 
15
- ```shell
16
- node -v
17
- ```
15
+ ```shell
16
+ node -v
17
+ ```
18
18
 
19
- - The `npm` package manager. To check if you already have `npm`, run the following command:
19
+ - The `npm` package manager. To check if you already have `npm`, run the following command:
20
20
 
21
- ```shell
22
- npm -v
23
- ```
21
+ ```shell
22
+ npm -v
23
+ ```
24
24
 
25
- If you need to install `Node.js` or `npm`, refer to instructions on the [official nodejs page](https://nodejs.org) or use the [Node version manager](https://github.com/nvm-sh/nvm).
25
+ If you need to install `Node.js` or `npm`, refer to instructions on the [official nodejs page](https://nodejs.org) or use the [Node version manager](https://github.com/nvm-sh/nvm).
26
26
 
27
27
  ### Install
28
28
 
@@ -60,30 +60,30 @@ The Neon CLI supports autocompletion, which you can configure in a few easy step
60
60
 
61
61
  ## Commands
62
62
 
63
- | Command | Subcommands | Description |
64
- |---------------------------------------------------------|----------------------------------------|---------------------------|
65
- | [auth](https://neon.tech/docs/reference/cli-auth) | | Authenticate |
66
- | [projects](https://neon.tech/docs/reference/cli-projects) | `list`, `create`, `update`, `delete`, `get` | Manage projects |
67
- | [me](../reference/cli-me) | | Show current user |
68
- | [branches](https://neon.tech/docs/reference/cli-branches) | `list`, `create`, `rename`, `add-compute`, `set-primary`, `delete`, `get` | Manage branches |
69
- | [databases](https://neon.tech/docs/reference/cli-databases) | `list`, `create`, `delete` | Manage databases |
70
- | [roles](https://neon.tech/docs/reference/cli-roles) | `list`, `create`, `delete` | Manage roles |
71
- | [operations](https://neon.tech/reference/cli-operations) | `list` | Manage operations |
72
- | [connection-string](https://neon.tech/reference/cli-connection-string) | | Get connection string |
73
- | [completion](https://neon.tech/reference/cli-completion) | | Generate a completion script |
63
+ | Command | Subcommands | Description |
64
+ | ---------------------------------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------- |
65
+ | [auth](https://neon.tech/docs/reference/cli-auth) | | Authenticate |
66
+ | [projects](https://neon.tech/docs/reference/cli-projects) | `list`, `create`, `update`, `delete`, `get` | Manage projects |
67
+ | [me](../reference/cli-me) | | Show current user |
68
+ | [branches](https://neon.tech/docs/reference/cli-branches) | `list`, `create`, `rename`, `add-compute`, `set-primary`, `delete`, `get` | Manage branches |
69
+ | [databases](https://neon.tech/docs/reference/cli-databases) | `list`, `create`, `delete` | Manage databases |
70
+ | [roles](https://neon.tech/docs/reference/cli-roles) | `list`, `create`, `delete` | Manage roles |
71
+ | [operations](https://neon.tech/reference/cli-operations) | `list` | Manage operations |
72
+ | [connection-string](https://neon.tech/reference/cli-connection-string) | | Get connection string |
73
+ | [completion](https://neon.tech/reference/cli-completion) | | Generate a completion script |
74
74
 
75
75
  ## Global options
76
76
 
77
77
  Global options are supported with any Neon CLI command.
78
78
 
79
- | Option | Description | Type | Default |
80
- | :--------- | :---------------------------------- | :----- | :-------------------------------- |
81
- | [-o, --output](#output)| Set the Neon CLI output format (`json`, `yaml`, or `table`) | string | table |
82
- | [--config-dir](#config-dir)| Path to the Neon CLI configuration directory | string | `/home/<user>/.config/neonctl` |
83
- | [--api-key](#api-key) | Neon API key | string | "" |
84
- | [--analytics](#analytics) | Manage analytics | boolean| true |
85
- | [-v, --version](#version) | Show the Neon CLI version number | boolean| - |
86
- | [-h, --help](#help) | Show the Neon CLI help | boolean| - |
79
+ | Option | Description | Type | Default |
80
+ | :-------------------------- | :---------------------------------------------------------- | :------ | :----------------------------- |
81
+ | [-o, --output](#output) | Set the Neon CLI output format (`json`, `yaml`, or `table`) | string | table |
82
+ | [--config-dir](#config-dir) | Path to the Neon CLI configuration directory | string | `/home/<user>/.config/neonctl` |
83
+ | [--api-key](#api-key) | Neon API key | string | "" |
84
+ | [--analytics](#analytics) | Manage analytics | boolean | true |
85
+ | [-v, --version](#version) | Show the Neon CLI version number | boolean | - |
86
+ | [-h, --help](#help) | Show the Neon CLI help | boolean | - |
87
87
 
88
88
  - <a id="output"></a>`-o, --output`
89
89
 
@@ -133,3 +133,23 @@ Global options are supported with any Neon CLI command.
133
133
 
134
134
  neonctl branches create --help
135
135
  ```
136
+
137
+ ## Contribute
138
+
139
+ To run the CLI locally execute build command after making changes:
140
+
141
+ ```shell
142
+ npm run build
143
+ ```
144
+
145
+ To develop continuously:
146
+
147
+ ```shell
148
+ npm run watch
149
+ ```
150
+
151
+ To run commands from the local build replace the `neonctl` command with `node dist`, for example:
152
+
153
+ ```shell
154
+ node dist branches --help
155
+ ```
package/commands/auth.js CHANGED
@@ -6,10 +6,11 @@ import { log } from '../log.js';
6
6
  import { getApiClient } from '../api.js';
7
7
  import { isCi } from '../env.js';
8
8
  import { CREDENTIALS_FILE } from '../config.js';
9
+ import { showHelpMiddleware } from '../help.js';
9
10
  export const command = 'auth';
10
11
  export const aliases = ['login'];
11
12
  export const describe = 'Authenticate';
12
- export const builder = (yargs) => yargs;
13
+ export const builder = (yargs) => yargs.middleware(showHelpMiddleware(yargs, true));
13
14
  export const handler = async (args) => {
14
15
  await authFlow(args);
15
16
  };
@@ -1,10 +1,11 @@
1
1
  import { EndpointType } from '@neondatabase/api-client';
2
2
  import { writer } from '../writer.js';
3
- import { branchCreateRequest } from '../parameters.gen.js';
4
- import { commandFailHandler } from '../utils/middlewares.js';
3
+ import { branchCreateRequest, branchCreateRequestEndpointOptions, } from '../parameters.gen.js';
5
4
  import { retryOnLock } from '../api.js';
6
5
  import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
7
6
  import { looksLikeBranchId, looksLikeLSN, looksLikeTimestamp, } from '../utils/formats.js';
7
+ import { showHelpMiddleware } from '../help.js';
8
+ import { psql } from '../utils/psql.js';
8
9
  const BRANCH_FIELDS = [
9
10
  'id',
10
11
  'name',
@@ -16,9 +17,7 @@ export const command = 'branches';
16
17
  export const describe = 'Manage branches';
17
18
  export const aliases = ['branch'];
18
19
  export const builder = (argv) => argv
19
- .demandCommand(1, '')
20
- .fail(commandFailHandler)
21
- .usage('usage: $0 branches <sub-command> [options]')
20
+ .usage('$0 branches <sub-command> [options]')
22
21
  .options({
23
22
  'project-id': {
24
23
  describe: 'Project ID',
@@ -45,6 +44,18 @@ export const builder = (argv) => argv
45
44
  default: EndpointType.ReadWrite,
46
45
  choices: Object.values(EndpointType),
47
46
  },
47
+ 'suspend-timeout': {
48
+ describe: branchCreateRequestEndpointOptions.suspend_timeout_seconds
49
+ .description,
50
+ type: 'number',
51
+ implies: 'compute',
52
+ default: 0,
53
+ },
54
+ psql: {
55
+ type: 'boolean',
56
+ describe: 'Connect to a new branch via psql',
57
+ default: false,
58
+ },
48
59
  }), async (args) => await create(args))
49
60
  .command('rename <id|name> <new-name>', 'Rename a branch', (yargs) => yargs, async (args) => await rename(args))
50
61
  .command('set-primary <id|name>', 'Set a branch as primary', (yargs) => yargs, async (args) => await setPrimary(args))
@@ -57,7 +68,8 @@ export const builder = (argv) => argv
57
68
  },
58
69
  }), async (args) => await addCompute(args))
59
70
  .command('delete <id|name>', 'Delete a branch', (yargs) => yargs, async (args) => await deleteBranch(args))
60
- .command('get <id|name>', 'Get a branch', (yargs) => yargs, async (args) => await get(args));
71
+ .command('get <id|name>', 'Get a branch', (yargs) => yargs, async (args) => await get(args))
72
+ .middleware(showHelpMiddleware(argv), true);
61
73
  export const handler = (args) => {
62
74
  return args;
63
75
  };
@@ -108,6 +120,7 @@ const create = async (props) => {
108
120
  ? [
109
121
  {
110
122
  type: props.type,
123
+ suspend_timeout_seconds: props.suspendTimeout,
111
124
  },
112
125
  ]
113
126
  : [],
@@ -130,6 +143,14 @@ const create = async (props) => {
130
143
  });
131
144
  }
132
145
  out.end();
146
+ if (props.psql) {
147
+ if (!data.connection_uris || !data.connection_uris?.length) {
148
+ throw new Error(`Branch ${data.branch.id} doesn't have a connection uri`);
149
+ }
150
+ const connection_uri = data.connection_uris[0].connection_uri;
151
+ const psqlArgs = props['--'];
152
+ await psql(connection_uri, psqlArgs);
153
+ }
133
154
  };
134
155
  const rename = async (props) => {
135
156
  const branchId = await branchIdFromProps(props);
@@ -22,6 +22,39 @@ describe('branches', () => {
22
22
  snapshot: true,
23
23
  },
24
24
  });
25
+ testCliCommand({
26
+ name: 'create branch and connect with psql',
27
+ args: [
28
+ 'branches',
29
+ 'create',
30
+ '--project-id',
31
+ 'test',
32
+ '--name',
33
+ 'test_branch',
34
+ '--psql',
35
+ ],
36
+ expected: {
37
+ snapshot: true,
38
+ },
39
+ });
40
+ testCliCommand({
41
+ name: 'create branch and connect with psql and psql args',
42
+ args: [
43
+ 'branches',
44
+ 'create',
45
+ '--project-id',
46
+ 'test',
47
+ '--name',
48
+ 'test_branch',
49
+ '--psql',
50
+ '--',
51
+ '-c',
52
+ 'SELECT 1',
53
+ ],
54
+ expected: {
55
+ snapshot: true,
56
+ },
57
+ });
25
58
  testCliCommand({
26
59
  name: 'create with readonly endpoint',
27
60
  args: [
@@ -101,6 +134,22 @@ describe('branches', () => {
101
134
  snapshot: true,
102
135
  },
103
136
  });
137
+ testCliCommand({
138
+ name: 'create with suspend timeout',
139
+ args: [
140
+ 'branches',
141
+ 'create',
142
+ '--project-id',
143
+ 'test',
144
+ '--name',
145
+ 'test_branch_with_suspend_timeout',
146
+ '--suspend-timeout',
147
+ '60',
148
+ ],
149
+ expected: {
150
+ snapshot: true,
151
+ },
152
+ });
104
153
  testCliCommand({
105
154
  name: 'delete by id',
106
155
  args: [
@@ -176,7 +225,13 @@ describe('branches', () => {
176
225
  });
177
226
  testCliCommand({
178
227
  name: 'get by id',
179
- args: ['branches', 'get', 'br-cloudy-branch-12345678', '--project-id', 'test'],
228
+ args: [
229
+ 'branches',
230
+ 'get',
231
+ 'br-cloudy-branch-12345678',
232
+ '--project-id',
233
+ 'test',
234
+ ],
180
235
  expected: {
181
236
  snapshot: true,
182
237
  },
@@ -1,11 +1,15 @@
1
1
  import { EndpointType } from '@neondatabase/api-client';
2
2
  import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
3
+ import { showHelpMiddleware } from '../help.js';
4
+ import { writer } from '../writer.js';
5
+ import { psql } from '../utils/psql.js';
3
6
  export const command = 'connection-string [branch]';
4
7
  export const aliases = ['cs'];
5
8
  export const describe = 'Get connection string';
6
9
  export const builder = (argv) => {
7
10
  return argv
8
- .usage('usage: $0 connection-string [branch] [options]')
11
+ .usage('$0 connection-string [branch] [options]')
12
+ .middleware(showHelpMiddleware(argv, true))
9
13
  .positional('branch', {
10
14
  describe: 'Branch name or id. If ommited will use the primary branch',
11
15
  type: 'string',
@@ -38,6 +42,15 @@ export const builder = (argv) => {
38
42
  choices: Object.values(EndpointType),
39
43
  describe: 'Endpoint type',
40
44
  },
45
+ extended: {
46
+ type: 'boolean',
47
+ describe: 'Show extended information',
48
+ },
49
+ psql: {
50
+ type: 'boolean',
51
+ describe: 'Connect to a database via psql using connection string',
52
+ default: false,
53
+ },
41
54
  })
42
55
  .middleware(fillSingleProject);
43
56
  };
@@ -81,14 +94,14 @@ export const handler = async (props) => {
81
94
  .map((d) => d.name)
82
95
  .join(', ')}`);
83
96
  }));
84
- const { data: password } = await props.apiClient.getProjectBranchRolePassword(props.projectId, endpoint.branch_id, role);
97
+ const { data: { password }, } = await props.apiClient.getProjectBranchRolePassword(props.projectId, endpoint.branch_id, role);
85
98
  const host = props.pooled
86
99
  ? endpoint.host.replace(endpoint.id, `${endpoint.id}-pooler`)
87
100
  : endpoint.host;
88
101
  const connectionString = new URL(`postgres://${host}`);
89
102
  connectionString.pathname = database;
90
103
  connectionString.username = role;
91
- connectionString.password = password.password;
104
+ connectionString.password = password;
92
105
  if (props.prisma) {
93
106
  connectionString.searchParams.set('connect_timeout', '30');
94
107
  if (props.pooled) {
@@ -96,5 +109,21 @@ export const handler = async (props) => {
96
109
  connectionString.searchParams.set('pgbouncer', 'true');
97
110
  }
98
111
  }
99
- process.stdout.write(connectionString.toString() + '\n');
112
+ if (props.psql) {
113
+ const psqlArgs = props['--'];
114
+ await psql(connectionString.toString(), psqlArgs);
115
+ }
116
+ else if (props.extended) {
117
+ writer(props).end({
118
+ connection_string: connectionString.toString(),
119
+ host,
120
+ role,
121
+ password,
122
+ database,
123
+ options: connectionString.searchParams.toString(),
124
+ }, { fields: ['host', 'role', 'password', 'database'] });
125
+ }
126
+ else {
127
+ process.stdout.write(connectionString.toString() + '\n');
128
+ }
100
129
  };
@@ -34,7 +34,7 @@ describe('connection_string', () => {
34
34
  },
35
35
  });
36
36
  testCliCommand({
37
- name: 'connection_string branch id',
37
+ name: 'connection_string branch id 8 digits',
38
38
  args: [
39
39
  'connection-string',
40
40
  'br-cloudy-branch-12345678',
@@ -66,40 +66,6 @@ describe('connection_string', () => {
66
66
  snapshot: true,
67
67
  },
68
68
  });
69
- testCliCommand({
70
- name: 'connection_string pooled branch id',
71
- args: [
72
- 'connection-string',
73
- 'br-sunny-branch-123456',
74
- '--project-id',
75
- 'test',
76
- '--database-name',
77
- 'test_db',
78
- '--role-name',
79
- 'test_role',
80
- '--pooled',
81
- ],
82
- expected: {
83
- snapshot: true,
84
- },
85
- });
86
- testCliCommand({
87
- name: 'connection_string pooled branch id',
88
- args: [
89
- 'connection-string',
90
- 'br-cloudy-branch-12345678',
91
- '--project-id',
92
- 'test',
93
- '--database-name',
94
- 'test_db',
95
- '--role-name',
96
- 'test_role',
97
- '--pooled',
98
- ],
99
- expected: {
100
- snapshot: true,
101
- },
102
- });
103
69
  testCliCommand({
104
70
  name: 'connection_string prisma',
105
71
  args: [
@@ -118,16 +84,17 @@ describe('connection_string', () => {
118
84
  },
119
85
  });
120
86
  testCliCommand({
121
- name: 'connection_string pooled branch id',
87
+ name: 'connection_string prisma pooled',
122
88
  args: [
123
89
  'connection-string',
124
- 'br-sunny-branch-123456',
90
+ 'test_branch',
125
91
  '--project-id',
126
92
  'test',
127
93
  '--database-name',
128
94
  'test_db',
129
95
  '--role-name',
130
96
  'test_role',
97
+ '--prisma',
131
98
  '--pooled',
132
99
  ],
133
100
  expected: {
@@ -135,82 +102,67 @@ describe('connection_string', () => {
135
102
  },
136
103
  });
137
104
  testCliCommand({
138
- name: 'connection_string pooled branch id',
105
+ name: 'connection_string prisma pooled extended',
139
106
  args: [
140
107
  'connection-string',
141
- 'br-cloudy-branch-12345678',
108
+ 'test_branch',
142
109
  '--project-id',
143
110
  'test',
144
111
  '--database-name',
145
112
  'test_db',
146
113
  '--role-name',
147
114
  'test_role',
115
+ '--prisma',
148
116
  '--pooled',
117
+ '--extended',
149
118
  ],
150
119
  expected: {
151
120
  snapshot: true,
152
121
  },
153
122
  });
154
123
  testCliCommand({
155
- name: 'connection_string prisma pooled',
156
- args: [
157
- 'connection-string',
158
- 'test_branch',
159
- '--project-id',
160
- 'test',
161
- '--database-name',
162
- 'test_db',
163
- '--role-name',
164
- 'test_role',
165
- '--prisma',
166
- '--pooled',
167
- ],
124
+ name: 'connection_string without any args should pass',
125
+ args: ['connection-string'],
126
+ mockDir: 'single_project',
168
127
  expected: {
169
128
  snapshot: true,
170
129
  },
171
130
  });
172
131
  testCliCommand({
173
- name: 'connection_string prisma pooled branch id',
132
+ name: 'connection_string with psql',
174
133
  args: [
175
134
  'connection-string',
176
- 'br-sunny-branch-123456',
135
+ 'test_branch',
177
136
  '--project-id',
178
137
  'test',
179
138
  '--database-name',
180
139
  'test_db',
181
140
  '--role-name',
182
141
  'test_role',
183
- '--prisma',
184
- '--pooled',
142
+ '--psql',
185
143
  ],
186
144
  expected: {
187
145
  snapshot: true,
188
146
  },
189
147
  });
190
148
  testCliCommand({
191
- name: 'connection_string prisma pooled branch id',
149
+ name: 'connection_string with psql and psql args',
192
150
  args: [
193
151
  'connection-string',
194
- 'br-cloudy-branch-12345678',
152
+ 'test_branch',
195
153
  '--project-id',
196
154
  'test',
197
155
  '--database-name',
198
156
  'test_db',
199
157
  '--role-name',
200
158
  'test_role',
201
- '--prisma',
202
- '--pooled',
159
+ '--psql',
160
+ '--',
161
+ '-c',
162
+ 'SELECT 1',
203
163
  ],
204
164
  expected: {
205
165
  snapshot: true,
206
166
  },
207
167
  });
208
- testCliCommand({
209
- name: 'connection_string without any args should pass',
210
- args: ['connection-string'],
211
- mockDir: 'single_project',
212
- expected: {
213
- snapshot: true,
214
- },
215
- });
216
168
  });
@@ -1,15 +1,14 @@
1
1
  import { retryOnLock } from '../api.js';
2
2
  import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
3
- import { commandFailHandler } from '../utils/middlewares.js';
4
3
  import { writer } from '../writer.js';
4
+ import { showHelpMiddleware } from '../help.js';
5
5
  const DATABASE_FIELDS = ['name', 'owner_name', 'created_at'];
6
6
  export const command = 'databases';
7
7
  export const describe = 'Manage databases';
8
8
  export const aliases = ['database', 'db'];
9
9
  export const builder = (argv) => argv
10
- .demandCommand(1, '')
11
- .fail(commandFailHandler)
12
- .usage('usage: $0 databases <sub-command> [options]')
10
+ .usage('$0 databases <sub-command> [options]')
11
+ .middleware(showHelpMiddleware(argv))
13
12
  .options({
14
13
  'project-id': {
15
14
  describe: 'Project ID',
@@ -1,11 +1,12 @@
1
1
  import { describe, expect } from '@jest/globals';
2
+ import chalk from 'chalk';
2
3
  import { testCliCommand } from '../test_utils/test_cli_command.js';
3
4
  describe('help', () => {
4
5
  testCliCommand({
5
6
  name: 'without args',
6
7
  args: [],
7
8
  expected: {
8
- stderr: expect.stringContaining('usage: neonctl <command> [options]'),
9
+ stderr: expect.stringContaining(`neonctl <command> ${chalk.green('[options]')}`),
9
10
  },
10
11
  });
11
12
  });
@@ -1,14 +1,13 @@
1
1
  import { fillSingleProject } from '../utils/enrichers.js';
2
- import { commandFailHandler } from '../utils/middlewares.js';
3
2
  import { writer } from '../writer.js';
3
+ import { showHelpMiddleware } from '../help.js';
4
4
  const OPERATIONS_FIELDS = ['id', 'action', 'status', 'created_at'];
5
5
  export const command = 'operations';
6
6
  export const describe = 'Manage operations';
7
7
  export const aliases = ['operation'];
8
8
  export const builder = (argv) => argv
9
- .demandCommand(1, '')
10
- .fail(commandFailHandler)
11
- .usage('usage: $0 operations <sub-command> [options]')
9
+ .usage('$0 operations <sub-command> [options]')
10
+ .middleware(showHelpMiddleware(argv))
12
11
  .options({
13
12
  'project-id': {
14
13
  describe: 'Project ID',
@@ -1,7 +1,8 @@
1
+ import { showHelpMiddleware } from '../help.js';
1
2
  import { log } from '../log.js';
2
3
  import { projectCreateRequest } from '../parameters.gen.js';
3
- import { commandFailHandler } from '../utils/middlewares.js';
4
4
  import { writer } from '../writer.js';
5
+ import { psql } from '../utils/psql.js';
5
6
  const PROJECT_FIELDS = ['id', 'name', 'region_id', 'created_at'];
6
7
  const REGIONS = [
7
8
  'aws-us-west-2',
@@ -16,9 +17,8 @@ export const describe = 'Manage projects';
16
17
  export const aliases = ['project'];
17
18
  export const builder = (argv) => {
18
19
  return argv
19
- .demandCommand(1, '')
20
- .fail(commandFailHandler)
21
- .usage('usage: $0 projects <sub-command> [options]')
20
+ .usage('$0 projects <sub-command> [options]')
21
+ .middleware(showHelpMiddleware(argv))
22
22
  .command('list', 'List projects', (yargs) => yargs, async (args) => {
23
23
  await list(args);
24
24
  })
@@ -31,6 +31,11 @@ export const builder = (argv) => {
31
31
  describe: `The region ID. Possible values: ${REGIONS.join(', ')}`,
32
32
  type: 'string',
33
33
  },
34
+ psql: {
35
+ type: 'boolean',
36
+ describe: 'Connect to a new project via psql',
37
+ default: false,
38
+ },
34
39
  }), async (args) => {
35
40
  await create(args);
36
41
  })
@@ -88,6 +93,11 @@ const create = async (props) => {
88
93
  title: 'Connection URIs',
89
94
  });
90
95
  out.end();
96
+ if (props.psql) {
97
+ const connection_uri = data.connection_uris[0].connection_uri;
98
+ const psqlArgs = props['--'];
99
+ await psql(connection_uri, psqlArgs);
100
+ }
91
101
  };
92
102
  const deleteProject = async (props) => {
93
103
  const { data } = await props.apiClient.deleteProject(props.id);
@@ -15,6 +15,20 @@ describe('projects', () => {
15
15
  snapshot: true,
16
16
  },
17
17
  });
18
+ testCliCommand({
19
+ name: 'create and connect with psql',
20
+ args: ['projects', 'create', '--name', 'test_project', '--psql'],
21
+ expected: {
22
+ snapshot: true,
23
+ },
24
+ });
25
+ testCliCommand({
26
+ name: 'create and connect with psql and psql args',
27
+ args: ['projects', 'create', '--name', 'test_project', '--psql', '--', '-c', 'SELECT 1'],
28
+ expected: {
29
+ snapshot: true,
30
+ },
31
+ });
18
32
  testCliCommand({
19
33
  name: 'delete',
20
34
  args: ['projects', 'delete', 'test'],
package/commands/roles.js CHANGED
@@ -1,15 +1,14 @@
1
1
  import { retryOnLock } from '../api.js';
2
2
  import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
3
- import { commandFailHandler } from '../utils/middlewares.js';
4
3
  import { writer } from '../writer.js';
4
+ import { showHelpMiddleware } from '../help.js';
5
5
  const ROLES_FIELDS = ['name', 'created_at'];
6
6
  export const command = 'roles';
7
7
  export const describe = 'Manage roles';
8
8
  export const aliases = ['role'];
9
9
  export const builder = (argv) => argv
10
- .demandCommand(1, '')
11
- .fail(commandFailHandler)
12
- .usage('usage: $0 roles <sub-command> [options]')
10
+ .middleware(showHelpMiddleware(argv))
11
+ .usage('$0 roles <sub-command> [options]')
13
12
  .options({
14
13
  'project-id': {
15
14
  describe: 'Project ID',
package/commands/user.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { showHelpMiddleware } from '../help.js';
1
2
  import { writer } from '../writer.js';
2
3
  export const command = 'me';
3
4
  export const describe = 'Show current user';
4
- export const builder = (yargs) => yargs;
5
+ export const builder = (yargs) => yargs.middleware(showHelpMiddleware(yargs, true));
5
6
  export const handler = async (args) => {
6
7
  await me(args);
7
8
  };
package/errors.js CHANGED
@@ -3,6 +3,9 @@ const ERROR_MATCHERS = [
3
3
  [/^Missing required argument: (.*)$/, 'MISSING_ARGUMENT'],
4
4
  ];
5
5
  export const matchErrorCode = (message) => {
6
+ if (!message) {
7
+ return 'UNKNOWN_ERROR';
8
+ }
6
9
  for (const [matcher, code] of ERROR_MATCHERS) {
7
10
  const match = message.match(matcher);
8
11
  if (match) {
package/help.js ADDED
@@ -0,0 +1,119 @@
1
+ import cliui from 'cliui';
2
+ import chalk from 'chalk';
3
+ import { consumeBlockIfMatches, consumeNextMatching, drawPointer, splitColumns, } from './utils/ui.js';
4
+ // target width for the leftmost column
5
+ const SPACE_WIDTH = 20;
6
+ const formatHelp = (help) => {
7
+ const lines = help.split('\n');
8
+ const result = [];
9
+ // full command, like `neonctl projects list`
10
+ const topLevelCommand = consumeNextMatching(lines, /^.*/);
11
+ if (topLevelCommand) {
12
+ result.push(chalk.bold(topLevelCommand.replace('[options]', chalk.reset.green('[options]'))));
13
+ result.push('');
14
+ }
15
+ // commands description block
16
+ // example command to see: neonctl projects
17
+ const commandsBlock = consumeBlockIfMatches(lines, /^Commands:/);
18
+ if (commandsBlock.length > 0) {
19
+ const header = commandsBlock.shift();
20
+ result.push(header);
21
+ const ui = cliui({
22
+ width: 0,
23
+ });
24
+ commandsBlock.forEach((line) => {
25
+ const [command, description] = splitColumns(line);
26
+ ui.div(chalk.cyan(command));
27
+ ui.div({
28
+ text: chalk.gray(drawPointer(SPACE_WIDTH)),
29
+ width: SPACE_WIDTH,
30
+ padding: [0, 0, 0, 0],
31
+ }, { text: description, padding: [0, 0, 0, 2] });
32
+ });
33
+ result.push(ui.toString());
34
+ result.push('');
35
+ }
36
+ // positional args block
37
+ // example command to see: neonctl branches rename
38
+ const positionalsBlock = consumeBlockIfMatches(lines, /Positionals:/);
39
+ if (positionalsBlock.length > 0) {
40
+ const header = positionalsBlock.shift();
41
+ result.push(header);
42
+ const ui = cliui({
43
+ width: 0,
44
+ });
45
+ positionalsBlock.forEach((line) => {
46
+ const [positional, description] = splitColumns(line);
47
+ ui.div({
48
+ text: positional,
49
+ width: SPACE_WIDTH,
50
+ padding: [0, 2, 0, 0],
51
+ }, {
52
+ text: description,
53
+ padding: [0, 0, 0, 0],
54
+ });
55
+ });
56
+ result.push(ui.toString());
57
+ result.push('');
58
+ }
59
+ // command description
60
+ // example command to see: neonctl projects list
61
+ const descritpionBlock = consumeBlockIfMatches(lines, /^(?!.*options:)/i);
62
+ if (descritpionBlock.length > 0) {
63
+ result.push(descritpionBlock.shift());
64
+ result.push('');
65
+ }
66
+ while (true) {
67
+ // there are two options blocks: global and specific
68
+ // example to see both: neonctl projects create
69
+ const optionsBlock = consumeBlockIfMatches(lines, /.*options:/i);
70
+ if (optionsBlock.length === 0) {
71
+ break;
72
+ }
73
+ result.push(optionsBlock.shift());
74
+ optionsBlock.forEach((line) => {
75
+ const [option, description] = splitColumns(line);
76
+ const ui = cliui({
77
+ width: 0,
78
+ });
79
+ if (option.startsWith('-')) {
80
+ ui.div({
81
+ text: chalk.green(option),
82
+ padding: [0, 0, 0, 0],
83
+ });
84
+ ui.div({
85
+ text: chalk.gray(drawPointer(SPACE_WIDTH)),
86
+ width: SPACE_WIDTH,
87
+ padding: [0, 2, 0, 0],
88
+ }, {
89
+ text: chalk.rgb(210, 210, 210)(description ?? ''),
90
+ padding: [0, 0, 0, 0],
91
+ });
92
+ }
93
+ else {
94
+ ui.div({
95
+ padding: [0, 0, 0, 0],
96
+ text: '',
97
+ width: SPACE_WIDTH,
98
+ }, {
99
+ text: chalk.rgb(210, 210, 210)(option),
100
+ padding: [0, 0, 0, 0],
101
+ });
102
+ }
103
+ result.push(ui.toString());
104
+ });
105
+ result.push('');
106
+ }
107
+ return [...result, ...lines];
108
+ };
109
+ export const showHelp = async (argv) => {
110
+ // add wrap to ensure that there are no line breaks
111
+ const help = await argv.wrap(500).getHelp();
112
+ process.stderr.write(formatHelp(help).join('\n') + '\n');
113
+ process.exit(0);
114
+ };
115
+ export const showHelpMiddleware = (argv, ignoreSubCmdPresence) => async (args) => {
116
+ if ((!ignoreSubCmdPresence && args._.length === 1) || args.help) {
117
+ await showHelp(argv);
118
+ }
119
+ };
package/index.js CHANGED
@@ -24,10 +24,14 @@ import { analyticsMiddleware, sendError } from './analytics.js';
24
24
  import { isCi } from './env.js';
25
25
  import { isAxiosError } from 'axios';
26
26
  import { matchErrorCode } from './errors.js';
27
+ import { showHelp } from './help.js';
27
28
  let builder = yargs(hideBin(process.argv));
28
29
  builder = builder
29
30
  .scriptName(pkg.name)
30
- .usage('usage: $0 <command> [options]')
31
+ .usage('$0 <command> [options]')
32
+ .parserConfiguration({
33
+ 'populate--': true,
34
+ })
31
35
  .help()
32
36
  .option('output', {
33
37
  alias: 'o',
@@ -92,11 +96,21 @@ builder = builder
92
96
  .middleware(analyticsMiddleware, true)
93
97
  .group('version', 'Global options:')
94
98
  .alias('version', 'v')
99
+ .help(false)
95
100
  .group('help', 'Global options:')
101
+ .option('help', {
102
+ describe: 'Show help',
103
+ type: 'boolean',
104
+ default: false,
105
+ })
96
106
  .alias('help', 'h')
97
107
  .completion()
98
108
  .scriptName(basename(process.argv[1]) === 'neon' ? 'neon' : 'neonctl')
99
109
  .fail(async (msg, err) => {
110
+ if (process.argv.some((arg) => arg === '--help' || arg === '-h')) {
111
+ await showHelp(builder);
112
+ process.exit(0);
113
+ }
100
114
  if (isAxiosError(err)) {
101
115
  if (err.code === 'ECONNABORTED') {
102
116
  log.error('Request timed out');
@@ -121,8 +135,8 @@ builder = builder
121
135
  });
122
136
  (async () => {
123
137
  const args = await builder.argv;
124
- if (args._.length === 0) {
125
- builder.showHelp();
138
+ if (args._.length === 0 || args.help) {
139
+ await showHelp(builder);
126
140
  process.exit(0);
127
141
  }
128
142
  })();
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.19.1",
8
+ "version": "1.21.0",
9
9
  "description": "CLI tool for NeonDB Cloud management",
10
10
  "main": "index.js",
11
11
  "author": "NeonDB",
@@ -32,6 +32,7 @@
32
32
  "@types/express": "^4.17.17",
33
33
  "@types/inquirer": "^9.0.3",
34
34
  "@types/node": "^18.7.13",
35
+ "@types/which": "^3.0.0",
35
36
  "@types/yargs": "^17.0.24",
36
37
  "@typescript-eslint/eslint-plugin": "^5.34.0",
37
38
  "@typescript-eslint/parser": "^5.34.0",
@@ -60,6 +61,7 @@
60
61
  "inquirer": "^9.2.6",
61
62
  "open": "^8.4.0",
62
63
  "openid-client": "^5.1.9",
64
+ "which": "^3.0.1",
63
65
  "yaml": "^2.1.1",
64
66
  "yargs": "^17.7.2"
65
67
  },
@@ -29,6 +29,9 @@ export const testCliCommand = ({ args, name, expected, mockDir = 'main', }) => {
29
29
  ...args,
30
30
  ], {
31
31
  stdio: 'pipe',
32
+ env: {
33
+ PATH: `mocks/bin:${process.env.PATH}`,
34
+ },
32
35
  });
33
36
  return new Promise((resolve, reject) => {
34
37
  cp.stdout?.on('data', (data) => {
@@ -1,5 +1,3 @@
1
- import yargs from 'yargs';
2
- import { hideBin } from 'yargs/helpers';
3
1
  /**
4
2
  * This middleware is needed to fill in the args for nested objects,
5
3
  * so that required arguments would work
@@ -20,10 +18,3 @@ export const fillInArgs = (args, currentArgs = args, acc = []) => {
20
18
  }
21
19
  });
22
20
  };
23
- export const commandFailHandler = async (_msg, _err, yyargs) => {
24
- const argv = yargs(hideBin(process.argv));
25
- if (argv.argv._.length === 1) {
26
- yyargs.showHelp();
27
- process.exit(1);
28
- }
29
- };
package/utils/psql.js ADDED
@@ -0,0 +1,17 @@
1
+ import { log } from '../log.js';
2
+ import { spawn } from 'child_process';
3
+ import which from 'which';
4
+ export const psql = async (connection_uri, args = []) => {
5
+ const psqlPathOrNull = await which('psql', { nothrow: true });
6
+ if (psqlPathOrNull === null) {
7
+ log.error(`psql is not available in the PATH`);
8
+ process.exit(1);
9
+ }
10
+ log.info('Connecting to the database using psql...');
11
+ const psql = spawn(psqlPathOrNull, [connection_uri, ...args], {
12
+ stdio: 'inherit',
13
+ });
14
+ psql.on('exit', (code) => {
15
+ process.exit(code === null ? 1 : code);
16
+ });
17
+ };
package/utils/ui.js ADDED
@@ -0,0 +1,52 @@
1
+ // returns the next string if matches the given matcher,
2
+ // otherwise returns null
3
+ // consumes the line from the lines array
4
+ export const consumeNextMatching = (lines, matcher) => {
5
+ while (lines.length > 0) {
6
+ const line = lines.shift().trim();
7
+ if (line === '') {
8
+ continue;
9
+ }
10
+ if (matcher.test(line)) {
11
+ return line;
12
+ }
13
+ return null;
14
+ }
15
+ return null;
16
+ };
17
+ // returns strings if next non-empty line matches the given matcher,
18
+ // otherwise returns empty array
19
+ // consumes the lines from the lines array
20
+ export const consumeBlockIfMatches = (lines, matcher) => {
21
+ const result = [];
22
+ if (lines.length === 0) {
23
+ return result;
24
+ }
25
+ let line = lines.shift();
26
+ while (line.trim() === '') {
27
+ line = lines.shift();
28
+ }
29
+ if (!matcher.test(line)) {
30
+ lines.unshift(line);
31
+ return result;
32
+ }
33
+ result.push(line);
34
+ while (lines.length > 0) {
35
+ line = lines.shift();
36
+ if (line.trim() === '') {
37
+ break;
38
+ }
39
+ result.push(line);
40
+ }
41
+ return result;
42
+ };
43
+ export const splitColumns = (line) => line.trim().split(/\s{2,}/);
44
+ export const drawPointer = (width) => {
45
+ const result = [];
46
+ result.push('└');
47
+ for (let i = 0; i < width - 4; i++) {
48
+ result.push('─');
49
+ }
50
+ result.push('>');
51
+ return result.join('');
52
+ };