neonctl 1.29.1 → 1.29.4

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.
Files changed (184) hide show
  1. package/analytics.js +78 -0
  2. package/api.js +35 -0
  3. package/auth.js +101 -0
  4. package/{src/cli.ts → cli.js} +0 -1
  5. package/commands/auth.js +102 -0
  6. package/commands/auth.test.js +42 -0
  7. package/commands/branches.js +303 -0
  8. package/commands/branches.test.js +321 -0
  9. package/commands/connection_string.js +158 -0
  10. package/commands/connection_string.test.js +253 -0
  11. package/commands/databases.js +79 -0
  12. package/commands/databases.test.js +51 -0
  13. package/commands/help.test.js +11 -0
  14. package/{src/commands/index.ts → commands/index.js} +10 -11
  15. package/commands/ip_allow.js +135 -0
  16. package/commands/ip_allow.test.js +78 -0
  17. package/commands/operations.js +28 -0
  18. package/commands/operations.test.js +11 -0
  19. package/commands/projects.js +186 -0
  20. package/commands/projects.test.js +132 -0
  21. package/commands/roles.js +57 -0
  22. package/commands/roles.test.js +42 -0
  23. package/commands/set_context.js +22 -0
  24. package/commands/set_context.test.js +53 -0
  25. package/commands/user.js +15 -0
  26. package/config.js +11 -0
  27. package/context.js +48 -0
  28. package/env.js +6 -0
  29. package/errors.js +16 -0
  30. package/help.js +146 -0
  31. package/index.js +168 -0
  32. package/log.js +15 -0
  33. package/package.json +1 -1
  34. package/parameters.gen.js +322 -0
  35. package/pkg.js +3 -45
  36. package/test_utils/mock_server.js +16 -0
  37. package/test_utils/oauth_server.js +9 -0
  38. package/test_utils/test_cli_command.js +80 -0
  39. package/types.js +1 -0
  40. package/utils/enrichers.js +49 -0
  41. package/utils/formats.js +5 -0
  42. package/utils/formats.test.js +32 -0
  43. package/utils/middlewares.js +20 -0
  44. package/utils/point_in_time.js +50 -0
  45. package/utils/psql.js +24 -0
  46. package/utils/string.js +5 -0
  47. package/utils/ui.js +59 -0
  48. package/writer.js +87 -0
  49. package/writer.test.js +86 -0
  50. package/.bump +0 -1
  51. package/.editorconfig +0 -7
  52. package/.eslintrc.cjs +0 -15
  53. package/.github/workflows/commitlint.yml +0 -46
  54. package/.github/workflows/pr.yml +0 -25
  55. package/.github/workflows/release.yml +0 -30
  56. package/.husky/commit-msg +0 -4
  57. package/.husky/pre-commit +0 -4
  58. package/.nvmrc +0 -1
  59. package/.prettierignore +0 -3
  60. package/.prettierrc.json +0 -3
  61. package/.releaserc.json +0 -47
  62. package/LICENSE +0 -202
  63. package/commitlint.config.cjs +0 -7
  64. package/generateOptionsFromSpec.ts +0 -68
  65. package/jest/setup.js +0 -5
  66. package/jest.config.ts +0 -199
  67. package/mocks/bin/psql.cjs +0 -9
  68. package/mocks/main/projects/GET.js +0 -27
  69. package/mocks/main/projects/POST.js +0 -22
  70. package/mocks/main/projects/shared/GET.js +0 -16
  71. package/mocks/main/projects/test/DELETE.json +0 -7
  72. package/mocks/main/projects/test/GET.json +0 -13
  73. package/mocks/main/projects/test/PATCH.js +0 -18
  74. package/mocks/main/projects/test/branches/GET.json +0 -25
  75. package/mocks/main/projects/test/branches/POST.js +0 -83
  76. package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/DELETE.json +0 -7
  77. package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/GET.json +0 -9
  78. package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/PATCH.js +0 -14
  79. package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/databases/GET.json +0 -6
  80. package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/databases/POST.js +0 -13
  81. package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/databases/test_db/DELETE.json +0 -6
  82. package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/endpoints/GET.json +0 -26
  83. package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/endpoints/POST.json +0 -6
  84. package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/roles/GET.json +0 -3
  85. package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/roles/POST.js +0 -14
  86. package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/roles/test_role/DELETE.json +0 -6
  87. package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/roles/test_role/reveal_password/GET.json +0 -3
  88. package/mocks/main/projects/test/branches/br-cloudy-branch-12345678/set_as_primary/POST.json +0 -9
  89. package/mocks/main/projects/test/branches/br-numbered-branch-123456/GET.json +0 -10
  90. package/mocks/main/projects/test/branches/br-sunny-branch-123456/DELETE.json +0 -7
  91. package/mocks/main/projects/test/branches/br-sunny-branch-123456/GET.json +0 -10
  92. package/mocks/main/projects/test/branches/br-sunny-branch-123456/PATCH.js +0 -14
  93. package/mocks/main/projects/test/branches/br-sunny-branch-123456/databases/GET.json +0 -6
  94. package/mocks/main/projects/test/branches/br-sunny-branch-123456/databases/POST.js +0 -13
  95. package/mocks/main/projects/test/branches/br-sunny-branch-123456/databases/test_db/DELETE.json +0 -6
  96. package/mocks/main/projects/test/branches/br-sunny-branch-123456/endpoints/GET.json +0 -26
  97. package/mocks/main/projects/test/branches/br-sunny-branch-123456/endpoints/POST.json +0 -6
  98. package/mocks/main/projects/test/branches/br-sunny-branch-123456/restore/POST.js +0 -16
  99. package/mocks/main/projects/test/branches/br-sunny-branch-123456/roles/GET.json +0 -3
  100. package/mocks/main/projects/test/branches/br-sunny-branch-123456/roles/POST.js +0 -14
  101. package/mocks/main/projects/test/branches/br-sunny-branch-123456/roles/test_role/DELETE.json +0 -6
  102. package/mocks/main/projects/test/branches/br-sunny-branch-123456/roles/test_role/reveal_password/GET.json +0 -3
  103. package/mocks/main/projects/test/branches/br-sunny-branch-123456/set_as_primary/POST.json +0 -9
  104. package/mocks/main/projects/test/endpoints/GET.json +0 -9
  105. package/mocks/main/projects/test/endpoints/POST.js +0 -32
  106. package/mocks/main/projects/test/endpoints/test_endpoint_id/DELETE.json +0 -7
  107. package/mocks/main/projects/test/endpoints/test_endpoint_id/GET.json +0 -9
  108. package/mocks/main/projects/test/endpoints/test_endpoint_id/PATCH.js +0 -17
  109. package/mocks/main/projects/test/operations/GET.json +0 -22
  110. package/mocks/main/users/me/GET.json +0 -5
  111. package/mocks/restore/projects/test/branches/GET.json +0 -21
  112. package/mocks/restore/projects/test/branches/br-another-branch-123456/GET.json +0 -6
  113. package/mocks/restore/projects/test/branches/br-another-branch-123456/restore/POST.js +0 -13
  114. package/mocks/restore/projects/test/branches/br-any-branch-123456/GET.json +0 -6
  115. package/mocks/restore/projects/test/branches/br-parent-tots-123456/GET.json +0 -7
  116. package/mocks/restore/projects/test/branches/br-parent-tots-123456/restore/POST.js +0 -14
  117. package/mocks/restore/projects/test/branches/br-self-tolsn-123456/GET.json +0 -6
  118. package/mocks/restore/projects/test/branches/br-self-tolsn-123456/restore/POST.js +0 -15
  119. package/mocks/single_project/projects/GET.json +0 -10
  120. package/mocks/single_project/projects/test-project-123456/GET.json +0 -14
  121. package/mocks/single_project/projects/test-project-123456/branches/GET.json +0 -11
  122. package/mocks/single_project/projects/test-project-123456/branches/br-main-branch-123456/databases/GET.json +0 -3
  123. package/mocks/single_project/projects/test-project-123456/branches/br-main-branch-123456/endpoints/GET.json +0 -10
  124. package/mocks/single_project/projects/test-project-123456/branches/br-main-branch-123456/roles/GET.json +0 -3
  125. package/mocks/single_project/projects/test-project-123456/branches/br-main-branch-123456/roles/test_role/reveal_password/GET.json +0 -3
  126. package/rollup.config.js +0 -20
  127. package/snapshots/commands/branches.test.snap +0 -221
  128. package/snapshots/commands/connection_string.test.snap +0 -70
  129. package/snapshots/commands/databases.test.snap +0 -20
  130. package/snapshots/commands/ip_allow.test.snap +0 -55
  131. package/snapshots/commands/operations.test.snap +0 -17
  132. package/snapshots/commands/projects.test.snap +0 -141
  133. package/snapshots/commands/roles.test.snap +0 -19
  134. package/snapshots/commands/set_context.test.snap +0 -30
  135. package/snapshots/writer.test.snap +0 -60
  136. package/snapshotsResolver.cjs +0 -32
  137. package/src/analytics.ts +0 -95
  138. package/src/api.ts +0 -44
  139. package/src/auth.ts +0 -137
  140. package/src/commands/auth.test.ts +0 -62
  141. package/src/commands/auth.ts +0 -148
  142. package/src/commands/branches.test.ts +0 -354
  143. package/src/commands/branches.ts +0 -451
  144. package/src/commands/connection_string.test.ts +0 -250
  145. package/src/commands/connection_string.ts +0 -210
  146. package/src/commands/databases.test.ts +0 -55
  147. package/src/commands/databases.ts +0 -129
  148. package/src/commands/help.test.ts +0 -13
  149. package/src/commands/ip_allow.test.ts +0 -86
  150. package/src/commands/ip_allow.ts +0 -202
  151. package/src/commands/operations.test.ts +0 -13
  152. package/src/commands/operations.ts +0 -41
  153. package/src/commands/projects.test.ts +0 -147
  154. package/src/commands/projects.ts +0 -275
  155. package/src/commands/roles.test.ts +0 -46
  156. package/src/commands/roles.ts +0 -100
  157. package/src/commands/set_context.test.ts +0 -64
  158. package/src/commands/set_context.ts +0 -27
  159. package/src/commands/user.ts +0 -21
  160. package/src/config.ts +0 -22
  161. package/src/context.ts +0 -61
  162. package/src/env.ts +0 -7
  163. package/src/errors.ts +0 -24
  164. package/src/help.ts +0 -185
  165. package/src/index.ts +0 -180
  166. package/src/log.ts +0 -16
  167. package/src/parameters.gen.ts +0 -332
  168. package/src/pkg.ts +0 -9
  169. package/src/test_utils/mock_server.ts +0 -27
  170. package/src/test_utils/oauth_server.ts +0 -10
  171. package/src/test_utils/test_cli_command.ts +0 -117
  172. package/src/types.ts +0 -25
  173. package/src/utils/enrichers.ts +0 -73
  174. package/src/utils/formats.test.ts +0 -41
  175. package/src/utils/formats.ts +0 -11
  176. package/src/utils/middlewares.ts +0 -23
  177. package/src/utils/point_in_time.ts +0 -86
  178. package/src/utils/psql.ts +0 -29
  179. package/src/utils/string.ts +0 -8
  180. package/src/utils/ui.ts +0 -64
  181. package/src/writer.test.ts +0 -98
  182. package/src/writer.ts +0 -131
  183. package/tsconfig.json +0 -17
  184. /package/{src/callback.html → callback.html} +0 -0
