neonctl 1.29.4 → 1.30.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
@@ -9,11 +9,13 @@ import pkg from './pkg.js';
9
9
  import { getApiClient } from './api.js';
10
10
  const WRITE_KEY = '3SQXn5ejjXWLEJ8xU2PRYhAotLtTaeeV';
11
11
  let client;
12
+ let clientInitialized = false;
12
13
  let userId = '';
13
14
  export const analyticsMiddleware = async (args) => {
14
- if (!args.analytics) {
15
+ if (!args.analytics || clientInitialized) {
15
16
  return;
16
17
  }
18
+ clientInitialized = true;
17
19
  try {
18
20
  const credentialsPath = join(args.configDir, CREDENTIALS_FILE);
19
21
  const credentials = readFileSync(credentialsPath, { encoding: 'utf-8' });
@@ -39,6 +41,7 @@ export const analyticsMiddleware = async (args) => {
39
41
  writeKey: WRITE_KEY,
40
42
  host: 'https://track.neon.tech',
41
43
  });
44
+ log.debug('Initialized CLI analytics');
42
45
  client.identify({
43
46
  userId: userId?.toString() ?? 'anonymous',
44
47
  });
@@ -54,9 +57,13 @@ export const analyticsMiddleware = async (args) => {
54
57
  ci: isCi(),
55
58
  },
56
59
  });
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 closeAnalytics = async () => {
62
+ if (client) {
63
+ log.debug('Flushing CLI analytics');
64
+ await client.closeAndFlush();
65
+ log.debug('Flushed CLI analytics');
66
+ }
60
67
  };
61
68
  export const sendError = (err, errCode) => {
62
69
  if (!client) {
@@ -73,6 +80,5 @@ export const sendError = (err, errCode) => {
73
80
  statusCode: axiosError?.response?.status,
74
81
  },
75
82
  });
76
- client.closeAndFlush();
77
83
  log.debug('Sent CLI error event: %s', errCode);
78
84
  };
package/auth.js CHANGED
@@ -5,6 +5,8 @@ import { join } from 'node:path';
5
5
  import open from 'open';
6
6
  import { log } from './log.js';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { sendError } from './analytics.js';
9
+ import { matchErrorCode } from './errors.js';
8
10
  // oauth server timeouts
9
11
  const SERVER_TIMEOUT = 10000;
10
12
  // where to wait for incoming redirect request from oauth server to arrive
@@ -18,6 +20,7 @@ const NEONCTL_SCOPES = [
18
20
  'urn:neoncloud:projects:update',
19
21
  'urn:neoncloud:projects:delete',
20
22
  ];
23
+ const AUTH_TIMEOUT_SECONDS = 60;
21
24
  export const defaultClientID = 'neonctl';
