@neondatabase/config 0.0.0 → 0.2.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.
Files changed (90) hide show
  1. package/LICENSE.md +178 -0
  2. package/dist/index.d.ts +10 -0
  3. package/dist/index.js +8 -0
  4. package/dist/lib/auth.d.ts +63 -0
  5. package/dist/lib/auth.d.ts.map +1 -0
  6. package/dist/lib/auth.js +93 -0
  7. package/dist/lib/auth.js.map +1 -0
  8. package/dist/lib/define-config.d.ts +43 -0
  9. package/dist/lib/define-config.d.ts.map +1 -0
  10. package/dist/lib/define-config.js +112 -0
  11. package/dist/lib/define-config.js.map +1 -0
  12. package/dist/lib/diff.d.ts +109 -0
  13. package/dist/lib/diff.d.ts.map +1 -0
  14. package/dist/lib/diff.js +205 -0
  15. package/dist/lib/diff.js.map +1 -0
  16. package/dist/lib/duration.d.ts +46 -0
  17. package/dist/lib/duration.d.ts.map +1 -0
  18. package/dist/lib/duration.js +96 -0
  19. package/dist/lib/duration.js.map +1 -0
  20. package/dist/lib/errors.d.ts +129 -0
  21. package/dist/lib/errors.d.ts.map +1 -0
  22. package/dist/lib/errors.js +168 -0
  23. package/dist/lib/errors.js.map +1 -0
  24. package/dist/lib/loader.d.ts +44 -0
  25. package/dist/lib/loader.d.ts.map +1 -0
  26. package/dist/lib/loader.js +119 -0
  27. package/dist/lib/loader.js.map +1 -0
  28. package/dist/lib/neon-api-real.d.ts +45 -0
  29. package/dist/lib/neon-api-real.d.ts.map +1 -0
  30. package/dist/lib/neon-api-real.js +582 -0
  31. package/dist/lib/neon-api-real.js.map +1 -0
  32. package/dist/lib/neon-api.d.ts +262 -0
  33. package/dist/lib/neon-api.d.ts.map +1 -0
  34. package/dist/lib/neon-api.js +1 -0
  35. package/dist/lib/patterns.d.ts +43 -0
  36. package/dist/lib/patterns.d.ts.map +1 -0
  37. package/dist/lib/patterns.js +76 -0
  38. package/dist/lib/patterns.js.map +1 -0
  39. package/dist/lib/schema.d.ts +130 -0
  40. package/dist/lib/schema.d.ts.map +1 -0
  41. package/dist/lib/schema.js +218 -0
  42. package/dist/lib/schema.js.map +1 -0
  43. package/dist/lib/types.d.ts +289 -0
  44. package/dist/lib/types.d.ts.map +1 -0
  45. package/dist/lib/types.js +1 -0
  46. package/dist/lib/wrap-neon-error.d.ts +30 -0
  47. package/dist/lib/wrap-neon-error.d.ts.map +1 -0
  48. package/dist/lib/wrap-neon-error.js +139 -0
  49. package/dist/lib/wrap-neon-error.js.map +1 -0
  50. package/dist/v1.d.ts +153 -0
  51. package/dist/v1.d.ts.map +1 -0
  52. package/dist/v1.js +69 -0
  53. package/dist/v1.js.map +1 -0
  54. package/package.json +67 -17
  55. package/.env.example +0 -5
  56. package/e2e/errors.e2e.test.ts +0 -52
  57. package/e2e/helpers.ts +0 -205
  58. package/e2e/load-env.ts +0 -29
  59. package/e2e/setup.ts +0 -24
  60. package/src/index.ts +0 -5
  61. package/src/lib/auth.test.ts +0 -166
  62. package/src/lib/auth.ts +0 -124
  63. package/src/lib/define-config.test.ts +0 -161
  64. package/src/lib/define-config.ts +0 -152
  65. package/src/lib/diff.test.ts +0 -142
  66. package/src/lib/diff.ts +0 -391
  67. package/src/lib/duration.test.ts +0 -105
  68. package/src/lib/duration.ts +0 -147
  69. package/src/lib/errors.test.ts +0 -26
  70. package/src/lib/errors.ts +0 -220
  71. package/src/lib/fake-neon-api.ts +0 -782
  72. package/src/lib/loader.test.ts +0 -35
  73. package/src/lib/loader.ts +0 -215
  74. package/src/lib/neon-api-real.test.ts +0 -72
  75. package/src/lib/neon-api-real.ts +0 -1123
  76. package/src/lib/neon-api.ts +0 -356
  77. package/src/lib/patterns.test.ts +0 -80
  78. package/src/lib/patterns.ts +0 -98
  79. package/src/lib/schema.test.ts +0 -88
  80. package/src/lib/schema.ts +0 -252
  81. package/src/lib/test-utils.ts +0 -83
  82. package/src/lib/types.ts +0 -268
  83. package/src/lib/wrap-neon-error.test.ts +0 -145
  84. package/src/lib/wrap-neon-error.ts +0 -204
  85. package/src/v1.test.ts +0 -33
  86. package/src/v1.ts +0 -148
  87. package/tsconfig.json +0 -4
  88. package/tsdown.config.ts +0 -19
  89. package/vitest.config.ts +0 -19
  90. package/vitest.e2e.config.ts +0 -29
