neonctl 1.18.2 → 1.19.1

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/analytics.js CHANGED
@@ -1,33 +1,63 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { Analytics } from '@segment/analytics-node';
4
+ import { isAxiosError } from 'axios';
5
+ import { CREDENTIALS_FILE } from './config.js';
2
6
  import { isCi } from './env.js';
7
+ import { log } from './log.js';
3
8
  import pkg from './pkg.js';
4
9
  const WRITE_KEY = '3SQXn5ejjXWLEJ8xU2PRYhAotLtTaeeV';
10
+ let client;
11
+ let userId = '';
5
12
  export const analyticsMiddleware = (args) => {
6
13
  if (!args.analytics) {
7
14
  return;
8
15
  }
9
- const client = new Analytics({
16
+ try {
17
+ const credentialsPath = join(args.configDir, CREDENTIALS_FILE);
18
+ const credentials = readFileSync(credentialsPath, { encoding: 'utf-8' });
19
+ userId = JSON.parse(credentials).user_id;
20
+ }
21
+ catch (err) {
22
+ log.debug('Failed to read credentials file', err);
23
+ }
24
+ client = new Analytics({
10
25
  writeKey: WRITE_KEY,
11
26
  host: 'https://track.neon.tech',
12
27
  });
13
- (args.apiClient?.getCurrentUserInfo() ??
14
- Promise.resolve({ data: { id: undefined } }))
15
- .then(({ data }) => data.id)
16
- .then((userId) => {
17
- client.track({
18
- userId,
19
- event: 'CLI Started',
20
- properties: {
21
- version: pkg.version,
22
- command: args._.join(' '),
23
- flags: {
24
- output: args.output,
25
- },
26
- ci: isCi(),
28
+ client.identify({
29
+ userId: userId?.toString() ?? 'anonymous',
30
+ });
31
+ client.track({
32
+ userId: userId ?? 'anonymous',
33
+ event: 'CLI Started',
34
+ properties: {
35
+ version: pkg.version,
36
+ command: args._.join(' '),
37
+ flags: {
38
+ output: args.output,
27
39
  },
28
- });
29
- client.closeAndFlush();
30
- })
31
- // eslint-disable-next-line @typescript-eslint/no-empty-function
32
- .catch(() => { });
40
+ ci: isCi(),
41
+ },
42
+ });
43
+ client.closeAndFlush();
44
+ log.debug('Sent CLI started event with userId: %s', userId);
45
+ };
46
+ export const sendError = (err, errCode) => {
47
+ if (!client) {
48
+ return;
49
+ }
50
+ const axiosError = isAxiosError(err) ? err : undefined;
51
+ client.track({
52
+ event: 'CLI Error',
53
+ userId: userId ?? 'anonymous',
54
+ properties: {
55
+ message: err.message,
56
+ stack: err.stack,
57
+ errCode,
58
+ statusCode: axiosError?.response?.status,
59
+ },
60
+ });
61
+ client.closeAndFlush();
62
+ log.debug('Sent CLI error event: %s', errCode);
33
63
  };
package/commands/auth.js CHANGED
@@ -5,7 +5,7 @@ import { auth, refreshToken } from '../auth.js';
5
5
  import { log } from '../log.js';
6
6
  import { getApiClient } from '../api.js';
7
7
  import { isCi } from '../env.js';
8
- const CREDENTIALS_FILE = 'credentials.json';
8
+ import { CREDENTIALS_FILE } from '../config.js';
9
9
  export const command = 'auth';
10
10
  export const aliases = ['login'];
11
11
  export const describe = 'Authenticate';
@@ -13,7 +13,7 @@ export const builder = (yargs) => yargs;
13
13
  export const handler = async (args) => {
14
14
  await authFlow(args);
15
15
  };
16
- export const authFlow = async ({ configDir, oauthHost, clientId, forceAuth, }) => {
16
+ export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceAuth, }) => {
17
17
  if (!forceAuth && isCi()) {
18
18
  throw new Error('Cannot run interactive auth in CI');
19
19
  }
@@ -22,17 +22,25 @@ export const authFlow = async ({ configDir, oauthHost, clientId, forceAuth, }) =
22
22
  clientId: clientId,
23
23
  });
24
24
  const credentialsPath = join(configDir, CREDENTIALS_FILE);
25
- updateCredentialsFile(credentialsPath, JSON.stringify(tokenSet));
25
+ await preserveCredentials(credentialsPath, tokenSet, getApiClient({
26
+ apiKey: tokenSet.access_token || '',
27
+ apiHost,
28
+ }));
26
29
  log.info(`Saved credentials to ${credentialsPath}`);
27
30
  log.info('Auth complete');
28
31
  return tokenSet.access_token || '';
29
32
  };