22
25
  custom.setHttpOptionsDefaults({
23
26
  timeout: SERVER_TIMEOUT,
@@ -38,6 +41,7 @@ export const auth = async ({ oauthHost, clientId }) => {
38
41
  //
39
42
  // Start HTTP server and wait till /callback is hit
40
43
  //
44
+ log.debug('Starting HTTP Server for callback');
41
45
  const server = createServer();
42
46
  server.listen(0, '127.0.0.1', function () {
43
47
  log.debug(`Listening on port ${this.address().port}`);
@@ -55,7 +59,10 @@ export const auth = async ({ oauthHost, clientId }) => {
55
59
  // we store the code_verifier in memory
56
60
  const codeVerifier = generators.codeVerifier();
57
61
  const codeChallenge = generators.codeChallenge(codeVerifier);
58
- return new Promise((resolve) => {
62
+ return new Promise((resolve, reject) => {
63
+ const timer = setTimeout(() => {
64
+ reject(new Error(`Authentication timed out after ${AUTH_TIMEOUT_SECONDS} seconds`));
65
+ }, AUTH_TIMEOUT_SECONDS * 1000);
59
66
  server.on('request', async (request, response) => {
60
67
  //
61
68
  // Wait for callback and follow oauth flow.
@@ -83,6 +90,7 @@ export const auth = async ({ oauthHost, clientId }) => {
83
90
  });
84
91
  response.writeHead(200, { 'Content-Type': 'text/html' });
85
92
  createReadStream(join(fileURLToPath(new URL('.', import.meta.url)), './callback.html')).pipe(response);
93
+ clearTimeout(timer);
86
94
  resolve(tokenSet);
87
95
  server.close();
88
96
  });
@@ -96,6 +104,12 @@ export const auth = async ({ oauthHost, clientId }) => {
96
104
  code_challenge: codeChallenge,
97
105
  code_challenge_method: 'S256',
98
106
  });
99
- open(authUrl);
107
+ log.info('Awaiting authentication in web browser.');
108
+ log.info(`Auth Url: ${authUrl}`);
109
+ open(authUrl).catch((err) => {
110
+ const msg = `Failed to open web browser. Please copy & paste auth url to authenticate in browser.`;
111
+ sendError(err || new Error(msg), matchErrorCode(msg || err?.message));
112
+ log.error(msg || err?.message);
113
+ });
100
114
  });
101
115
  };
@@ -6,7 +6,7 @@ import { runMockServer } from '../test_utils/mock_server';
6
6
  jest.unstable_mockModule('open', () => ({
7
7
  __esModule: true,
8
8
  default: jest.fn((url) => {
9
- axios.get(url);
9
+ return axios.get(url);
10
10
  }),
11
11
  }));
12
12
  // "open" module should be imported after mocking
@@ -7,6 +7,7 @@ import { looksLikeBranchId, looksLikeLSN, looksLikeTimestamp, } from '../utils/f
7
7
  import { psql } from '../utils/psql.js';
8
8
  import { parsePointInTime } from '../utils/point_in_time.js';
9
9
  import { log } from '../log.js';
10
+ import { parseSchemaDiffParams, schemaDiff } from './schema_diff.js';
10
11
  const BRANCH_FIELDS = [
11
12
  'id',
12
13
  'name',
@@ -117,7 +118,47 @@ export const builder = (argv) => argv
117
118
  },
118
119
  }), async (args) => await addCompute(args))
119
120
  .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
+ .command('get <id|name>', 'Get a branch', (yargs) => yargs, async (args) => await get(args))
122
+ .command({
123
+ command: 'schema-diff [base-branch] [compare-source[@(timestamp|lsn)]]',
124
+ aliases: ['sd'],
125
+ describe: "Compare the latest schemas of any two branches, or compare a branch to its own or another branch's history.",
126
+ builder: (yargs) => {
127
+ return yargs
128
+ .middleware((args) => (args.compareSource = args['compare-source@(timestamp']))
129
+ .middleware(parseSchemaDiffParams)
130
+ .options({
131
+ database: {
132
+ alias: 'db',
133
+ type: 'string',
134
+ description: 'Name of the database for which the schema comparison is performed',
135
+ },
136
+ })
137
+ .example([
138
+ [
139
+ '$0 branches schema-diff main br-compare-branch-123456',
140
+ 'Compares the main branch to the head of the branch with ID br-compare-branch-123456',
141
+ ],
142
+ [
143
+ '$0 branches schema-diff main compare@2024-06-01T00:00:00Z',
144
+ 'Compares the main branch to the state of the compare branch at timestamp 2024-06-01T00:00:00.000Z',
145
+ ],
146
+ [
147
+ '$0 branches schema-diff my-branch ^self@0/123456',
148
+ 'Compares my-branch to LSN 0/123456 from its own history',
149
+ ],
150
+ [
151
+ '$0 branches schema-diff my-branch ^parent',
152
+ 'Compares my-branch to the head of its parent branch',
153
+ ],
154
+ [
155
+ '$0 branches schema-diff',
156
+ "If a branch is specified in 'set-context', compares this branch to its parent. Otherwise, compares the primary branch to its parent.",
157
+ ],
158
+ ]);
159
+ },
160
+ handler: async (args) => schemaDiff(args),
161
+ });
121
162
  export const handler = (args) => {
122
163
  return args;
123
164
  };
@@ -0,0 +1,150 @@
1
+ import { createPatch } from 'diff';
2
+ import chalk from 'chalk';
3
+ import { writer } from '../writer.js';
4
+ import { branchIdFromProps } from '../utils/enrichers.js';
5
+ import { parsePointInTime, } from '../utils/point_in_time.js';
6
+ import { isAxiosError } from 'axios';
7
+ import { sendError } from '../analytics.js';
8
+ import { log } from '../log.js';
9
+ const COLORS = {
10
+ added: chalk.green,
11
+ removed: chalk.red,
12
+ header: chalk.yellow,
13
+ section: chalk.magenta,
14
+ };
15
+ export const schemaDiff = async (props) => {
16
+ props.branch = props.baseBranch || props.branch;
17
+ const baseBranch = await branchIdFromProps(props);
18
+ let pointInTime = await parsePointInTime({
19
+ pointInTime: props.compareSource,
20
+ targetBranchId: baseBranch,
21
+ projectId: props.projectId,
22
+ api: props.apiClient,
23
+ });
24
+ // Swap base and compare points if comparing with parent branch
25
+ const comparingWithParent = props.compareSource.startsWith('^parent');
26
+ let baseBranchPoint = {
27
+ branchId: baseBranch,
28
+ tag: 'head',
29
+ };
30
+ [baseBranchPoint, pointInTime] = comparingWithParent
31
+ ? [pointInTime, baseBranchPoint]
32
+ : [baseBranchPoint, pointInTime];
33
+ const baseDatabases = await fetchDatabases(baseBranch, props);
34
+ if (props.database) {
35
+ const database = baseDatabases.find((db) => db.name === props.database);
36
+ if (!database) {
37
+ throw new Error(`Database ${props.database} does not exist in base branch ${baseBranch}`);
38
+ }
39
+ const patch = await createSchemaDiff(baseBranchPoint, pointInTime, database, props);
40
+ writer(props).text(colorize(patch));
41
+ return;
42
+ }
43
+ await Promise.all(baseDatabases.map(async (database) => {
44
+ const patch = await createSchemaDiff(baseBranchPoint, pointInTime, database, props);
45
+ writer(props).text(colorize(patch));
46
+ }));
47
+ };
48
+ const fetchDatabases = async (branch, props) => {
49
+ return props.apiClient
50
+ .listProjectBranchDatabases(props.projectId, branch)
51
+ .then((response) => response.data.databases);
52
+ };
53
+ const createSchemaDiff = async (baseBranch, pointInTime, database, props) => {
54
+ const [baseSchema, compareSchema] = await Promise.all([
55
+ fetchSchema(baseBranch, database, props),
56
+ fetchSchema(pointInTime, database, props),
57
+ ]);
58
+ return createPatch(`Database: ${database.name}`, baseSchema, compareSchema, generateHeader(baseBranch), generateHeader(pointInTime));
59
+ };
60
+ const fetchSchema = async (pointInTime, database, props) => {
61
+ try {
62
+ return props.apiClient
63
+ .getProjectBranchSchema({
64
+ projectId: props.projectId,
65
+ branchId: pointInTime.branchId,
66
+ db_name: database.name,
67
+ role: database.owner_name,
68
+ ...pointInTimeParams(pointInTime),
69
+ })
70
+ .then((response) => response.data.sql ?? '');
71
+ }
72
+ catch (error) {
73
+ if (isAxiosError(error)) {
74
+ const data = error.response?.data;
75
+ sendError(error, 'API_ERROR');
76
+ throw new Error(data.message ??
77
+ `Error while fetching schema for branch ${pointInTime.branchId}`);
78
+ }
79
+ throw error;
80
+ }
81
+ };
82
+ const colorize = (patch) => {
83
+ return patch
84
+ .replace(/^([^\n]+)\n([^\n]+)\n/m, '') // Remove first two lines
85
+ .replace(/^-.*/gm, colorizer('removed'))
86
+ .replace(/^\+.*/gm, colorizer('added'))
87
+ .replace(/^@@.+@@.*/gm, colorizer('section'));
88
+ };
89
+ const colorizer = (colorId) => {
90
+ const color = COLORS[colorId];
91
+ return (line) => color(line);
92
+ };
93
+ const pointInTimeParams = (pointInTime) => {
94
+ switch (pointInTime.tag) {
95
+ case 'timestamp':
96
+ return {
97
+ timestamp: pointInTime.timestamp,
98
+ };
99
+ case 'lsn':
100
+ return {
101
+ lsn: pointInTime.lsn ?? undefined,
102
+ };
103
+ default:
104
+ return {};
105
+ }
106
+ };
107
+ const generateHeader = (pointInTime) => {
108
+ const header = `(Branch: ${pointInTime.branchId}`;
109
+ switch (pointInTime.tag) {
110
+ case 'timestamp':
111
+ return `${header} at ${pointInTime.timestamp})`;
112
+ case 'lsn':
113
+ return `${header} at ${pointInTime.lsn})`;
114
+ default:
115
+ return `${header})`;
116
+ }
117
+ };
118
+ /*
119
+ The command has two positional optional arguments - [base-branch] and [compare-source]
120
+ If only one argument is specified, we should consider it as `compare-source`
121
+ and `base-branch` will be either read from context or the primary branch of project.
122
+ If no branches are specified, compare the context branch with its parent
123
+ */
124
+ export const parseSchemaDiffParams = async (props) => {
125
+ if (!props.compareSource) {
126
+ if (props.baseBranch) {
127
+ props.compareSource = props.baseBranch;
128
+ props.baseBranch = props.branch;
129
+ }
130
+ else if (props.branch) {
131
+ const { data } = await props.apiClient.listProjectBranches(props.projectId);
132
+ const contextBranch = data.branches.find((b) => b.id === props.branch || b.name === props.branch);
133
+ if (contextBranch?.parent_id == undefined) {
134
+ throw new Error(`No branch specified. Your context branch (${props.branch}) has no parent, so no comparison is possible.`);
135
+ }
136
+ log.info(`No branches specified. Comparing your context branch '${props.branch}' with its parent`);
137
+ props.compareSource = '^parent';
138
+ }
139
+ else {
140
+ const { data } = await props.apiClient.listProjectBranches(props.projectId);
141
+ const primaryBranch = data.branches.find((b) => b.primary);
142
+ if (primaryBranch?.parent_id == undefined) {
143
+ throw new Error('No branch specified. Include a base branch or add a set-context branch to continue. Your primary branch has no parent, so no comparison is possible.');
144
+ }
145
+ log.info(`No branches specified. Comparing primary branch with its parent`);
146
+ props.compareSource = '^parent';
147
+ }
148
+ }
149
+ return props;
150
+ };
package/errors.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const ERROR_MATCHERS = [
2
2
  [/^Unknown command: (.*)$/, 'UNKNOWN_COMMAND'],
3
3
  [/^Missing required argument: (.*)$/, 'MISSING_ARGUMENT'],
4
+ [/^Failed to open web browser. (.*)$/, 'AUTH_BROWSER_FAILED'],
4
5
  ];
5
6
  export const matchErrorCode = (message) => {
6
7
  if (!message) {
package/index.js CHANGED
@@ -20,7 +20,7 @@ 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, sendError } from './analytics.js';
23
+ import { analyticsMiddleware, closeAnalytics, sendError } from './analytics.js';
24
24
  import { isAxiosError } from 'axios';
25
25
  import { matchErrorCode } from './errors.js';
26
26
  import { showHelp } from './help.js';
@@ -96,6 +96,12 @@ builder = builder
96
96
  type: 'string',
97
97
  default: currentContextFile,
98
98
  },
99
+ color: {
100
+ group: 'Global options:',
101
+ describe: 'Colorize the output. Example: --no-color, --color false',
102
+ type: 'boolean',
103
+ default: true,
104
+ },
99
105
  })