package/analytics.js ADDED
@@ -0,0 +1,78 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { Analytics } from '@segment/analytics-node';
4
+ import { isAxiosError } from 'axios';
5
+ import { CREDENTIALS_FILE } from './config.js';
6
+ import { isCi } from './env.js';
7
+ import { log } from './log.js';
8
+ import pkg from './pkg.js';
9
+ import { getApiClient } from './api.js';
10
+ const WRITE_KEY = '3SQXn5ejjXWLEJ8xU2PRYhAotLtTaeeV';
11
+ let client;
12
+ let userId = '';
13
+ export const analyticsMiddleware = async (args) => {
14
+ if (!args.analytics) {
15
+ return;
16
+ }
17
+ try {
18
+ const credentialsPath = join(args.configDir, CREDENTIALS_FILE);
19
+ const credentials = readFileSync(credentialsPath, { encoding: 'utf-8' });
20
+ userId = JSON.parse(credentials).user_id;
21
+ }
22
+ catch (err) {
23
+ log.debug('Failed to read credentials file', err);
24
+ }
25
+ try {
26
+ if (!userId && args.apiKey) {
27
+ const apiClient = getApiClient({
28
+ apiKey: args.apiKey,
29
+ apiHost: args.apiHost,
30
+ });
31
+ const resp = await apiClient?.getCurrentUserInfo?.();
32
+ userId = resp?.data?.id;
33
+ }
34
+ }
35
+ catch (err) {
36
+ log.debug('Failed to get user id from api', err);
37
+ }
38
+ client = new Analytics({
39
+ writeKey: WRITE_KEY,
40
+ host: 'https://track.neon.tech',
41
+ });
42
+ client.identify({
43
+ userId: userId?.toString() ?? 'anonymous',
44
+ });
45
+ client.track({
46
+ userId: userId ?? 'anonymous',
47
+ event: 'CLI Started',
48
+ properties: {
49
+ version: pkg.version,
50
+ command: args._.join(' '),
51
+ flags: {
52
+ output: args.output,
53
+ },
54
+ ci: isCi(),
55
+ },
56
+ });
57
+ log.debug('Flushing CLI started event with userId: %s', userId);
58
+ await client.closeAndFlush();
59
+ log.debug('Flushed CLI started event with userId: %s', userId);
60
+ };
61
+ export const sendError = (err, errCode) => {
62
+ if (!client) {
63
+ return;
64
+ }
65
+ const axiosError = isAxiosError(err) ? err : undefined;
66
+ client.track({
67
+ event: 'CLI Error',
68
+ userId: userId ?? 'anonymous',
69
+ properties: {
70
+ message: err.message,
71
+ stack: err.stack,
72
+ errCode,
73
+ statusCode: axiosError?.response?.status,
74
+ },
75
+ });
76
+ client.closeAndFlush();
77
+ log.debug('Sent CLI error event: %s', errCode);
78
+ };
package/api.js ADDED
@@ -0,0 +1,35 @@
1
+ import { createApiClient } from '@neondatabase/api-client';
2
+ import { isAxiosError } from 'axios';
3
+ import { log } from './log.js';
4
+ import pkg from './pkg.js';
5
+ export const getApiClient = ({ apiKey, apiHost }) => createApiClient({
6
+ apiKey,
7
+ baseURL: apiHost,
8
+ timeout: 60000,
9
+ headers: {
10
+ 'User-Agent': `neonctl v${pkg.version}`,
11
+ },
12
+ });
13
+ const RETRY_COUNT = 5;
14
+ const RETRY_DELAY = 3000;
15
+ export const retryOnLock = async (fn) => {
16
+ let attempt = 0;
17
+ let errOut;
18
+ while (attempt < RETRY_COUNT) {
19
+ try {
20
+ return await fn();
21
+ }
22
+ catch (err) {
23
+ errOut = err;
24
+ if (isAxiosError(err) && err.response?.status === 423) {
25
+ attempt++;
26
+ log.info(`Resource is locked. Waiting ${RETRY_DELAY}ms before retrying...`);
27
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
28
+ }
29
+ else {
30
+ throw err;
31
+ }
32
+ }
33
+ }
34
+ throw errOut;
35
+ };
package/auth.js ADDED
@@ -0,0 +1,101 @@
1
+ import { custom, generators, Issuer } from 'openid-client';
2
+ import { createServer } from 'node:http';
3
+ import { createReadStream } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import open from 'open';
6
+ import { log } from './log.js';
7
+ import { fileURLToPath } from 'node:url';
8
+ // oauth server timeouts
9
+ const SERVER_TIMEOUT = 10000;
10
+ // where to wait for incoming redirect request from oauth server to arrive
11
+ const REDIRECT_URI = (port) => `http://127.0.0.1:${port}/callback`;
12
+ // These scopes cannot be cancelled, they are always needed.
13
+ const ALWAYS_PRESENT_SCOPES = ['openid', 'offline', 'offline_access'];
14
+ const NEONCTL_SCOPES = [
15
+ ...ALWAYS_PRESENT_SCOPES,
16
+ 'urn:neoncloud:projects:create',
17
+ 'urn:neoncloud:projects:read',
18
+ 'urn:neoncloud:projects:update',
19
+ 'urn:neoncloud:projects:delete',
20
+ ];
21
+ export const defaultClientID = 'neonctl';
22
+ custom.setHttpOptionsDefaults({
23
+ timeout: SERVER_TIMEOUT,
24
+ });
25
+ export const refreshToken = async ({ oauthHost, clientId }, tokenSet) => {
26
+ log.debug('Discovering oauth server');
27
+ const issuer = await Issuer.discover(oauthHost);
28
+ const neonOAuthClient = new issuer.Client({
29
+ token_endpoint_auth_method: 'none',
30
+ client_id: clientId,
31
+ response_types: ['code'],
32
+ });
33
+ return await neonOAuthClient.refresh(tokenSet);
34
+ };
35
+ export const auth = async ({ oauthHost, clientId }) => {
36
+ log.debug('Discovering oauth server');
37
+ const issuer = await Issuer.discover(oauthHost);
38
+ //
39
+ // Start HTTP server and wait till /callback is hit
40
+ //
41
+ const server = createServer();
42
+ server.listen(0, '127.0.0.1', function () {
43
+ log.debug(`Listening on port ${this.address().port}`);
44
+ });
45
+ await new Promise((resolve) => server.once('listening', resolve));
46
+ const listen_port = server.address().port;
47
+ const neonOAuthClient = new issuer.Client({
48
+ token_endpoint_auth_method: 'none',
49
+ client_id: clientId,
50
+ redirect_uris: [REDIRECT_URI(listen_port)],
51
+ response_types: ['code'],
52
+ });
53
+ // https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.1.8
54
+ const state = generators.state();
55
+ // we store the code_verifier in memory
56
+ const codeVerifier = generators.codeVerifier();
57
+ const codeChallenge = generators.codeChallenge(codeVerifier);
58
+ return new Promise((resolve) => {
59
+ server.on('request', async (request, response) => {
60
+ //
61
+ // Wait for callback and follow oauth flow.
62
+ //
63
+ if (!request.url?.startsWith('/callback')) {
64
+ response.writeHead(404);
65
+ response.end();
66
+ return;
67
+ }
68
+ // process the CORS preflight OPTIONS request
69
+ if (request.method === 'OPTIONS') {
70
+ response.writeHead(200, {
71
+ 'Access-Control-Allow-Origin': '*',
72
+ 'Access-Control-Allow-Methods': 'GET, POST',
73
+ 'Access-Control-Allow-Headers': 'Content-Type',
74
+ });
75
+ response.end();
76
+ return;
77
+ }
78
+ log.debug(`Callback received: ${request.url}`);
79
+ const params = neonOAuthClient.callbackParams(request);
80
+ const tokenSet = await neonOAuthClient.callback(REDIRECT_URI(listen_port), params, {
81
+ code_verifier: codeVerifier,
82
+ state,
83
+ });
84
+ response.writeHead(200, { 'Content-Type': 'text/html' });
85
+ createReadStream(join(fileURLToPath(new URL('.', import.meta.url)), './callback.html')).pipe(response);
86
+ resolve(tokenSet);
87
+ server.close();
88
+ });
89
+ //
90
+ // Open browser to let user authenticate
91
+ //
92
+ const scopes = clientId == defaultClientID ? NEONCTL_SCOPES : ALWAYS_PRESENT_SCOPES;
93
+ const authUrl = neonOAuthClient.authorizationUrl({
94
+ scope: scopes.join(' '),
95
+ state,
96
+ code_challenge: codeChallenge,
97
+ code_challenge_method: 'S256',
98
+ });
99
+ open(authUrl);
100
+ });
101
+ };
@@ -1,3 +1,2 @@
1
1
  #!/usr/bin/env node
