neonctl 2.11.0 → 2.13.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/README.md +3 -2
- package/analytics.js +16 -3
- package/commands/auth.js +22 -1
- package/commands/auth.test.js +31 -1
- package/commands/branches.js +6 -0
- package/commands/branches.test.js +13 -0
- package/commands/connection_string.js +1 -0
- package/help.js +3 -3
- package/index.js +52 -24
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -145,13 +145,14 @@ Global options are supported with any Neon CLI command.
|
|
|
145
145
|
To run the CLI locally, execute the build command after making changes:
|
|
146
146
|
|
|
147
147
|
```shell
|
|
148
|
-
|
|
148
|
+
bun install
|
|
149
|
+
bun run build
|
|
149
150
|
```
|
|
150
151
|
|
|
151
152
|
To develop continuously:
|
|
152
153
|
|
|
153
154
|
```shell
|
|
154
|
-
|
|
155
|
+
bun run watch
|
|
155
156
|
```
|
|
156
157
|
|
|
157
158
|
To run commands from the local build, replace the `neonctl` command with `node dist`; for example:
|
package/analytics.js
CHANGED
|
@@ -25,13 +25,26 @@ export const analyticsMiddleware = async (args) => {
|
|
|
25
25
|
log.debug('Failed to read credentials file', err);
|
|
26
26
|
}
|
|
27
27
|
try {
|
|
28
|
-
if (
|
|
28
|
+
if (args.apiKey) {
|
|
29
29
|
const apiClient = getApiClient({
|
|
30
30
|
apiKey: args.apiKey,
|
|
31
31
|
apiHost: args.apiHost,
|
|
32
32
|
});
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// Populating api key details for analytics
|
|
34
|
+
const authDetailsResponse = await apiClient.getAuthDetails();
|
|
35
|
+
const authDetails = authDetailsResponse.data;
|
|
36
|
+
args.accountId = authDetails.account_id;
|
|
37
|
+
args.authMethod = authDetails.auth_method;
|
|
38
|
+
args.authData = authDetails.auth_data;
|
|
39
|
+
// Get user id if not org api key
|
|
40
|
+
if (!userId && authDetails.auth_method !== 'api_key_org') {
|
|
41
|
+
const resp = await apiClient?.getCurrentUserInfo?.();
|
|
42
|
+
userId = resp?.data?.id;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
args.accountId = userId;
|
|
47
|
+
args.authMethod = 'oauth';
|
|
35
48
|
}
|
|
36
49
|
}
|
|
37
50
|
catch (err) {
|
package/commands/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { TokenSet } from 'openid-client';
|
|
@@ -153,4 +153,25 @@ export const ensureAuth = async (props) => {
|
|
|
153
153
|
apiHost: props.apiHost,
|
|
154
154
|
});
|
|
155
155
|
};
|
|
156
|
+
/**
|
|
157
|
+
* Deletes the credentials file at the specified path
|
|
158
|
+
* @param configDir Directory where credentials file is stored
|
|
159
|
+
*/
|
|
160
|
+
export const deleteCredentials = (configDir) => {
|
|
161
|
+
const credentialsPath = join(configDir, CREDENTIALS_FILE);
|
|
162
|
+
try {
|
|
163
|
+
if (existsSync(credentialsPath)) {
|
|
164
|
+
rmSync(credentialsPath);
|
|
165
|
+
log.info('Deleted credentials from %s', credentialsPath);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
log.debug('Credentials file %s does not exist', credentialsPath);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
const typedErr = err instanceof Error ? err : new Error('Unknown error');
|
|
173
|
+
log.error('Failed to delete credentials: %s', typedErr.message);
|
|
174
|
+
throw new Error('CREDENTIALS_DELETE_FAILED');
|
|
175
|
+
}
|
|
176
|
+
};
|
|
156
177
|
const md5hash = (s) => createHash('md5').update(s).digest('hex');
|
package/commands/auth.test.js
CHANGED
|
@@ -6,7 +6,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, vi } from 'vitest';
|
|
|
6
6
|
import * as authModule from '../auth';
|
|
7
7
|
import { test } from '../test_utils/fixtures';
|
|
8
8
|
import { startOauthServer } from '../test_utils/oauth_server';
|
|
9
|
-
import { authFlow, ensureAuth } from './auth';
|
|
9
|
+
import { authFlow, ensureAuth, deleteCredentials } from './auth';
|
|
10
10
|
vi.mock('open', () => ({ default: vi.fn((url) => axios.get(url)) }));
|
|
11
11
|
vi.mock('../pkg.ts', () => ({ default: { version: '0.0.0' } }));
|
|
12
12
|
describe('auth', () => {
|
|
@@ -163,3 +163,33 @@ describe('ensureAuth', () => {
|
|
|
163
163
|
expect(props.apiKey).toBe('new-token');
|
|
164
164
|
});
|
|
165
165
|
});
|
|
166
|
+
describe('deleteCredentials', () => {
|
|
167
|
+
let configDir = '';
|
|
168
|
+
beforeAll(() => {
|
|
169
|
+
configDir = mkdtempSync('test-config-delete');
|
|
170
|
+
});
|
|
171
|
+
afterAll(() => {
|
|
172
|
+
rmSync(configDir, { recursive: true });
|
|
173
|
+
});
|
|
174
|
+
test('should successfully delete credentials file', () => {
|
|
175
|
+
const credentialsPath = join(configDir, 'credentials.json');
|
|
176
|
+
writeFileSync(credentialsPath, 'test-content', { mode: 0o700 });
|
|
177
|
+
expect(existsSync(credentialsPath)).toBe(true);
|
|
178
|
+
deleteCredentials(configDir);
|
|
179
|
+
expect(existsSync(credentialsPath)).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
test('should handle non-existent file gracefully', () => {
|
|
182
|
+
const nonExistentDir = mkdtempSync('test-config-nonexistent');
|
|
183
|
+
// Ensure the file doesn't exist
|
|
184
|
+
const credentialsPath = join(nonExistentDir, 'credentials.json');
|
|
185
|
+
if (existsSync(credentialsPath)) {
|
|
186
|
+
rmSync(credentialsPath);
|
|
187
|
+
}
|
|
188
|
+
expect(existsSync(credentialsPath)).toBe(false);
|
|
189
|
+
// Should not throw an error
|
|
190
|
+
expect(() => {
|
|
191
|
+
deleteCredentials(nonExistentDir);
|
|
192
|
+
}).not.toThrow();
|
|
193
|
+
rmSync(nonExistentDir, { recursive: true });
|
|
194
|
+
});
|
|
195
|
+
});
|
package/commands/branches.js
CHANGED
|
@@ -142,6 +142,10 @@ export const builder = (argv) => argv
|
|
|
142
142
|
describe: 'The number of Compute Units. Could be a fixed size (e.g. "2") or a range delimited by a dash (e.g. "0.5-3").',
|
|
143
143
|
type: 'string',
|
|
144
144
|
},
|
|
145
|
+
name: {
|
|
146
|
+
type: 'string',
|
|
147
|
+
describe: 'Optional name of the compute',
|
|
148
|
+
},
|
|
145
149
|
}), (args) => addCompute(args))
|
|
146
150
|
.command('delete <id|name>', 'Delete a branch', (yargs) => yargs, (args) => deleteBranch(args))
|
|
147
151
|
.command('get <id|name>', 'Get a branch', (yargs) => yargs, (args) => get(args))
|
|
@@ -319,11 +323,13 @@ const get = async (props) => {
|
|
|
319
323
|
};
|
|
320
324
|
const addCompute = async (props) => {
|
|
321
325
|
const branchId = await branchIdFromProps(props);
|
|
326
|
+
const computeName = props.name ? { name: props.name } : null;
|
|
322
327
|
const { data } = await retryOnLock(() => props.apiClient.createProjectEndpoint(props.projectId, {
|
|
323
328
|
endpoint: {
|
|
324
329
|
branch_id: branchId,
|
|
325
330
|
type: props.type,
|
|
326
331
|
...(props.cu ? getComputeUnits(props.cu) : undefined),
|
|
332
|
+
...computeName,
|
|
327
333
|
},
|
|
328
334
|
}));
|
|
329
335
|
writer(props).end(data.endpoint, {
|
|
@@ -274,6 +274,19 @@ describe('branches', () => {
|
|
|
274
274
|
'0.5-2',
|
|
275
275
|
]);
|
|
276
276
|
});
|
|
277
|
+
test('add compute with a name', async ({ testCliCommand }) => {
|
|
278
|
+
await testCliCommand([
|
|
279
|
+
'branches',
|
|
280
|
+
'add-compute',
|
|
281
|
+
'test_branch_with_autoscaling',
|
|
282
|
+
'--project-id',
|
|
283
|
+
'test',
|
|
284
|
+
'--cu',
|
|
285
|
+
'0.5-2',
|
|
286
|
+
'--name',
|
|
287
|
+
'My fancy new compute',
|
|
288
|
+
]);
|
|
289
|
+
});
|
|
277
290
|
/* reset */
|
|
278
291
|
test('reset branch to parent', async ({ testCliCommand }) => {
|
|
279
292
|
await testCliCommand([
|
|
@@ -131,6 +131,7 @@ export const handler = async (props) => {
|
|
|
131
131
|
}
|
|
132
132
|
if (props.ssl !== 'omit') {
|
|
133
133
|
connectionString.searchParams.set('sslmode', props.ssl);
|
|
134
|
+
connectionString.searchParams.set('channel_binding', 'require');
|
|
134
135
|
}
|
|
135
136
|
if (parsedPIT.tag === 'lsn') {
|
|
136
137
|
connectionString.searchParams.set('options', `neon_lsn:${parsedPIT.lsn}`);
|
package/help.js
CHANGED
|
@@ -71,9 +71,9 @@ const formatHelp = (help) => {
|
|
|
71
71
|
}
|
|
72
72
|
// command description
|
|
73
73
|
// example command to see: neonctl projects list
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
result.push(...
|
|
74
|
+
const descriptionBlock = consumeBlockIfMatches(lines, /^(?!.*options:)/i);
|
|
75
|
+
if (descriptionBlock.length > 0) {
|
|
76
|
+
result.push(...descriptionBlock);
|
|
77
77
|
result.push('');
|
|
78
78
|
}
|
|
79
79
|
while (true) {
|
package/index.js
CHANGED
|
@@ -13,7 +13,7 @@ axiosDebug({
|
|
|
13
13
|
debug(error);
|
|
14
14
|
},
|
|
15
15
|
});
|
|
16
|
-
import { ensureAuth } from './commands/auth.js';
|
|
16
|
+
import { ensureAuth, deleteCredentials } from './commands/auth.js';
|
|
17
17
|
import { defaultDir, ensureConfigDir } from './config.js';
|
|
18
18
|
import { log } from './log.js';
|
|
19
19
|
import { defaultClientID } from './auth.js';
|
|
@@ -143,19 +143,33 @@ builder = builder
|
|
|
143
143
|
.scriptName(basename(process.argv[1]) === 'neon' ? 'neon' : 'neonctl')
|
|
144
144
|
.epilog('For more information, visit https://neon.tech/docs/reference/neon-cli')
|
|
145
145
|
.wrap(null)
|
|
146
|
-
.fail(
|
|
146
|
+
.fail(false);
|
|
147
|
+
async function handleError(msg, err) {
|
|
147
148
|
if (process.argv.some((arg) => arg === '--help' || arg === '-h')) {
|
|
148
149
|
await showHelp(builder);
|
|
149
150
|
process.exit(0);
|
|
150
151
|
}
|
|
152
|
+
// Log stack trace if available
|
|
153
|
+
if (err instanceof Error && err.stack) {
|
|
154
|
+
log.debug('Stack: %s', err.stack);
|
|
155
|
+
}
|
|
151
156
|
if (isAxiosError(err)) {
|
|
152
157
|
if (err.code === 'ECONNABORTED') {
|
|
153
158
|
log.error('Request timed out');
|
|
154
159
|
sendError(err, 'REQUEST_TIMEOUT');
|
|
160
|
+
return false;
|
|
155
161
|
}
|
|
156
162
|
else if (err.response?.status === 401) {
|
|
157
163
|
sendError(err, 'AUTH_FAILED');
|
|
158
|
-
log.
|
|
164
|
+
log.info('Authentication failed, deleting credentials...');
|
|
165
|
+
try {
|
|
166
|
+
deleteCredentials(defaultDir);
|
|
167
|
+
return true; // Allow retry for auth failures
|
|
168
|
+
}
|
|
169
|
+
catch (deleteErr) {
|
|
170
|
+
log.debug('Failed to delete credentials: %s', deleteErr instanceof Error ? deleteErr.message : 'unknown error');
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
159
173
|
}
|
|
160
174
|
else {
|
|
161
175
|
if (err.response?.data?.message) {
|
|
@@ -163,33 +177,47 @@ builder = builder
|
|
|
163
177
|
}
|
|
164
178
|
log.debug('status: %d %s | path: %s', err.response?.status, err.response?.statusText, err.request?.path);
|
|
165
179
|
sendError(err, 'API_ERROR');
|
|
180
|
+
return false;
|
|
166
181
|
}
|
|
167
182
|
}
|
|
168
183
|
else {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (err?.stack) {
|
|
174
|
-
log.debug('Stack: %s', err.stack);
|
|
184
|
+
const error = err instanceof Error ? err : new Error(msg || 'Unknown error');
|
|
185
|
+
sendError(error, matchErrorCode(error.message));
|
|
186
|
+
log.error(error.message);
|
|
187
|
+
return false;
|
|
175
188
|
}
|
|
176
|
-
|
|
177
|
-
});
|
|
189
|
+
}
|
|
178
190
|
void (async () => {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
191
|
+
// Main loop with max 2 attempts (initial + 1 retry):
|
|
192
|
+
let attempts = 0;
|
|
193
|
+
const MAX_ATTEMPTS = 2;
|
|
194
|
+
while (attempts < MAX_ATTEMPTS) {
|
|
195
|
+
try {
|
|
196
|
+
const args = await builder.argv;
|
|
197
|
+
// Send analytics for a successful attempt
|
|
198
|
+
trackEvent('cli_command_success', {
|
|
199
|
+
...getAnalyticsEventProperties(args),
|
|
200
|
+
projectId: args.projectId,
|
|
201
|
+
branchId: args.branchId,
|
|
202
|
+
accountId: args.accountId,
|
|
203
|
+
authMethod: args.authMethod,
|
|
204
|
+
authData: args.authData,
|
|
205
|
+
});
|
|
206
|
+
if (args._.length === 0 || args.help) {
|
|
207
|
+
await showHelp(builder);
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
await closeAnalytics();
|
|
188
211
|
process.exit(0);
|
|
189
212
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
213
|
+
catch (err) {
|
|
214
|
+
attempts++;
|
|
215
|
+
const shouldRetry = await handleError('', err);
|
|
216
|
+
if (!shouldRetry || attempts >= MAX_ATTEMPTS) {
|
|
217
|
+
await closeAnalytics();
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
// If shouldRetry is true and we haven't hit max attempts, loop continues
|
|
221
|
+
}
|
|
194
222
|
}
|
|
195
223
|
})();
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"url": "git+ssh://git@github.com/neondatabase/neonctl.git"
|
|
6
6
|
},
|
|
7
7
|
"type": "module",
|
|
8
|
-
"version": "2.
|
|
8
|
+
"version": "2.13.0",
|
|
9
9
|
"description": "CLI tool for NeonDB Cloud management",
|
|
10
10
|
"main": "index.js",
|
|
11
11
|
"author": "NeonDB",
|
|
@@ -110,6 +110,5 @@
|
|
|
110
110
|
"eslint --cache --fix",
|
|
111
111
|
"prettier --write"
|
|
112
112
|
]
|
|
113
|
-
}
|
|
114
|
-
"packageManager": "pnpm@9.4.0+sha512.f549b8a52c9d2b8536762f99c0722205efc5af913e77835dbccc3b0b0b2ca9e7dc8022b78062c17291c48e88749c70ce88eb5a74f1fa8c4bf5e18bb46c8bd83a"
|
|
113
|
+
}
|
|
115
114
|
}
|