neonctl 2.8.0 → 2.9.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
@@ -66,6 +66,10 @@ export const sendError = (err, errCode) => {
66
66
  return;
67
67
  }
68
68
  const axiosError = isAxiosError(err) ? err : undefined;
69
+ const requestId = axiosError?.response?.headers['x-neon-ret-request-id'];
70
+ if (requestId) {
71
+ log.debug('Failed request ID: %s', requestId);
72
+ }
69
73
  client.track({
70
74
  event: 'CLI Error',
71
75
  userId: userId ?? 'anonymous',
@@ -74,6 +78,7 @@ export const sendError = (err, errCode) => {
74
78
  stack: err.stack,
75
79
  errCode,
76
80
  statusCode: axiosError?.response?.status,
81
+ requestId: requestId,
77
82
  },
78
83
  });
79
84
  log.debug('Sent CLI error event: %s', errCode);
package/commands/auth.js CHANGED
@@ -1,11 +1,12 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
1
2
  import { join } from 'node:path';
2
- import { writeFileSync, existsSync, readFileSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
3
4
  import { TokenSet } from 'openid-client';
4
- import { auth, refreshToken } from '../auth.js';
5
- import { log } from '../log.js';
6
5
  import { getApiClient } from '../api.js';
7
- import { isCi } from '../env.js';
6
+ import { auth, refreshToken } from '../auth.js';
8
7
  import { CREDENTIALS_FILE } from '../config.js';
8
+ import { isCi } from '../env.js';
9
+ import { log } from '../log.js';
9
10
  export const command = 'auth';
10
11
  export const aliases = ['login'];
11
12
  export const describe = 'Authenticate';
@@ -24,11 +25,16 @@ export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceA
24
25
  clientId: clientId,
25
26
  });
26
27
  const credentialsPath = join(configDir, CREDENTIALS_FILE);
27
- await preserveCredentials(credentialsPath, tokenSet, getApiClient({
28
- apiKey: tokenSet.access_token || '',
29
- apiHost,
30
- }));
31
- log.info(`Saved credentials to ${credentialsPath}`);
28
+ try {
29
+ await preserveCredentials(credentialsPath, tokenSet, getApiClient({
30
+ apiKey: tokenSet.access_token || '',
31
+ apiHost,
32
+ }));
33
+ }
34
+ catch {
35
+ log.error('Failed to save credentials');
36
+ return '';
37
+ }
32
38
  log.info('Auth complete');
33
39
  return tokenSet.access_token || '';
34
40
  };
@@ -42,12 +48,65 @@ const preserveCredentials = async (path, credentials, apiClient) => {
42
48
  writeFileSync(path, contents, {
43
49
  mode: 0o700,
44
50
  });
51
+ log.info('Saved credentials to %s', path);
52
+ log.debug('Credentials MD5 hash: %s', md5hash(contents));
53
+ };
54
+ const isCompleteTokenSet = (tokenSet) => {
55
+ return !!(tokenSet.access_token &&
56
+ tokenSet.refresh_token &&
57
+ tokenSet.expires_at);
58
+ };
59
+ const handleExistingToken = async (tokenSet, props, credentialsPath) => {
60
+ // Use existing access_token, if present and valid
61
+ if (!!tokenSet.access_token && !tokenSet.expired()) {
62
+ const apiClient = getApiClient({
63
+ apiKey: tokenSet.access_token,
64
+ apiHost: props.apiHost,
65
+ });
66
+ return { apiKey: tokenSet.access_token, apiClient };
67
+ }
68
+ // Either access_token is missing or its expired. Refresh the token
69
+ log.debug(tokenSet.expired()
70
+ ? 'Token is expired, attempting refresh'
71
+ : 'Token is missing access_token, attempting refresh');
72
+ if (!tokenSet.refresh_token) {
73
+ log.debug('TokenSet is missing refresh_token, starting authentication');
74
+ return null;
75
+ }
76
+ try {
77
+ const refreshedTokenSet = await refreshToken({
78
+ oauthHost: props.oauthHost,
79
+ clientId: props.clientId,
80
+ }, tokenSet);
81
+ if (!isCompleteTokenSet(refreshedTokenSet)) {
82
+ log.debug('Refreshed token is invalid or missing access_token');
83
+ return null;
84
+ }
85
+ const apiKey = refreshedTokenSet.access_token;
86
+ const apiClient = getApiClient({
87
+ apiKey,
88
+ apiHost: props.apiHost,
89
+ });
90
+ await preserveCredentials(credentialsPath, refreshedTokenSet, apiClient);
91
+ log.debug('Token refresh successful');
92
+ return { apiKey, apiClient };
93
+ }
94
+ catch (err) {
95
+ const typedErr = err instanceof Error ? err : new Error('Unknown error');
96
+ log.debug('Failed to refresh token: %s', typedErr.message);
97
+ throw new Error('AUTH_REFRESH_FAILED');
98
+ }
45
99
  };