2
-
3
2
  import './index.js';
@@ -0,0 +1,102 @@
1
+ import { join } from 'node:path';
2
+ import { writeFileSync, existsSync, readFileSync } from 'node:fs';
3
+ import { TokenSet } from 'openid-client';
4
+ import { auth, refreshToken } from '../auth.js';
5
+ import { log } from '../log.js';
6
+ import { getApiClient } from '../api.js';
7
+ import { isCi } from '../env.js';
8
+ import { CREDENTIALS_FILE } from '../config.js';
9
+ export const command = 'auth';
10
+ export const aliases = ['login'];
11
+ export const describe = 'Authenticate';
12
+ export const builder = (yargs) => yargs.option('context-file', {
13
+ hidden: true,
14
+ });
15
+ export const handler = async (args) => {
16
+ await authFlow(args);
17
+ };
18
+ export const authFlow = async ({ configDir, oauthHost, clientId, apiHost, forceAuth, }) => {
19
+ if (!forceAuth && isCi()) {
20
+ throw new Error('Cannot run interactive auth in CI');
21
+ }
22
+ const tokenSet = await auth({
23
+ oauthHost: oauthHost,
24
+ clientId: clientId,
25
+ });
26
+ 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}`);
32
+ log.info('Auth complete');
33
+ return tokenSet.access_token || '';
34
+ };
35
+ const preserveCredentials = async (path, credentials, apiClient) => {
36
+ const { data: { id }, } = await apiClient.getCurrentUserInfo();
37
+ const contents = JSON.stringify({
38
+ ...credentials,
39
+ user_id: id,
40
+ });
41
+ // correctly sets needed permissions for the credentials file
42
+ writeFileSync(path, contents, {
43
+ mode: 0o700,
44
+ });
45
+ };
46
+ export const ensureAuth = async (props) => {
47
+ if (props._.length === 0 || props.help) {
48
+ return;
49
+ }
50
+ if (props.apiKey || props._[0] === 'auth') {
51
+ props.apiClient = getApiClient({
52
+ apiKey: props.apiKey,
53
+ apiHost: props.apiHost,
54
+ });
55
+ return;
56
+ }
57
+ const credentialsPath = join(props.configDir, CREDENTIALS_FILE);
58
+ if (existsSync(credentialsPath)) {
59
+ try {
60
+ const tokenSetContents = await JSON.parse(readFileSync(credentialsPath, 'utf8'));
61
+ 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((e) => {
68
+ log.error('failed to refresh token\n%s', e?.message);
69
+ process.exit(1);
70
+ });
71
+ props.apiKey = refreshedTokenSet.access_token || 'UNKNOWN';
72
+ props.apiClient = getApiClient({
73
+ apiKey: props.apiKey,
74
+ apiHost: props.apiHost,
75
+ });
76
+ await preserveCredentials(credentialsPath, refreshedTokenSet, props.apiClient);
77
+ return;
78
+ }
79
+ const token = tokenSet.access_token || 'UNKNOWN';
80
+ props.apiKey = token;
81
+ props.apiClient = getApiClient({
82
+ apiKey: props.apiKey,
83
+ apiHost: props.apiHost,
84
+ });
85
+ return;
86
+ }
87
+ catch (e) {
88
+ if (e.code !== 'ENOENT') {
89
+ // not a "file does not exist" error
90
+ throw e;
91
+ }
92
+ props.apiKey = await authFlow(props);
93
+ }
94
+ }
95
+ else {
96
+ props.apiKey = await authFlow(props);
97
+ }
98
+ props.apiClient = getApiClient({
99
+ apiKey: props.apiKey,
100
+ apiHost: props.apiHost,
101
+ });
102
+ };
@@ -0,0 +1,42 @@
1
+ import axios from 'axios';
2
+ import { beforeAll, describe, test, jest, afterAll, expect, } from '@jest/globals';
3
+ import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
4
+ import { startOauthServer } from '../test_utils/oauth_server';
5
+ import { runMockServer } from '../test_utils/mock_server';
6
+ jest.unstable_mockModule('open', () => ({
7
+ __esModule: true,
8
+ default: jest.fn((url) => {
9
+ axios.get(url);
10
+ }),
11
+ }));
12
+ // "open" module should be imported after mocking
13
+ const authModule = await import('./auth');
14
+ describe('auth', () => {
15
+ let configDir = '';
16
+ let oauthServer;
17
+ let mockServer;
18
+ beforeAll(async () => {
19
+ configDir = mkdtempSync('test-config');
20
+ oauthServer = await startOauthServer();
21
+ mockServer = await runMockServer('main');
22
+ });
23
+ afterAll(async () => {
24
+ rmSync(configDir, { recursive: true });
25
+ await oauthServer.stop();
26
+ await new Promise((resolve) => mockServer.close(resolve));
27
+ });
28
+ test('should auth', async () => {
29
+ await authModule.authFlow({
30
+ _: ['auth'],
31
+ apiHost: `http://localhost:${mockServer.address().port}`,
32
+ clientId: 'test-client-id',
33
+ configDir,
34
+ forceAuth: true,
35
+ oauthHost: `http://localhost:${oauthServer.address().port}`,
36
+ });
37
+ const credentials = JSON.parse(readFileSync(`${configDir}/credentials.json`, 'utf-8'));
38
+ expect(credentials.access_token).toEqual(expect.any(String));
39
+ expect(credentials.refresh_token).toEqual(expect.any(String));
40
+ expect(credentials.user_id).toEqual(expect.any(String));
41
+ });
42
+ });
@@ -0,0 +1,303 @@
1
+ import { EndpointType } from '@neondatabase/api-client';
2
+ import { writer } from '../writer.js';
3
+ import { branchCreateRequest } from '../parameters.gen.js';
4
+ import { retryOnLock } from '../api.js';
5
+ import { branchIdFromProps, branchIdResolve, fillSingleProject, } from '../utils/enrichers.js';
6
+ import { looksLikeBranchId, looksLikeLSN, looksLikeTimestamp, } from '../utils/formats.js';
7
+ import { psql } from '../utils/psql.js';
8
+ import { parsePointInTime } from '../utils/point_in_time.js';
9
+ import { log } from '../log.js';
10
+ const BRANCH_FIELDS = [
11
+ 'id',
12
+ 'name',
13
+ 'primary',
14
+ 'created_at',
15
+ 'updated_at',
16
+ ];
17
+ const BRANCH_FIELDS_RESET = [
18
+ 'id',
19
+ 'name',
20
+ 'primary',
21
+ 'created_at',
22
+ 'last_reset_at',
23
+ ];
24
+ export const command = 'branches';
25
+ export const describe = 'Manage branches';
26
+ export const aliases = ['branch'];
27
+ export const builder = (argv) => argv
28
+ .usage('$0 branches <sub-command> [options]')
29
+ .options({
30
+ 'project-id': {
31
+ describe: 'Project ID',
32
+ type: 'string',
33
+ },
34
+ })
35
+ .middleware(fillSingleProject)
36
+ .command('list', 'List branches', (yargs) => yargs, async (args) => await list(args))
37
+ .command('create', 'Create a branch', (yargs) => yargs.options({
38
+ name: branchCreateRequest['branch.name'],
39
+ parent: {
40
+ describe: 'Parent branch name or id or timestamp or LSN. Defaults to the primary branch',
41
+ type: 'string',
42
+ },
43
+ compute: {
44
+ describe: 'Create a branch with or without a compute. By default branch is created with a read-write compute. To create a branch without compute use --no-compute',
45
+ type: 'boolean',
46
+ default: true,
47
+ },
48
+ type: {
49
+ describe: 'Type of compute to add',
50
+ type: 'string',
51
+ implies: 'compute',
52
+ default: EndpointType.ReadWrite,
53
+ choices: Object.values(EndpointType),
54
+ },
55
+ 'suspend-timeout': {
56
+ describe: 'Duration of inactivity in seconds after which the compute endpoint is\nautomatically suspended. The value `0` means use the global default.\nThe value `-1` means never suspend. The default value is `300` seconds (5 minutes).\nThe maximum value is `604800` seconds (1 week).',
57
+ type: 'number',
58
+ implies: 'compute',
59
+ default: 0,
60
+ },
61
+ psql: {
62
+ type: 'boolean',
63
+ describe: 'Connect to a new branch via psql',
64
+ default: false,
65
+ },
66
+ }), async (args) => await create(args))
67
+ .command('reset <id|name>', 'Reset a branch', (yargs) => yargs.options({
68
+ parent: {
69
+ describe: 'Reset to a parent branch',
70
+ type: 'boolean',
71
+ default: false,
72
+ },
73
+ 'preserve-under-name': {
74
+ describe: 'Name under which to preserve the old branch',
75
+ },
76
+ }), async (args) => await reset(args))
77
+ .command('restore <target-id|name> <source>[@(timestamp|lsn)]', 'Restores a branch to a specific point in time\n<source> can be: ^self, ^parent, or <source-branch-id|name>', (yargs) => yargs
78
+ // we want to show meaningful help for the command
79
+ // but it makes yargs to fail on parsing the command
80
+ // so we need to fill in the missing args manually
81
+ .middleware((args) => {
82
+ args.id = args.targetId;
83
+ args.pointInTime = args['source@(timestamp'];
84
+ })
85
+ .usage('$0 branches restore <target-id|name> <source>[@(timestamp|lsn)]')
86
+ .options({
87
+ 'preserve-under-name': {
88
+ describe: 'Name under which to preserve the old branch',
89
+ },
90
+ })
91
+ .example([
92
+ [
93
+ '$0 branches restore main br-source-branch-123456',
94
+ 'Restores main to the head of the branch with id br-source-branch-123456',
95
+ ],
96
+ [
97
+ '$0 branches restore main source@2021-01-01T00:00:00Z',
98
+ 'Restores main to the timestamp 2021-01-01T00:00:00Z of the source branch',
99
+ ],
100
+ [
101
+ '$0 branches restore my-branch ^self@0/123456',
102
+ 'Restores my-branch to the LSN 0/123456 from its own history',
103
+ ],
104
+ [
105
+ '$0 branches restore my-branch ^parent',
106
+ 'Restore my-branch to the head of its parent branch',
107
+ ],
108
+ ]), async (args) => await restore(args))
109
+ .command('rename <id|name> <new-name>', 'Rename a branch', (yargs) => yargs, async (args) => await rename(args))
110
+ .command('set-primary <id|name>', 'Set a branch as primary', (yargs) => yargs, async (args) => await setPrimary(args))
111
+ .command('add-compute <id|name>', 'Add a compute to a branch', (yargs) => yargs.options({
112
+ type: {
113
+ type: 'string',
114
+ choices: Object.values(EndpointType),
115
+ describe: 'Type of compute to add',
116
+ default: EndpointType.ReadOnly,
117
+ },
118
+ }), async (args) => await addCompute(args))
119
+ .command('delete <id|name>', 'Delete a branch', (yargs) => yargs, async (args) => await deleteBranch(args))
120
+ .command('get <id|name>', 'Get a branch', (yargs) => yargs, async (args) => await get(args));
121
+ export const handler = (args) => {
122
+ return args;
123
+ };
124
+ const list = async (props) => {
125
+ const { data } = await props.apiClient.listProjectBranches(props.projectId);
126
+ writer(props).end(data.branches, {
127
+ fields: BRANCH_FIELDS,
128
+ });
129
+ };
130
+ const create = async (props) => {
131
+ const parentProps = await (() => {
132
+ if (!props.parent) {
133
+ return props.apiClient
134
+ .listProjectBranches(props.projectId)
135
+ .then(({ data }) => {
136
+ const branch = data.branches.find((b) => b.primary);
137
+ if (!branch) {
138
+ throw new Error('No primary branch found');
139
+ }
140
+ return { parent_id: branch.id };
141
+ });
142
+ }
143
+ if (looksLikeLSN(props.parent)) {
144
+ return { parent_lsn: props.parent };
145
+ }
146
+ if (looksLikeTimestamp(props.parent)) {
147
+ return { parent_timestamp: props.parent };
148
+ }
149
+ if (looksLikeBranchId(props.parent)) {
150
+ return { parent_id: props.parent };
151
+ }
152
+ return props.apiClient
153
+ .listProjectBranches(props.projectId)
154
+ .then(({ data }) => {
155
+ const branch = data.branches.find((b) => b.name === props.parent);
156
+ if (!branch) {
157
+ throw new Error(`Branch ${props.parent} not found`);
158
+ }
159
+ return { parent_id: branch.id };
160
+ });
161
+ })();
162
+ const { data } = await retryOnLock(() => props.apiClient.createProjectBranch(props.projectId, {
163
+ branch: {
164
+ name: props.name,
165
+ ...parentProps,
166
+ },
167
+ endpoints: props.compute
168
+ ? [
169
+ {
170
+ type: props.type,
171
+ suspend_timeout_seconds: props.suspendTimeout === 0 ? undefined : props.suspendTimeout,
172
+ },
173
+ ]
174
+ : [],
175
+ }));
176
+ const out = writer(props);
177
+ out.write(data.branch, {
178
+ fields: BRANCH_FIELDS,
179
+ title: 'branch',
180
+ });
181
+ if (data.endpoints?.length > 0) {
182
+ out.write(data.endpoints, {
183
+ fields: ['id', 'created_at'],
184
+ title: 'endpoints',
185
+ });
186
+ }
187
+ if (data.connection_uris && data.connection_uris?.length > 0) {
188
+ out.write(data.connection_uris, {
189
+ fields: ['connection_uri'],
190
+ title: 'connection_uris',
191
+ });
192
+ }
193
+ out.end();
194
+ if (props.psql) {
195
+ if (!data.connection_uris || !data.connection_uris?.length) {
196
+ throw new Error(`Branch ${data.branch.id} doesn't have a connection uri`);
197
+ }
198
+ const connection_uri = data.connection_uris[0].connection_uri;
199
+ const psqlArgs = props['--'];
200
+ await psql(connection_uri, psqlArgs);
201
+ }
202
+ };
203
+ const rename = async (props) => {
204
+ const branchId = await branchIdFromProps(props);
205
+ const { data } = await retryOnLock(() => props.apiClient.updateProjectBranch(props.projectId, branchId, {
206
+ branch: {
207
+ name: props.newName,
208
+ },
209
+ }));
210
+ writer(props).end(data.branch, {
211
+ fields: BRANCH_FIELDS,
212
+ });
213
+ };
214
+ const setPrimary = async (props) => {
215
+ const branchId = await branchIdFromProps(props);
216
+ const { data } = await retryOnLock(() => props.apiClient.setPrimaryProjectBranch(props.projectId, branchId));
217
+ writer(props).end(data.branch, {
218
+ fields: BRANCH_FIELDS,
219
+ });
220
+ };
221
+ const deleteBranch = async (props) => {
222
+ const branchId = await branchIdFromProps(props);
223
+ const { data } = await retryOnLock(() => props.apiClient.deleteProjectBranch(props.projectId, branchId));
224
+ writer(props).end(data.branch, {
225
+ fields: BRANCH_FIELDS,
226
+ });
227
+ };
228
+ const get = async (props) => {
229
+ const branchId = await branchIdFromProps(props);
230
+ const { data } = await props.apiClient.getProjectBranch(props.projectId, branchId);
231
+ writer(props).end(data.branch, {
232
+ fields: BRANCH_FIELDS,
233
+ });
234
+ };
235
+ const addCompute = async (props) => {
236
+ const branchId = await branchIdFromProps(props);
237
+ const { data } = await retryOnLock(() => props.apiClient.createProjectEndpoint(props.projectId, {
238
+ endpoint: {
239
+ branch_id: branchId,
240
+ type: props.type,
241
+ },
242
+ }));
243
+ writer(props).end(data.endpoint, {
244
+ fields: ['id', 'host'],
245
+ });
246
+ };
247
+ const reset = async (props) => {
248
+ if (!props.parent) {
249
+ throw new Error('Only resetting to parent is supported for now');
250
+ }
251
+ const branchId = await branchIdFromProps(props);
252
+ const { data: { branch: { parent_id }, }, } = await props.apiClient.getProjectBranch(props.projectId, branchId);
253
+ if (!parent_id) {
254
+ throw new Error('Branch has no parent');
255
+ }
256
+ const { data } = await retryOnLock(() => props.apiClient.restoreProjectBranch(props.projectId, branchId, {
257
+ source_branch_id: parent_id,
258
+ preserve_under_name: props.preserveUnderName || undefined,
259
+ }));
260
+ const resultBranch = data.branch;
261
+ writer(props).end(resultBranch, {
262
+ // need to reset types until we expose reset api
263
+ fields: BRANCH_FIELDS_RESET,
264
+ });
265
+ };
266
+ const restore = async (props) => {
267
+ const targetBranchId = await branchIdResolve({
268
+ branch: props.id,
269
+ projectId: props.projectId,
270
+ apiClient: props.apiClient,
271
+ });
272
+ const pointInTime = await parsePointInTime({
273
+ pointInTime: props.pointInTime,
274
+ targetBranchId,
275
+ projectId: props.projectId,
276
+ api: props.apiClient,
277
+ });
278
+ log.info(`Restoring branch ${targetBranchId} to the branch ${pointInTime.branchId} ${(pointInTime.tag === 'lsn' && 'LSN ' + pointInTime.lsn) ||
279
+ (pointInTime.tag === 'timestamp' &&
280
+ 'timestamp ' + pointInTime.timestamp) ||
281
+ 'head'}`);
282
+ const { data } = await retryOnLock(() => props.apiClient.restoreProjectBranch(props.projectId, targetBranchId, {
283
+ source_branch_id: pointInTime.branchId,
284
+ preserve_under_name: props.preserveUnderName || undefined,
285
+ ...(pointInTime.tag === 'lsn' && { source_lsn: pointInTime.lsn }),
286
+ ...(pointInTime.tag === 'timestamp' && {
287
+ source_timestamp: pointInTime.timestamp,
288
+ }),
289
+ }));
290
+ const branch = data.branch;
291
+ const writeInst = writer(props).write(branch, {
292
+ title: 'Restored branch',
293
+ fields: ['id', 'name', 'last_reset_at'],
294
+ });
295
+ if (props.preserveUnderName && branch.parent_id) {
296
+ const { data } = await props.apiClient.getProjectBranch(props.projectId, branch.parent_id);
297
+ writeInst.write(data.branch, {
298
+ title: 'Backup branch',
299
+ fields: ['id', 'name'],
300
+ });
301
+ }
302
+ writeInst.end();
303
+ };