neonctl 2.8.0 → 2.9.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/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';
@@ -28,7 +29,6 @@ export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceA
28
29
  apiKey: tokenSet.access_token || '',
29
30
  apiHost,
30
31
  }));
31
- log.info(`Saved credentials to ${credentialsPath}`);
32
32
  log.info('Auth complete');
33
33
  return tokenSet.access_token || '';
34
34
  };
@@ -42,12 +42,65 @@ const preserveCredentials = async (path, credentials, apiClient) => {
42
42
  writeFileSync(path, contents, {
43
43
  mode: 0o700,
44
44
  });
45
+ log.info('Saved credentials to %s', path);
46
+ log.debug('Credentials MD5 hash: %s', md5hash(contents));
47
+ };
48
+ const isCompleteTokenSet = (tokenSet) => {
49
+ return !!(tokenSet.access_token &&
50
+ tokenSet.refresh_token &&
51
+ tokenSet.expires_at);
52
+ };
53
+ const handleExistingToken = async (tokenSet, props, credentialsPath) => {
54
+ // Use existing access_token, if present and valid
55
+ if (!!tokenSet.access_token && !tokenSet.expired()) {
56
+ const apiClient = getApiClient({
57
+ apiKey: tokenSet.access_token,
58
+ apiHost: props.apiHost,
59
+ });
60
+ return { apiKey: tokenSet.access_token, apiClient };
61
+ }
62
+ // Either access_token is missing or its expired. Refresh the token
63
+ log.debug(tokenSet.expired()
64
+ ? 'Token is expired, attempting refresh'
65
+ : 'Token is missing access_token, attempting refresh');
66
+ if (!tokenSet.refresh_token) {
67
+ log.debug('TokenSet is missing refresh_token, starting authentication');
68
+ return null;
69
+ }
70
+ try {
71
+ const refreshedTokenSet = await refreshToken({
72
+ oauthHost: props.oauthHost,
73
+ clientId: props.clientId,
74
+ }, tokenSet);
75
+ if (!isCompleteTokenSet(refreshedTokenSet)) {
76
+ log.debug('Refreshed token is invalid or missing access_token');
77
+ return null;
78
+ }
79
+ const apiKey = refreshedTokenSet.access_token;
80
+ const apiClient = getApiClient({
81
+ apiKey,
82
+ apiHost: props.apiHost,
83
+ });
84
+ await preserveCredentials(credentialsPath, refreshedTokenSet, apiClient);
85
+ log.debug('Token refresh successful');
86
+ return { apiKey, apiClient };
87
+ }
88
+ catch (err) {
89
+ const typedErr = err instanceof Error ? err : new Error('Unknown error');
90
+ log.debug('Failed to refresh token: %s', typedErr.message);
91
+ throw new Error('AUTH_REFRESH_FAILED');
92
+ }
45
93
  };
46
94
  export const ensureAuth = async (props) => {
95
+ // Skip auth for help command or no command
47
96
  if (props._.length === 0 || props.help) {
48
97
  return;
49
98
  }
99
+ // Use existing API key or handle auth command
50
100
  if (props.apiKey || props._[0] === 'auth') {
101
+ if (props.apiKey) {
102
+ log.debug('Using an API key to authorize requests');
103
+ }
51
104
  props.apiClient = getApiClient({
52
105
  apiKey: props.apiKey,
53
106
  apiHost: props.apiHost,
@@ -55,49 +108,42 @@ export const ensureAuth = async (props) => {
55
108
  return;
56
109
  }
57
110
  const credentialsPath = join(props.configDir, CREDENTIALS_FILE);
111
+ // Handle case when credentials file exists
58
112
  if (existsSync(credentialsPath)) {
113
+ log.debug('Trying to read credentials from %s', credentialsPath);
59
114
  try {
60
- const tokenSetContents = await JSON.parse(readFileSync(credentialsPath, 'utf8'));
115
+ const contents = readFileSync(credentialsPath, 'utf8');
116
+ log.debug('Credentials MD5 hash: %s', md5hash(contents));
117
+ const tokenSetContents = JSON.parse(contents);
61
118
  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);
119
+ // Try to use existing token or refresh it
120
+ const result = await handleExistingToken(tokenSet, props, credentialsPath);
121
+ if (result) {
122
+ props.apiKey = result.apiKey;
123
+ props.apiClient = result.apiClient;
78
124
  return;
79
125
  }
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
126
  }
88
- catch (e) {
89
- if (e.code !== 'ENOENT') {
90
- // not a "file does not exist" error
91
- throw e;
127
+ catch (err) {
128
+ if (!(err instanceof Error && err.message === 'AUTH_REFRESH_FAILED') &&
129
+ err.code !== 'ENOENT' &&
130
+ !(err instanceof SyntaxError)) {
131
+ // Throw for any errors except auth refresh failure, missing file, or invalid credentials file
132
+ throw err;
92
133
  }
93
- props.apiKey = await authFlow(props);
134
+ // Fall through to new auth flow for auth failures
135
+ log.debug('Ensure auth failed, starting authentication', err);
94
136
  }
95
137
  }
96
138
  else {
97
- props.apiKey = await authFlow(props);
139
+ log.debug('Credentials file %s does not exist, starting authentication', credentialsPath);
98
140
  }
141
+ // Start new auth flow if no valid token exists or refresh failed
142
+ const apiKey = await authFlow(props);
143
+ props.apiKey = apiKey;
99
144
  props.apiClient = getApiClient({
100
- apiKey: props.apiKey,
145
+ apiKey,
101
146
  apiHost: props.apiHost,
102
147
  });
103
148
  };
149
+ 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/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.0",
9
9
  "description": "CLI tool for NeonDB Cloud management",
10
10
  "main": "index.js",
11
11
  "author": "NeonDB",