100
106
  .middleware((args) => fillInArgs(args), true)
101
107
  .help(false)
@@ -156,13 +162,20 @@ builder = builder
156
162
  sendError(err || new Error(msg), matchErrorCode(msg || err?.message));
157
163
  log.error(msg || err?.message);
158
164
  }
165
+ await closeAnalytics();
159
166
  err?.stack && log.debug('Stack: %s', err.stack);
160
167
  process.exit(1);
161
168
  });
162
169
  (async () => {
163
- const args = await builder.argv;
164
- if (args._.length === 0 || args.help) {
165
- await showHelp(builder);
166
- process.exit(0);
170
+ try {
171
+ const args = await builder.argv;
172
+ if (args._.length === 0 || args.help) {
173
+ await showHelp(builder);
174
+ process.exit(0);
175
+ }
176
+ await closeAnalytics();
177
+ }
178
+ catch {
179
+ // noop
167
180
  }
168
181
  })();
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": "1.29.4",
8
+ "version": "1.30.0",
9
9
  "description": "CLI tool for NeonDB Cloud management",
10
10
  "main": "index.js",
11
11
  "author": "NeonDB",
@@ -29,6 +29,7 @@
29
29
  "@semantic-release/exec": "^6.0.3",
30
30
  "@semantic-release/git": "^10.0.1",
31
31
  "@types/cli-table": "^0.3.0",
32
+ "@types/diff": "^5.2.1",
32
33
  "@types/express": "^4.17.17",
33
34
  "@types/inquirer": "^9.0.3",
34
35
  "@types/node": "^18.7.13",
@@ -53,15 +54,16 @@
53
54
  "typescript": "^4.7.4"
54
55
  },