30
- // updateCredentialsFile correctly sets needed permissions for the credentials file
31
- function updateCredentialsFile(path, contents) {
33
+ const preserveCredentials = async (path, credentials, apiClient) => {
34
+ const { data: { id }, } = await apiClient.getCurrentUserInfo();
35
+ const contents = JSON.stringify({
36
+ ...credentials,
37
+ user_id: id,
38
+ });
39
+ // correctly sets needed permissions for the credentials file
32
40
  writeFileSync(path, contents, {
33
41
  mode: 0o700,
34
42
  });
35
- }
43
+ };
36
44
  export const ensureAuth = async (props) => {
37
45
  if (props._.length === 0) {
38
46
  return;
@@ -63,7 +71,7 @@ export const ensureAuth = async (props) => {
63
71
  apiKey: props.apiKey,
64
72
  apiHost: props.apiHost,
65
73
  });
66
- updateCredentialsFile(credentialsPath, JSON.stringify(refreshedTokenSet));
74
+ await preserveCredentials(credentialsPath, refreshedTokenSet, props.apiClient);
67
75
  return;
68
76
  }
69
77
  const token = tokenSet.access_token || 'UNKNOWN';
@@ -2,6 +2,7 @@ import axios from 'axios';
2
2
  import { beforeAll, describe, test, jest, afterAll, expect, } from '@jest/globals';
3
3
  import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
4
4
  import { startOauthServer } from '../test_utils/oauth_server';
