neonctl 2.27.1 → 2.28.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 +33 -1
- package/dist/analytics.js +21 -5
- package/dist/api.js +641 -11
- package/dist/cli.js +8 -1
- package/dist/commands/auth.js +7 -0
- package/dist/commands/branches.js +7 -4
- package/dist/commands/bucket.js +67 -36
- package/dist/commands/checkout.js +3 -3
- package/dist/commands/config.js +22 -0
- package/dist/commands/connection_string.js +1 -1
- package/dist/commands/data_api.js +5 -6
- package/dist/commands/databases.js +6 -3
- package/dist/commands/functions.js +5 -7
- package/dist/commands/index.js +2 -0
- package/dist/commands/link.js +10 -17
- package/dist/commands/neon_auth.js +8 -11
- package/dist/commands/projects.js +4 -4
- package/dist/commands/psql.js +1 -1
- package/dist/commands/roles.js +6 -3
- package/dist/commands/schema_diff.js +3 -4
- package/dist/commands/status.js +40 -0
- package/dist/context.js +16 -0
- package/dist/current_branch_fast_path.js +55 -0
- package/dist/errors.js +63 -0
- package/dist/functions_api.js +1 -1
- package/dist/index.js +21 -20
- package/dist/parameters.gen.js +14 -14
- package/dist/storage_api.js +7 -8
- package/dist/test_utils/fixtures.js +45 -15
- package/dist/utils/api_enums.js +33 -0
- package/dist/utils/branch_picker.js +1 -1
- package/dist/utils/enrichers.js +11 -4
- package/package.json +8 -9
package/dist/commands/auth.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { getApiClient } from '../api.js';
|
|
5
5
|
import { auth, refreshToken } from '../auth.js';
|
|
6
|
+
import { isCurrentBranchProbe } from '../context.js';
|
|
6
7
|
import { CREDENTIALS_FILE } from '../config.js';
|
|
7
8
|
import { isCi } from '../env.js';
|
|
8
9
|
import { log } from '../log.js';
|
|
@@ -100,6 +101,12 @@ export const ensureAuth = async (props) => {
|
|
|
100
101
|
if (props._.length === 0 || props.help) {
|
|
101
102
|
return;
|
|
102
103
|
}
|
|
104
|
+
// `(config) status --current-branch` is a purely-local read of `.neon`; it must
|
|
105
|
+
// never refresh a token or pop a browser login. Skip auth entirely (the handler
|
|
106
|
+
// doesn't use an API client in this mode).
|
|
107
|
+
if (isCurrentBranchProbe(props)) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
103
110
|
// `dev` runs a function locally. It injects the selected branch's env vars
|
|
104
111
|
// when credentials happen to be available, but must never trigger an
|
|
105
112
|
// interactive login: use an API key or existing stored credentials if
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EndpointType } from '
|
|
1
|
+
import { EndpointType } from '../utils/api_enums.js';
|
|
2
2
|
import { contextBranch, readContextFile } from '../context.js';
|
|
3
3
|
import { writer } from '../writer.js';
|
|
4
4
|
import { branchCreateRequest } from '../parameters.gen.js';
|
|
@@ -367,9 +367,12 @@ const setDefault = async (props) => {
|
|
|
367
367
|
const deleteBranch = async (props) => {
|
|
368
368
|
const branchId = await branchIdFromProps(props);
|
|
369
369
|
const { data } = await retryOnLock(() => props.apiClient.deleteProjectBranch(props.projectId, branchId));
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
370
|
+
// A 204 (branch already gone) carries no body; only a 200 returns it.
|
|
371
|
+
if (data) {
|
|
372
|
+
writer(props).end(data.branch, {
|
|
373
|
+
fields: BRANCH_FIELDS,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
373
376
|
};
|
|
374
377
|
const get = async (props) => {
|
|
375
378
|
const branchId = await branchIdFromProps(props);
|
package/dist/commands/bucket.js
CHANGED
|
@@ -2,8 +2,7 @@ import { createReadStream, createWriteStream } from 'node:fs';
|
|
|
2
2
|
import { stat, unlink } from 'node:fs/promises';
|
|
3
3
|
import { basename } from 'node:path';
|
|
4
4
|
import { pipeline } from 'node:stream/promises';
|
|
5
|
-
import
|
|
6
|
-
import { retryOnLock } from '../api.js';
|
|
5
|
+
import { isNeonApiError, retryOnLock } from '../api.js';
|
|
7
6
|
import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
|
|
8
7
|
import { log } from '../log.js';
|
|
9
8
|
import { writer } from '../writer.js';
|
|
@@ -222,7 +221,7 @@ const deleteBucket = async (props) => {
|
|
|
222
221
|
}));
|
|
223
222
|
}
|
|
224
223
|
catch (err) {
|
|
225
|
-
if (
|
|
224
|
+
if (isNeonApiError(err) && err.status === 404) {
|
|
226
225
|
throw new Error(`Bucket "${props.name}" not found on branch ${branchId}.`);
|
|
227
226
|
}
|
|
228
227
|
throw err;
|
|
@@ -332,14 +331,42 @@ const objectNotFoundFallback = (key, bucket, branchId) => `Object "${key}" not f
|
|
|
332
331
|
// misreported as a missing object; otherwise fall back to a clean default. Used
|
|
333
332
|
// for the JSON (non-streamed) endpoints where the body is already parsed.
|
|
334
333
|
const objectNotFoundMessage = (err, key, bucket, branchId) => {
|
|
335
|
-
if (
|
|
336
|
-
const serverMessage = serverErrorMessage(err.
|
|
334
|
+
if (isNeonApiError(err)) {
|
|
335
|
+
const serverMessage = serverErrorMessage(err.data);
|
|
337
336
|
if (serverMessage !== undefined) {
|
|
338
337
|
return serverMessage;
|
|
339
338
|
}
|
|
340
339
|
}
|
|
341
340
|
return objectNotFoundFallback(key, bucket, branchId);
|
|
342
341
|
};
|
|
342
|
+
// Stream a file from disk as a WHATWG `ReadableStream` suitable for a `fetch`
|
|
343
|
+
// request body, applying backpressure so we never read faster than the upload
|
|
344
|
+
// drains. Uses the global `ReadableStream` (what `fetch` expects) directly, so
|
|
345
|
+
// there's no Node-vs-DOM stream type bridging.
|
|
346
|
+
const fileToWebStream = (path) => {
|
|
347
|
+
const source = createReadStream(path);
|
|
348
|
+
return new ReadableStream({
|
|
349
|
+
start(controller) {
|
|
350
|
+
source.on('data', (chunk) => {
|
|
351
|
+
controller.enqueue(new Uint8Array(chunk));
|
|
352
|
+
if ((controller.desiredSize ?? 0) <= 0)
|
|
353
|
+
source.pause();
|
|
354
|
+
});
|
|
355
|
+
source.on('end', () => {
|
|
356
|
+
controller.close();
|
|
357
|
+
});
|
|
358
|
+
source.on('error', (err) => {
|
|
359
|
+
controller.error(err instanceof Error ? err : new Error(String(err)));
|
|
360
|
+
});
|
|
361
|
+
},
|
|
362
|
+
pull() {
|
|
363
|
+
source.resume();
|
|
364
|
+
},
|
|
365
|
+
cancel() {
|
|
366
|
+
source.destroy();
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
};
|
|
343
370
|
const getObject = async (props) => {
|
|
344
371
|
const branchId = await branchIdFromProps(props);
|
|
345
372
|
const { bucket, rest: key } = splitBucketTarget(props.target);
|
|
@@ -356,11 +383,11 @@ const getObject = async (props) => {
|
|
|
356
383
|
});
|
|
357
384
|
}
|
|
358
385
|
catch (err) {
|
|
359
|
-
if (
|
|
386
|
+
if (isNeonApiError(err) && err.status === 404) {
|
|
360
387
|
// The download response is a stream, so a 404 body arrives as a stream
|
|
361
388
|
// too; drain and parse it to recover the server's message (which
|
|
362
389
|
// distinguishes a missing bucket from a missing object).
|
|
363
|
-
const serverMessage = await streamErrorMessage(err.
|
|
390
|
+
const serverMessage = await streamErrorMessage(err.data);
|
|
364
391
|
throw new Error(serverMessage ?? objectNotFoundFallback(key, bucket, branchId));
|
|
365
392
|
}
|
|
366
393
|
throw err;
|
|
@@ -417,52 +444,56 @@ const putObject = async (props) => {
|
|
|
417
444
|
}));
|
|
418
445
|
}
|
|
419
446
|
catch (err) {
|
|
420
|
-
if (
|
|
421
|
-
const status = err.
|
|
447
|
+
if (isNeonApiError(err)) {
|
|
448
|
+
const status = err.status;
|
|
422
449
|
if (status === 404) {
|
|
423
450
|
throw new Error(objectNotFoundMessage(err, key, bucket, branchId));
|
|
424
451
|
}
|
|
425
452
|
// Any other HTTP error from the console (e.g. 403 when the caller lacks
|
|
426
453
|
// write permission on the bucket) carries the same JSON `{ message }`
|
|
427
|
-
// body, so surface that rather than a bare
|
|
428
|
-
//
|
|
429
|
-
const serverMessage = serverErrorMessage(err.
|
|
454
|
+
// body, so surface that rather than a bare error. When the body has no
|
|
455
|
+
// usable message, fall back to a clean status-bearing error.
|
|
456
|
+
const serverMessage = serverErrorMessage(err.data);
|
|
430
457
|
throw new Error(serverMessage ??
|
|
431
458
|
`Failed to presign upload for "${key}" in bucket "${bucket}" on branch ${branchId}${status !== undefined ? ` (HTTP ${status})` : ''}: ${err.message}`);
|
|
432
459
|
}
|
|
433
460
|
throw err;
|
|
434
461
|
}
|
|
435
|
-
// Stream the file straight into the PUT body
|
|
436
|
-
// The presigned URL targets the branch S3 data-plane endpoint
|
|
437
|
-
// this PUT
|
|
462
|
+
// Stream the file straight into the PUT body via `fetch`; never buffer the
|
|
463
|
+
// whole file. The presigned URL targets the branch S3 data-plane endpoint
|
|
464
|
+
// directly, so this PUT bypasses the console API entirely.
|
|
438
465
|
//
|
|
439
466
|
// `presign.headers` carries the signature-relevant headers (e.g. host,
|
|
440
467
|
// content-type); the server does not sign Content-Length, so we set it
|
|
441
|
-
// ourselves from the stat'd size
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
468
|
+
// ourselves from the stat'd size. `redirect: 'error'` ensures we never resend
|
|
469
|
+
// the file bytes and signed headers to a different host if the data-plane
|
|
470
|
+
// endpoint were to answer with a redirect. `duplex: 'half'` is required by
|
|
471
|
+
// fetch when streaming a request body.
|
|
472
|
+
const upload = {
|
|
473
|
+
method: 'PUT',
|
|
474
|
+
headers: {
|
|
475
|
+
...presign.headers,
|
|
476
|
+
'Content-Length': String(fileSize),
|
|
477
|
+
},
|
|
478
|
+
body: fileToWebStream(props.file),
|
|
479
|
+
redirect: 'error',
|
|
480
|
+
duplex: 'half',
|
|
481
|
+
};
|
|
482
|
+
let uploadResponse;
|
|
445
483
|
try {
|
|
446
|
-
await
|
|
447
|
-
headers: {
|
|
448
|
-
...presign.headers,
|
|
449
|
-
'Content-Length': fileSize,
|
|
450
|
-
},
|
|
451
|
-
maxBodyLength: Infinity,
|
|
452
|
-
maxContentLength: Infinity,
|
|
453
|
-
maxRedirects: 0,
|
|
454
|
-
});
|
|
484
|
+
uploadResponse = await fetch(presign.url, upload);
|
|
455
485
|
}
|
|
456
486
|
catch (err) {
|
|
487
|
+
// A transport-level failure (DNS, connection reset, redirect when none is
|
|
488
|
+
// allowed). Surface a clean message without leaking the signed URL.
|
|
489
|
+
throw new Error(`Failed to upload "${props.file}" to "${key}" in bucket "${bucket}" on branch ${branchId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
490
|
+
}
|
|
491
|
+
if (!uploadResponse.ok) {
|
|
457
492
|
// The upload targets the S3 data plane, whose error bodies are XML rather
|
|
458
493
|
// than the JSON `{ message }` the console returns, so surface the status
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
const status = err.response?.status;
|
|
463
|
-
throw new Error(`Failed to upload "${props.file}" to "${key}" in bucket "${bucket}" on branch ${branchId}${status !== undefined ? ` (HTTP ${status})` : ''}: ${err.message}`);
|
|
464
|
-
}
|
|
465
|
-
throw err;
|
|
494
|
+
// rather than the body. Never include the presigned URL, which carries the
|
|
495
|
+
// signature.
|
|
496
|
+
throw new Error(`Failed to upload "${props.file}" to "${key}" in bucket "${bucket}" on branch ${branchId} (HTTP ${uploadResponse.status}): Request failed with status code ${uploadResponse.status}`);
|
|
466
497
|
}
|
|
467
498
|
log.info(`File "${props.file}" uploaded to "${key}" in bucket "${bucket}" on branch ${branchId}`);
|
|
468
499
|
};
|
|
@@ -497,7 +528,7 @@ const deleteObject = async (props) => {
|
|
|
497
528
|
}));
|
|
498
529
|
}
|
|
499
530
|
catch (err) {
|
|
500
|
-
if (
|
|
531
|
+
if (isNeonApiError(err) && err.status === 404) {
|
|
501
532
|
throw new Error(objectNotFoundMessage(err, rest, bucket, branchId));
|
|
502
533
|
}
|
|
503
534
|
throw err;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isNeonApiError } from '../api.js';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import prompts from 'prompts';
|
|
4
4
|
import { applyContext, contextBranch, readContextFile } from '../context.js';
|
|
@@ -206,7 +206,7 @@ const resolveOrgId = async (props, projectId) => {
|
|
|
206
206
|
return data.project.org_id ?? undefined;
|
|
207
207
|
}
|
|
208
208
|
catch (err) {
|
|
209
|
-
if (
|
|
209
|
+
if (isNeonApiError(err) && err.status === 401) {
|
|
210
210
|
throw err;
|
|
211
211
|
}
|
|
212
212
|
log.debug('checkout: could not resolve org id for project %s: %s', projectId, err instanceof Error ? err.message : String(err));
|
|
@@ -280,7 +280,7 @@ const tryAutoDetectProject = async (props) => {
|
|
|
280
280
|
// `fillSingleProject` throws on "No projects found" / "Multiple projects
|
|
281
281
|
// found" — both mean we can't pick a project automatically. Network/auth
|
|
282
282
|
// errors are real and should surface to the user.
|
|
283
|
-
if (
|
|
283
|
+
if (isNeonApiError(err)) {
|
|
284
284
|
throw err;
|
|
285
285
|
}
|
|
286
286
|
log.debug('checkout: could not auto-detect a single project: %s', err instanceof Error ? err.message : String(err));
|
package/dist/commands/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { resolveConfig } from '@neondatabase/config';
|
|
3
3
|
import { apply, createBranch as createBranchFromPolicy, inspect, loadConfigFromFile, plan, } from '@neondatabase/config-runtime';
|
|
4
4
|
import { toNeonConfigView } from '../config_format.js';
|
|
5
|
+
import { contextBranch, readContextFile } from '../context.js';
|
|
5
6
|
import { log } from '../log.js';
|
|
6
7
|
import { isCi } from '../env.js';
|
|
7
8
|
import { loadEnvFileIntoProcess } from '../env_file.js';
|
|
@@ -93,6 +94,12 @@ export const builder = (argv) => argv
|
|
|
93
94
|
type: 'boolean',
|
|
94
95
|
default: false,
|
|
95
96
|
},
|
|
97
|
+
'current-branch': {
|
|
98
|
+
describe: 'Print only the linked branch name from the local .neon file ' +
|
|
99
|
+
'(no network). Exits non-zero when no branch is pinned.',
|
|
100
|
+
type: 'boolean',
|
|
101
|
+
default: false,
|
|
102
|
+
},
|
|
96
103
|
}), (args) => status(args))
|
|
97
104
|
.command('plan', 'Show what `config apply` would change (dry run)', (yargs) => yargs.options({
|
|
98
105
|
config: {
|
|
@@ -126,6 +133,21 @@ const loadConfig = async (props) => {
|
|
|
126
133
|
return config;
|
|
127
134
|
};
|
|
128
135
|
export const status = async (props) => {
|
|
136
|
+
// `--current-branch` short-circuits here (before resolveBranchRef), so it wins
|
|
137
|
+
// over --config-json and ignores --output. See ConfigProps.currentBranch / isCurrentBranchProbe.
|
|
138
|
+
if (props.currentBranch) {
|
|
139
|
+
const branch = contextBranch(readContextFile(props.contextFile));
|
|
140
|
+
if (branch) {
|
|
141
|
+
process.stdout.write(`${branch}\n`);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
// No branch pinned: hint on stderr and exit non-zero (grep-style) so a prompt's
|
|
145
|
+
// `when` hides the segment cleanly instead of rendering a bare icon.
|
|
146
|
+
log.info('No branch pinned. Run `neonctl checkout <branch>` to pin a branch and pull its env vars.');
|
|
147
|
+
process.exitCode = 1;
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
129
151
|
const branch = await resolveBranchRef(props);
|
|
130
152
|
// `--config-json` is a script-friendly mode that emits only JSON to stdout, so keep it
|
|
131
153
|
// pristine; the regular human view gets the "which branch am I inspecting" guardrail.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EndpointType
|
|
1
|
+
import { EndpointType } from '../utils/api_enums.js';
|
|
2
2
|
import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
|
|
3
3
|
import { writer } from '../writer.js';
|
|
4
4
|
import { psql } from '../utils/psql.js';
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { retryOnLock } from '../api.js';
|
|
1
|
+
import { isNeonApiError, retryOnLock } from '../api.js';
|
|
3
2
|
import { branchIdFromProps, fillSingleProject, resolveSingleDatabase, } from '../utils/enrichers.js';
|
|
4
3
|
import { log } from '../log.js';
|
|
5
4
|
import { writer } from '../writer.js';
|
|
@@ -222,7 +221,7 @@ const update = async (props) => {
|
|
|
222
221
|
current = data.settings ?? undefined;
|
|
223
222
|
}
|
|
224
223
|
catch (err) {
|
|
225
|
-
if (
|
|
224
|
+
if (isNeonApiError(err) && err.status === 404) {
|
|
226
225
|
throw new Error(`Data API is not provisioned for ${database} on branch ${branchId}. Run \`neonctl data-api create\` first.`);
|
|
227
226
|
}
|
|
228
227
|
throw err;
|
|
@@ -239,7 +238,7 @@ const update = async (props) => {
|
|
|
239
238
|
await retryOnLock(() => props.apiClient.updateProjectBranchDataApi(props.projectId, branchId, database, body));
|
|
240
239
|
}
|
|
241
240
|
catch (err) {
|
|
242
|
-
if (
|
|
241
|
+
if (isNeonApiError(err) && err.status === 404) {
|
|
243
242
|
throw new Error(`Data API is not provisioned for ${database} on branch ${branchId}. Run \`neonctl data-api create\` first.`);
|
|
244
243
|
}
|
|
245
244
|
throw err;
|
|
@@ -258,7 +257,7 @@ const refreshSchema = async (props) => {
|
|
|
258
257
|
await retryOnLock(() => props.apiClient.updateProjectBranchDataApi(props.projectId, branchId, database, {}));
|
|
259
258
|
}
|
|
260
259
|
catch (err) {
|
|
261
|
-
if (
|
|
260
|
+
if (isNeonApiError(err) && err.status === 404) {
|
|
262
261
|
throw new Error(`Data API is not provisioned for ${database} on branch ${branchId}. Run \`neonctl data-api create\` first.`);
|
|
263
262
|
}
|
|
264
263
|
throw err;
|
|
@@ -277,7 +276,7 @@ const deleteDataApi = async (props) => {
|
|
|
277
276
|
await retryOnLock(() => props.apiClient.deleteProjectBranchDataApi(props.projectId, branchId, database));
|
|
278
277
|
}
|
|
279
278
|
catch (err) {
|
|
280
|
-
if (
|
|
279
|
+
if (isNeonApiError(err) && err.status === 404) {
|
|
281
280
|
throw new Error(`Data API is not provisioned for ${database} on branch ${branchId}.`);
|
|
282
281
|
}
|
|
283
282
|
throw err;
|
|
@@ -73,7 +73,10 @@ export const create = async (props) => {
|
|
|
73
73
|
export const deleteDb = async (props) => {
|
|
74
74
|
const branchId = await branchIdFromProps(props);
|
|
75
75
|
const { data } = await retryOnLock(() => props.apiClient.deleteProjectBranchDatabase(props.projectId, branchId, props.database));
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
// A 204 (database already gone) carries no body; only a 200 returns it.
|
|
77
|
+
if (data) {
|
|
78
|
+
writer(props).end(data.database, {
|
|
79
|
+
fields: DATABASE_FIELDS,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
79
82
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { isNeonApiError } from '../api.js';
|
|
4
4
|
import { retryOnLock } from '../api.js';
|
|
5
5
|
import { log } from '../log.js';
|
|
6
6
|
import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
|
|
@@ -159,10 +159,8 @@ const emitDeployResult = (props, deployment, fn) => {
|
|
|
159
159
|
};
|
|
160
160
|
// A poll error worth retrying: a network error (no HTTP response), a 5xx, or a
|
|
161
161
|
// 404 from eventual consistency. Anything else (e.g. 401/403) is surfaced.
|
|
162
|
-
const isTransient = (err) =>
|
|
163
|
-
(err.
|
|
164
|
-
err.response.status === 404 ||
|
|
165
|
-
err.response.status >= 500);
|
|
162
|
+
const isTransient = (err) => isNeonApiError(err) &&
|
|
163
|
+
(err.status === undefined || err.status === 404 || err.status >= 500);
|
|
166
164
|
const deploy = async (props) => {
|
|
167
165
|
if (props.path !== undefined || props.entry !== undefined) {
|
|
168
166
|
throw new Error('--path and --entry were removed. Use --src <dir>; the entry point ' +
|
|
@@ -206,7 +204,7 @@ const deploy = async (props) => {
|
|
|
206
204
|
before = fn.current_deployment?.id;
|
|
207
205
|
}
|
|
208
206
|
catch (err) {
|
|
209
|
-
if (!(
|
|
207
|
+
if (!(isNeonApiError(err) && err.status === 404))
|
|
210
208
|
throw err;
|
|
211
209
|
}
|
|
212
210
|
await retryOnLock(() => createDeployment(props.apiClient, props.projectId, branchId, props.slug, {
|
|
@@ -341,7 +339,7 @@ const deleteFn = async (props) => {
|
|
|
341
339
|
await retryOnLock(() => deleteFunction(props.apiClient, props.projectId, branchId, props.slug));
|
|
342
340
|
}
|
|
343
341
|
catch (err) {
|
|
344
|
-
if (
|
|
342
|
+
if (isNeonApiError(err) && err.status === 404) {
|
|
345
343
|
throw new Error(`Function "${props.slug}" not found on branch ${branchId}.`);
|
|
346
344
|
}
|
|
347
345
|
throw err;
|
package/dist/commands/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import * as neonAuth from './neon_auth.js';
|
|
|
19
19
|
import * as functions from './functions.js';
|
|
20
20
|
import * as dev from './dev.js';
|
|
21
21
|
import * as config from './config.js';
|
|
22
|
+
import * as status from './status.js';
|
|
22
23
|
import * as deploy from './deploy.js';
|
|
23
24
|
import * as env from './env.js';
|
|
24
25
|
import * as bucket from './bucket.js';
|
|
@@ -45,6 +46,7 @@ export default [
|
|
|
45
46
|
functions,
|
|
46
47
|
dev,
|
|
47
48
|
config,
|
|
49
|
+
status,
|
|
48
50
|
deploy,
|
|
49
51
|
env,
|
|
50
52
|
bucket,
|
package/dist/commands/link.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isNeonApiError, messageFromBody } from '../api.js';
|
|
2
2
|
import prompts from 'prompts';
|
|
3
3
|
import { applyContext, contextBranch, readContextFile, setContext, updateContextFile, } from '../context.js';
|
|
4
4
|
import { isCi } from '../env.js';
|
|
@@ -267,7 +267,7 @@ class LinkInputError extends Error {
|
|
|
267
267
|
this.agentCode = agentCode;
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
|
-
const httpStatus = (err) =>
|
|
270
|
+
const httpStatus = (err) => isNeonApiError(err) ? err.status : undefined;
|
|
271
271
|
/**
|
|
272
272
|
* Fetch a project, turning the common failure modes into clear, actionable
|
|
273
273
|
* errors. 401 is rethrown so the global handler can refresh credentials;
|
|
@@ -803,13 +803,9 @@ const emitAgent = (response) => {
|
|
|
803
803
|
// ----------------------------------------------------------------------------
|
|
804
804
|
const ORG_KEY_LIMITED_FRAGMENT = 'not allowed for organization API keys';
|
|
805
805
|
const isOrgKeyLimitedError = (err) => {
|
|
806
|
-
if (!
|
|
806
|
+
if (!isNeonApiError(err))
|
|
807
807
|
return false;
|
|
808
|
-
const
|
|
809
|
-
if (data === undefined || data === null || typeof data !== 'object') {
|
|
810
|
-
return false;
|
|
811
|
-
}
|
|
812
|
-
const message = data.message;
|
|
808
|
+
const message = messageFromBody(err.data);
|
|
813
809
|
return (typeof message === 'string' && message.includes(ORG_KEY_LIMITED_FRAGMENT));
|
|
814
810
|
};
|
|
815
811
|
const fetchOrganizations = async (props) => {
|
|
@@ -879,13 +875,10 @@ const toAgentError = (err) => {
|
|
|
879
875
|
if (err instanceof LinkInputError) {
|
|
880
876
|
return { status: 'error', code: err.agentCode, message: err.message };
|
|
881
877
|
}
|
|
882
|
-
if (
|
|
883
|
-
const status = err.
|
|
884
|
-
const
|
|
885
|
-
const
|
|
886
|
-
? data.message
|
|
887
|
-
: undefined;
|
|
888
|
-
const message = typeof apiMessage === 'string' && apiMessage.length > 0
|
|
878
|
+
if (isNeonApiError(err)) {
|
|
879
|
+
const status = err.status;
|
|
880
|
+
const apiMessage = messageFromBody(err.data);
|
|
881
|
+
const message = apiMessage !== undefined && apiMessage.length > 0
|
|
889
882
|
? apiMessage
|
|
890
883
|
: err.message;
|
|
891
884
|
let code = 'API_ERROR';
|
|
@@ -968,8 +961,8 @@ const fetchRegions = async (props) => {
|
|
|
968
961
|
}
|
|
969
962
|
}
|
|
970
963
|
catch (err) {
|
|
971
|
-
if (
|
|
972
|
-
log.debug('getActiveRegions failed (%s), falling back to the static region list.', err.
|
|
964
|
+
if (isNeonApiError(err)) {
|
|
965
|
+
log.debug('getActiveRegions failed (%s), falling back to the static region list.', err.status ?? err.code ?? err.message);
|
|
973
966
|
}
|
|
974
967
|
else {
|
|
975
968
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { NeonAuthSupportedAuthProvider, NeonAuthOauthProviderId, NeonAuthOauthProviderType, NeonAuthEmailVerificationMethod, } from '
|
|
2
|
-
import { isAxiosError } from 'axios';
|
|
1
|
+
import { NeonAuthSupportedAuthProvider, NeonAuthOauthProviderId, NeonAuthOauthProviderType, NeonAuthEmailVerificationMethod, } from '../utils/api_enums.js';
|
|
3
2
|
import chalk from 'chalk';
|
|
4
|
-
import { retryOnLock } from '../api.js';
|
|
3
|
+
import { codeFromBody, isNeonApiError, retryOnLock } from '../api.js';
|
|
5
4
|
import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
|
|
6
5
|
import { writer } from '../writer.js';
|
|
7
6
|
// Shared styled output helpers
|
|
@@ -453,7 +452,7 @@ const enable = async (props) => {
|
|
|
453
452
|
})));
|
|
454
453
|
}
|
|
455
454
|
catch (err) {
|
|
456
|
-
if (
|
|
455
|
+
if (isNeonApiError(err) && err.status === 409) {
|
|
457
456
|
alreadyEnabled = true;
|
|
458
457
|
({ data } = await props.apiClient.getNeonAuth(props.projectId, branchId));
|
|
459
458
|
}
|
|
@@ -492,7 +491,7 @@ const status = async (props) => {
|
|
|
492
491
|
({ data } = await props.apiClient.getNeonAuth(props.projectId, branchId));
|
|
493
492
|
}
|
|
494
493
|
catch (err) {
|
|
495
|
-
if (
|
|
494
|
+
if (isNeonApiError(err) && err.status === 404) {
|
|
496
495
|
printMessage('Neon Auth is not configured for this branch');
|
|
497
496
|
return;
|
|
498
497
|
}
|
|
@@ -546,9 +545,8 @@ const oauthProviderAdd = async (props) => {
|
|
|
546
545
|
}));
|
|
547
546
|
}
|
|
548
547
|
catch (err) {
|
|
549
|
-
if (
|
|
550
|
-
err.
|
|
551
|
-
'INVALID_SHARED_OAUTH_PROVIDER') {
|
|
548
|
+
if (isNeonApiError(err) &&
|
|
549
|
+
codeFromBody(err.data) === 'INVALID_SHARED_OAUTH_PROVIDER') {
|
|
552
550
|
throw new Error(`The "${props.providerId}" provider requires your own OAuth app credentials.\n` +
|
|
553
551
|
`Re-run with --oauth-client-id and --oauth-client-secret to provide them.\n` +
|
|
554
552
|
`Create an OAuth app at your provider and use those credentials.`);
|
|
@@ -579,9 +577,8 @@ const oauthProviderUpdate = async (props) => {
|
|
|
579
577
|
}));
|
|
580
578
|
}
|
|
581
579
|
catch (err) {
|
|
582
|
-
if (
|
|
583
|
-
err.
|
|
584
|
-
'INVALID_SHARED_OAUTH_PROVIDER') {
|
|
580
|
+
if (isNeonApiError(err) &&
|
|
581
|
+
codeFromBody(err.data) === 'INVALID_SHARED_OAUTH_PROVIDER') {
|
|
585
582
|
throw new Error(`The "${props.providerId}" provider requires your own OAuth app credentials.\n` +
|
|
586
583
|
`Re-run with --oauth-client-id and --oauth-client-secret to provide them.\n` +
|
|
587
584
|
`Create an OAuth app at your provider and use those credentials.`);
|
|
@@ -4,7 +4,7 @@ import { writer } from '../writer.js';
|
|
|
4
4
|
import { psql } from '../utils/psql.js';
|
|
5
5
|
import { updateContextFile } from '../context.js';
|
|
6
6
|
import { getComputeUnits } from '../utils/compute_units.js';
|
|
7
|
-
import {
|
|
7
|
+
import { isNeonApiError, messageFromBody } from '../api.js';
|
|
8
8
|
import prompts from 'prompts';
|
|
9
9
|
import { isCi } from '../env.js';
|
|
10
10
|
export const PROJECT_FIELDS = [
|
|
@@ -315,9 +315,9 @@ const handleMissingOrgId = async (args, cmd) => {
|
|
|
315
315
|
}
|
|
316
316
|
};
|
|
317
317
|
const isOrgIdError = (err) => {
|
|
318
|
-
return (
|
|
319
|
-
err.
|
|
320
|
-
err.
|
|
318
|
+
return (isNeonApiError(err) &&
|
|
319
|
+
err.status === 400 &&
|
|
320
|
+
messageFromBody(err.data)?.includes('org_id is required'));
|
|
321
321
|
};
|
|
322
322
|
const selectOrg = async (props) => {
|
|
323
323
|
const { data: { organizations }, } = await props.apiClient.getCurrentUserOrganizations();
|
package/dist/commands/psql.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EndpointType } from '
|
|
1
|
+
import { EndpointType } from '../utils/api_enums.js';
|
|
2
2
|
import { fillSingleProject } from '../utils/enrichers.js';
|
|
3
3
|
import { handler as connectionStringHandler, SSL_MODES, } from './connection_string.js';
|
|
4
4
|
export const command = 'psql [branch]';
|
package/dist/commands/roles.js
CHANGED
|
@@ -56,7 +56,10 @@ export const create = async (props) => {
|
|
|
56
56
|
export const deleteRole = async (props) => {
|
|
57
57
|
const branchId = await branchIdFromProps(props);
|
|
58
58
|
const { data } = await retryOnLock(() => props.apiClient.deleteProjectBranchRole(props.projectId, branchId, props.role));
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
// A 204 (role already gone) carries no body; only a 200 returns the role.
|
|
60
|
+
if (data) {
|
|
61
|
+
writer(props).end(data.role, {
|
|
62
|
+
fields: ROLES_FIELDS,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
62
65
|
};
|
|
@@ -3,7 +3,7 @@ import chalk from 'chalk';
|
|
|
3
3
|
import { writer } from '../writer.js';
|
|
4
4
|
import { branchIdFromProps } from '../utils/enrichers.js';
|
|
5
5
|
import { parsePointInTime, } from '../utils/point_in_time.js';
|
|
6
|
-
import {
|
|
6
|
+
import { isNeonApiError, messageFromBody } from '../api.js';
|
|
7
7
|
import { sendError } from '../analytics.js';
|
|
8
8
|
import { log } from '../log.js';
|
|
9
9
|
const COLORS = {
|
|
@@ -68,10 +68,9 @@ const fetchSchema = async (pointInTime, database, props) => {
|
|
|
68
68
|
return response.data.sql ?? '';
|
|
69
69
|
}
|
|
70
70
|
catch (error) {
|
|
71
|
-
if (
|
|
72
|
-
const data = error.response?.data;
|
|
71
|
+
if (isNeonApiError(error)) {
|
|
73
72
|
sendError(error, 'API_ERROR');
|
|
74
|
-
throw new Error(data
|
|
73
|
+
throw new Error(messageFromBody(error.data) ??
|
|
75
74
|
`Error while fetching schema for branch ${pointInTime.branchId}`);
|
|
76
75
|
}
|
|
77
76
|
throw error;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { fillSingleProject } from '../utils/enrichers.js';
|
|
2
|
+
import { status } from './config.js';
|
|
3
|
+
/**
|
|
4
|
+
* `neon status` is a top-level alias for `neon config status` — the most-reached-for
|
|
5
|
+
* config subcommand. It mirrors that command's options (including `--current-branch`,
|
|
6
|
+
* the offline branch probe) and delegates to the same `status` handler.
|
|
7
|
+
*
|
|
8
|
+
* Because it has a handler but no subcommands, `status` must also be listed in
|
|
9
|
+
* `NO_SUBCOMMANDS_VERBS` (see index.ts) so the help-fallback middleware doesn't
|
|
10
|
+
* intercept a bare `neon status`.
|
|
11
|
+
*/
|
|
12
|
+
export const command = 'status';
|
|
13
|
+
export const describe = "Show the branch's live Neon state (alias of `config status`)";
|
|
14
|
+
export const builder = (argv) => argv
|
|
15
|
+
.usage('$0 status [options]')
|
|
16
|
+
.options({
|
|
17
|
+
'project-id': {
|
|
18
|
+
describe: 'Project ID',
|
|
19
|
+
type: 'string',
|
|
20
|
+
},
|
|
21
|
+
branch: {
|
|
22
|
+
describe: 'Branch ID or name',
|
|
23
|
+
type: 'string',
|
|
24
|
+
},
|
|
25
|
+
'config-json': {
|
|
26
|
+
describe: "Print only the branch's live config as neon.ts-shaped JSON " +
|
|
27
|
+
'(services + branch tuning + preview), to stdout. Useful for ' +
|
|
28
|
+
'scripting or copying into a neon.ts.',
|
|
29
|
+
type: 'boolean',
|
|
30
|
+
default: false,
|
|
31
|
+
},
|
|
32
|
+
'current-branch': {
|
|
33
|
+
describe: 'Print only the linked branch name from the local .neon file ' +
|
|
34
|
+
'(no network). Exits non-zero when no branch is pinned.',
|
|
35
|
+
type: 'boolean',
|
|
36
|
+
default: false,
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
.middleware(fillSingleProject);
|
|
40
|
+
export const handler = (args) => status(args);
|
package/dist/context.js
CHANGED
|
@@ -8,6 +8,22 @@ import { log } from './log.js';
|
|
|
8
8
|
* working.
|
|
9
9
|
*/
|
|
10
10
|
export const contextBranch = (context) => context.branch ?? context.branchId;
|
|
11
|
+
/**
|
|
12
|
+
* True when the invocation is the offline "current branch" probe:
|
|
13
|
+
* `(config) status --current-branch`. This mode only reads the pinned branch
|
|
14
|
+
* from the local `.neon` file (for shell prompts like starship), so it MUST
|
|
15
|
+
* NOT touch the network — several middlewares (auth, analytics, single-project
|
|
16
|
+
* resolution) consult this to early-return and skip their API calls / login.
|
|
17
|
+
*
|
|
18
|
+
* Gated on the exact command as well as the flag so an accidental
|
|
19
|
+
* `--current-branch` on an unrelated command (e.g. `config plan`, where the flag
|
|
20
|
+
* is undefined but non-strict yargs still parses it) can't silently skip
|
|
21
|
+
* auth/analytics. The probe is only `status` (the top-level alias) or
|
|
22
|
+
* `config status` (`_ = ['config', 'status']`).
|
|
23
|
+
*/
|
|
24
|
+
export const isCurrentBranchProbe = (args) => args.currentBranch === true &&
|
|
25
|
+
(args._[0] === 'status' ||
|
|
26
|
+
(args._[0] === 'config' && args._[1] === 'status'));
|
|
11
27
|
const CONTEXT_FILE = '.neon';
|
|
12
28
|
const GITIGNORE_FILE = '.gitignore';
|
|
13
29
|
const wrapWithContextFile = (dir) => resolve(dir, CONTEXT_FILE);
|