55
56
  "dependencies": {
56
- "@neondatabase/api-client": "1.5.0",
57
+ "@neondatabase/api-client": "1.7.0",
57
58
  "@segment/analytics-node": "^1.0.0-beta.26",
58
59
  "axios": "^1.4.0",
59
60
  "axios-debug-log": "^1.0.0",
60
61
  "chalk": "^5.2.0",
61
62
  "cli-table": "^0.3.11",
63
+ "diff": "^5.2.0",
62
64
  "inquirer": "^9.2.6",
63
- "open": "^8.4.0",
64
- "openid-client": "^5.1.9",
65
+ "open": "^10.1.0",
66
+ "openid-client": "^5.6.5",
65
67
  "which": "^3.0.1",
66
68
  "yaml": "^2.1.1",
67
69
  "yargs": "^17.7.2"
package/parameters.gen.js CHANGED
@@ -30,10 +30,15 @@ export const projectCreateRequest = {
30
30
  description: "A list of IP addresses that are allowed to connect to the endpoint.",
31
31
  demandOption: false,
32
32
  },
33
+ 'project.settings.allowed_ips.protected_branches_only': {
34
+ type: "boolean",
35
+ description: "If true, the list will be applied only to protected branches.",
36
+ demandOption: false,
37
+ },
33
38
  'project.settings.allowed_ips.primary_branch_only': {
34
39
  type: "boolean",
35
40
  description: "If true, the list will be applied only to the primary branch.",
36
- demandOption: true,
41
+ demandOption: false,
37
42
  },
38
43
  'project.settings.enable_logical_replication': {
39
44
  type: "boolean",
@@ -91,6 +96,11 @@ export const projectCreateRequest = {
91
96
  description: "The number of seconds to retain the point-in-time restore (PITR) backup history for this project.\nThe default is 604800 seconds (7 days).\n",
92
97
  demandOption: false,
93
98
  },
99
+ 'project.org_id': {
100
+ type: "string",
101
+ description: "Organization id in case the project created belongs to an organization.\nIf not present, project is owned by a user and not by org.\n",
102
+ demandOption: false,
103
+ },
94
104
  };
95
105
  export const projectUpdateRequest = {
96
106
  'project.settings.quota.active_time_seconds': {
@@ -123,10 +133,15 @@ export const projectUpdateRequest = {
123
133
  description: "A list of IP addresses that are allowed to connect to the endpoint.",
124
134
  demandOption: false,
125
135
  },
136
+ 'project.settings.allowed_ips.protected_branches_only': {
137
+ type: "boolean",
138
+ description: "If true, the list will be applied only to protected branches.",
139
+ demandOption: false,
140
+ },
126
141
  'project.settings.allowed_ips.primary_branch_only': {
127
142
  type: "boolean",
128
143
  description: "If true, the list will be applied only to the primary branch.",
129
- demandOption: true,
144
+ demandOption: false,
130
145
  },
131
146
  'project.settings.enable_logical_replication': {
132
147
  type: "boolean",
@@ -18,8 +18,14 @@ export const parsePITBranch = (input) => {
18
18
  ? { tag: 'lsn', lsn: exactPIT }
19
19
  : { tag: 'timestamp', timestamp: exactPIT }),
20
20
  };
21
- if (result.tag === 'timestamp' && !looksLikeTimestamp(result.timestamp)) {
22
- throw new PointInTimeParseError('Invalid source branch format');
21
+ if (result.tag === 'timestamp') {
22
+ const timestamp = result.timestamp;
23
+ if (!looksLikeTimestamp(timestamp)) {
24
+ throw new PointInTimeParseError(`Invalid source branch format - ${input}`);
25
+ }
26
+ if (Date.parse(timestamp) > Date.now()) {
27
+ throw new PointInTimeParseError(`Timestamp can not be in future - ${input}`);
28
+ }
23
29
  }
24
30
  return result;
25
31
  };
package/writer.js CHANGED
@@ -71,6 +71,9 @@ export const writer = (props) => {
71
71
  chunks.push({ data, config });
72
72
  return this;
73
73
  },
74
+ text(data) {
75
+ return out.write(data);
76
+ },
74
77
  end: (...args) => {
75
78
  if (args.length === 2) {
76
79
  chunks.push({ data: args[0], config: args[1] });