5
+ import { runMockServer } from '../test_utils/mock_server';
5
6
  jest.unstable_mockModule('open', () => ({
6
7
  __esModule: true,
7
8
  default: jest.fn((url) => {
@@ -12,26 +13,30 @@ jest.unstable_mockModule('open', () => ({
12
13
  const authModule = await import('./auth');
13
14
  describe('auth', () => {
14
15
  let configDir = '';
15
- let server;
16
+ let oauthServer;
17
+ let mockServer;
16
18
  beforeAll(async () => {
17
19
  configDir = mkdtempSync('test-config');
18
- server = await startOauthServer();
20
+ oauthServer = await startOauthServer();
21
+ mockServer = await runMockServer('main');
19
22
  });
20
- afterAll(() => {
23
+ afterAll(async () => {
21
24
  rmSync(configDir, { recursive: true });
22
- server.stop();
25
+ await oauthServer.stop();
26
+ await new Promise((resolve) => mockServer.close(resolve));
23
27
  });
24
28
  test('should auth', async () => {
25
29
  await authModule.authFlow({
26
30
  _: ['auth'],
27
- apiHost: 'http://localhost:1111',
31
+ apiHost: `http://localhost:${mockServer.address().port}`,
28
32
  clientId: 'test-client-id',
29
33
  configDir,
30
34
  forceAuth: true,
31
- oauthHost: 'http://localhost:7777',
35
+ oauthHost: `http://localhost:${oauthServer.address().port}`,
32
36
  });
33
37
  const credentials = JSON.parse(readFileSync(`${configDir}/credentials.json`, 'utf-8'));
34
38
  expect(credentials.access_token).toEqual(expect.any(String));
35
39
  expect(credentials.refresh_token).toEqual(expect.any(String));
40
+ expect(credentials.user_id).toEqual(expect.any(String));
36
41
  });
37
42
  });
@@ -114,6 +114,19 @@ describe('branches', () => {
114
114
  snapshot: true,
115
115
  },
116
116
  });
117
+ testCliCommand({
118
+ name: 'delete by id',
119
+ args: [
120
+ 'branches',
121
+ 'delete',
122
+ 'br-cloudy-branch-12345678',
123
+ '--project-id',
124
+ 'test',
125
+ ],
126
+ expected: {
127
+ snapshot: true,
128
+ },
129
+ });
117
130
  testCliCommand({
118
131
  name: 'rename',
119
132
  args: [
@@ -141,6 +154,19 @@ describe('branches', () => {
141
154
  snapshot: true,
142
155
  },
143
156
  });
157
+ testCliCommand({
158
+ name: 'set primary by id',
159
+ args: [
160
+ 'branches',
161
+ 'set-primary',
162
+ 'br-cloudy-branch-12345678',
163
+ '--project-id',
164
+ 'test',
165
+ ],
166
+ expected: {
167
+ snapshot: true,
168
+ },
169
+ });
144
170
  testCliCommand({
145
171
  name: 'get by id',
146
172
  args: ['branches', 'get', 'br-sunny-branch-123456', '--project-id', 'test'],
@@ -148,6 +174,13 @@ describe('branches', () => {
148
174
  snapshot: true,
149
175
  },
150
176
  });
177
+ testCliCommand({
178
+ name: 'get by id',
179
+ args: ['branches', 'get', 'br-cloudy-branch-12345678', '--project-id', 'test'],
180
+ expected: {
181
+ snapshot: true,
182
+ },
183
+ });
151
184
  testCliCommand({
152
185
  name: 'get by name',
153
186
  args: ['branches', 'get', 'test_branch', '--project-id', 'test'],
@@ -17,6 +17,38 @@ describe('connection_string', () => {
17
17
  snapshot: true,
18
18
  },
19
19
  });
20
+ testCliCommand({
21
+ name: 'connection_string branch id',
22
+ args: [
23
+ 'connection-string',
24
+ 'br-sunny-branch-123456',
25
+ '--project-id',
26
+ 'test',
27
+ '--database-name',
28
+ 'test_db',
29
+ '--role-name',
30
+ 'test_role',
31
+ ],
32
+ expected: {
33
+ snapshot: true,
34
+ },
35
+ });
36
+ testCliCommand({
37
+ name: 'connection_string branch id',
38
+ args: [
39
+ 'connection-string',
40
+ 'br-cloudy-branch-12345678',
41
+ '--project-id',
42
+ 'test',
43
+ '--database-name',
44
+ 'test_db',
45
+ '--role-name',
46
+ 'test_role',
47
+ ],
48
+ expected: {
49
+ snapshot: true,
50
+ },
51
+ });
20
52
  testCliCommand({
21
53
  name: 'connection_string pooled',
22
54
  args: [
@@ -34,6 +66,40 @@ describe('connection_string', () => {
34
66
  snapshot: true,
35
67
  },
36
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
+ });
37
103
  testCliCommand({
38
104
  name: 'connection_string prisma',
39
105
  args: [
@@ -51,6 +117,40 @@ describe('connection_string', () => {
51
117
  snapshot: true,
52
118
  },
53
119
  });
120
+ testCliCommand({
121
+ name: 'connection_string pooled branch id',
122
+ args: [
123
+ 'connection-string',
124
+ 'br-sunny-branch-123456',
125
+ '--project-id',
126
+ 'test',
127
+ '--database-name',
128
+ 'test_db',
129
+ '--role-name',
130
+ 'test_role',
131
+ '--pooled',
132
+ ],
133
+ expected: {
134
+ snapshot: true,
135
+ },
136
+ });
137
+ testCliCommand({
138
+ name: 'connection_string pooled branch id',
139
+ args: [
140
+ 'connection-string',
141
+ 'br-cloudy-branch-12345678',
142
+ '--project-id',
143
+ 'test',
144
+ '--database-name',
145
+ 'test_db',
146
+ '--role-name',
147
+ 'test_role',
148
+ '--pooled',
149
+ ],
150
+ expected: {
151
+ snapshot: true,
152
+ },
153
+ });
54
154
  testCliCommand({
55
155
  name: 'connection_string prisma pooled',
56
156
  args: [
@@ -69,6 +169,42 @@ describe('connection_string', () => {
69
169
  snapshot: true,
70
170
  },
71
171
  });
172
+ testCliCommand({
173
+ name: 'connection_string prisma pooled branch id',
174
+ args: [
175
+ 'connection-string',
176
+ 'br-sunny-branch-123456',
177
+ '--project-id',
178
+ 'test',
179
+ '--database-name',
180
+ 'test_db',
181
+ '--role-name',
182
+ 'test_role',
183
+ '--prisma',
184
+ '--pooled',
185
+ ],
186
+ expected: {
187
+ snapshot: true,
188
+ },
189
+ });
190
+ testCliCommand({
191
+ name: 'connection_string prisma pooled branch id',
192
+ args: [
193
+ 'connection-string',
194
+ 'br-cloudy-branch-12345678',
195
+ '--project-id',
196
+ 'test',
197
+ '--database-name',
198
+ 'test_db',
199
+ '--role-name',
200
+ 'test_role',
201
+ '--prisma',
202
+ '--pooled',
203
+ ],
204
+ expected: {
205
+ snapshot: true,
206
+ },
207
+ });
72
208
  testCliCommand({
73
209
  name: 'connection_string without any args should pass',
74
210
  args: ['connection-string'],
@@ -82,8 +82,11 @@ const create = async (props) => {
82
82
  project,
83
83
  });
84
84
  const out = writer(props);
85
- out.write(data.project, { fields: PROJECT_FIELDS });
86
- out.write(data.connection_uris, { fields: ['connection_uri'] });
85
+ out.write(data.project, { fields: PROJECT_FIELDS, title: 'Project' });
86
+ out.write(data.connection_uris, {
87
+ fields: ['connection_uri'],
88
+ title: 'Connection URIs',
89
+ });
87
90
  out.end();
88
91
  };
89
92
  const deleteProject = async (props) => {
package/config.js CHANGED
@@ -2,6 +2,7 @@ import { join } from 'node:path';
2
2
  import { homedir } from 'node:os';
3
3
  import { existsSync, mkdirSync } from 'node:fs';
4
4
  import { isCi } from './env.js';
5
+ export const CREDENTIALS_FILE = 'credentials.json';
5
6
  export const defaultDir = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'neonctl');
6
7
  export const ensureConfigDir = async ({ 'config-dir': configDir, 'force-auth': forceAuth, }) => {
7
8
  if (!existsSync(configDir) && (!isCi() || forceAuth)) {
package/errors.js ADDED
@@ -0,0 +1,13 @@
1
+ const ERROR_MATCHERS = [
2
+ [/^Unknown command: (.*)$/, 'UNKNOWN_COMMAND'],
3
+ [/^Missing required argument: (.*)$/, 'MISSING_ARGUMENT'],
4
+ ];
5
+ export const matchErrorCode = (message) => {
6
+ for (const [matcher, code] of ERROR_MATCHERS) {
7
+ const match = message.match(matcher);
8
+ if (match) {
9
+ return code;
10
+ }
11
+ }
12
+ return 'UNKNOWN_ERROR';
13
+ };
package/index.js CHANGED
@@ -20,9 +20,10 @@ import { defaultClientID } from './auth.js';
20
20
  import { fillInArgs } from './utils/middlewares.js';
21
21
  import pkg from './pkg.js';
22
22
  import commands from './commands/index.js';
23
- import { analyticsMiddleware } from './analytics.js';
23
+ import { analyticsMiddleware, sendError } from './analytics.js';
24
24
  import { isCi } from './env.js';
25
25
  import { isAxiosError } from 'axios';
26
+ import { matchErrorCode } from './errors.js';
26
27
  let builder = yargs(hideBin(process.argv));
27
28
  builder = builder
28
29
  .scriptName(pkg.name)
@@ -88,7 +89,7 @@ builder = builder
88
89
  type: 'boolean',
89
90
  default: !isCi(),
90
91
  })
91
- .middleware(analyticsMiddleware)
92
+ .middleware(analyticsMiddleware, true)
92
93
  .group('version', 'Global options:')
93
94
  .alias('version', 'v')
94
95
  .group('help', 'Global options:')
@@ -99,16 +100,20 @@ builder = builder
99
100
  if (isAxiosError(err)) {
100
101
  if (err.code === 'ECONNABORTED') {
101
102
  log.error('Request timed out');
103
+ sendError(err, 'REQUEST_TIMEOUT');
102
104
  }
103
105
  else if (err.response?.status === 401) {
106
+ sendError(err, 'AUTH_FAILED');
104
107
  log.error('Authentication failed, please run `neonctl auth`');
105
108
  }
106
109
  else {
107
110
  log.debug('Fail: %d | %s', err.response?.status, err.response?.statusText);
108
111
  log.error(err.response?.data?.message);
112
+ sendError(err, 'API_ERROR');
109
113
  }
110
114
  }
111
115
  else {
116
+ sendError(err || new Error(msg), matchErrorCode(msg || err?.message));
112
117
  log.error(msg || err?.message);
113
118
  }
114
119
  err?.stack && log.debug('Stack: %s', err.stack);
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.18.2",
8
+ "version": "1.19.1",
9
9
  "description": "CLI tool for NeonDB Cloud management",
10
10
  "main": "index.js",
11
11
  "author": "NeonDB",
@@ -8,7 +8,7 @@ export const runMockServer = async (mockDir) => new Promise((resolve) => {
8
8
  app.use('/', emocks(join(process.cwd(), 'mocks', mockDir)));
9
9
  const server = app.listen(0);
10
10
  server.on('listening', () => {
11
+ resolve(server);
11
12
  log.info('Mock server listening at %d', server.address().port);
12
13
  });
13
- resolve(server);
14
14
  });
@@ -3,7 +3,7 @@ import { OAuth2Server } from 'oauth2-mock-server';
3
3
  export const startOauthServer = async () => {
4
4
  const server = new OAuth2Server();
5
5
  await server.issuer.keys.generate('RS256');
6
- await server.start(7777, 'localhost');
6
+ await server.start(0, 'localhost');
7
7
  log.info('Started OAuth server');
8
8
  return server;
9
9
  };
@@ -0,0 +1,4 @@
1
+ export const toSnakeCase = (str) => str
2
+ .split(' ')
3
+ .map((word) => word.toLowerCase())
4
+ .join('_');
package/writer.js CHANGED
@@ -2,6 +2,7 @@ import YAML from 'yaml';
2
2
  import Table from 'cli-table';
3
3
  import chalk from 'chalk';
4
4
  import { isCi } from './env.js';
5
+ import { toSnakeCase } from './utils/string.js';
5
6
  /**
6
7
  *
7
8
  * Parses the output format, takes data and writes the output to stdout.
@@ -32,7 +33,7 @@ export const writer = (props) => {
32
33
  out.write(YAML.stringify(chunks.length === 1
33
34
  ? chunks[0].data
34
35
  : Object.fromEntries(chunks.map(({ config, data }, idx) => [
35
- config.title ?? idx,
36
+ config.title ? toSnakeCase(config.title) : idx,
36
37
  data,
37
38
  ])), null, 2));
38
39
  return;
@@ -41,7 +42,7 @@ export const writer = (props) => {
41
42
  out.write(JSON.stringify(chunks.length === 1
42
43
  ? chunks[0].data
43
44
  : Object.fromEntries(chunks.map(({ config, data }, idx) => [
44
- config.title ?? idx,
45
+ config.title ? toSnakeCase(config.title) : idx,
45
46
  data,
46
47
  ])), null, 2));
47
48
  return;