neon 3.2.2 → 3.4.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 +70 -30
- package/dist/_chunks/{auth_selection-DGgq6ifc.js → auth_selection-CV_3n1du.js} +1 -1
- package/dist/_chunks/{cmd_pipeline-CUbBO9U_.js → cmd_pipeline-CEk93dGN.js} +8 -6
- package/dist/_chunks/credential_io-D2vw1UOw.js +409 -0
- package/dist/_chunks/{paths-DMq0Lt7a.js → paths-g3DRqJiD.js} +1 -1
- package/dist/_chunks/{profiles-Ir29rqns.js → profiles-CVXNvU2r.js} +106 -66
- package/dist/_chunks/{psql-DWH-kc69.js → psql-BNljLda2.js} +8 -7
- package/dist/_chunks/{rolldown-runtime-D7D4PA-g.js → rolldown-runtime-8H4AJuhK.js} +1 -0
- package/dist/analytics.js +19 -5
- package/dist/auth_context.js +25 -24
- package/dist/commands/api.js +1 -1
- package/dist/commands/api_keys.js +1 -1
- package/dist/commands/auth.js +121 -80
- package/dist/commands/bootstrap.js +2 -2
- package/dist/commands/branches.js +1 -1
- package/dist/commands/bucket.js +1 -1
- package/dist/commands/checkout.js +1 -1
- package/dist/commands/config.js +1 -1
- package/dist/commands/connection_string.js +2 -2
- package/dist/commands/data_api.js +1 -1
- package/dist/commands/databases.js +1 -1
- package/dist/commands/deploy.js +1 -1
- package/dist/commands/dev.js +1 -1
- package/dist/commands/diff.js +1 -1
- package/dist/commands/env.js +1 -1
- package/dist/commands/functions.js +1 -1
- package/dist/commands/init.js +2 -2
- package/dist/commands/inspect.js +33 -8
- package/dist/commands/ip_allow.js +1 -1
- package/dist/commands/link.js +1 -1
- package/dist/commands/logs.js +1 -1
- package/dist/commands/neon_auth.js +1 -1
- package/dist/commands/open.js +1 -1
- package/dist/commands/operations.js +1 -1
- package/dist/commands/orgs.js +1 -1
- package/dist/commands/profile.js +183 -202
- package/dist/commands/projects.js +1 -1
- package/dist/commands/psql.js +1 -1
- package/dist/commands/roles.js +1 -1
- package/dist/commands/schema_diff.js +15 -13
- package/dist/commands/set_context.js +1 -1
- package/dist/commands/snapshots.js +1 -1
- package/dist/commands/status.js +1 -1
- package/dist/commands/user.js +1 -1
- package/dist/commands/vpc_endpoints.js +1 -1
- package/dist/config.js +1 -1
- package/dist/credential_io.js +2 -0
- package/dist/index.js +27 -25
- package/dist/init/auth.js +14 -25
- package/dist/keyring.js +42 -0
- package/dist/psql/cli.js +1 -1
- package/dist/psql/command/cmd_connect.js +4 -3
- package/dist/psql/command/cmd_copy.js +65 -62
- package/dist/psql/command/cmd_io.js +1 -1
- package/dist/psql/command/cmd_pipeline.js +1 -1
- package/dist/psql/command/dispatch.js +1 -1
- package/dist/psql/core/common.js +1 -1
- package/dist/psql/core/mainloop.js +1 -1
- package/dist/psql/core/startup.js +1 -1
- package/dist/psql/index.js +1 -1
- package/dist/psql/io/psqlrc.js +13 -11
- package/dist/psql/print/aligned.js +9 -5
- package/dist/psql/wire/tls.js +11 -9
- package/dist/retire_credential.js +86 -0
- package/dist/utils/inspect_db.js +109 -3
- package/dist/utils/inspect_queries.js +26 -7
- package/dist/utils/middlewares.js +1 -1
- package/package.json +5 -4
- package/dist/_chunks/credentials-MYdHdKah.js +0 -188
- package/dist/_chunks/secure_file-BucZj4yQ.js +0 -39
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { a as OAUTH, d as scopeOf, i as API_KEY, l as interpretCredentials, t as storeFor, u as isSameCredential } from "./_chunks/credential_io-D2vw1UOw.js";
|
|
2
|
+
import { log } from "./log.js";
|
|
3
|
+
import { getApiClient } from "./api.js";
|
|
4
|
+
import { revokeToken } from "./auth.js";
|
|
5
|
+
//#region src/retire_credential.ts
|
|
6
|
+
/**
|
|
7
|
+
* Malformed credentials cannot be revoked, but they must remain replaceable and removable.
|
|
8
|
+
*/
|
|
9
|
+
const readOutgoingCredential = (configDir, at) => {
|
|
10
|
+
const listing = storeFor(configDir).inspect(at);
|
|
11
|
+
const store = listing.storage === "keyring" ? "keyring" : "file";
|
|
12
|
+
const unusable = (reason) => {
|
|
13
|
+
log.warning("%s. Nothing in it could be revoked.", reason.replace(/\.$/, ""));
|
|
14
|
+
return null;
|
|
15
|
+
};
|
|
16
|
+
if (listing.reason !== void 0 && listing.credentials === null) {
|
|
17
|
+
if (at.storage === "keyring") {
|
|
18
|
+
log.warning("Could not read the OS keyring item for profile \"%s\"; nothing in it could be revoked.", at.profile);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return unusable(listing.reason);
|
|
22
|
+
}
|
|
23
|
+
if (listing.credentials === null) return null;
|
|
24
|
+
try {
|
|
25
|
+
const credential = interpretCredentials(listing.credentials, at, store);
|
|
26
|
+
if (credential.kind === "oauth") return {
|
|
27
|
+
kind: OAUTH,
|
|
28
|
+
tokens: listing.credentials
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
kind: API_KEY,
|
|
32
|
+
apiKey: credential.apiKey,
|
|
33
|
+
...typeof listing.credentials.key_id === "number" ? { keyId: listing.credentials.key_id } : {},
|
|
34
|
+
scope: scopeOf(listing.credentials)
|
|
35
|
+
};
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return unusable(err instanceof Error ? err.message : String(err));
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Read the outgoing credential before overwrite, but retire it only after the replacement is
|
|
42
|
+
* durable: overwrite destroys revocation metadata, while early retirement can strand the
|
|
43
|
+
* profile without a working credential.
|
|
44
|
+
*/
|
|
45
|
+
const retirePreviousCredential = async (props, name, existing, replacementKey) => {
|
|
46
|
+
if (existing === null) return;
|
|
47
|
+
if (existing.kind === "api_key") {
|
|
48
|
+
if (isSameCredential(existing.apiKey, replacementKey)) {
|
|
49
|
+
log.debug("The replacement is the credential already stored; nothing to retire.");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (existing.keyId === void 0) {
|
|
53
|
+
log.warning("Profile \"%s\" held an API key that was supplied rather than minted here, so it stays live on the account — find it with `neon api-keys list`.", name);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const client = getApiClient({
|
|
57
|
+
apiKey: existing.apiKey,
|
|
58
|
+
apiHost: props.apiHost
|
|
59
|
+
});
|
|
60
|
+
log.info(await withdrawKey(client, existing.scope, existing.keyId) ? `Revoked the key it replaces (id ${existing.keyId})` : `Could not revoke the key it replaces (id ${existing.keyId}); it may still be live. Remove it with: neon api-keys revoke ${existing.keyId}`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const revoked = await revokeTokenSet(existing.tokens, props);
|
|
64
|
+
log.info(revoked ? "Signed out the session it replaced" : "Could not sign out the session it replaced; it will expire on its own");
|
|
65
|
+
};
|
|
66
|
+
/** A failed revoke must not strand the caller. */
|
|
67
|
+
const withdrawKey = async (client, scope, keyId) => {
|
|
68
|
+
if (!Number.isSafeInteger(keyId) || keyId <= 0) return false;
|
|
69
|
+
try {
|
|
70
|
+
const { data } = scope.orgId ? await client.revokeOrgApiKey(scope.orgId, keyId) : await client.revokeApiKey(keyId);
|
|
71
|
+
return data.revoked === true && data.id === keyId;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
log.error("Failed to revoke API key %d: %s", keyId, err instanceof Error ? err.message : String(err));
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const revokeTokenSet = async (credentials, props) => {
|
|
78
|
+
const tokenSet = credentials;
|
|
79
|
+
return await revokeToken({
|
|
80
|
+
oauthHost: props.oauthHost,
|
|
81
|
+
clientId: props.clientId,
|
|
82
|
+
...props.allowUnsafeTls ? { allowUnsafeTls: props.allowUnsafeTls } : {}
|
|
83
|
+
}, tokenSet);
|
|
84
|
+
};
|
|
85
|
+
//#endregion
|
|
86
|
+
export { readOutgoingCredential, retirePreviousCredential, revokeTokenSet, withdrawKey };
|
package/dist/utils/inspect_db.js
CHANGED
|
@@ -2,7 +2,7 @@ import { EndpointType } from "./api_enums.js";
|
|
|
2
2
|
import { branchIdFromProps } from "./enrichers.js";
|
|
3
3
|
import { parsePITBranch } from "./point_in_time.js";
|
|
4
4
|
import { PgConnection } from "../psql/wire/connection.js";
|
|
5
|
-
import { o as parseConnectionUri } from "../_chunks/psql-
|
|
5
|
+
import { o as parseConnectionUri } from "../_chunks/psql-BNljLda2.js";
|
|
6
6
|
//#region src/utils/inspect_db.ts
|
|
7
7
|
const SSL_MODES = [
|
|
8
8
|
"require",
|
|
@@ -11,6 +11,60 @@ const SSL_MODES = [
|
|
|
11
11
|
"omit"
|
|
12
12
|
];
|
|
13
13
|
/**
|
|
14
|
+
* Keep the database column on one-database branches so adding another database
|
|
15
|
+
* does not change the output schema. Compute-wide views run once because every
|
|
16
|
+
* database returns the same rows.
|
|
17
|
+
*/
|
|
18
|
+
const selectInspectTargets = (input) => {
|
|
19
|
+
if (input.dbUrlDatabase !== void 0) return {
|
|
20
|
+
databases: [input.dbUrlDatabase],
|
|
21
|
+
includeDatabaseColumn: false
|
|
22
|
+
};
|
|
23
|
+
if (input.databaseName !== void 0) {
|
|
24
|
+
if (input.databaseName === "") throw new Error("--database-name cannot be empty. Omit the flag to cover every database.");
|
|
25
|
+
return {
|
|
26
|
+
databases: [input.databaseName],
|
|
27
|
+
includeDatabaseColumn: false
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (input.branchDatabases.length === 0) throw new Error("No databases found for the branch");
|
|
31
|
+
if (input.scope === "compute") return {
|
|
32
|
+
databases: [input.branchDatabases[0]],
|
|
33
|
+
includeDatabaseColumn: false
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
databases: [...input.branchDatabases].sort((a, b) => a.localeCompare(b, "en")),
|
|
37
|
+
includeDatabaseColumn: true
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
const formatInspectQueryError = (input) => {
|
|
41
|
+
if (input.dbUrl !== void 0 || input.databaseName !== void 0) return;
|
|
42
|
+
const missingExtension = input.requiresExtension !== void 0 && input.reason.includes(`"${input.requiresExtension}"`);
|
|
43
|
+
const hint = !input.offerDatabaseNameHint ? "" : missingExtension ? `. Pass --database-name to try a database that already has the "${input.requiresExtension}" extension.` : input.scope === "compute" ? ". Pass --database-name to connect through a different database." : ". Pass --database-name to inspect one database.";
|
|
44
|
+
return `${input.reason} (database ${input.database})${hint}`;
|
|
45
|
+
};
|
|
46
|
+
const connectionUriForDatabase = (connectionUri, database) => {
|
|
47
|
+
const url = new URL(connectionUri);
|
|
48
|
+
url.pathname = `/${encodeURIComponent(database)}`;
|
|
49
|
+
return url.toString();
|
|
50
|
+
};
|
|
51
|
+
const listBranchDatabases = async (props) => {
|
|
52
|
+
const projectId = props.projectId;
|
|
53
|
+
const parsedPIT = props.branch ? parsePITBranch(props.branch) : {
|
|
54
|
+
tag: "head",
|
|
55
|
+
branch: ""
|
|
56
|
+
};
|
|
57
|
+
const branchId = await branchIdFromProps({
|
|
58
|
+
...props,
|
|
59
|
+
...props.branch ? { branch: parsedPIT.branch } : {}
|
|
60
|
+
});
|
|
61
|
+
const { data: { databases } } = await props.apiClient.listProjectBranchDatabases(projectId, branchId);
|
|
62
|
+
return {
|
|
63
|
+
branchId,
|
|
64
|
+
names: databases.map((d) => d.name)
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
14
68
|
* Resolve a branch's live Postgres connection details via the Neon API
|
|
15
69
|
* (endpoint → role → password → database → URL, honoring point-in-time,
|
|
16
70
|
* pooling, prisma tuning, and SSL mode). Mirrors how `connection-string`
|
|
@@ -45,7 +99,7 @@ const resolveConnectionUri = async (props) => {
|
|
|
45
99
|
let host = props.pooled ? endpoint.host.replace(endpoint.id, `${endpoint.id}-pooler`) : endpoint.host;
|
|
46
100
|
if (parsedPIT.tag !== "head") host = endpoint.host.replace(endpoint.id, endpoint.branch_id);
|
|
47
101
|
const connectionString = new URL(`postgresql://${host}`);
|
|
48
|
-
connectionString.pathname = database
|
|
102
|
+
connectionString.pathname = `/${encodeURIComponent(database)}`;
|
|
49
103
|
connectionString.username = role;
|
|
50
104
|
connectionString.password = password;
|
|
51
105
|
if (props.prisma) {
|
|
@@ -71,6 +125,58 @@ const resolveConnectionUri = async (props) => {
|
|
|
71
125
|
options: connectionString.searchParams.toString()
|
|
72
126
|
};
|
|
73
127
|
};
|
|
128
|
+
const resolveInspectTargets = async (props, scope) => {
|
|
129
|
+
if (props.dbUrl) {
|
|
130
|
+
const parsed = parseConnectionUri(props.dbUrl);
|
|
131
|
+
const selection = selectInspectTargets({
|
|
132
|
+
dbUrlDatabase: parsed.database,
|
|
133
|
+
branchDatabases: [],
|
|
134
|
+
scope
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
targets: [{
|
|
138
|
+
database: selection.databases[0],
|
|
139
|
+
connectionUri: props.dbUrl
|
|
140
|
+
}],
|
|
141
|
+
includeDatabaseColumn: selection.includeDatabaseColumn,
|
|
142
|
+
branchDatabaseCount: 1
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (props.databaseName !== void 0) {
|
|
146
|
+
selectInspectTargets({
|
|
147
|
+
databaseName: props.databaseName,
|
|
148
|
+
branchDatabases: [],
|
|
149
|
+
scope
|
|
150
|
+
});
|
|
151
|
+
const resolved = await resolveConnectionUri(props);
|
|
152
|
+
return {
|
|
153
|
+
targets: [{
|
|
154
|
+
database: resolved.database,
|
|
155
|
+
connectionUri: resolved.connectionUri
|
|
156
|
+
}],
|
|
157
|
+
includeDatabaseColumn: false,
|
|
158
|
+
branchDatabaseCount: 1
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const { branchId, names } = await listBranchDatabases(props);
|
|
162
|
+
if (names.length === 0) throw new Error(`No databases found for the branch: ${branchId}`);
|
|
163
|
+
const selection = selectInspectTargets({
|
|
164
|
+
branchDatabases: names,
|
|
165
|
+
scope
|
|
166
|
+
});
|
|
167
|
+
const first = await resolveConnectionUri({
|
|
168
|
+
...props,
|
|
169
|
+
databaseName: selection.databases[0]
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
targets: selection.databases.map((database) => ({
|
|
173
|
+
database,
|
|
174
|
+
connectionUri: connectionUriForDatabase(first.connectionUri, database)
|
|
175
|
+
})),
|
|
176
|
+
includeDatabaseColumn: selection.includeDatabaseColumn,
|
|
177
|
+
branchDatabaseCount: names.length
|
|
178
|
+
};
|
|
179
|
+
};
|
|
74
180
|
const rowsFrom = (result) => {
|
|
75
181
|
const names = result.fields.map((f) => f.name);
|
|
76
182
|
return result.rows.map((row) => {
|
|
@@ -108,4 +214,4 @@ const runInspectQuery = async (connectionUri, sql, options = {}) => {
|
|
|
108
214
|
}
|
|
109
215
|
};
|
|
110
216
|
//#endregion
|
|
111
|
-
export { SSL_MODES, resolveConnectionUri, runInspectQuery };
|
|
217
|
+
export { SSL_MODES, connectionUriForDatabase, formatInspectQueryError, resolveConnectionUri, resolveInspectTargets, runInspectQuery, selectInspectTargets };
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const INSPECT_QUERIES = {
|
|
3
3
|
"table-sizes": {
|
|
4
4
|
describe: "Size of each table (including TOAST), largest first (pg_table_size)",
|
|
5
|
+
scope: "database",
|
|
5
6
|
fields: [
|
|
6
7
|
"schema",
|
|
7
8
|
"name",
|
|
@@ -23,6 +24,7 @@ const INSPECT_QUERIES = {
|
|
|
23
24
|
},
|
|
24
25
|
"index-sizes": {
|
|
25
26
|
describe: "Size of each index, largest first (pg_relation_size)",
|
|
27
|
+
scope: "database",
|
|
26
28
|
fields: [
|
|
27
29
|
"schema",
|
|
28
30
|
"name",
|
|
@@ -44,6 +46,7 @@ const INSPECT_QUERIES = {
|
|
|
44
46
|
},
|
|
45
47
|
"unused-indexes": {
|
|
46
48
|
describe: "Non-unique indexes with few scans — candidates for removal (pg_stat_user_indexes)",
|
|
49
|
+
scope: "database",
|
|
47
50
|
fields: [
|
|
48
51
|
"table",
|
|
49
52
|
"index",
|
|
@@ -67,6 +70,7 @@ const INSPECT_QUERIES = {
|
|
|
67
70
|
},
|
|
68
71
|
"seq-scans": {
|
|
69
72
|
describe: "Number of sequential scans recorded against each table (pg_stat_user_tables)",
|
|
73
|
+
scope: "database",
|
|
70
74
|
fields: [
|
|
71
75
|
"schema",
|
|
72
76
|
"name",
|
|
@@ -84,13 +88,15 @@ const INSPECT_QUERIES = {
|
|
|
84
88
|
},
|
|
85
89
|
"long-running-queries": {
|
|
86
90
|
describe: "Queries running longer than 5 minutes (pg_stat_activity)",
|
|
91
|
+
scope: "database",
|
|
87
92
|
fields: [
|
|
88
93
|
"pid",
|
|
89
94
|
"duration",
|
|
90
95
|
"state",
|
|
91
96
|
"query"
|
|
92
97
|
],
|
|
93
|
-
emptyMessage: "No long-running queries.",
|
|
98
|
+
emptyMessage: "No long-running queries in this database.",
|
|
99
|
+
emptyMessageAll: "No long-running queries in any database.",
|
|
94
100
|
sql: `
|
|
95
101
|
SELECT
|
|
96
102
|
pid,
|
|
@@ -98,7 +104,8 @@ const INSPECT_QUERIES = {
|
|
|
98
104
|
state,
|
|
99
105
|
query
|
|
100
106
|
FROM pg_stat_activity
|
|
101
|
-
WHERE
|
|
107
|
+
WHERE datname = current_database()
|
|
108
|
+
AND state <> 'idle'
|
|
102
109
|
AND query NOT ILIKE '%pg_stat_activity%'
|
|
103
110
|
AND now() - query_start > interval '5 minutes'
|
|
104
111
|
ORDER BY now() - query_start DESC;
|
|
@@ -106,6 +113,7 @@ const INSPECT_QUERIES = {
|
|
|
106
113
|
},
|
|
107
114
|
locks: {
|
|
108
115
|
describe: "Locks held with the acquiring query and its age (pg_locks + pg_stat_activity)",
|
|
116
|
+
scope: "database",
|
|
109
117
|
fields: [
|
|
110
118
|
"pid",
|
|
111
119
|
"relname",
|
|
@@ -115,7 +123,8 @@ const INSPECT_QUERIES = {
|
|
|
115
123
|
"age",
|
|
116
124
|
"query"
|
|
117
125
|
],
|
|
118
|
-
emptyMessage: "No locks held.",
|
|
126
|
+
emptyMessage: "No locks held in this database.",
|
|
127
|
+
emptyMessageAll: "No locks held in any database.",
|
|
119
128
|
sql: `
|
|
120
129
|
SELECT
|
|
121
130
|
a.pid,
|
|
@@ -128,13 +137,15 @@ const INSPECT_QUERIES = {
|
|
|
128
137
|
FROM pg_locks l
|
|
129
138
|
JOIN pg_stat_activity a ON a.pid = l.pid
|
|
130
139
|
LEFT JOIN pg_class c ON c.oid = l.relation
|
|
131
|
-
WHERE a.
|
|
140
|
+
WHERE a.datname = current_database()
|
|
141
|
+
AND a.query <> '<insufficient privilege>'
|
|
132
142
|
AND l.pid <> pg_backend_pid()
|
|
133
143
|
ORDER BY a.query_start;
|
|
134
144
|
`
|
|
135
145
|
},
|
|
136
146
|
outliers: {
|
|
137
147
|
describe: "Queries taking the most cumulative execution time (needs pg_stat_statements)",
|
|
148
|
+
scope: "database",
|
|
138
149
|
fields: [
|
|
139
150
|
"total_exec_time",
|
|
140
151
|
"prop_exec_time",
|
|
@@ -161,6 +172,7 @@ const INSPECT_QUERIES = {
|
|
|
161
172
|
},
|
|
162
173
|
calls: {
|
|
163
174
|
describe: "Most frequently called queries (needs pg_stat_statements)",
|
|
175
|
+
scope: "database",
|
|
164
176
|
fields: [
|
|
165
177
|
"ncalls",
|
|
166
178
|
"total_exec_time",
|
|
@@ -186,7 +198,8 @@ const INSPECT_QUERIES = {
|
|
|
186
198
|
`
|
|
187
199
|
},
|
|
188
200
|
"lfc-hit-rate": {
|
|
189
|
-
describe: "Local File Cache hit rate (needs neon extension)",
|
|
201
|
+
describe: "Local File Cache hit rate (compute-wide, needs neon extension)",
|
|
202
|
+
scope: "compute",
|
|
190
203
|
fields: ["name", "ratio"],
|
|
191
204
|
emptyMessage: "No LFC stats available.",
|
|
192
205
|
requiresExtension: "neon",
|
|
@@ -207,7 +220,8 @@ const INSPECT_QUERIES = {
|
|
|
207
220
|
`
|
|
208
221
|
},
|
|
209
222
|
"working-set": {
|
|
210
|
-
describe: "Estimated working set vs LFC size (needs neon extension)",
|
|
223
|
+
describe: "Estimated working set vs LFC size (compute-wide, needs neon extension)",
|
|
224
|
+
scope: "compute",
|
|
211
225
|
fields: [
|
|
212
226
|
"window",
|
|
213
227
|
"working_set",
|
|
@@ -239,6 +253,7 @@ const INSPECT_QUERIES = {
|
|
|
239
253
|
},
|
|
240
254
|
"vacuum-stats": {
|
|
241
255
|
describe: "Autovacuum status per table: last (auto)vacuum, dead tuples, threshold",
|
|
256
|
+
scope: "database",
|
|
242
257
|
fields: [
|
|
243
258
|
"schema",
|
|
244
259
|
"table",
|
|
@@ -273,6 +288,7 @@ const INSPECT_QUERIES = {
|
|
|
273
288
|
},
|
|
274
289
|
bloat: {
|
|
275
290
|
describe: "Estimated table/index bloat (statistical estimate, no extension needed)",
|
|
291
|
+
scope: "database",
|
|
276
292
|
fields: [
|
|
277
293
|
"type",
|
|
278
294
|
"schema",
|
|
@@ -333,7 +349,8 @@ const INSPECT_QUERIES = {
|
|
|
333
349
|
`
|
|
334
350
|
},
|
|
335
351
|
"replication-slots": {
|
|
336
|
-
describe: "Replication slots: kind, status, client, restart/confirmed-flush LSNs, and lag (pg_replication_slots + pg_stat_replication)",
|
|
352
|
+
describe: "Replication slots (compute-wide): kind, status, client, restart/confirmed-flush LSNs, and lag (pg_replication_slots + pg_stat_replication)",
|
|
353
|
+
scope: "compute",
|
|
337
354
|
fields: [
|
|
338
355
|
"slot_name",
|
|
339
356
|
"slot_type",
|
|
@@ -379,6 +396,7 @@ const INSPECT_QUERIES = {
|
|
|
379
396
|
},
|
|
380
397
|
subscriptions: {
|
|
381
398
|
describe: "Per-table logical replication progress on this subscriber (pg_subscription_rel)",
|
|
399
|
+
scope: "database",
|
|
382
400
|
fields: [
|
|
383
401
|
"subscription",
|
|
384
402
|
"table_name",
|
|
@@ -386,6 +404,7 @@ const INSPECT_QUERIES = {
|
|
|
386
404
|
"lsn"
|
|
387
405
|
],
|
|
388
406
|
emptyMessage: "No subscriptions found on this database.",
|
|
407
|
+
emptyMessageAll: "No subscriptions found on any database.",
|
|
389
408
|
sql: `
|
|
390
409
|
SELECT
|
|
391
410
|
sub.subname AS subscription,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as recordCredentialInputs } from "../_chunks/auth_selection-
|
|
1
|
+
import { r as recordCredentialInputs } from "../_chunks/auth_selection-CV_3n1du.js";
|
|
2
2
|
//#region src/utils/middlewares.ts
|
|
3
3
|
/**
|
|
4
4
|
* Resolves `--api-key` from `NEON_API_KEY` when the flag is absent, leaving it an
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neon",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"neon",
|
|
@@ -63,11 +63,12 @@
|
|
|
63
63
|
"yaml": "^2.9.0",
|
|
64
64
|
"yargs": "17.7.2",
|
|
65
65
|
"yoctocolors": "^2.1.2",
|
|
66
|
-
"@neon/sdk": "2.0.0",
|
|
67
66
|
"@neon/config": "1.0.0",
|
|
67
|
+
"@neon/sdk": "2.0.0",
|
|
68
68
|
"@neon/config-runtime": "1.0.0"
|
|
69
69
|
},
|
|
70
70
|
"optionalDependencies": {
|
|
71
|
+
"@napi-rs/keyring": "1.3.0",
|
|
71
72
|
"esbuild": "0.28.1"
|
|
72
73
|
},
|
|
73
74
|
"devDependencies": {
|
|
@@ -97,8 +98,8 @@
|
|
|
97
98
|
"typescript": "^5.9.0",
|
|
98
99
|
"vitest": "^3.0.9",
|
|
99
100
|
"@neon-internals/cli-core": "0.0.0",
|
|
100
|
-
"@neon/
|
|
101
|
-
"@neon
|
|
101
|
+
"@neon-internals/env-core": "0.0.0",
|
|
102
|
+
"@neon/e2e-harness": "0.0.0"
|
|
102
103
|
},
|
|
103
104
|
"publishConfig": {
|
|
104
105
|
"access": "public",
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import { t as writeSecretFile } from "./secure_file-BucZj4yQ.js";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
//#region ../../internals/cli-core/dist/credentials.js
|
|
4
|
-
/**
|
|
5
|
-
* # Stored credentials — one file per account, two kinds
|
|
6
|
-
*
|
|
7
|
-
* A profile points at exactly one credentials file (see `./profiles.ts`), and that file says
|
|
8
|
-
* what kind of credential it holds. Adding API-key support this way rather than adding a
|
|
9
|
-
* second pointer to `profiles.json` keeps a profile what it already was — one name, one path
|
|
10
|
-
* — and means `profiles.json` needs no schema change at all.
|
|
11
|
-
*
|
|
12
|
-
* ```json
|
|
13
|
-
* // oauth: every file written before this existed. An absent `type` means this.
|
|
14
|
-
* { "access_token": "…", "refresh_token": "…", "expires_at": 1786…, "user_id": "…" }
|
|
15
|
-
*
|
|
16
|
-
* // api_key, stored by `neon profile create --api-key`
|
|
17
|
-
* { "type": "api_key", "api_key": "napi_…", "user_id": "…" }
|
|
18
|
-
*
|
|
19
|
-
* // api_key minted by `--mint --org-id`, which records the scope it was issued at
|
|
20
|
-
* { "type": "api_key", "api_key": "napi_…", "key_id": 123, "org_id": "org-…" }
|
|
21
|
-
* ```
|
|
22
|
-
*
|
|
23
|
-
* ## One profile, one kind
|
|
24
|
-
*
|
|
25
|
-
* A credentials file holds an API key or an OAuth session, never both, and `type` states
|
|
26
|
-
* which. An earlier draft let the two coexist — the idea being that a key could keep the
|
|
27
|
-
* session it was minted from and so rotate without a browser. It did not survive review, for
|
|
28
|
-
* two reasons that are worth recording so nobody rebuilds it:
|
|
29
|
-
*
|
|
30
|
-
* 1. **It never worked.** The resolver returned the key without testing it, so a revoked key
|
|
31
|
-
* failed to mint and never fell back to the session sitting beside it.
|
|
32
|
-
* 2. **It could mix accounts.** Nothing compared the identity of the credential being written
|
|
33
|
-
* with the one already there, so a profile could hold one account's session and another's
|
|
34
|
-
* key, told apart only by a single string. Flip or lose `type` and the profile silently
|
|
35
|
-
* becomes a different person.
|
|
36
|
-
*
|
|
37
|
-
* Recovery from a dead key is therefore one browser login — `neon profile create <name>
|
|
38
|
-
* --mint --force` — which is what the retained session was supposed to save and never did.
|
|
39
|
-
*
|
|
40
|
-
* ## Older releases
|
|
41
|
-
*
|
|
42
|
-
* A CLI predating this reads the pointer, finds no `type` it understands, ignores it, and
|
|
43
|
-
* looks for `access_token`. An `api_key` profile has none, so an older release falls through
|
|
44
|
-
* to its browser login rather than crashing. That it does not crash is why `credentials`
|
|
45
|
-
* stays a required pointer: an entry without one makes 2.41 and 2.42 throw
|
|
46
|
-
* `ERR_INVALID_ARG_TYPE` from `resolveEntryPath`.
|
|
47
|
-
*/
|
|
48
|
-
const OAUTH = "oauth";
|
|
49
|
-
const API_KEY = "api_key";
|
|
50
|
-
/**
|
|
51
|
-
* Which credential in this file authenticates, by declaration alone.
|
|
52
|
-
*
|
|
53
|
-
* An unrecognised `type` throws rather than falling back to `oauth`. A file we cannot
|
|
54
|
-
* interpret is a misconfiguration the user has to see: treating it as OAuth would send them
|
|
55
|
-
* to a browser login that silently replaces a credential they meant to keep, and treating it
|
|
56
|
-
* as an API key would authenticate with whatever `api_key` happened to be there.
|
|
57
|
-
*
|
|
58
|
-
* This deliberately does not check that an `api_key` file has a key — `neon profile list`
|
|
59
|
-
* needs the kind of a file it is not about to authenticate with, and must be able to report a
|
|
60
|
-
* broken one rather than throwing halfway through a table.
|
|
61
|
-
*/
|
|
62
|
-
const credentialKind = (credentials, at) => {
|
|
63
|
-
const declared = credentials.type;
|
|
64
|
-
if (declared === void 0 || declared === "oauth") return OAUTH;
|
|
65
|
-
if (declared === "api_key") return API_KEY;
|
|
66
|
-
throw new Error(`${at.path} declares a "type" this version does not understand. Expected "${OAUTH}" or "${API_KEY}". ${repair(at)}`);
|
|
67
|
-
};
|
|
68
|
-
/**
|
|
69
|
-
* The way out of a credentials file that cannot be read.
|
|
70
|
-
*
|
|
71
|
-
* One sentence, shared by every such error, because they all have the same two answers: write
|
|
72
|
-
* a new credential over it, or delete it and start again.
|
|
73
|
-
*/
|
|
74
|
-
const repair = (at) => `Replace it deliberately with \`neon profile create ${at.profile} --force\`, or delete the file.`;
|
|
75
|
-
/**
|
|
76
|
-
* Resolve what to authenticate with, validating that the declared kind is actually usable.
|
|
77
|
-
*
|
|
78
|
-
* An `api_key` file with no key is a hard error rather than a fall-through to OAuth: the user
|
|
79
|
-
* asked for a key, and quietly opening a browser instead would replace the credential they
|
|
80
|
-
* were trying to fix.
|
|
81
|
-
*/
|
|
82
|
-
const interpretCredentials = (credentials, at) => {
|
|
83
|
-
if (credentialKind(credentials, at) === "oauth") return { kind: OAUTH };
|
|
84
|
-
const apiKey = nonEmpty(credentials.api_key);
|
|
85
|
-
if (apiKey === void 0) throw new Error(`${at.path} declares "type": "${API_KEY}" but has no "api_key" value. ${repair(at)}`);
|
|
86
|
-
return {
|
|
87
|
-
kind: API_KEY,
|
|
88
|
-
apiKey
|
|
89
|
-
};
|
|
90
|
-
};
|
|
91
|
-
/**
|
|
92
|
-
* Read and classify a credentials file, without deciding what to do about it.
|
|
93
|
-
*
|
|
94
|
-
* A permission or I/O error still throws: there may be a perfectly good credential here that
|
|
95
|
-
* we cannot see, and treating that as absent would send the user to a browser login that
|
|
96
|
-
* overwrites it.
|
|
97
|
-
*/
|
|
98
|
-
const inspectCredentials = (path) => {
|
|
99
|
-
let contents;
|
|
100
|
-
try {
|
|
101
|
-
contents = readFileSync(path, "utf8");
|
|
102
|
-
} catch (err) {
|
|
103
|
-
if (err.code === "ENOENT") return { kind: "absent" };
|
|
104
|
-
throw err;
|
|
105
|
-
}
|
|
106
|
-
let parsed;
|
|
107
|
-
try {
|
|
108
|
-
parsed = JSON.parse(contents);
|
|
109
|
-
} catch {
|
|
110
|
-
return {
|
|
111
|
-
kind: "unusable",
|
|
112
|
-
reason: `${path} is not valid JSON, so the credential in it cannot be read`
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {
|
|
116
|
-
kind: "unusable",
|
|
117
|
-
reason: `${path} does not contain a credentials object`
|
|
118
|
-
};
|
|
119
|
-
return {
|
|
120
|
-
kind: "ok",
|
|
121
|
-
credentials: parsed
|
|
122
|
-
};
|
|
123
|
-
};
|
|
124
|
-
/**
|
|
125
|
-
* The credential at `path`, or `null` when the file is not there.
|
|
126
|
-
*
|
|
127
|
-
* A damaged file is an error, not an absence. Treating it as absent — which is what this used to
|
|
128
|
-
* do — meant any read-only command could repair it by starting a browser sign-in and overwriting
|
|
129
|
-
* it, **possibly as a different account**, with the user never having asked for a repair and no
|
|
130
|
-
* way back to whatever was in the file. Failing here costs one deliberate command; the message
|
|
131
|
-
* names it.
|
|
132
|
-
*
|
|
133
|
-
* `profile list` and telemetry use {@link inspectCredentials} instead, because describing a
|
|
134
|
-
* broken credential is not the same as using one.
|
|
135
|
-
*/
|
|
136
|
-
const readCredentials = (at) => {
|
|
137
|
-
const read = inspectCredentials(at.path);
|
|
138
|
-
if (read.kind === "unusable") throw new Error(`${read.reason}. ${repair(at)}`);
|
|
139
|
-
return read.kind === "ok" ? read.credentials : null;
|
|
140
|
-
};
|
|
141
|
-
const writeCredentials = (path, credentials) => {
|
|
142
|
-
writeSecretFile(path, JSON.stringify(credentials));
|
|
143
|
-
};
|
|
144
|
-
/**
|
|
145
|
-
* Build an `api_key` credentials object. Nothing from a previous credential is carried over.
|
|
146
|
-
*
|
|
147
|
-
* The scope is stored because it is not recoverable from the secret: `rotate-key` has to mint
|
|
148
|
-
* the replacement on the same endpoint, and an org or project key minted as an account key
|
|
149
|
-
* would silently widen what the profile reaches.
|
|
150
|
-
*/
|
|
151
|
-
const apiKeyCredentials = ({ apiKey, keyId, userId, scope }) => ({
|
|
152
|
-
type: API_KEY,
|
|
153
|
-
api_key: apiKey,
|
|
154
|
-
...keyId !== void 0 ? { key_id: keyId } : {},
|
|
155
|
-
...userId !== void 0 ? { user_id: userId } : {},
|
|
156
|
-
...scope?.orgId !== void 0 ? { org_id: scope.orgId } : {},
|
|
157
|
-
...scope?.projectId !== void 0 ? { project_id: scope.projectId } : {}
|
|
158
|
-
});
|
|
159
|
-
/** The scope recorded on a stored credential. */
|
|
160
|
-
const scopeOf = (credentials) => ({
|
|
161
|
-
...typeof credentials.org_id === "string" ? { orgId: credentials.org_id } : {},
|
|
162
|
-
...typeof credentials.project_id === "string" ? { projectId: credentials.project_id } : {}
|
|
163
|
-
});
|
|
164
|
-
/** How to describe a scope in output. */
|
|
165
|
-
const describeScope = (scope) => {
|
|
166
|
-
if (scope.projectId !== void 0) return `project ${scope.projectId}`;
|
|
167
|
-
if (scope.orgId !== void 0) return `org ${scope.orgId}`;
|
|
168
|
-
return "account";
|
|
169
|
-
};
|
|
170
|
-
function nonEmpty(value) {
|
|
171
|
-
if (typeof value !== "string") return void 0;
|
|
172
|
-
const trimmed = value.trim();
|
|
173
|
-
return trimmed === "" ? void 0 : trimmed;
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* Whether a stored credential is the same secret as the one about to replace it.
|
|
177
|
-
*
|
|
178
|
-
* Re-storing the key a profile already holds is a no-op, not a replacement — and retiring it
|
|
179
|
-
* would revoke the credential the command has just committed to. Trimmed on both sides, because
|
|
180
|
-
* a key read from a file or a pipe arrives with a trailing newline.
|
|
181
|
-
*/
|
|
182
|
-
const isSameCredential = (existingKey, replacementKey) => {
|
|
183
|
-
if (existingKey === void 0 || replacementKey === void 0) return false;
|
|
184
|
-
const trimmed = existingKey.trim();
|
|
185
|
-
return trimmed !== "" && trimmed === replacementKey.trim();
|
|
186
|
-
};
|
|
187
|
-
//#endregion
|
|
188
|
-
export { describeScope as a, isSameCredential as c, writeCredentials as d, credentialKind as i, readCredentials as l, OAUTH as n, inspectCredentials as o, apiKeyCredentials as r, interpretCredentials as s, API_KEY as t, scopeOf as u };
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, join } from "node:path";
|
|
3
|
-
//#region ../../internals/cli-core/dist/secure_file.js
|
|
4
|
-
/**
|
|
5
|
-
* Write a secret to disk owner-only, by creating a temporary file in the same directory and
|
|
6
|
-
* renaming it over the target.
|
|
7
|
-
*
|
|
8
|
-
* The rename is what makes this correct rather than merely tidy. `writeFileSync`'s `mode`
|
|
9
|
-
* applies only when it *creates* the file, so writing over an existing credentials file
|
|
10
|
-
* leaves whatever permissions it already had — a file created `0700` by an older release
|
|
11
|
-
* stays `0700` forever, and one created before a umask change stays world-readable. Renaming
|
|
12
|
-
* a fresh inode into place means every write lands at {@link SECRET_FILE_MODE}, so the
|
|
13
|
-
* permissions repair themselves instead of being inherited.
|
|
14
|
-
*
|
|
15
|
-
* It also closes the window where a reader could see the file at default permissions: the
|
|
16
|
-
* temporary file is created `0600` *before* it holds the secret's final name, and `rename`
|
|
17
|
-
* is atomic within a directory, so there is no moment at which the target is readable by
|
|
18
|
-
* anyone else and no moment at which it is half-written.
|
|
19
|
-
*
|
|
20
|
-
* The temporary name carries the pid so two processes writing at once cannot collide on it.
|
|
21
|
-
*/
|
|
22
|
-
const writeSecretFile = (path, contents) => {
|
|
23
|
-
const directory = dirname(path);
|
|
24
|
-
const temporary = join(directory, `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
|
|
25
|
-
try {
|
|
26
|
-
writeFileSync(temporary, contents, {
|
|
27
|
-
encoding: "utf8",
|
|
28
|
-
mode: 384
|
|
29
|
-
});
|
|
30
|
-
renameSync(temporary, path);
|
|
31
|
-
} catch (err) {
|
|
32
|
-
try {
|
|
33
|
-
unlinkSync(temporary);
|
|
34
|
-
} catch {}
|
|
35
|
-
throw err;
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
//#endregion
|
|
39
|
-
export { writeSecretFile as t };
|