package/e2e/helpers.ts DELETED
@@ -1,205 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { createApiClient } from "@neondatabase/api-client";
3
- import { test } from "vitest";
4
- import type { NeonApi } from "../src/lib/neon-api.js";
5
- import { createRealNeonApi } from "../src/lib/neon-api-real.js";
6
-
7
- /**
8
- * Every e2e-created project is named `neon-ts-e2e-<uuid>`. Tests can register
9
- * `track(id)` to opt into the per-test cleanup hook. The suite-level
10
- * {@link sweepOrphans} additionally deletes leftovers from a previous failed run.
11
- */
12
- const PROJECT_PREFIX = "neon-ts-e2e-";
13
-
14
- /**
15
- * Default Neon region used by every e2e test that creates a project. Override per-test
16
- * by passing `region` to `defineConfig`.
17
- */
18
- export const DEFAULT_REGION = "aws-us-east-2";
19
-
20
- /** Generate a project name guaranteed not to collide with anything else in the org. */
21
- export function uniqueProjectName(suffix?: string): string {
22
- const id = randomUUID().slice(0, 8);
23
- return suffix
24
- ? `${PROJECT_PREFIX}${id}-${suffix}`
25
- : `${PROJECT_PREFIX}${id}`;
26
- }
27
-
28
- function requireApiKey(): string {
29
- const key = process.env.NEON_API_KEY;
30
- if (!key || key.trim() === "") {
31
- throw new Error(
32
- "NEON_API_KEY is not set. Create packages/config/.env (see .env.example) before running test:e2e.",
33
- );
34
- }
35
- return key;
36
- }
37
-
38
- /** The same real NeonApi adapter the SDK uses internally — exercised end-to-end. */
39
- export function makeRealApi(): NeonApi {
40
- return createRealNeonApi({ apiKey: requireApiKey() });
41
- }
42
-
43
- /**
44
- * Create a real Neon project via the NeonApi adapter directly. `pushConfig` no longer
45
- * provisions projects (callers are expected to run `neonctl link` first), so every e2e
46
- * test that needs a fresh project to push against goes through this helper instead.
47
- */
48
- export async function bootstrapProject(
49
- api: NeonApi,
50
- args: { name: string; region: string },
51
- ): Promise<string> {
52
- const created = await api.createProject({
53
- name: args.name,
54
- regionId: args.region,
55
- });
56
- return created.id;
57
- }
58
-
59
- /** Lower-level Neon client. Used by cleanup and a few setup helpers. */
60
- function makeRawClient(): ReturnType<typeof createApiClient> {
61
- return createApiClient({ apiKey: requireApiKey() });
62
- }
63
-
64
- /**
65
- * Discriminates the key currently configured. Project-scoped keys can't list projects;
66
- * org/user-scoped keys can.
67
- */
68
- export type ApiKeyScope =
69
- | { kind: "org-or-user"; canCreate: true }
70
- | { kind: "project"; projectId: string; canCreate: false };
71
-
72
- /**
73
- * Probe the configured API key to find out what it can do. Memoised because we only need
74
- * to do this once per process.
75
- */
76
- let cachedScope: ApiKeyScope | undefined;
77
- export async function detectApiKeyScope(): Promise<ApiKeyScope> {
78
- if (cachedScope) return cachedScope;
79
- const client = makeRawClient();
80
- try {
81
- await client.listProjects({ limit: 1 });
82
- cachedScope = { kind: "org-or-user", canCreate: true };
83
- return cachedScope;
84
- } catch (err) {
85
- const status = (err as { response?: { status?: number } } | undefined)
86
- ?.response?.status;
87
- if (status !== 401 && status !== 403) throw err;
88
- }
89
- const fixedProjectId = process.env.NEON_PROJECT_ID;
90
- if (!fixedProjectId || fixedProjectId.trim() === "") {
91
- throw new Error(
92
- "API key cannot list projects (looks project-scoped) and NEON_PROJECT_ID is not set. " +
93
- "Set NEON_PROJECT_ID in packages/config/.env to target a fixed project for the bounded e2e subset.",
94
- );
95
- }
96
- cachedScope = {
97
- kind: "project",
98
- projectId: fixedProjectId,
99
- canCreate: false,
100
- };
101
- return cachedScope;
102
- }
103
-
104
- /**
105
- * Delete a project, ignoring "already gone" errors so cleanup is idempotent. Refuses to
106
- * delete anything that isn't prefixed with {@link PROJECT_PREFIX} so a mis-typed id can
107
- * never wipe an unrelated project.
108
- */
109
- async function deleteProject(projectId: string): Promise<void> {
110
- const client = makeRawClient();
111
- const project = await client.getProject(projectId).catch((err) => {
112
- const status = (err as { response?: { status?: number } } | undefined)
113
- ?.response?.status;
114
- if (status === 404 || status === 410) return null;
115
- throw err;
116
- });
117
- if (!project) return;
118
- if (!project.data.project.name.startsWith(PROJECT_PREFIX)) {
119
- throw new Error(
120
- `Refusing to delete project ${projectId} ("${project.data.project.name}"): does not match the e2e prefix.`,
121
- );
122
- }
123
- // Retry on 423 (locked while a previous mutation is in flight) so cleanup is robust.
124
- const maxAttempts = 12;
125
- let delay = 500;
126
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
127
- try {
128
- await client.deleteProject(projectId);
129
- return;
130
- } catch (err) {
131
- const status = (
132
- err as { response?: { status?: number } } | undefined
133
- )?.response?.status;
134
- if (status === 404 || status === 410) return;
135
- if (status !== 423 || attempt === maxAttempts) throw err;
136
- await sleep(delay);
137
- delay = Math.min(delay * 2, 5_000);
138
- }
139
- }
140
- }
141
-
142
- /**
143
- * List every project whose name starts with {@link PROJECT_PREFIX} and delete them.
144
- * Called once at suite start to mop up orphans from a previous failed run.
145
- */
146
- export async function sweepOrphans(): Promise<{ swept: string[] }> {
147
- const scope = await detectApiKeyScope();
148
- if (scope.kind === "project") return { swept: [] };
149
- const client = makeRawClient();
150
- const swept: string[] = [];
151
- let cursor: string | undefined;
152
- while (true) {
153
- const res = await client.listProjects({
154
- limit: 100,
155
- ...(cursor ? { cursor } : {}),
156
- });
157
- for (const project of res.data.projects) {
158
- if (project.name.startsWith(PROJECT_PREFIX)) {
159
- await deleteProject(project.id);
160
- swept.push(project.id);
161
- }
162
- }
163
- const next = (res.data as { pagination?: { next?: string } }).pagination
164
- ?.next;
165
- if (!next || next === cursor) break;
166
- cursor = next;
167
- }
168
- return { swept };
169
- }
170
-
171
- /**
172
- * A vitest `test.extend` fixture that tracks every project id a test creates and deletes
173
- * each one in the cleanup phase, even if the test failed mid-way. Use `track(id)` to
174
- * register ids — cleanup runs regardless of outcome.
175
- */
176
- export const e2eTest = test.extend<{
177
- track: (projectId: string) => void;
178
- }>({
179
- // biome-ignore lint/correctness/noEmptyPattern: vitest's fixture API requires this exact shape.
180
- track: async ({}, use) => {
181
- const created: string[] = [];
182
- await use((projectId: string) => {
183
- created.push(projectId);
184
- });
185
- for (const projectId of created) {
186
- try {
187
- await deleteProject(projectId);
188
- } catch (err) {
189
- // Surface, but don't fail on cleanup errors — the orphan sweep on the next
190
- // run will mop up anything we miss.
191
- console.error(
192
- `[e2e cleanup] failed to delete ${projectId}: ${(err as Error).message}`,
193
- );
194
- }
195
- }
196
- },
197
- });
198
-
199
- /**
200
- * Some Neon operations are eventually consistent (notably branch creation finishing
201
- * `init` → `ready`). A small wait avoids racing on subsequent reads.
202
- */
203
- function sleep(ms: number): Promise<void> {
204
- return new Promise((resolve) => setTimeout(resolve, ms));
205
- }
package/e2e/load-env.ts DELETED
@@ -1,29 +0,0 @@
1
- // Loaded by vitest.e2e.config.ts `setupFiles`; not imported statically.
2
- // fallow-ignore-file unused-file
3
- import { existsSync, readFileSync } from "node:fs";
4
- import { dirname, resolve } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
-
7
- /**
8
- * Vitest setup file — loads `packages/config/.env` into `process.env` so e2e tests can
9
- * read `NEON_API_KEY` (and friends). Node 22 has `--env-file` but it's per-process; doing
10
- * it here keeps the test command short (`pnpm test:e2e`).
11
- *
12
- * Lines starting with `#` are treated as comments; everything else is parsed as
13
- * `KEY=value` (no quoting / interpolation — we keep this minimal on purpose).
14
- */
15
- const here = dirname(fileURLToPath(import.meta.url));
16
- const envPath = resolve(here, "..", ".env");
17
-
18
- if (existsSync(envPath)) {
19
- const raw = readFileSync(envPath, "utf-8");
20
- for (const rawLine of raw.split("\n")) {
21
- const line = rawLine.trim();
22
- if (line === "" || line.startsWith("#")) continue;
23
- const eq = line.indexOf("=");
24
- if (eq <= 0) continue;
25
- const key = line.slice(0, eq).trim();
26
- const value = line.slice(eq + 1).trim();
27
- if (process.env[key] === undefined) process.env[key] = value;
28
- }
29
- }
package/e2e/setup.ts DELETED
@@ -1,24 +0,0 @@
1
- // Loaded by vitest.e2e.config.ts `setupFiles`; not imported statically.
2
- // fallow-ignore-file unused-file
3
- import { beforeAll } from "vitest";
4
- import { detectApiKeyScope, sweepOrphans } from "./helpers.js";
5
-
6
- /**
7
- * Suite-level setup. Runs once before any e2e test:
8
- * 1. Probes the configured API key to detect its scope. We do this here so a misconfigured
9
- * environment fails fast with a clear message rather than surfacing as cryptic 403s
10
- * inside individual tests.
11
- * 2. When the key is org/user-scoped, sweep any orphaned `neon-ts-e2e-*` projects
12
- * left over from a previous run.
13
- */
14
- beforeAll(async () => {
15
- const scope = await detectApiKeyScope();
16
- if (scope.kind === "org-or-user") {
17
- const { swept } = await sweepOrphans();
18
- if (swept.length > 0) {
19
- console.warn(
20
- `[e2e setup] swept ${swept.length} orphaned project(s) from a previous run.`,
21
- );
22
- }
23
- }
24
- });
package/src/index.ts DELETED
@@ -1,5 +0,0 @@
1
- /**
2
- * The default entry point re-exports the latest stable version. New consumers should import
3
- * directly from `@neondatabase/config/v1` to opt in to a specific major.
4
- */
5
- export * from "./v1.js";
@@ -1,166 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
- import { readNeonctlCredentials, resolveApiKey } from "./auth.js";
3
- import { makeTempRepo, stubCleanNeonEnv } from "./test-utils.js";
4
-
5
- const cleanups: Array<() => void> = [];
6
- afterEach(() => {
7
- while (cleanups.length > 0) cleanups.shift()?.();
8
- });
9
-
10
- beforeEach(() => {
11
- stubCleanNeonEnv();
12
- });
13
-
14
- function setupHome(files: Record<string, string | null>): string {
15
- const repo = makeTempRepo(files);
16
- cleanups.push(repo.cleanup);
17
- return repo.root;
18
- }
19
-
20
- describe("readNeonctlCredentials", () => {
21
- test("reads access_token from <home>/.config/neonctl/credentials.json by default", () => {
22
- const home = setupHome({
23
- ".config/neonctl/credentials.json": JSON.stringify({
24
- access_token: "oauth-token-abc",
25
- refresh_token: "rt-xyz",
26
- }),
27
- });
28
- vi.stubEnv("HOME", home);
29
- const creds = readNeonctlCredentials();
30
- expect(creds?.access_token).toBe("oauth-token-abc");
31
- expect(creds?.refresh_token).toBe("rt-xyz");
32
- });
33
-
34
- test("honours NEONCTL_CONFIG_DIR over the default location", () => {
35
- const home = setupHome({
36
- ".config/neonctl/credentials.json": JSON.stringify({
37
- access_token: "default-loc",
38
- }),
39
- "custom/credentials.json": JSON.stringify({
40
- access_token: "from-env-dir",
41
- }),
42
- });
43
- vi.stubEnv("HOME", home);
44
- vi.stubEnv("NEONCTL_CONFIG_DIR", `${home}/custom`);
45
- const creds = readNeonctlCredentials();
46
- expect(creds?.access_token).toBe("from-env-dir");
47
- });
48
-
49
- test("honours explicit configDir over the env var", () => {
50
- const home = setupHome({
51
- ".config/neonctl/credentials.json": JSON.stringify({
52
- access_token: "default-loc",
53
- }),
54
- "env/credentials.json": JSON.stringify({
55
- access_token: "from-env-dir",
56
- }),
57
- "opt/credentials.json": JSON.stringify({
58
- access_token: "from-option",
59
- }),
60
- });
61
- vi.stubEnv("HOME", home);
62
- vi.stubEnv("NEONCTL_CONFIG_DIR", `${home}/env`);
63
- const creds = readNeonctlCredentials({ configDir: `${home}/opt` });
64
- expect(creds?.access_token).toBe("from-option");
65
- });
66
-
67
- test("returns null when the file is missing", () => {
68
- const home = setupHome({ ".config/neonctl/.keep": "" });
69
- vi.stubEnv("HOME", home);
70
- expect(readNeonctlCredentials()).toBeNull();
71
- });
72
-
73
- test("returns null on malformed JSON instead of throwing", () => {
74
- const home = setupHome({
75
- ".config/neonctl/credentials.json": "not json",
76
- });
77
- vi.stubEnv("HOME", home);
78
- expect(readNeonctlCredentials()).toBeNull();
79
- });
80
-
81
- test("returns null when access_token is missing or empty", () => {
82
- const home = setupHome({
83
- ".config/neonctl/credentials.json": JSON.stringify({
84
- refresh_token: "rt-only",
85
- }),
86
- });
87
- vi.stubEnv("HOME", home);
88
- expect(readNeonctlCredentials()).toBeNull();
89
- const home2 = setupHome({
90
- ".config/neonctl/credentials.json": JSON.stringify({
91
- access_token: "",
92
- }),
93
- });
94
- vi.stubEnv("HOME", home2);
95
- expect(readNeonctlCredentials()).toBeNull();
96
- });
97
-
98
- test("returns null when no home dir resolvable", () => {
99
- // `stubCleanNeonEnv()` already cleared HOME and USERPROFILE.
100
- expect(readNeonctlCredentials()).toBeNull();
101
- });
102
-
103
- test("falls back to USERPROFILE on Windows-style env", () => {
104
- const winHome = setupHome({
105
- ".config/neonctl/credentials.json": JSON.stringify({
106
- access_token: "win-token",
107
- }),
108
- });
109
- vi.stubEnv("USERPROFILE", winHome);
110
- const creds = readNeonctlCredentials();
111
- expect(creds?.access_token).toBe("win-token");
112
- });
113
- });
114
-
115
- describe("resolveApiKey — priority chain", () => {
116
- test("explicit option wins over env wins over credentials.json", () => {
117
- const home = setupHome({
118
- ".config/neonctl/credentials.json": JSON.stringify({
119
- access_token: "from-file",
120
- }),
121
- });
122
- vi.stubEnv("HOME", home);
123
- vi.stubEnv("NEON_API_KEY", "from-env");
124
- expect(resolveApiKey({ apiKey: "from-option" })).toEqual({
125
- token: "from-option",
126
- source: "option",
127
- });
128
-
129
- expect(resolveApiKey()).toEqual({ token: "from-env", source: "env" });
130
-
131
- vi.stubEnv("NEON_API_KEY", undefined);
132
- expect(resolveApiKey()).toEqual({
133
- token: "from-file",
134
- source: "neonctl",
135
- });
136
- });
137
-
138
- test("returns null when no source provides a token", () => {
139
- const home = setupHome({ ".config/neonctl/.keep": "" });
140
- vi.stubEnv("HOME", home);
141
- expect(resolveApiKey()).toBeNull();
142
- });
143
-
144
- test("treats whitespace-only option / env as missing", () => {
145
- const home = setupHome({
146
- ".config/neonctl/credentials.json": JSON.stringify({
147
- access_token: "from-file",
148
- }),
149
- });
150
- vi.stubEnv("HOME", home);
151
- vi.stubEnv("NEON_API_KEY", " ");
152
- expect(resolveApiKey({ apiKey: " " })).toEqual({
153
- token: "from-file",
154
- source: "neonctl",
155
- });
156
- });
157
-
158
- test("trims whitespace around the resolved token", () => {
159
- const home = setupHome({ ".config/neonctl/.keep": "" });
160
- vi.stubEnv("HOME", home);
161
- expect(resolveApiKey({ apiKey: " napi_x " })).toEqual({
162
- token: "napi_x",
163
- source: "option",
164
- });
165
- });
166
- });
package/src/lib/auth.ts DELETED
@@ -1,124 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { resolve } from "node:path";
3
- import { ErrorCode, PlatformError } from "./errors.js";
4
- import type { NeonApi } from "./neon-api.js";
5
- import { createRealNeonApi } from "./neon-api-real.js";
6
-
7
- /**
8
- * Minimal shape of `~/.config/neonctl/credentials.json` we read. `neonctl` writes more
9
- * fields (refresh_token, expires_at, …) but only `access_token` is what we need — it's a
10
- * Bearer token the Neon API accepts on the same endpoints `napi_*` API keys do.
11
- */
12
- export interface NeonctlCredentials {
13
- access_token: string;
14
- [key: string]: unknown;
15
- }
16
-
17
- /**
18
- * Locate and read the OAuth credentials neonctl writes after `neon auth`.
19
- *
20
- * Resolution:
21
- * 1. `options.configDir` (explicit override — mirrors neonctl's `--config-dir` flag).
22
- * 2. `NEONCTL_CONFIG_DIR` environment variable.
23
- * 3. `<home>/.config/neonctl/credentials.json` (the neonctl default; `home` reads
24
- * `HOME`, falling back to `USERPROFILE` for Windows parity).
25
- *
26
- * Returns `null` (never throws) when the file is missing, unreadable, malformed, or has
27
- * no `access_token` — so callers can use this as a quiet fallback in a resolution chain
28
- * without try/catch noise.
29
- */
30
- export function readNeonctlCredentials(
31
- options: { configDir?: string } = {},
32
- ): NeonctlCredentials | null {
33
- const home = process.env.HOME ?? process.env.USERPROFILE;
34
- const configDir =
35
- options.configDir ??
36
- process.env.NEONCTL_CONFIG_DIR ??
37
- (home ? resolve(home, ".config", "neonctl") : undefined);
38
- if (!configDir) return null;
39
-
40
- const credentialsPath = resolve(configDir, "credentials.json");
41
- if (!existsSync(credentialsPath)) return null;
42
-
43
- let raw: string;
44
- try {
45
- raw = readFileSync(credentialsPath, "utf-8");
46
- } catch {
47
- return null;
48
- }
49
-
50
- let parsed: unknown;
51
- try {
52
- parsed = JSON.parse(raw);
53
- } catch {
54
- return null;
55
- }
56
-
57
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
58
- return null;
59
- const obj = parsed as Record<string, unknown>;
60
- if (typeof obj.access_token !== "string" || obj.access_token === "")
61
- return null;
62
- return obj as NeonctlCredentials;
63
- }
64
-
65
- /**
66
- * Resolution chain for the Bearer token sent to the Neon API. Each entry wins over the
67
- * next:
68
- *
69
- * 1. `options.apiKey` (explicit).
70
- * 2. `NEON_API_KEY` environment variable.
71
- * 3. `access_token` from `~/.config/neonctl/credentials.json` (or `NEONCTL_CONFIG_DIR`).
72
- *
73
- * Returns `null` when no source provides one. Callers wrap the null case in a
74
- * `PLATFORM_MISSING_API_KEY` error with a message tailored to the operation.
75
- */
76
- export function resolveApiKey(
77
- options: { apiKey?: string; configDir?: string } = {},
78
- ): { token: string; source: "option" | "env" | "neonctl" } | null {
79
- if (options.apiKey && options.apiKey.trim() !== "") {
80
- return { token: options.apiKey.trim(), source: "option" };
81
- }
82
- const envKey = process.env.NEON_API_KEY;
83
- if (typeof envKey === "string" && envKey.trim() !== "") {
84
- return { token: envKey.trim(), source: "env" };
85
- }
86
- const creds = readNeonctlCredentials(
87
- options.configDir ? { configDir: options.configDir } : {},
88
- );
89
- if (creds) {
90
- return { token: creds.access_token, source: "neonctl" };
91
- }
92
- return null;
93
- }
94
-
95
- /**
96
- * Resolve the Neon API key via the standard chain (option → `NEON_API_KEY` env →
97
- * `~/.config/neonctl/credentials.json`) and construct a real {@link NeonApi} adapter from
98
- * it, or throw a uniform `PLATFORM_MISSING_API_KEY` error if no key can be found.
99
- *
100
- * Used by `pullConfig`, `pushConfig`, `fetchEnv`, and `branch` to build their default
101
- * `NeonApi` when the caller doesn't inject one. `operation` is the calling function's
102
- * name (e.g. `"pushConfig"`, `"branch"`) — it's prepended to the error message so users
103
- * can tell which call surfaced the missing key.
104
- */
105
- export function createNeonApiFromOptions(
106
- operation: string,
107
- options: {
108
- apiKey?: string;
109
- } = {},
110
- ): NeonApi {
111
- const resolved = resolveApiKey(
112
- options.apiKey ? { apiKey: options.apiKey } : {},
113
- );
114
- if (resolved) return createRealNeonApi({ apiKey: resolved.token });
115
- throw new PlatformError(
116
- ErrorCode.MissingApiKey,
117
- [
118
- `${operation} has no Neon API key to work with.`,
119
- "Tried (in order): `apiKey` option, NEON_API_KEY env, and `~/.config/neonctl/credentials.json`.",
120
- "Either pass `apiKey` directly, set NEON_API_KEY, run `npx neonctl auth` to populate the credentials file, or pass a custom `api` adapter (e.g. an in-memory fake for tests).",
121
- "Generate a key at https://console.neon.tech/app/settings/api-keys.",
122
- ].join(" "),
123
- );
124
- }