neonctl 1.29.5 → 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 +11 -5
- package/commands/branches.js +42 -1
- package/commands/schema_diff.js +150 -0
- package/index.js +18 -5
- package/package.json +4 -2
- package/parameters.gen.js +17 -2
- package/utils/point_in_time.js +8 -2
- package/writer.js +3 -0
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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/commands/branches.js
CHANGED
|
@@ -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/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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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.
|
|
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,12 +54,13 @@
|
|
|
53
54
|
"typescript": "^4.7.4"
|
|
54
55
|
},
|
|
55
56
|
"dependencies": {
|
|
56
|
-
"@neondatabase/api-client": "1.
|
|
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
65
|
"open": "^10.1.0",
|
|
64
66
|
"openid-client": "^5.6.5",
|
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:
|
|
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:
|
|
144
|
+
demandOption: false,
|
|
130
145
|
},
|
|
131
146
|
'project.settings.enable_logical_replication': {
|
|
132
147
|
type: "boolean",
|
package/utils/point_in_time.js
CHANGED
|
@@ -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'
|
|
22
|
-
|
|
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