46
100
  export const ensureAuth = async (props) => {
101
+ // Skip auth for help command or no command
47
102
  if (props._.length === 0 || props.help) {
48
103
  return;
49
104
  }
105
+ // Use existing API key or handle auth command
50
106
  if (props.apiKey || props._[0] === 'auth') {
107
+ if (props.apiKey) {
108
+ log.debug('Using an API key to authorize requests');
109
+ }
51
110
  props.apiClient = getApiClient({
52
111
  apiKey: props.apiKey,
53
112
  apiHost: props.apiHost,
@@ -55,49 +114,42 @@ export const ensureAuth = async (props) => {
55
114
  return;
56
115
  }
57
116
  const credentialsPath = join(props.configDir, CREDENTIALS_FILE);
117
+ // Handle case when credentials file exists
58
118
  if (existsSync(credentialsPath)) {
119
+ log.debug('Trying to read credentials from %s', credentialsPath);
59
120
  try {
60
- const tokenSetContents = await JSON.parse(readFileSync(credentialsPath, 'utf8'));
121
+ const contents = readFileSync(credentialsPath, 'utf8');
122
+ log.debug('Credentials MD5 hash: %s', md5hash(contents));
123
+ const tokenSetContents = JSON.parse(contents);
61
124
  const tokenSet = new TokenSet(tokenSetContents);
62
- if (tokenSet.expired()) {
63
- log.debug('using refresh token to update access token');
64
- const refreshedTokenSet = await refreshToken({
65
- oauthHost: props.oauthHost,
66
- clientId: props.clientId,
67
- }, tokenSet).catch((err) => {
68
- const typedErr = err && err instanceof Error ? err : undefined;
69
- log.error('failed to refresh token\n%s', typedErr?.message);
70
- process.exit(1);
71
- });
72
- props.apiKey = refreshedTokenSet.access_token || 'UNKNOWN';
73
- props.apiClient = getApiClient({
74
- apiKey: props.apiKey,
75
- apiHost: props.apiHost,
76
- });
77
- await preserveCredentials(credentialsPath, refreshedTokenSet, props.apiClient);
125
+ // Try to use existing token or refresh it
126
+ const result = await handleExistingToken(tokenSet, props, credentialsPath);
127
+ if (result) {
128
+ props.apiKey = result.apiKey;
129
+ props.apiClient = result.apiClient;
78
130
  return;
79
131
  }
80
- const token = tokenSet.access_token || 'UNKNOWN';
81
- props.apiKey = token;
82
- props.apiClient = getApiClient({
83
- apiKey: props.apiKey,
84
- apiHost: props.apiHost,
85
- });
86
- return;
87
132
  }
88
- catch (e) {
89
- if (e.code !== 'ENOENT') {
90
- // not a "file does not exist" error
91
- throw e;
133
+ catch (err) {
134
+ if (!(err instanceof Error && err.message === 'AUTH_REFRESH_FAILED') &&
135
+ err.code !== 'ENOENT' &&
136
+ !(err instanceof SyntaxError)) {
137
+ // Throw for any errors except auth refresh failure, missing file, or invalid credentials file
138
+ throw err;
92
139
  }
93
- props.apiKey = await authFlow(props);
140
+ // Fall through to new auth flow for auth failures
141
+ log.debug('Ensure auth failed, starting authentication', err);
94
142
  }
95
143
  }
96
144
  else {
97
- props.apiKey = await authFlow(props);
145
+ log.debug('Credentials file %s does not exist, starting authentication', credentialsPath);
98
146
  }
147
+ // Start new auth flow if no valid token exists or refresh failed
148
+ const apiKey = await authFlow(props);
149
+ props.apiKey = apiKey;
99
150
  props.apiClient = getApiClient({
100
- apiKey: props.apiKey,
151
+ apiKey,
101
152
  apiHost: props.apiHost,
102
153
  });
103
154
  };
155
+ const md5hash = (s) => createHash('md5').update(s).digest('hex');
@@ -1,9 +1,12 @@
1
1
  import axios from 'axios';
2
- import { vi, beforeAll, describe, afterAll, expect } from 'vitest';
3
- import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
4
- import { startOauthServer } from '../test_utils/oauth_server';
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
3
+ import { TokenSet } from 'openid-client';
4
+ import { join } from 'path';
5
+ import { afterAll, beforeAll, beforeEach, describe, expect, vi } from 'vitest';
6
+ import * as authModule from '../auth';
5
7
  import { test } from '../test_utils/fixtures';
6
- import { authFlow } from './auth';
8
+ import { startOauthServer } from '../test_utils/oauth_server';
9
+ import { authFlow, ensureAuth } from './auth';
7
10
  vi.mock('open', () => ({ default: vi.fn((url) => axios.get(url)) }));
8
11
  vi.mock('../pkg.ts', () => ({ default: { version: '0.0.0' } }));
9
12
  describe('auth', () => {
@@ -33,3 +36,130 @@ describe('auth', () => {
33
36
  expect(credentials.user_id).toEqual(expect.any(String));
34
37
  });
35
38
  });
39
+ describe('ensureAuth', () => {
40
+ let configDir = '';
41
+ let oauthServer;
42
+ let mockApiClient;
43
+ let authSpy;
44
+ let refreshTokenSpy;
45
+ beforeAll(async () => {
46
+ configDir = mkdtempSync('test-config');
47
+ oauthServer = await startOauthServer();
48
+ mockApiClient = {};
49
+ authSpy = vi.spyOn(authModule, 'auth');
50
+ refreshTokenSpy = vi.spyOn(authModule, 'refreshToken');
51
+ });
52
+ afterAll(async () => {
53
+ rmSync(configDir, { recursive: true });
54
+ await oauthServer.stop();
55
+ vi.restoreAllMocks();
56
+ });
57
+ beforeEach(() => {
58
+ authSpy.mockClear();
59
+ refreshTokenSpy.mockClear();
60
+ });
61
+ const setupTestProps = (server) => ({
62
+ _: ['some-command'],
63
+ configDir,
64
+ oauthHost: `http://localhost:${oauthServer.address().port}`,
65
+ clientId: 'test-client-id',
66
+ forceAuth: true,
67
+ apiKey: '',
68
+ apiHost: `http://localhost:${server.address().port}`,
69
+ help: false,
70
+ apiClient: mockApiClient,
71
+ });
72
+ test('should start new auth flow when refresh token fails', async ({ runMockServer, }) => {
73
+ refreshTokenSpy.mockImplementationOnce(() => Promise.reject(new Error('AUTH_REFRESH_FAILED')));
74
+ authSpy.mockImplementationOnce(() => Promise.resolve(new TokenSet({
75
+ access_token: 'new-auth-token',
76
+ refresh_token: 'new-refresh-token',
77
+ expires_at: Math.floor(Date.now() / 1000) + 3600,
78
+ })));
79
+ const server = await runMockServer('main');
80
+ const expiredTokenSet = new TokenSet({
81
+ access_token: 'expired-token',
82
+ refresh_token: 'refresh-token',
83
+ expires_at: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
84
+ });
85
+ writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(expiredTokenSet), { mode: 0o700 });
86
+ const props = setupTestProps(server);
87
+ await ensureAuth(props);
88
+ expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
89
+ expect(authSpy).toHaveBeenCalledTimes(1);
90
+ expect(props.apiKey).toBe('new-auth-token');
91
+ });
92
+ test('should trigger auth flow when credentials.json does not exist', async ({ runMockServer, }) => {
93
+ const server = await runMockServer('main');
94
+ // Ensure the credentials file does not exist
95
+ const credentialsPath = join(configDir, 'credentials.json');
96
+ if (existsSync(credentialsPath)) {
97
+ rmSync(credentialsPath);
98
+ }
99
+ const props = setupTestProps(server);
100
+ await ensureAuth(props);
101
+ expect(authSpy).toHaveBeenCalledTimes(1);
102
+ expect(refreshTokenSpy).not.toHaveBeenCalled();
103
+ expect(props.apiKey).toEqual(expect.any(String));
104
+ });
105
+ test('should trigger auth flow when credentials.json is invalid', async ({ runMockServer, }) => {
106
+ const server = await runMockServer('main');
107
+ // Write an empty credentials file
108
+ writeFileSync(join(configDir, 'credentials.json'), '', { mode: 0o700 });
109
+ const props = setupTestProps(server);
110
+ await ensureAuth(props);
111
+ expect(authSpy).toHaveBeenCalledTimes(1);
112
+ expect(refreshTokenSpy).not.toHaveBeenCalled();
113
+ expect(props.apiKey).toEqual(expect.any(String));
114
+ });
115
+ test('should try refresh when token is missing access_token but has refresh_token', async ({ runMockServer, }) => {
116
+ const server = await runMockServer('main');
117
+ const tokenWithoutAccess = new TokenSet({
118
+ refresh_token: 'refresh-token',
119
+ });
120
+ writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(tokenWithoutAccess), { mode: 0o700 });
121
+ refreshTokenSpy.mockImplementationOnce(() => Promise.resolve(new TokenSet({
122
+ access_token: 'refreshed-token',
123
+ refresh_token: 'new-refresh-token',
124
+ expires_at: Math.floor(Date.now() / 1000) + 3600,
125
+ })));
126
+ const props = setupTestProps(server);
127
+ await ensureAuth(props);
128
+ expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
129
+ expect(authSpy).not.toHaveBeenCalled();
130
+ expect(props.apiKey).toBe('refreshed-token');
131
+ });
132
+ test('should use existing valid token', async ({ runMockServer }) => {
133
+ const server = await runMockServer('main');
134
+ const validTokenSet = new TokenSet({
135
+ access_token: 'valid-token',
136
+ refresh_token: 'refresh-token',
137
+ expires_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
138
+ });
139
+ writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(validTokenSet), { mode: 0o700 });
140
+ const props = setupTestProps(server);
141
+ await ensureAuth(props);
142
+ expect(authSpy).not.toHaveBeenCalled();
143
+ expect(refreshTokenSpy).not.toHaveBeenCalled();
144
+ expect(props.apiKey).toBe('valid-token');
145
+ });
146
+ test('should successfully refresh expired token', async ({ runMockServer, }) => {
147
+ refreshTokenSpy.mockImplementationOnce(() => Promise.resolve(new TokenSet({
148
+ access_token: 'new-token',
149
+ refresh_token: 'new-refresh-token',
150
+ expires_at: Math.floor(Date.now() / 1000) + 3600,
151
+ })));
152
+ const server = await runMockServer('main');
153
+ const expiredTokenSet = new TokenSet({
154
+ access_token: 'expired-token',
155
+ refresh_token: 'refresh-token',
156
+ expires_at: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
157
+ });
158
+ writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(expiredTokenSet), { mode: 0o700 });
159
+ const props = setupTestProps(server);
160
+ await ensureAuth(props);
161
+ expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
162
+ expect(authSpy).not.toHaveBeenCalled();
163
+ expect(props.apiKey).toBe('new-token');
164
+ });
165
+ });
package/commands/index.js CHANGED
@@ -10,7 +10,6 @@ import * as roles from './roles.js';
10
10
  import * as operations from './operations.js';
11
11
  import * as cs from './connection_string.js';
12
12
  import * as setContext from './set_context.js';
13
- import * as bootstrap from './bootstrap/index.js';
14
13
  export default [
15
14
  auth,
16
15
  users,
@@ -24,5 +23,4 @@ export default [
24
23
  operations,
25
24
  cs,
26
25
  setContext,
27
- bootstrap,
28
26
  ];
package/index.js CHANGED
@@ -35,8 +35,6 @@ const NO_SUBCOMMANDS_VERBS = [
35
35
  'connection-string',
36
36
  'set-context',
37
37
  // aliases
38
- 'create-app',
39
- 'bootstrap',
40
38
  ];
41
39
  let builder = yargs(hideBin(process.argv));
42
40
  builder = builder
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "url": "git+ssh://git@github.com/neondatabase/neonctl.git"
6
6
  },
7
7
  "type": "module",
8
- "version": "2.8.0",
8
+ "version": "2.9.1",
9
9
  "description": "CLI tool for NeonDB Cloud management",
10
10
  "main": "index.js",
11
11
  "author": "NeonDB",
@@ -51,7 +51,7 @@
51
51
  "strip-ansi": "^7.1.0",
52
52
  "typescript": "^4.7.4",
53
53
  "typescript-eslint": "v8.0.0-alpha.41",
54
- "vitest": "^1.6.0"
54
+ "vitest": "^1.6.1"
55
55
  },
56
56
  "dependencies": {
57
57
  "@neondatabase/api-client": "1.12.0",
@@ -62,7 +62,6 @@
62
62
  "cli-table": "^0.3.11",
63
63
  "crypto-random-string": "^5.0.0",
64
64
  "diff": "^5.2.0",
65
- "inquirer": "^9.2.6",
66
65
  "open": "^10.1.0",
67
66
  "openid-client": "^5.6.5",
68
67
  "prompts": "2.4.2",
@@ -1,6 +0,0 @@
1
- // See https://github.com/nextauthjs/cli/blob/8443988fe7e7f078ead32288dcd1b01b9443f13a/commands/secret.js#L9
2
- // for reference.
3
- export function getAuthjsSecret() {
4
- const bytes = crypto.getRandomValues(new Uint8Array(32));
5
- return Buffer.from(bytes.toString(), 'base64').toString('base64');
6
- }