@neondatabase/env 1.0.0 → 1.0.1
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 +13 -0
- package/dist/cli.js +971 -6
- package/dist/cli.js.map +1 -1
- package/dist/{_shared/env-core/env.js → env.js} +62 -41
- package/dist/env.js.map +1 -0
- package/dist/index.d.ts +528 -3
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +197 -2
- package/dist/{lib/parse-env.js.map → index.js.map} +1 -1
- package/package.json +8 -6
- package/dist/_shared/auth_selection.d.ts +0 -95
- package/dist/_shared/auth_selection.d.ts.map +0 -1
- package/dist/_shared/auth_selection.js +0 -85
- package/dist/_shared/auth_selection.js.map +0 -1
- package/dist/_shared/credentials.d.ts +0 -186
- package/dist/_shared/credentials.d.ts.map +0 -1
- package/dist/_shared/credentials.js +0 -190
- package/dist/_shared/credentials.js.map +0 -1
- package/dist/_shared/env-core/env.d.ts +0 -424
- package/dist/_shared/env-core/env.d.ts.map +0 -1
- package/dist/_shared/env-core/env.js.map +0 -1
- package/dist/_shared/env-core/reuse-secrets.d.ts +0 -95
- package/dist/_shared/env-core/reuse-secrets.d.ts.map +0 -1
- package/dist/_shared/env-core/reuse-secrets.js +0 -181
- package/dist/_shared/env-core/reuse-secrets.js.map +0 -1
- package/dist/_shared/paths.d.ts +0 -116
- package/dist/_shared/paths.d.ts.map +0 -1
- package/dist/_shared/paths.js +0 -153
- package/dist/_shared/paths.js.map +0 -1
- package/dist/_shared/profiles.d.ts +0 -145
- package/dist/_shared/profiles.d.ts.map +0 -1
- package/dist/_shared/profiles.js +0 -228
- package/dist/_shared/profiles.js.map +0 -1
- package/dist/_shared/secure_file.d.ts +0 -25
- package/dist/_shared/secure_file.d.ts.map +0 -1
- package/dist/_shared/secure_file.js +0 -43
- package/dist/_shared/secure_file.js.map +0 -1
- package/dist/config/dist/lib/define-config.d.ts +0 -20
- package/dist/config/dist/lib/define-config.d.ts.map +0 -1
- package/dist/config/dist/lib/neon-api.d.ts +0 -375
- package/dist/config/dist/lib/neon-api.d.ts.map +0 -1
- package/dist/config/dist/lib/types.d.ts +0 -603
- package/dist/config/dist/lib/types.d.ts.map +0 -1
- package/dist/config/dist/v1.d.ts +0 -5
- package/dist/lib/cli/commands.d.ts +0 -68
- package/dist/lib/cli/commands.d.ts.map +0 -1
- package/dist/lib/cli/commands.js +0 -233
- package/dist/lib/cli/commands.js.map +0 -1
- package/dist/lib/cli/resolve-api-key.d.ts +0 -29
- package/dist/lib/cli/resolve-api-key.d.ts.map +0 -1
- package/dist/lib/cli/resolve-api-key.js +0 -74
- package/dist/lib/cli/resolve-api-key.js.map +0 -1
- package/dist/lib/cli/resolve-context.d.ts +0 -34
- package/dist/lib/cli/resolve-context.d.ts.map +0 -1
- package/dist/lib/cli/resolve-context.js +0 -88
- package/dist/lib/cli/resolve-context.js.map +0 -1
- package/dist/lib/parse-env.d.ts +0 -95
- package/dist/lib/parse-env.d.ts.map +0 -1
- package/dist/lib/parse-env.js +0 -198
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,976 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { c as previewCredentialScopes, i as credentialName, l as resolveBranchPolicy, n as createApiFromOptions, o as fetchEnvKeys, r as credentialEnvKeys, s as policyEnvKeys, t as NEON_ENV_VAR_KEYS, u as toEntries } from "./env.js";
|
|
3
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import yargs from "yargs";
|
|
6
6
|
import { hideBin } from "yargs/helpers";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
9
|
+
import { ConfigLoadError, ErrorCode, MissingContextError, PlatformError, credentialScopesSatisfied, loadConfigFromFile } from "@neon/config/v1";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
//#region ../../internals/env-core/dist/reuse-secrets.js
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a branch's env while keeping one-time secrets the caller already holds.
|
|
14
|
+
*
|
|
15
|
+
* {@link fetchEnvKeys} — and the public `fetchEnv` — only ever *fetch*. The Neon API returns a
|
|
16
|
+
* credential's `api_token` / `s3_secret_access_key` exactly once, at mint time, so "fetching"
|
|
17
|
+
* them means minting a new credential; a plain `fetchEnv` on every `neon dev` start or `env
|
|
18
|
+
* pull` would leave a live credential behind each time. This is the wrapper that avoids that:
|
|
19
|
+
* it looks at what the caller already has, decides what is still usable, and asks `fetchEnv`
|
|
20
|
+
* for only the rest.
|
|
21
|
+
*
|
|
22
|
+
* The check is a real verification, not a presence test. A persisted secret is kept only when
|
|
23
|
+
* it names a credential that still exists on this branch, is not revoked or expired, and
|
|
24
|
+
* carries every scope the policy needs. A `.env.example` placeholder, a credential revoked in
|
|
25
|
+
* the console, one copied in from another branch, or one predating a newly-enabled feature all
|
|
26
|
+
* fail that check and get replaced.
|
|
27
|
+
*
|
|
28
|
+
* None of this needs local bookkeeping, because the secrets carry their own credential id:
|
|
29
|
+
* `AWS_ACCESS_KEY_ID` **is** the credential's `tokenId` (the storage gateway authenticates
|
|
30
|
+
* against the full id), and the AI Gateway token is minted as `nt_live_<tokenIdShort>_<secret>`,
|
|
31
|
+
* where `tokenIdShort` is what the credentials list reports. The env source being replaced is
|
|
32
|
+
* the record of what the last call issued.
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* import { fetchEnvReusingSecrets } from "@neon-internals/env-core/reuse-secrets";
|
|
36
|
+
*
|
|
37
|
+
* const { vars, credential } = await fetchEnvReusingSecrets(config, {
|
|
38
|
+
* projectId,
|
|
39
|
+
* branch: "main",
|
|
40
|
+
* env: { ...process.env, ...readEnvFile(".env") },
|
|
41
|
+
* });
|
|
42
|
+
* if (credential.issued) console.log(`new values for ${credential.keys.join(", ")}`);
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
async function fetchEnvReusingSecrets(config, options) {
|
|
46
|
+
const { env: source = process.env, keys: requestedKeys, revokeSuperseded = true, ...fetchOptions } = options;
|
|
47
|
+
const api = options.api ?? createApiFromOptions(options);
|
|
48
|
+
const { branch, desired } = await resolveBranchPolicy(config, options, api);
|
|
49
|
+
const allPolicyKeys = policyEnvKeys(desired);
|
|
50
|
+
const requested = requestedKeys ? new Set(requestedKeys) : null;
|
|
51
|
+
const selectedPolicyKeys = requested === null ? allPolicyKeys : allPolicyKeys.filter((key) => requested.has(key));
|
|
52
|
+
const selected = new Set(selectedPolicyKeys);
|
|
53
|
+
const K = NEON_ENV_VAR_KEYS;
|
|
54
|
+
const storageCredentialSelected = (desired.preview?.buckets.length ?? 0) > 0 && (selected.has(K.storage.accessKeyId) || selected.has(K.storage.secretAccessKey));
|
|
55
|
+
const gatewayCredentialSelected = (desired.preview?.aiGatewayEnabled ?? false) && selected.has(K.aiGateway.apiKey);
|
|
56
|
+
const secretKeys = credentialEnvKeys({
|
|
57
|
+
storage: storageCredentialSelected,
|
|
58
|
+
aiGateway: gatewayCredentialSelected
|
|
59
|
+
}).filter((key) => selected.has(key));
|
|
60
|
+
if (secretKeys.length === 0) {
|
|
61
|
+
const fetched = await fetchEnvKeys(config, fetchOptions, requested === null ? null : selectedPolicyKeys);
|
|
62
|
+
return {
|
|
63
|
+
vars: preferPersisted(toEntries(fetched), source),
|
|
64
|
+
credential: {
|
|
65
|
+
issued: false,
|
|
66
|
+
keys: [],
|
|
67
|
+
revoked: [],
|
|
68
|
+
superseded: []
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const persisted = readPersistedSecrets(source);
|
|
73
|
+
const storageCredentialManaged = requested === null || storageCredentialSelected;
|
|
74
|
+
const gatewayCredentialManaged = requested === null || gatewayCredentialSelected;
|
|
75
|
+
const complete = (!storageCredentialSelected || Boolean(persisted.accessKeyId && persisted.secretAccessKey)) && (!gatewayCredentialSelected || Boolean(persisted.apiToken));
|
|
76
|
+
const named = storageCredentialManaged && persisted.accessKeyId !== "" || gatewayCredentialManaged && persisted.apiToken !== "" ? namedCredentials(await api.listCredentials(options.projectId, branch.id), persisted) : {
|
|
77
|
+
storage: null,
|
|
78
|
+
gateway: null
|
|
79
|
+
};
|
|
80
|
+
const reusable = complete ? reusableCredential(named, {
|
|
81
|
+
storageEnabled: storageCredentialSelected,
|
|
82
|
+
gatewayEnabled: gatewayCredentialSelected
|
|
83
|
+
}) : null;
|
|
84
|
+
const scopes = previewCredentialScopes(desired.preview, {
|
|
85
|
+
storage: storageCredentialSelected,
|
|
86
|
+
aiGateway: gatewayCredentialSelected
|
|
87
|
+
});
|
|
88
|
+
const keep = reusable !== null && credentialScopesSatisfied(reusable.scopes, scopes);
|
|
89
|
+
const fetchKeys = keep ? selectedPolicyKeys.filter((key) => !secretKeys.includes(key)) : selectedPolicyKeys;
|
|
90
|
+
const fetched = await fetchEnvKeys(config, {
|
|
91
|
+
...fetchOptions,
|
|
92
|
+
branchId: branch.id,
|
|
93
|
+
api
|
|
94
|
+
}, fetchKeys);
|
|
95
|
+
const vars = preferPersisted(toEntries(fetched), source);
|
|
96
|
+
if (keep) {
|
|
97
|
+
for (const key of secretKeys) {
|
|
98
|
+
const value = source[key];
|
|
99
|
+
if (value !== void 0) vars[key] = value;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
vars,
|
|
103
|
+
credential: {
|
|
104
|
+
issued: false,
|
|
105
|
+
keys: secretKeys,
|
|
106
|
+
revoked: [],
|
|
107
|
+
superseded: []
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const ours = /* @__PURE__ */ new Set();
|
|
112
|
+
for (const meta of [storageCredentialManaged ? named.storage : null, gatewayCredentialManaged ? named.gateway : null]) if (meta !== null && meta.principalType === "user" && meta.name === credentialName(branch.name)) ours.add(meta.tokenId);
|
|
113
|
+
if (revokeSuperseded) for (const tokenId of ours) await api.revokeCredential(options.projectId, branch.id, tokenId);
|
|
114
|
+
return {
|
|
115
|
+
vars,
|
|
116
|
+
credential: {
|
|
117
|
+
issued: true,
|
|
118
|
+
keys: secretKeys,
|
|
119
|
+
revoked: revokeSuperseded ? [...ours] : [],
|
|
120
|
+
superseded: revokeSuperseded ? [] : [...ours]
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Read the branch credential's secrets out of an env source. */
|
|
125
|
+
function readPersistedSecrets(source) {
|
|
126
|
+
const storage = NEON_ENV_VAR_KEYS.storage;
|
|
127
|
+
const gateway = NEON_ENV_VAR_KEYS.aiGateway;
|
|
128
|
+
return {
|
|
129
|
+
accessKeyId: source[storage.accessKeyId] ?? "",
|
|
130
|
+
secretAccessKey: source[storage.secretAccessKey] ?? "",
|
|
131
|
+
apiToken: source[gateway.apiKey] ?? ""
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Keep a persisted value rather than overwriting it with an empty fetched one.
|
|
136
|
+
*
|
|
137
|
+
* Neon Auth's `base_url` is the case that needs this: integrations created before the API
|
|
138
|
+
* returned it answer with an empty string, and the persisted copy is the only one left. An
|
|
139
|
+
* empty fetched value never carries more information than a non-empty persisted one, so
|
|
140
|
+
* preferring the latter is safe for every var — and it keeps a pull from blanking a working
|
|
141
|
+
* line in someone's `.env`.
|
|
142
|
+
*/
|
|
143
|
+
function preferPersisted(vars, source) {
|
|
144
|
+
const out = { ...vars };
|
|
145
|
+
for (const [key, value] of Object.entries(out)) {
|
|
146
|
+
if (value !== "") continue;
|
|
147
|
+
const persisted = source[key];
|
|
148
|
+
if (persisted !== void 0 && persisted !== "") out[key] = persisted;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The credential id embedded in an AI Gateway token. The API mints them as
|
|
154
|
+
* `nt_live_<tokenIdShort>_<secret>`, and `tokenIdShort` is the public identifier the credentials
|
|
155
|
+
* list reports — so a persisted token names the credential that issued it. Returns `null` for
|
|
156
|
+
* anything not in that shape (a `.env.example` placeholder, a hand-typed value), which callers
|
|
157
|
+
* treat as unverifiable.
|
|
158
|
+
*/
|
|
159
|
+
function gatewayTokenIdShort(apiToken) {
|
|
160
|
+
return /^nt_live_([^_]+)_.+$/.exec(apiToken)?.[1] ?? null;
|
|
161
|
+
}
|
|
162
|
+
/** Whether an issued credential can still be used: not revoked, not past its expiry. */
|
|
163
|
+
function isLiveCredential(meta, now) {
|
|
164
|
+
if (meta.revokedAt !== void 0) return false;
|
|
165
|
+
if (meta.expiresAt === void 0) return true;
|
|
166
|
+
const expiresAt = Date.parse(meta.expiresAt);
|
|
167
|
+
return Number.isNaN(expiresAt) || expiresAt > now;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The live credentials the persisted secrets name — at most one per half. A half that names
|
|
171
|
+
* nothing contributes nothing, which is what a placeholder, a credential revoked in the
|
|
172
|
+
* console, and one copied in from another branch all look like from here.
|
|
173
|
+
*/
|
|
174
|
+
function namedCredentials(live, persisted) {
|
|
175
|
+
const usable = live.filter((meta) => isLiveCredential(meta, Date.now()));
|
|
176
|
+
const shortId = persisted.apiToken ? gatewayTokenIdShort(persisted.apiToken) : null;
|
|
177
|
+
return {
|
|
178
|
+
storage: persisted.accessKeyId ? usable.find((meta) => meta.tokenId === persisted.accessKeyId) ?? null : null,
|
|
179
|
+
gateway: shortId ? usable.find((meta) => meta.tokenIdShort === shortId) ?? null : null
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The credential the persisted secrets can be *reused* as, or `null`.
|
|
184
|
+
*
|
|
185
|
+
* Strict on purpose: every half the policy enables has to name a live credential, and when both
|
|
186
|
+
* features are enabled they must name the *same* one — they share a single credential, so
|
|
187
|
+
* halves that disagree came from two different calls and neither can be trusted.
|
|
188
|
+
*/
|
|
189
|
+
function reusableCredential(named, enabled) {
|
|
190
|
+
if (enabled.storageEnabled && enabled.gatewayEnabled) return named.storage && named.gateway && named.storage.tokenId === named.gateway.tokenId ? named.storage : null;
|
|
191
|
+
if (enabled.storageEnabled) return named.storage;
|
|
192
|
+
if (enabled.gatewayEnabled) return named.gateway;
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region ../../internals/cli-core/dist/paths.js
|
|
197
|
+
/**
|
|
198
|
+
* # Where the Neon CLIs keep their files on disk
|
|
199
|
+
*
|
|
200
|
+
**Deliberately impure.** It reads environment variables and touches the filesystem, which
|
|
201
|
+
* `@neon/config` — the package this used to be a subpath of — must never do from its root
|
|
202
|
+
* export. It lives here instead of there precisely so that a policy-facing package does not
|
|
203
|
+
* carry implementor-only code.
|
|
204
|
+
*
|
|
205
|
+
* It exists because three separate readers each grew their own answer to "where is the
|
|
206
|
+
* config directory", and all three disagreed: `packages/cli` honoured `XDG_CONFIG_HOME` but
|
|
207
|
+
* not `NEONCTL_CONFIG_DIR`, `packages/env` honoured the env var but not XDG, and the init
|
|
208
|
+
* flow hardcoded `~/.config/neonctl`. With `XDG_CONFIG_HOME` set, the CLI wrote
|
|
209
|
+
* credentials somewhere the other two never looked.
|
|
210
|
+
*
|
|
211
|
+
* ## The directory
|
|
212
|
+
*
|
|
213
|
+
* `neon` is the current name; `neonctl` is the legacy one, kept readable forever. Resolution,
|
|
214
|
+
* each entry winning over the next:
|
|
215
|
+
*
|
|
216
|
+
* 1. An explicit directory (a `--config-dir` flag) — **exact**, no legacy fallback.
|
|
217
|
+
* 2. `NEON_CONFIG_DIR` — exact.
|
|
218
|
+
* 3. `NEONCTL_CONFIG_DIR` (legacy name) — exact.
|
|
219
|
+
* 4. `$XDG_CONFIG_HOME/neon`, else `<home>/.config/neon`.
|
|
220
|
+
*
|
|
221
|
+
* An explicitly chosen directory is never paired with a fallback: `--config-dir /tmp/ci` that
|
|
222
|
+
* quietly read `~/.config/neonctl` would defeat the point of passing it.
|
|
223
|
+
*
|
|
224
|
+
* ## The files
|
|
225
|
+
*
|
|
226
|
+
* {@link resolveConfigFile} answers "which path should I use for this file", and it is the
|
|
227
|
+
* same answer for reading and writing:
|
|
228
|
+
*
|
|
229
|
+
* - Present in `neon/` → use it.
|
|
230
|
+
* - Present only in `neonctl/` → **use it there, in place.** An existing credentials file is
|
|
231
|
+
* never copied or moved, so nothing is left behind to go stale and no other tool starts
|
|
232
|
+
* reading an abandoned token.
|
|
233
|
+
* - Present in neither → the new location. New files only ever appear under `neon/`.
|
|
234
|
+
*/
|
|
235
|
+
/** Current directory name. New files are created here. */
|
|
236
|
+
const CONFIG_DIR_NAME = "neon";
|
|
237
|
+
/** Legacy directory name, read forever so existing installs keep working untouched. */
|
|
238
|
+
const LEGACY_CONFIG_DIR_NAME = "neonctl";
|
|
239
|
+
/** Where files are created. See the module docs for the precedence. */
|
|
240
|
+
function configDir(options = {}) {
|
|
241
|
+
const explicit = explicitDir(options);
|
|
242
|
+
if (explicit) return explicit;
|
|
243
|
+
return join(configHome(options.env ?? process.env), CONFIG_DIR_NAME);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The legacy directory, or `undefined` when the location was chosen explicitly (in which
|
|
247
|
+
* case there is no legacy counterpart to fall back to).
|
|
248
|
+
*/
|
|
249
|
+
function legacyConfigDir(options = {}) {
|
|
250
|
+
if (explicitDir(options)) return void 0;
|
|
251
|
+
return join(configHome(options.env ?? process.env), LEGACY_CONFIG_DIR_NAME);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Resolve one file inside the config directory. Prefers the current location, falls back to
|
|
255
|
+
* an existing legacy file **in place**, and otherwise points at the current location so new
|
|
256
|
+
* files are created there.
|
|
257
|
+
*/
|
|
258
|
+
function resolveConfigFile(fileName, options = {}) {
|
|
259
|
+
const dir = configDir(options);
|
|
260
|
+
const current = resolve(dir, fileName);
|
|
261
|
+
if (existsSync(current)) return {
|
|
262
|
+
path: current,
|
|
263
|
+
dir,
|
|
264
|
+
isLegacy: false,
|
|
265
|
+
exists: true
|
|
266
|
+
};
|
|
267
|
+
const legacyDir = legacyConfigDir(options);
|
|
268
|
+
if (legacyDir) {
|
|
269
|
+
const legacy = resolve(legacyDir, fileName);
|
|
270
|
+
if (existsSync(legacy)) return {
|
|
271
|
+
path: legacy,
|
|
272
|
+
dir: legacyDir,
|
|
273
|
+
isLegacy: true,
|
|
274
|
+
exists: true
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
path: current,
|
|
279
|
+
dir,
|
|
280
|
+
isLegacy: false,
|
|
281
|
+
exists: false
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/** `$XDG_CONFIG_HOME`, else `<home>/.config`. Falls back to a relative `.config` with no home. */
|
|
285
|
+
function configHome(env) {
|
|
286
|
+
const xdg = nonEmpty$3(env.XDG_CONFIG_HOME);
|
|
287
|
+
if (xdg) return xdg;
|
|
288
|
+
const home = nonEmpty$3(env.HOME) ?? nonEmpty$3(env.USERPROFILE);
|
|
289
|
+
return home ? join(home, ".config") : ".config";
|
|
290
|
+
}
|
|
291
|
+
function explicitDir(options) {
|
|
292
|
+
const env = options.env ?? process.env;
|
|
293
|
+
return nonEmpty$3(options.dir) ?? nonEmpty$3(env.NEON_CONFIG_DIR) ?? nonEmpty$3(env.NEONCTL_CONFIG_DIR);
|
|
294
|
+
}
|
|
295
|
+
function nonEmpty$3(value) {
|
|
296
|
+
if (typeof value !== "string") return void 0;
|
|
297
|
+
const trimmed = value.trim();
|
|
298
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
299
|
+
}
|
|
300
|
+
const CREDENTIALS_FILE = "credentials.json";
|
|
301
|
+
/**
|
|
302
|
+
* Default for `--config-dir`: `$XDG_CONFIG_HOME/neon`, else `~/.config/neon`.
|
|
303
|
+
*
|
|
304
|
+
* The directory was called `neonctl` until the CLI was renamed. An existing one is still read —
|
|
305
|
+
* see {@link credentialsPath} — but it is never written to, moved, or deleted.
|
|
306
|
+
*/
|
|
307
|
+
const defaultDir = configDir();
|
|
308
|
+
/**
|
|
309
|
+
* Where this invocation's `credentials.json` lives.
|
|
310
|
+
*
|
|
311
|
+
* When `--config-dir` was left at its default, an existing file in the legacy `neonctl`
|
|
312
|
+
* directory is used **in place**: an install that predates the rename keeps working, and its
|
|
313
|
+
* credentials are never duplicated into a second location where one copy could go stale while
|
|
314
|
+
* another tool still reads it.
|
|
315
|
+
*
|
|
316
|
+
* A `--config-dir` the user actually passed is used exactly as given. Falling back out of an
|
|
317
|
+
* explicitly chosen directory would defeat the reason for choosing it — a CI run pointed at a
|
|
318
|
+
* scratch directory must never pick up a developer's real credentials.
|
|
319
|
+
*/
|
|
320
|
+
const credentialsPath = (dir) => resolveConfigFile(CREDENTIALS_FILE, dir === defaultDir ? {} : { dir }).path;
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region ../../internals/cli-core/dist/profiles.js
|
|
323
|
+
/**
|
|
324
|
+
* # Profiles — several Neon accounts in one config directory
|
|
325
|
+
*
|
|
326
|
+
* A profile is **a pointer to a credentials file**. Nothing more. That constraint is what
|
|
327
|
+
* keeps the feature small: there is no mirror, no per-profile directory tree, no persistent
|
|
328
|
+
* "active profile" state to fall out of sync, and no migration.
|
|
329
|
+
*
|
|
330
|
+
* ```
|
|
331
|
+
* ~/.config/neon/
|
|
332
|
+
* ├── credentials.json # this IS the DEFAULT profile, not a copy of it
|
|
333
|
+
* ├── credentials.work.json # created by `neon auth --profile work`
|
|
334
|
+
* └── profiles.json # created only once a second profile exists
|
|
335
|
+
* ```
|
|
336
|
+
*
|
|
337
|
+
* `profiles.json` maps a name to a path, and the path may point anywhere — which is what
|
|
338
|
+
* makes adopting an existing directory a one-line edit rather than an import command:
|
|
339
|
+
*
|
|
340
|
+
* ```json
|
|
341
|
+
* {
|
|
342
|
+
* "version": 1,
|
|
343
|
+
* "profiles": {
|
|
344
|
+
* "DEFAULT": { "credentials": "credentials.json" },
|
|
345
|
+
* "work": {
|
|
346
|
+
* "credentials": "../neonctl-databricks/credentials.json",
|
|
347
|
+
* "label": "someone@example.com"
|
|
348
|
+
* }
|
|
349
|
+
* }
|
|
350
|
+
* }
|
|
351
|
+
* ```
|
|
352
|
+
*
|
|
353
|
+
* ## Selection
|
|
354
|
+
*
|
|
355
|
+
* `--profile` → `NEON_PROFILE` → `DEFAULT`. Per invocation, like `AWS_PROFILE`; there is no
|
|
356
|
+
* `profile use` command, so nothing persists that could disagree with what you typed.
|
|
357
|
+
*
|
|
358
|
+
* ## Compatibility
|
|
359
|
+
*
|
|
360
|
+
* An install with no `profiles.json` is already a valid `DEFAULT`-only state: `DEFAULT`
|
|
361
|
+
* resolves to `credentials.json` in the config directory (including an existing one in the
|
|
362
|
+
* legacy `neonctl` directory — see `./paths.ts`). Nothing is created until a second
|
|
363
|
+
* profile is, and nothing is ever moved.
|
|
364
|
+
*/
|
|
365
|
+
const PROFILES_FILE = "profiles.json";
|
|
366
|
+
/** The implicit profile. Backed by plain `credentials.json`, with or without a profiles file. */
|
|
367
|
+
const DEFAULT_PROFILE = "DEFAULT";
|
|
368
|
+
/** Profile names become part of a filename, so keep them boring. */
|
|
369
|
+
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
370
|
+
/** Where `profiles.json` lives for this config directory (whether or not it exists yet). */
|
|
371
|
+
const profilesFilePath = (dir) => resolveConfigFile(PROFILES_FILE, dir === defaultDir ? {} : { dir }).path;
|
|
372
|
+
/**
|
|
373
|
+
* Read and classify `profiles.json` without deciding what to do about it.
|
|
374
|
+
*
|
|
375
|
+
* Entry keys and shapes are validated here rather than at each use. A key is a profile name,
|
|
376
|
+
* and a name that `assertValidProfileName` would reject cannot have been written by this CLI —
|
|
377
|
+
* it would travel into error messages as a recovery command nobody can run, and into a
|
|
378
|
+
* `credentials.<name>.json` filename.
|
|
379
|
+
*/
|
|
380
|
+
const inspectProfiles = (dir) => {
|
|
381
|
+
const path = profilesFilePath(dir);
|
|
382
|
+
if (!existsSync(path)) return { kind: "absent" };
|
|
383
|
+
const broken = (why) => ({
|
|
384
|
+
kind: "unusable",
|
|
385
|
+
reason: `${path} could not be read as a profiles file: ${why}`
|
|
386
|
+
});
|
|
387
|
+
let contents;
|
|
388
|
+
try {
|
|
389
|
+
contents = readFileSync(path, "utf8");
|
|
390
|
+
} catch (err) {
|
|
391
|
+
const code = err.code;
|
|
392
|
+
return broken(code ? `reading it failed with ${code}` : "reading it failed");
|
|
393
|
+
}
|
|
394
|
+
let parsed;
|
|
395
|
+
try {
|
|
396
|
+
parsed = JSON.parse(contents);
|
|
397
|
+
} catch {
|
|
398
|
+
return broken("it is not valid JSON");
|
|
399
|
+
}
|
|
400
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return broken("it does not contain an object");
|
|
401
|
+
const profiles = parsed.profiles;
|
|
402
|
+
if (profiles === null || typeof profiles !== "object" || Array.isArray(profiles)) return broken("it has no `profiles` object");
|
|
403
|
+
for (const [name, entry] of Object.entries(profiles)) {
|
|
404
|
+
if (!NAME_PATTERN.test(name)) return broken(`"${name}" is not a valid profile name`);
|
|
405
|
+
if (entry === null || typeof entry !== "object" || typeof entry.credentials !== "string" || entry.credentials.trim() === "") return broken(`profile "${name}" has no \`credentials\` path`);
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
kind: "ok",
|
|
409
|
+
file: {
|
|
410
|
+
version: 1,
|
|
411
|
+
profiles
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
};
|
|
415
|
+
/** Resolve a profile to an absolute credentials path. Throws when a named profile is unknown. */
|
|
416
|
+
const resolveProfile = (dir, name) => {
|
|
417
|
+
const read = inspectProfiles(dir);
|
|
418
|
+
if (read.kind === "unusable" && name !== "DEFAULT") throw new Error(`${read.reason}. Fix or delete the file — every named profile is defined in it.`);
|
|
419
|
+
const file = read.kind === "ok" ? read.file : null;
|
|
420
|
+
const entry = file?.profiles[name];
|
|
421
|
+
if (entry) return {
|
|
422
|
+
name,
|
|
423
|
+
credentialsPath: resolveEntryPath(dir, entry.credentials),
|
|
424
|
+
...entry.label ? { label: entry.label } : {},
|
|
425
|
+
...entry.userId ? { userId: entry.userId } : {},
|
|
426
|
+
declared: true
|
|
427
|
+
};
|
|
428
|
+
if (name === "DEFAULT") return {
|
|
429
|
+
name,
|
|
430
|
+
credentialsPath: credentialsPath(dir),
|
|
431
|
+
declared: false
|
|
432
|
+
};
|
|
433
|
+
const known = file ? Object.keys(file.profiles).join(", ") : DEFAULT_PROFILE;
|
|
434
|
+
throw new Error(`Unknown profile "${name}". Known profiles: ${known}. Create it with \`neon profile create ${name}\`.`);
|
|
435
|
+
};
|
|
436
|
+
const resolveEntryPath = (dir, entry) => isAbsolute(entry) ? entry : resolve(profilesDir(dir), entry);
|
|
437
|
+
/** `profiles.json` may sit in the legacy directory, so entries resolve against its own dir. */
|
|
438
|
+
const profilesDir = (dir) => resolve(profilesFilePath(dir), "..");
|
|
439
|
+
//#endregion
|
|
440
|
+
//#region ../../internals/cli-core/dist/auth_selection.js
|
|
441
|
+
const selectCredential = ({ apiKeyFlag, profileFlag, apiKeyEnv, profileEnv }) => {
|
|
442
|
+
const flagKey = nonEmpty$2(apiKeyFlag);
|
|
443
|
+
const flagProfile = nonEmpty$2(profileFlag);
|
|
444
|
+
if (flagKey !== void 0 && flagProfile !== void 0) throw new Error("Pass either --api-key or --profile, not both. --api-key supplies a credential directly; --profile selects a stored one.");
|
|
445
|
+
if (flagKey !== void 0) return {
|
|
446
|
+
source: "explicit-api-key",
|
|
447
|
+
apiKey: flagKey
|
|
448
|
+
};
|
|
449
|
+
if (flagProfile !== void 0) return {
|
|
450
|
+
source: "profile",
|
|
451
|
+
profile: flagProfile,
|
|
452
|
+
explicit: true
|
|
453
|
+
};
|
|
454
|
+
const envKey = nonEmpty$2(apiKeyEnv);
|
|
455
|
+
const envProfile = nonEmpty$2(profileEnv);
|
|
456
|
+
if (envKey !== void 0) return {
|
|
457
|
+
source: "ambient-api-key",
|
|
458
|
+
apiKey: envKey,
|
|
459
|
+
...envProfile !== void 0 ? { ignoredProfile: envProfile } : {}
|
|
460
|
+
};
|
|
461
|
+
return {
|
|
462
|
+
source: "profile",
|
|
463
|
+
profile: envProfile ?? "DEFAULT",
|
|
464
|
+
explicit: envProfile !== void 0
|
|
465
|
+
};
|
|
466
|
+
};
|
|
467
|
+
/** The warning for an ambient key that displaced an ambient profile, or `null`. */
|
|
468
|
+
const displacedProfileWarning = (selection) => selection.source === "ambient-api-key" && selection.ignoredProfile !== void 0 ? `NEON_API_KEY is set, so profile "${selection.ignoredProfile}" from NEON_PROFILE was ignored. Pass --profile ${selection.ignoredProfile} to use it instead.` : null;
|
|
469
|
+
function nonEmpty$2(value) {
|
|
470
|
+
if (typeof value !== "string") return void 0;
|
|
471
|
+
const trimmed = value.trim();
|
|
472
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
473
|
+
}
|
|
474
|
+
//#endregion
|
|
475
|
+
//#region ../../internals/cli-core/dist/credentials.js
|
|
476
|
+
/**
|
|
477
|
+
* # Stored credentials — one file per account, two kinds
|
|
478
|
+
*
|
|
479
|
+
* A profile points at exactly one credentials file (see `./profiles.ts`), and that file says
|
|
480
|
+
* what kind of credential it holds. Adding API-key support this way rather than adding a
|
|
481
|
+
* second pointer to `profiles.json` keeps a profile what it already was — one name, one path
|
|
482
|
+
* — and means `profiles.json` needs no schema change at all.
|
|
483
|
+
*
|
|
484
|
+
* ```json
|
|
485
|
+
* // oauth: every file written before this existed. An absent `type` means this.
|
|
486
|
+
* { "access_token": "…", "refresh_token": "…", "expires_at": 1786…, "user_id": "…" }
|
|
487
|
+
*
|
|
488
|
+
* // api_key, stored by `neon profile create --api-key`
|
|
489
|
+
* { "type": "api_key", "api_key": "napi_…", "user_id": "…" }
|
|
490
|
+
*
|
|
491
|
+
* // api_key minted by `--mint --org-id`, which records the scope it was issued at
|
|
492
|
+
* { "type": "api_key", "api_key": "napi_…", "key_id": 123, "org_id": "org-…" }
|
|
493
|
+
* ```
|
|
494
|
+
*
|
|
495
|
+
* ## One profile, one kind
|
|
496
|
+
*
|
|
497
|
+
* A credentials file holds an API key or an OAuth session, never both, and `type` states
|
|
498
|
+
* which. An earlier draft let the two coexist — the idea being that a key could keep the
|
|
499
|
+
* session it was minted from and so rotate without a browser. It did not survive review, for
|
|
500
|
+
* two reasons that are worth recording so nobody rebuilds it:
|
|
501
|
+
*
|
|
502
|
+
* 1. **It never worked.** The resolver returned the key without testing it, so a revoked key
|
|
503
|
+
* failed to mint and never fell back to the session sitting beside it.
|
|
504
|
+
* 2. **It could mix accounts.** Nothing compared the identity of the credential being written
|
|
505
|
+
* with the one already there, so a profile could hold one account's session and another's
|
|
506
|
+
* key, told apart only by a single string. Flip or lose `type` and the profile silently
|
|
507
|
+
* becomes a different person.
|
|
508
|
+
*
|
|
509
|
+
* Recovery from a dead key is therefore one browser login — `neon profile create <name>
|
|
510
|
+
* --mint --force` — which is what the retained session was supposed to save and never did.
|
|
511
|
+
*
|
|
512
|
+
* ## Older releases
|
|
513
|
+
*
|
|
514
|
+
* A CLI predating this reads the pointer, finds no `type` it understands, ignores it, and
|
|
515
|
+
* looks for `access_token`. An `api_key` profile has none, so an older release falls through
|
|
516
|
+
* to its browser login rather than crashing. That it does not crash is why `credentials`
|
|
517
|
+
* stays a required pointer: an entry without one makes 2.41 and 2.42 throw
|
|
518
|
+
* `ERR_INVALID_ARG_TYPE` from `resolveEntryPath`.
|
|
519
|
+
*/
|
|
520
|
+
const OAUTH = "oauth";
|
|
521
|
+
const API_KEY = "api_key";
|
|
522
|
+
/**
|
|
523
|
+
* Which credential in this file authenticates, by declaration alone.
|
|
524
|
+
*
|
|
525
|
+
* An unrecognised `type` throws rather than falling back to `oauth`. A file we cannot
|
|
526
|
+
* interpret is a misconfiguration the user has to see: treating it as OAuth would send them
|
|
527
|
+
* to a browser login that silently replaces a credential they meant to keep, and treating it
|
|
528
|
+
* as an API key would authenticate with whatever `api_key` happened to be there.
|
|
529
|
+
*
|
|
530
|
+
* This deliberately does not check that an `api_key` file has a key — `neon profile list`
|
|
531
|
+
* needs the kind of a file it is not about to authenticate with, and must be able to report a
|
|
532
|
+
* broken one rather than throwing halfway through a table.
|
|
533
|
+
*/
|
|
534
|
+
const credentialKind = (credentials, at) => {
|
|
535
|
+
const declared = credentials.type;
|
|
536
|
+
if (declared === void 0 || declared === "oauth") return OAUTH;
|
|
537
|
+
if (declared === "api_key") return API_KEY;
|
|
538
|
+
throw new Error(`${at.path} declares a "type" this version does not understand. Expected "${OAUTH}" or "${API_KEY}". ${repair(at)}`);
|
|
539
|
+
};
|
|
540
|
+
/**
|
|
541
|
+
* The way out of a credentials file that cannot be read.
|
|
542
|
+
*
|
|
543
|
+
* One sentence, shared by every such error, because they all have the same two answers: write
|
|
544
|
+
* a new credential over it, or delete it and start again.
|
|
545
|
+
*/
|
|
546
|
+
const repair = (at) => `Replace it deliberately with \`neon profile create ${at.profile} --force\`, or delete the file.`;
|
|
547
|
+
/**
|
|
548
|
+
* Resolve what to authenticate with, validating that the declared kind is actually usable.
|
|
549
|
+
*
|
|
550
|
+
* An `api_key` file with no key is a hard error rather than a fall-through to OAuth: the user
|
|
551
|
+
* asked for a key, and quietly opening a browser instead would replace the credential they
|
|
552
|
+
* were trying to fix.
|
|
553
|
+
*/
|
|
554
|
+
const interpretCredentials = (credentials, at) => {
|
|
555
|
+
if (credentialKind(credentials, at) === "oauth") return { kind: OAUTH };
|
|
556
|
+
const apiKey = nonEmpty$1(credentials.api_key);
|
|
557
|
+
if (apiKey === void 0) throw new Error(`${at.path} declares "type": "${API_KEY}" but has no "api_key" value. ${repair(at)}`);
|
|
558
|
+
return {
|
|
559
|
+
kind: API_KEY,
|
|
560
|
+
apiKey
|
|
561
|
+
};
|
|
562
|
+
};
|
|
563
|
+
/**
|
|
564
|
+
* Read and classify a credentials file, without deciding what to do about it.
|
|
565
|
+
*
|
|
566
|
+
* A permission or I/O error still throws: there may be a perfectly good credential here that
|
|
567
|
+
* we cannot see, and treating that as absent would send the user to a browser login that
|
|
568
|
+
* overwrites it.
|
|
569
|
+
*/
|
|
570
|
+
const inspectCredentials = (path) => {
|
|
571
|
+
let contents;
|
|
572
|
+
try {
|
|
573
|
+
contents = readFileSync(path, "utf8");
|
|
574
|
+
} catch (err) {
|
|
575
|
+
if (err.code === "ENOENT") return { kind: "absent" };
|
|
576
|
+
throw err;
|
|
577
|
+
}
|
|
578
|
+
let parsed;
|
|
579
|
+
try {
|
|
580
|
+
parsed = JSON.parse(contents);
|
|
581
|
+
} catch {
|
|
582
|
+
return {
|
|
583
|
+
kind: "unusable",
|
|
584
|
+
reason: `${path} is not valid JSON, so the credential in it cannot be read`
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {
|
|
588
|
+
kind: "unusable",
|
|
589
|
+
reason: `${path} does not contain a credentials object`
|
|
590
|
+
};
|
|
591
|
+
return {
|
|
592
|
+
kind: "ok",
|
|
593
|
+
credentials: parsed
|
|
594
|
+
};
|
|
595
|
+
};
|
|
596
|
+
function nonEmpty$1(value) {
|
|
597
|
+
if (typeof value !== "string") return void 0;
|
|
598
|
+
const trimmed = value.trim();
|
|
599
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
600
|
+
}
|
|
601
|
+
//#endregion
|
|
602
|
+
//#region src/lib/cli/resolve-api-key.ts
|
|
603
|
+
/**
|
|
604
|
+
* Resolve the Neon API key for a `neon-env` CLI invocation.
|
|
605
|
+
*
|
|
606
|
+
* Precedence is the `neon` CLI's, from the same module: **an explicit flag beats an ambient
|
|
607
|
+
* environment variable.** `--api-key` and `--profile` together is an error; `--profile` beats
|
|
608
|
+
* `NEON_API_KEY`; `--api-key` beats `NEON_PROFILE`; two ambient sources resolve to the key.
|
|
609
|
+
*
|
|
610
|
+
* Sharing that decision rather than restating it is the point. An earlier version of this file
|
|
611
|
+
* checked `NEON_API_KEY` before the selected profile, so `NEON_API_KEY=… neon-env run --profile
|
|
612
|
+
* work` silently used the wrong account — the very bug this feature fixes in `neon`.
|
|
613
|
+
*
|
|
614
|
+
* The CLI owns the resolution because `@neon/config` and `@neon/env`'s root export are
|
|
615
|
+
* deliberately environment- and filesystem-agnostic: they accept an explicit `apiKey` and
|
|
616
|
+
* nothing else, so the ambient sources a *user* expects have to be read out here.
|
|
617
|
+
*/
|
|
618
|
+
function resolveApiKey(options) {
|
|
619
|
+
const env = options.env ?? process.env;
|
|
620
|
+
const selection = selectCredential({
|
|
621
|
+
...options.apiKey !== void 0 ? { apiKeyFlag: options.apiKey } : {},
|
|
622
|
+
...options.profile !== void 0 ? { profileFlag: options.profile } : {},
|
|
623
|
+
...env.NEON_API_KEY !== void 0 ? { apiKeyEnv: env.NEON_API_KEY } : {},
|
|
624
|
+
...env.NEON_PROFILE !== void 0 ? { profileEnv: env.NEON_PROFILE } : {}
|
|
625
|
+
});
|
|
626
|
+
const displaced = displacedProfileWarning(selection);
|
|
627
|
+
if (displaced !== null) (options.warn ?? ((message) => process.stderr.write(`${message}\n`)))(displaced);
|
|
628
|
+
if (selection.source !== "profile") return selection.apiKey;
|
|
629
|
+
return readStoredCredential(selection, env);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* The credential stored for the selected profile.
|
|
633
|
+
*
|
|
634
|
+
* Two different situations, deliberately not merged. A **missing** credential under `DEFAULT` is
|
|
635
|
+
* the ordinary not-signed-in state and resolves to no key; under a profile the user named it is
|
|
636
|
+
* an error, because reporting a missing credential would hide that the real problem is the name
|
|
637
|
+
* they typed. A **damaged** credential is always an error: the file is there, it is not an
|
|
638
|
+
* absence, and no amount of signing in elsewhere explains it.
|
|
639
|
+
*/
|
|
640
|
+
function readStoredCredential(selection, env) {
|
|
641
|
+
const { profile, explicit } = selection;
|
|
642
|
+
/**
|
|
643
|
+
* An *absence* is only an error when the user named the profile. Not being signed in under
|
|
644
|
+
* `DEFAULT` is the ordinary state, and the library's `PLATFORM_MISSING_API_KEY` says it
|
|
645
|
+
* better than a stack trace.
|
|
646
|
+
*/
|
|
647
|
+
const absent = (reason) => {
|
|
648
|
+
if (explicit) throw new Error(reason);
|
|
649
|
+
};
|
|
650
|
+
let path;
|
|
651
|
+
try {
|
|
652
|
+
path = profile === "DEFAULT" ? resolveConfigFile("credentials.json", { env }).path : resolveProfile(configDir({ env }), profile).credentialsPath;
|
|
653
|
+
} catch (err) {
|
|
654
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
655
|
+
}
|
|
656
|
+
const read = inspectCredentials(path);
|
|
657
|
+
if (read.kind === "absent") return absent(`Profile "${profile}" has no stored credential at ${path}. Sign in with \`neon profile create ${profile}\`.`);
|
|
658
|
+
if (read.kind === "unusable") throw new Error(`${read.reason}. Replace it deliberately with \`neon profile create ${profile} --force\`, or delete the file.`);
|
|
659
|
+
const credential = interpretCredentials(read.credentials, {
|
|
660
|
+
path,
|
|
661
|
+
profile
|
|
662
|
+
});
|
|
663
|
+
if (credential.kind === "api_key") return credential.apiKey;
|
|
664
|
+
const token = read.credentials.access_token;
|
|
665
|
+
if (typeof token === "string" && token.trim() !== "") return token.trim();
|
|
666
|
+
throw new Error(`Profile "${profile}" holds a browser sign-in with no usable token at ${path}. Sign in again with \`neon auth --profile ${profile}\`.`);
|
|
667
|
+
}
|
|
668
|
+
//#endregion
|
|
669
|
+
//#region src/lib/cli/resolve-context.ts
|
|
670
|
+
/**
|
|
671
|
+
* Resolve `projectId` and `branch` for a CLI invocation. Precedence (each wins over the
|
|
672
|
+
* next): explicit flag → `NEON_*` env var → `.neon[/project.json]` walked up from `cwd`.
|
|
673
|
+
*
|
|
674
|
+
* Returns the resolved values plus a list of human-readable reasons for any field that
|
|
675
|
+
* could not be resolved (so the caller can render one combined error).
|
|
676
|
+
*/
|
|
677
|
+
function resolveContext(options) {
|
|
678
|
+
const env = options.env ?? process.env;
|
|
679
|
+
const file = findNeonFile(options.cwd);
|
|
680
|
+
const projectId = nonEmpty(options.projectId) ?? nonEmpty(env.NEON_PROJECT_ID) ?? file?.projectId;
|
|
681
|
+
const branch = nonEmpty(options.branch) ?? nonEmpty(env.NEON_BRANCH) ?? nonEmpty(env.NEON_BRANCH_ID) ?? file?.branch;
|
|
682
|
+
const missing = [];
|
|
683
|
+
if (!projectId) missing.push("project id — pass `--project-id`, set `NEON_PROJECT_ID`, or add `projectId` to `.neon` (run `npx neon link`).");
|
|
684
|
+
if (!branch) missing.push("branch — pass `--branch`, set `NEON_BRANCH`/`NEON_BRANCH_ID`, or add `branch` to `.neon` (run `npx neon link` / `neon checkout <branch>`).");
|
|
685
|
+
if (!projectId || !branch) return {
|
|
686
|
+
ok: false,
|
|
687
|
+
missing
|
|
688
|
+
};
|
|
689
|
+
return {
|
|
690
|
+
ok: true,
|
|
691
|
+
context: {
|
|
692
|
+
projectId,
|
|
693
|
+
branch
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Walk up from `cwd` looking for `.neon/project.json` (preferred) or `.neon` (neonctl
|
|
699
|
+
* convention). Stops at the first `.git` directory or the home directory. Read-only.
|
|
700
|
+
*/
|
|
701
|
+
function findNeonFile(cwd) {
|
|
702
|
+
let current = resolve(cwd);
|
|
703
|
+
const stop = resolve(homedir());
|
|
704
|
+
let lastSeen = null;
|
|
705
|
+
while (true) {
|
|
706
|
+
const parsed = readNeonFileAt(resolve(current, ".neon", "project.json")) ?? readNeonFileAt(resolve(current, ".neon"));
|
|
707
|
+
if (parsed) return parsed;
|
|
708
|
+
if (current === stop) return null;
|
|
709
|
+
if (existsSync(resolve(current, ".git"))) return null;
|
|
710
|
+
const parent = dirname(current);
|
|
711
|
+
if (parent === current || parent === lastSeen) return null;
|
|
712
|
+
lastSeen = current;
|
|
713
|
+
current = parent;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
function readNeonFileAt(path) {
|
|
717
|
+
if (!isFile(path)) return null;
|
|
718
|
+
let raw;
|
|
719
|
+
try {
|
|
720
|
+
raw = readFileSync(path, "utf-8");
|
|
721
|
+
} catch {
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
let parsed;
|
|
725
|
+
try {
|
|
726
|
+
parsed = JSON.parse(raw);
|
|
727
|
+
} catch {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
731
|
+
const obj = parsed;
|
|
732
|
+
const out = {};
|
|
733
|
+
if (typeof obj.projectId === "string" && obj.projectId !== "") out.projectId = obj.projectId;
|
|
734
|
+
const branch = typeof obj.branch === "string" && obj.branch !== "" ? obj.branch : typeof obj.branchId === "string" && obj.branchId !== "" ? obj.branchId : void 0;
|
|
735
|
+
if (branch) out.branch = branch;
|
|
736
|
+
return out;
|
|
737
|
+
}
|
|
738
|
+
function isFile(path) {
|
|
739
|
+
try {
|
|
740
|
+
return statSync(path).isFile();
|
|
741
|
+
} catch {
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
function nonEmpty(value) {
|
|
746
|
+
if (typeof value !== "string") return void 0;
|
|
747
|
+
const trimmed = value.trim();
|
|
748
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
749
|
+
}
|
|
750
|
+
//#endregion
|
|
751
|
+
//#region src/lib/cli/commands.ts
|
|
752
|
+
/** File `env run` reads to layer one-time auth keys. Matches the Vercel/Next.js convention. */
|
|
753
|
+
const DEFAULT_ENV_FILE = ".env.local";
|
|
754
|
+
/**
|
|
755
|
+
* Implementation of `neon-env run -- <cmd...>`. Loads `neon.ts`, fetches the env from
|
|
756
|
+
* Neon, then spawns the user-supplied command with the env vars injected on top of the
|
|
757
|
+
* inherited `process.env`. Stdio is inherited so interactive dev servers keep working.
|
|
758
|
+
* The parent process exits with the child's exit code.
|
|
759
|
+
*/
|
|
760
|
+
async function runEnvRun(options, ctx) {
|
|
761
|
+
if (options.command.length === 0) return failure([
|
|
762
|
+
"`env run` requires a command to spawn.",
|
|
763
|
+
"Usage: neon-env run -- <command> [args...]",
|
|
764
|
+
"Example: neon-env run -- npm run dev"
|
|
765
|
+
].join("\n"));
|
|
766
|
+
const resolved = resolveContext({
|
|
767
|
+
cwd: ctx.cwd,
|
|
768
|
+
...options.projectId ? { projectId: options.projectId } : {},
|
|
769
|
+
...options.branch ? { branch: options.branch } : {}
|
|
770
|
+
});
|
|
771
|
+
if (!resolved.ok) return failure(["`env run` could not resolve the Neon project and branch:", ...resolved.missing.map((m) => ` - ${m}`)].join("\n"), 3);
|
|
772
|
+
let injected;
|
|
773
|
+
try {
|
|
774
|
+
injected = await loadConfigAndFetchEnv(options, ctx, resolved.context);
|
|
775
|
+
} catch (err) {
|
|
776
|
+
return handleError(err);
|
|
777
|
+
}
|
|
778
|
+
const [executable, ...args] = options.command;
|
|
779
|
+
return {
|
|
780
|
+
exitCode: await spawnAndWait(executable, args, {
|
|
781
|
+
cwd: ctx.cwd,
|
|
782
|
+
env: {
|
|
783
|
+
...process.env,
|
|
784
|
+
...injected
|
|
785
|
+
}
|
|
786
|
+
}),
|
|
787
|
+
stdout: "",
|
|
788
|
+
stderr: ""
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Implementation of `neon-env export`. Resolves the branch's Neon env the same way `run`
|
|
793
|
+
* does (neon.ts policy + linked branch), then writes it to stdout — as dotenv lines or JSON —
|
|
794
|
+
* instead of spawning a process, so other env tools can consume it. For example, varlock can
|
|
795
|
+
* bulk-load it with `@setValuesBulk(exec("neon-env export --format json"), format=json)`.
|
|
796
|
+
*/
|
|
797
|
+
async function runEnvExport(options, ctx) {
|
|
798
|
+
const resolved = resolveContext({
|
|
799
|
+
cwd: ctx.cwd,
|
|
800
|
+
...options.projectId ? { projectId: options.projectId } : {},
|
|
801
|
+
...options.branch ? { branch: options.branch } : {}
|
|
802
|
+
});
|
|
803
|
+
if (!resolved.ok) return failure(["`env export` could not resolve the Neon project and branch:", ...resolved.missing.map((m) => ` - ${m}`)].join("\n"), 3);
|
|
804
|
+
let entries;
|
|
805
|
+
try {
|
|
806
|
+
entries = await loadConfigAndFetchEnv(options, ctx, resolved.context);
|
|
807
|
+
} catch (err) {
|
|
808
|
+
return handleError(err);
|
|
809
|
+
}
|
|
810
|
+
return {
|
|
811
|
+
exitCode: 0,
|
|
812
|
+
stdout: options.format === "json" ? `${JSON.stringify(entries, null, 2)}\n` : toDotenv(entries),
|
|
813
|
+
stderr: ""
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
/** Render an env map as dotenv `KEY=value` lines, quoting values that need it. */
|
|
817
|
+
function toDotenv(entries) {
|
|
818
|
+
const lines = Object.entries(entries).map(([key, value]) => formatDotenvLine(key, value));
|
|
819
|
+
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Render a single `KEY=value` dotenv line, double-quoting (and escaping) values that contain
|
|
823
|
+
* whitespace, `#`, quotes, or `=` so connection strings round-trip through dotenv parsers.
|
|
824
|
+
*/
|
|
825
|
+
function formatDotenvLine(key, value) {
|
|
826
|
+
if (!/[\s#"'=]/.test(value)) return `${key}=${value}`;
|
|
827
|
+
return `${key}="${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Load `neon.ts`, then resolve the branch env for the explicitly-resolved project + branch.
|
|
831
|
+
* Layers `.env.local` (next to the config file) into the env source so re-runs keep the
|
|
832
|
+
* one-time secrets the Neon API only returns once — the branch credential's, and any Auth
|
|
833
|
+
* values a pre-`base_url` integration can no longer report. Uses
|
|
834
|
+
* {@link fetchEnvReusingSecrets} rather than a bare `fetchEnv` so a run that already has a
|
|
835
|
+
* working credential verifies and keeps it instead of minting another one per invocation.
|
|
836
|
+
*/
|
|
837
|
+
async function loadConfigAndFetchEnv(options, ctx, resolved) {
|
|
838
|
+
const { config, resolvedPath } = await loadConfigFromFile({
|
|
839
|
+
...options.configPath ? { path: options.configPath } : {},
|
|
840
|
+
cwd: ctx.cwd
|
|
841
|
+
});
|
|
842
|
+
const envFileSource = join(dirname(resolvedPath), DEFAULT_ENV_FILE);
|
|
843
|
+
const fileEnv = existsSync(envFileSource) ? parseEnvFile(readFileSync(envFileSource, "utf-8")) : {};
|
|
844
|
+
const apiKey = resolveApiKey({
|
|
845
|
+
...options.apiKey ? { apiKey: options.apiKey } : {},
|
|
846
|
+
...options.profile ? { profile: options.profile } : {}
|
|
847
|
+
});
|
|
848
|
+
const { vars } = await fetchEnvReusingSecrets(config, {
|
|
849
|
+
projectId: resolved.projectId,
|
|
850
|
+
branch: resolved.branch,
|
|
851
|
+
env: {
|
|
852
|
+
...process.env,
|
|
853
|
+
...fileEnv
|
|
854
|
+
},
|
|
855
|
+
...ctx.api ? { api: ctx.api } : {},
|
|
856
|
+
...apiKey ? { apiKey } : {}
|
|
857
|
+
});
|
|
858
|
+
return vars;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Spawn a child process with stdio inherited so dev servers stay interactive. Resolves
|
|
862
|
+
* with the child's exit code (treating signal terminations as code 1 so the CLI surfaces
|
|
863
|
+
* a non-zero exit consistently).
|
|
864
|
+
*/
|
|
865
|
+
function spawnAndWait(command, args, options) {
|
|
866
|
+
return new Promise((resolve) => {
|
|
867
|
+
const child = spawn(command, args, {
|
|
868
|
+
cwd: options.cwd,
|
|
869
|
+
env: options.env,
|
|
870
|
+
stdio: "inherit"
|
|
871
|
+
});
|
|
872
|
+
child.on("error", (err) => {
|
|
873
|
+
process.stderr.write(`neon-env run: failed to spawn '${command}': ${err.message}\n`);
|
|
874
|
+
resolve(1);
|
|
875
|
+
});
|
|
876
|
+
child.on("exit", (code, signal) => {
|
|
877
|
+
if (typeof code === "number") {
|
|
878
|
+
resolve(code);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (signal) {
|
|
882
|
+
process.stderr.write(`neon-env run: child terminated by signal ${signal}\n`);
|
|
883
|
+
resolve(1);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
resolve(1);
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
function parseEnvFile(body) {
|
|
891
|
+
const out = {};
|
|
892
|
+
for (const line of body.split("\n")) {
|
|
893
|
+
const parsed = parseEnvLine(line);
|
|
894
|
+
if (parsed) out[parsed.key] = parsed.value;
|
|
895
|
+
}
|
|
896
|
+
return out;
|
|
897
|
+
}
|
|
898
|
+
function parseEnvLine(line) {
|
|
899
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
900
|
+
const key = match?.[1];
|
|
901
|
+
const rawValue = match?.[2];
|
|
902
|
+
if (key === void 0 || rawValue === void 0) return null;
|
|
903
|
+
return {
|
|
904
|
+
key,
|
|
905
|
+
value: unescapeEnvValue(rawValue.trim())
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
function unescapeEnvValue(value) {
|
|
909
|
+
if (value.length >= 2 && value.startsWith("\"") && value.endsWith("\"")) return value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
910
|
+
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
|
|
911
|
+
return value;
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Stable exit code per `PlatformError` code. Mirrors the table in the config package so
|
|
915
|
+
* shell pipelines can branch on the specific failure mode without parsing free text.
|
|
916
|
+
*/
|
|
917
|
+
const EXIT_CODE_BY_PLATFORM_ERROR_CODE = {
|
|
918
|
+
[ErrorCode.MissingApiKey]: 1,
|
|
919
|
+
[ErrorCode.Unauthorized]: 6,
|
|
920
|
+
[ErrorCode.Forbidden]: 7,
|
|
921
|
+
[ErrorCode.NotFound]: 8,
|
|
922
|
+
[ErrorCode.RateLimited]: 9,
|
|
923
|
+
[ErrorCode.NetworkError]: 10,
|
|
924
|
+
[ErrorCode.ServerError]: 11,
|
|
925
|
+
[ErrorCode.Locked]: 11,
|
|
926
|
+
[ErrorCode.InternalError]: 99
|
|
927
|
+
};
|
|
928
|
+
function handleError(err) {
|
|
929
|
+
if (err instanceof MissingContextError) return errorResult(err, `Missing context: ${err.message}`, 3);
|
|
930
|
+
if (err instanceof ConfigLoadError) return errorResult(err, `Failed to load config: ${err.message}`, 4);
|
|
931
|
+
if (err instanceof PlatformError && err.code === ErrorCode.MissingApiKey) return errorResult(err, [
|
|
932
|
+
"No Neon API key. `neon-env` looks for one in this order:",
|
|
933
|
+
" - the `--api-key` flag",
|
|
934
|
+
" - the `NEON_API_KEY` environment variable",
|
|
935
|
+
" - `credentials.json` in `NEONCTL_CONFIG_DIR` (else `~/.config/neonctl`) — run `neon auth` to create it"
|
|
936
|
+
].join("\n"), EXIT_CODE_BY_PLATFORM_ERROR_CODE[ErrorCode.MissingApiKey] ?? 1);
|
|
937
|
+
if (err instanceof PlatformError) {
|
|
938
|
+
const exitCode = EXIT_CODE_BY_PLATFORM_ERROR_CODE[err.code];
|
|
939
|
+
if (exitCode !== void 0) return errorResult(err, err.message, exitCode);
|
|
940
|
+
return errorResult(err, `[${err.code}] ${err.message}`, 5);
|
|
941
|
+
}
|
|
942
|
+
if (err instanceof Error) return errorResult(err, err.message, 1);
|
|
943
|
+
return failure(String(err), 1);
|
|
944
|
+
}
|
|
945
|
+
function errorResult(err, message, exitCode) {
|
|
946
|
+
const result = {
|
|
947
|
+
exitCode,
|
|
948
|
+
stdout: "",
|
|
949
|
+
stderr: `${message}\n`
|
|
950
|
+
};
|
|
951
|
+
const debug = buildDebugInfo(err);
|
|
952
|
+
if (debug) result.debugInfo = debug;
|
|
953
|
+
return result;
|
|
954
|
+
}
|
|
955
|
+
function buildDebugInfo(err) {
|
|
956
|
+
if (!(err instanceof Error)) return void 0;
|
|
957
|
+
const lines = [];
|
|
958
|
+
if (err instanceof PlatformError) {
|
|
959
|
+
lines.push(`code : ${err.code}`);
|
|
960
|
+
if (Object.keys(err.details).length > 0) lines.push(`details : ${JSON.stringify(err.details, null, 2)}`);
|
|
961
|
+
}
|
|
962
|
+
if (err.cause instanceof Error) lines.push(`cause : ${err.cause.name}: ${err.cause.message}`);
|
|
963
|
+
if (err.stack) lines.push(err.stack);
|
|
964
|
+
return lines.length > 0 ? lines.join("\n") : void 0;
|
|
965
|
+
}
|
|
966
|
+
function failure(message, exitCode = 1) {
|
|
967
|
+
return {
|
|
968
|
+
exitCode,
|
|
969
|
+
stdout: "",
|
|
970
|
+
stderr: `${message}\n`
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
//#endregion
|
|
7
974
|
//#region src/cli.ts
|
|
8
975
|
const pkgVersion = readPackageVersion();
|
|
9
976
|
const argv = yargs(hideBin(process.argv)).scriptName("neon-env").usage("$0 <command> [options]").parserConfiguration({ "populate--": true }).option("debug", {
|
|
@@ -49,10 +1016,9 @@ const command = String(argv._[0]);
|
|
|
49
1016
|
const cwd = process.cwd();
|
|
50
1017
|
let result;
|
|
51
1018
|
switch (command) {
|
|
52
|
-
case "run":
|
|
53
|
-
const passthrough = Array.isArray(argv["--"]) ? argv["--"].map(String) : [];
|
|
1019
|
+
case "run":
|
|
54
1020
|
result = await runEnvRun({
|
|
55
|
-
command:
|
|
1021
|
+
command: Array.isArray(argv["--"]) ? argv["--"].map(String) : [],
|
|
56
1022
|
...typeof argv.config === "string" ? { configPath: argv.config } : {},
|
|
57
1023
|
...typeof argv["project-id"] === "string" ? { projectId: argv["project-id"] } : {},
|
|
58
1024
|
...typeof argv.branch === "string" ? { branch: argv.branch } : {},
|
|
@@ -60,7 +1026,6 @@ switch (command) {
|
|
|
60
1026
|
...typeof argv.profile === "string" ? { profile: argv.profile } : {}
|
|
61
1027
|
}, { cwd });
|
|
62
1028
|
break;
|
|
63
|
-
}
|
|
64
1029
|
case "export":
|
|
65
1030
|
result = await runEnvExport({
|
|
66
1031
|
format: argv.format === "json" ? "json" : "dotenv",
|