neonctl 2.15.0 → 2.16.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/auth.js CHANGED
@@ -1,4 +1,4 @@
1
- import { custom, generators, Issuer } from 'openid-client';
1
+ import * as client from 'openid-client';
2
2
  import { createServer } from 'node:http';
3
3
  import { createReadStream } from 'node:fs';
4
4
  import { join } from 'node:path';
@@ -7,6 +7,7 @@ import { log } from './log.js';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { sendError } from './analytics.js';
9
9
  import { matchErrorCode } from './errors.js';
10
+ import { extendTokenSet } from './utils/auth.js';
10
11
  // oauth server timeouts
11
12
  const SERVER_TIMEOUT = 10000;
12
13
  // where to wait for incoming redirect request from oauth server to arrive
@@ -27,22 +28,22 @@ const NEONCTL_SCOPES = [
27
28
  ];
28
29
  const AUTH_TIMEOUT_SECONDS = 60;
29
30
  export const defaultClientID = 'neonctl';
30
- custom.setHttpOptionsDefaults({
31
- timeout: SERVER_TIMEOUT,
32
- });
33
- export const refreshToken = async ({ oauthHost, clientId }, tokenSet) => {
31
+ export const refreshToken = async ({ oauthHost, clientId, allowUnsafeTls }, tokenSet) => {
34
32
  log.debug('Discovering oauth server');
35
- const issuer = await Issuer.discover(oauthHost);
36
- const neonOAuthClient = new issuer.Client({
37
- token_endpoint_auth_method: 'none',
38
- client_id: clientId,
39
- response_types: ['code'],
33
+ const configuration = await client.discovery(new URL(oauthHost), clientId, { token_endpoint_auth_method: 'none' }, client.None(), {
34
+ timeout: SERVER_TIMEOUT,
35
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
36
+ execute: allowUnsafeTls ? [client.allowInsecureRequests] : undefined,
40
37
  });
41
- return await neonOAuthClient.refresh(tokenSet);
38
+ return await client.refreshTokenGrant(configuration, tokenSet.refresh_token);
42
39
  };
43
- export const auth = async ({ oauthHost, clientId }) => {
40
+ export const auth = async ({ oauthHost, clientId, allowUnsafeTls, }) => {
44
41
  log.debug('Discovering oauth server');
45
- const issuer = await Issuer.discover(oauthHost);
42
+ const configuration = await client.discovery(new URL(oauthHost), clientId, { token_endpoint_auth_method: 'none' }, client.None(), {
43
+ timeout: SERVER_TIMEOUT,
44
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
45
+ execute: allowUnsafeTls ? [client.allowInsecureRequests] : undefined,
46
+ });
46
47
  //
47
48
  // Start HTTP server and wait till /callback is hit
48
49
  //
@@ -53,17 +54,11 @@ export const auth = async ({ oauthHost, clientId }) => {
53
54
  });
54
55
  await new Promise((resolve) => server.once('listening', resolve));
55
56
  const listen_port = server.address().port;
56
- const neonOAuthClient = new issuer.Client({
57
- token_endpoint_auth_method: 'none',
58
- client_id: clientId,
59
- redirect_uris: [REDIRECT_URI(listen_port)],
60
- response_types: ['code'],
61
- });
62
57
  // https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.1.8
63
- const state = generators.state();
58
+ const state = client.randomState();
64
59
  // we store the code_verifier in memory
65
- const codeVerifier = generators.codeVerifier();
66
- const codeChallenge = generators.codeChallenge(codeVerifier);
60
+ const codeVerifier = client.randomPKCECodeVerifier();
61
+ const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
67
62
  return new Promise((resolve, reject) => {
68
63
  const timer = setTimeout(() => {
69
64
  reject(new Error(`Authentication timed out after ${AUTH_TIMEOUT_SECONDS} seconds`));
@@ -88,15 +83,16 @@ export const auth = async ({ oauthHost, clientId }) => {
88
83
  return;
89
84
  }
90
85
  log.debug(`Callback received: ${request.url}`);
91
- const params = neonOAuthClient.callbackParams(request);
92
- const tokenSet = await neonOAuthClient.callback(REDIRECT_URI(listen_port), params, {
93
- code_verifier: codeVerifier,
94
- state,
86
+ const tokenSet = await client.authorizationCodeGrant(configuration, new URL(request.url, `http://127.0.0.1:${listen_port}`), {
87
+ pkceCodeVerifier: codeVerifier,
88
+ expectedState: state,
95
89
  });
96
90
  response.writeHead(200, { 'Content-Type': 'text/html' });
97
91
  createReadStream(join(fileURLToPath(new URL('.', import.meta.url)), './callback.html')).pipe(response);
98
92
  clearTimeout(timer);
99
- resolve(tokenSet);
93
+ const exp = new Date();
94
+ exp.setSeconds(exp.getSeconds() + (tokenSet.expires_in ?? 0));
95
+ resolve(extendTokenSet(tokenSet));
100
96
  server.close();
101
97
  };
102
98
  server.on('request', (req, res) => {
@@ -106,15 +102,16 @@ export const auth = async ({ oauthHost, clientId }) => {
106
102
  // Open browser to let user authenticate
107
103
  //
108
104
  const scopes = clientId == defaultClientID ? NEONCTL_SCOPES : ALWAYS_PRESENT_SCOPES;
109
- const authUrl = neonOAuthClient.authorizationUrl({
105
+ const authUrl = client.buildAuthorizationUrl(configuration, {
110
106
  scope: scopes.join(' '),
111
107
  state,
112
108
  code_challenge: codeChallenge,
113
109
  code_challenge_method: 'S256',
110
+ redirect_uri: REDIRECT_URI(listen_port),
114
111
  });
115
112
  log.info('Awaiting authentication in web browser.');
116
113
  log.info(`Auth Url: ${authUrl}`);
117
- open(authUrl).catch((err) => {
114
+ open(authUrl.href).catch((err) => {
118
115
  const msg = `Failed to open web browser. Please copy & paste auth url to authenticate in browser.`;
119
116
  const typedErr = err && err instanceof Error ? err : undefined;
120
117
  sendError(typedErr || new Error(msg), matchErrorCode(msg));
package/commands/auth.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
- import { TokenSet } from 'openid-client';
5
4
  import { getApiClient } from '../api.js';
6
5
  import { auth, refreshToken } from '../auth.js';
7
6
  import { CREDENTIALS_FILE } from '../config.js';
8
7
  import { isCi } from '../env.js';
9
8
  import { log } from '../log.js';
9
+ import { extendTokenSet } from '../utils/auth.js';
10
10
  export const command = 'auth';
11
11
  export const aliases = ['login'];
12
12
  export const describe = 'Authenticate';
@@ -16,13 +16,14 @@ export const builder = (yargs) => yargs.option('context-file', {
16
16
  export const handler = async (args) => {
17
17
  await authFlow(args);
18
18
  };
19
- export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceAuth, }) => {
19
+ export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceAuth, allowUnsafeTls, }) => {
20
20
  if (!forceAuth && isCi()) {
21
21
  throw new Error('Cannot run interactive auth in CI');
22
22
  }
23
23
  const tokenSet = await auth({
24
24
  oauthHost: oauthHost,
25
25
  clientId: clientId,
26
+ allowUnsafeTls,
26
27
  });
27
28
  const credentialsPath = join(configDir, CREDENTIALS_FILE);
28
29
  try {
@@ -49,17 +50,13 @@ const preserveCredentials = async (path, credentials, apiClient) => {
49
50
  writeFileSync(path, contents, {
50
51
  mode: 0o700,
51
52
  });
52
- log.info('Saved credentials to %s', path);
53
+ log.debug('Saved credentials to %s', path);
53
54
  log.debug('Credentials MD5 hash: %s', md5hash(contents));
54
55
  };
55
- const isCompleteTokenSet = (tokenSet) => {
56
- return !!(tokenSet.access_token &&
57
- tokenSet.refresh_token &&
58
- tokenSet.expires_at);
59
- };
60
56
  const handleExistingToken = async (tokenSet, props, credentialsPath) => {
61
57
  // Use existing access_token, if present and valid
62
- if (!!tokenSet.access_token && !tokenSet.expired()) {
58
+ if (tokenSet.access_token && tokenSet.expires_at > Date.now()) {
59
+ log.debug('Using existing valid access_token');
63
60
  const apiClient = getApiClient({
64
61
  apiKey: tokenSet.access_token,
65
62
  apiHost: props.apiHost,
@@ -67,7 +64,7 @@ const handleExistingToken = async (tokenSet, props, credentialsPath) => {
67
64
  return { apiKey: tokenSet.access_token, apiClient };
68
65
  }
69
66
  // Either access_token is missing or its expired. Refresh the token
70
- log.debug(tokenSet.expired()
67
+ log.debug(tokenSet.expires_at < Date.now()
71
68
  ? 'Token is expired, attempting refresh'
72
69
  : 'Token is missing access_token, attempting refresh');
73
70
  if (!tokenSet.refresh_token) {
@@ -78,17 +75,16 @@ const handleExistingToken = async (tokenSet, props, credentialsPath) => {
78
75
  const refreshedTokenSet = await refreshToken({
79
76
  oauthHost: props.oauthHost,
80
77
  clientId: props.clientId,
78
+ allowUnsafeTls: props.allowUnsafeTls,
81
79
  }, tokenSet);
82
- if (!isCompleteTokenSet(refreshedTokenSet)) {
83
- log.debug('Refreshed token is invalid or missing access_token');
84
- return null;
85
- }
86
- const apiKey = refreshedTokenSet.access_token;
80
+ // Extend the token set with expires_at
81
+ const extendedTokenSet = extendTokenSet(refreshedTokenSet);
82
+ const apiKey = extendedTokenSet.access_token;
87
83
  const apiClient = getApiClient({
88
84
  apiKey,
89
85
  apiHost: props.apiHost,
90
86
  });
91
- await preserveCredentials(credentialsPath, refreshedTokenSet, apiClient);
87
+ await preserveCredentials(credentialsPath, extendedTokenSet, apiClient);
92
88
  log.debug('Token refresh successful');
93
89
  return { apiKey, apiClient };
94
90
  }
@@ -121,8 +117,7 @@ export const ensureAuth = async (props) => {
121
117
  try {
122
118
  const contents = readFileSync(credentialsPath, 'utf8');
123
119
  log.debug('Credentials MD5 hash: %s', md5hash(contents));
124
- const tokenSetContents = JSON.parse(contents);
125
- const tokenSet = new TokenSet(tokenSetContents);
120
+ const tokenSet = JSON.parse(contents);
126
121
  // Try to use existing token or refresh it
127
122
  const result = await handleExistingToken(tokenSet, props, credentialsPath);
128
123
  if (result) {
@@ -1,6 +1,5 @@
1
1
  import axios from 'axios';
2
2
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
3
- import { TokenSet } from 'openid-client';
4
3
  import { join } from 'path';
5
4
  import { afterAll, beforeAll, beforeEach, describe, expect, vi } from 'vitest';
6
5
  import * as authModule from '../auth';
@@ -29,6 +28,7 @@ describe('auth', () => {
29
28
  configDir,
30
29
  forceAuth: true,
31
30
  oauthHost: `http://localhost:${oauthServer.address().port}`,
31
+ allowUnsafeTls: true,
32
32
  });
33
33
  const credentials = JSON.parse(readFileSync(`${configDir}/credentials.json`, 'utf-8'));
34
34
  expect(credentials.access_token).toEqual(expect.any(String));
@@ -68,20 +68,21 @@ describe('ensureAuth', () => {
68
68
  apiHost: `http://localhost:${server.address().port}`,
69
69
  help: false,
70
70
  apiClient: mockApiClient,
71
+ allowUnsafeTls: true,
71
72
  });
72
73
  test('should start new auth flow when refresh token fails', async ({ runMockServer, }) => {
73
74
  refreshTokenSpy.mockImplementationOnce(() => Promise.reject(new Error('AUTH_REFRESH_FAILED')));
74
- authSpy.mockImplementationOnce(() => Promise.resolve(new TokenSet({
75
+ authSpy.mockImplementationOnce(() => Promise.resolve({
75
76
  access_token: 'new-auth-token',
76
77
  refresh_token: 'new-refresh-token',
77
78
  expires_at: Math.floor(Date.now() / 1000) + 3600,
78
- })));
79
+ }));
79
80
  const server = await runMockServer('main');
80
- const expiredTokenSet = new TokenSet({
81
+ const expiredTokenSet = {
81
82
  access_token: 'expired-token',
82
83
  refresh_token: 'refresh-token',
83
- expires_at: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
84
- });
84
+ expires_at: Date.now() - 3600 * 1000,
85
+ };
85
86
  writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(expiredTokenSet), { mode: 0o700 });
86
87
  const props = setupTestProps(server);
87
88
  await ensureAuth(props);
@@ -114,15 +115,15 @@ describe('ensureAuth', () => {
114
115
  });
115
116
  test('should try refresh when token is missing access_token but has refresh_token', async ({ runMockServer, }) => {
116
117
  const server = await runMockServer('main');
117
- const tokenWithoutAccess = new TokenSet({
118
+ const tokenWithoutAccess = {
118
119
  refresh_token: 'refresh-token',
119
- });
120
+ };
120
121
  writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(tokenWithoutAccess), { mode: 0o700 });
121
- refreshTokenSpy.mockImplementationOnce(() => Promise.resolve(new TokenSet({
122
+ refreshTokenSpy.mockImplementationOnce(() => Promise.resolve({
122
123
  access_token: 'refreshed-token',
123
124
  refresh_token: 'new-refresh-token',
124
125
  expires_at: Math.floor(Date.now() / 1000) + 3600,
125
- })));
126
+ }));
126
127
  const props = setupTestProps(server);
127
128
  await ensureAuth(props);
128
129
  expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
@@ -131,11 +132,11 @@ describe('ensureAuth', () => {
131
132
  });
132
133
  test('should use existing valid token', async ({ runMockServer }) => {
133
134
  const server = await runMockServer('main');
134
- const validTokenSet = new TokenSet({
135
+ const validTokenSet = {
135
136
  access_token: 'valid-token',
136
137
  refresh_token: 'refresh-token',
137
- expires_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
138
- });
138
+ expires_at: Date.now() + 3600 * 1000, // 1 hour from now
139
+ };
139
140
  writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(validTokenSet), { mode: 0o700 });
140
141
  const props = setupTestProps(server);
141
142
  await ensureAuth(props);
@@ -144,17 +145,17 @@ describe('ensureAuth', () => {
144
145
  expect(props.apiKey).toBe('valid-token');
145
146
  });
146
147
  test('should successfully refresh expired token', async ({ runMockServer, }) => {
147
- refreshTokenSpy.mockImplementationOnce(() => Promise.resolve(new TokenSet({
148
+ refreshTokenSpy.mockImplementationOnce(() => Promise.resolve({
148
149
  access_token: 'new-token',
149
150
  refresh_token: 'new-refresh-token',
150
151
  expires_at: Math.floor(Date.now() / 1000) + 3600,
151
- })));
152
+ }));
152
153
  const server = await runMockServer('main');
153
- const expiredTokenSet = new TokenSet({
154
+ const expiredTokenSet = {
154
155
  access_token: 'expired-token',
155
156
  refresh_token: 'refresh-token',
156
- expires_at: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
157
- });
157
+ expires_at: Date.now() - 3600 * 1000, // expired 1 hour ago
158
+ };
158
159
  writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(expiredTokenSet), { mode: 0o700 });
159
160
  const props = setupTestProps(server);
160
161
  await ensureAuth(props);
package/commands/index.js CHANGED
@@ -10,6 +10,7 @@ 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 init from './init.js';
13
14
  export default [
14
15
  auth,
15
16
  users,
@@ -23,4 +24,5 @@ export default [
23
24
  operations,
24
25
  cs,
25
26
  setContext,
27
+ init,
26
28
  ];
@@ -0,0 +1,44 @@
1
+ import { execa } from 'execa';
2
+ import { log } from '../log.js';
3
+ import { sendError } from '../analytics.js';
4
+ export const command = 'init';
5
+ export const describe = 'Initialize a new Neon project using your AI coding assistant';
6
+ export const builder = (yargs) => yargs
7
+ .option('context-file', {
8
+ hidden: true,
9
+ })
10
+ .strict(false);
11
+ export const handler = async (args) => {
12
+ const passThruArgs = args['--'] || [];
13
+ await runNeonInit(passThruArgs);
14
+ };
15
+ const runNeonInit = async (args) => {
16
+ try {
17
+ await execa('npx', ['neon-init', ...args], {
18
+ stdio: 'inherit',
19
+ });
20
+ }
21
+ catch (error) {
22
+ // Check if it's an ENOENT error (command not found)
23
+ if (error?.code === 'ENOENT') {
24
+ log.error('npx is not available in the PATH');
25
+ sendError(error, 'NPX_NOT_FOUND');
26
+ process.exit(1);
27
+ }
28
+ // Check if the process was killed by a signal (user cancelled)
29
+ else if (error?.signal) {
30
+ process.exit(1);
31
+ }
32
+ // Handle all other errors
33
+ else {
34
+ const exitError = new Error(`failed to run neon-init`);
35
+ sendError(exitError, 'NEON_INIT_FAILED');
36
+ if (typeof error?.exitCode === 'number') {
37
+ process.exit(error.exitCode);
38
+ }
39
+ else {
40
+ process.exit(1);
41
+ }
42
+ }
43
+ }
44
+ };
@@ -0,0 +1,10 @@
1
+ import { describe } from 'vitest';
2
+ import { test } from '../test_utils/fixtures';
3
+ describe('init', () => {
4
+ test('init should run neon-init', async ({ testCliCommand }) => {
5
+ await testCliCommand(['init']);
6
+ });
7
+ test('init with an argument', async ({ testCliCommand }) => {
8
+ await testCliCommand(['init', '--', '--debug']);
9
+ });
10
+ });
package/index.js CHANGED
@@ -34,6 +34,7 @@ const NO_SUBCOMMANDS_VERBS = [
34
34
  'cs',
35
35
  'connection-string',
36
36
  'set-context',
37
+ 'init',
37
38
  // aliases
38
39
  ];
39
40
  let builder = yargs(hideBin(process.argv));
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.15.0",
8
+ "version": "2.16.0",
9
9
  "description": "CLI tool for NeonDB Cloud management",
10
10
  "main": "index.js",
11
11
  "author": "NeonDB",
@@ -43,7 +43,7 @@
43
43
  "express": "^4.18.2",
44
44
  "husky": "^8.0.3",
45
45
  "lint-staged": "^13.0.3",
46
- "oauth2-mock-server": "^6.0.0",
46
+ "oauth2-mock-server": "^8.1.0",
47
47
  "pkg": "^5.8.1",
48
48
  "prettier": "^3.1.0",
49
49
  "rollup": "^3.26.2",
@@ -62,8 +62,9 @@
62
62
  "cli-table": "^0.3.11",
63
63
  "crypto-random-string": "^5.0.0",
64
64
  "diff": "^5.2.0",
65
+ "execa": "^9.6.0",
65
66
  "open": "^10.1.0",
66
- "openid-client": "^5.6.5",
67
+ "openid-client": "^6.8.1",
67
68
  "prompts": "2.4.2",
68
69
  "validate-npm-package-name": "5.0.1",
69
70
  "which": "^3.0.1",
@@ -4,6 +4,6 @@ export const startOauthServer = async () => {
4
4
  const server = new OAuth2Server();
5
5
  await server.issuer.keys.generate('RS256');
6
6
  await server.start(0, 'localhost');
7
- log.debug('Started OAuth server');
7
+ log.debug('Started OAuth server on port %d', server.address().port);
8
8
  return server;
9
9
  };
package/utils/auth.js ADDED
@@ -0,0 +1,5 @@
1
+ export const extendTokenSet = (tokenSet) => {
2
+ const exp = new Date();
3
+ exp.setSeconds(exp.getSeconds() + (tokenSet.expires_in ?? 0));
4
+ return { ...tokenSet, expires_at: exp.getTime() };
5
+ };
package/utils/formats.js CHANGED
@@ -1,5 +1,18 @@
1
- const HAIKU_REGEX = /^[a-z]+-[a-z]+-[a-z0-9]+$/;
1
+ const HAIKU_REGEX = /^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$/;
2
2
  export const looksLikeBranchId = (branch) => branch.startsWith('br-') && HAIKU_REGEX.test(branch.substring(3));
3
3
  const LSN_REGEX = /^[a-fA-F0-9]{1,8}\/[a-fA-F0-9]{1,8}$/;
4
4
  export const looksLikeLSN = (lsn) => LSN_REGEX.test(lsn);
5
- export const looksLikeTimestamp = (timestamp) => !isNaN(Date.parse(timestamp));
5
+ export const looksLikeTimestamp = (timestamp) => {
6
+ if (isNaN(Date.parse(timestamp)))
7
+ return false;
8
+ /**
9
+ * @info
10
+ * Check for ISO 8601/RFC 3339 format patterns
11
+ * Must contain 'T' separator and end with 'Z' or timezone offset
12
+ *
13
+ * `Date.parse` aggressive parsing will attempt a date out of any string.
14
+ * so if a branch name has a number, `Date.parse` will return a valid timestamp.
15
+ */
16
+ const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?([+-]\d{2}:\d{2}|Z)$/;
17
+ return iso8601Regex.test(timestamp);
18
+ };