@neondatabase/env 0.0.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/.env.example +5 -0
- package/README.md +66 -0
- package/e2e/env.e2e.test.ts +36 -0
- package/e2e/helpers.ts +188 -0
- package/e2e/load-env.ts +29 -0
- package/e2e/setup.ts +24 -0
- package/package.json +22 -0
- package/src/cli.ts +107 -0
- package/src/index.ts +5 -0
- package/src/lib/cli/commands.test.ts +101 -0
- package/src/lib/cli/commands.ts +267 -0
- package/src/lib/cli/resolve-context.test.ts +242 -0
- package/src/lib/cli/resolve-context.ts +142 -0
- package/src/lib/env.test.ts +172 -0
- package/src/lib/env.ts +610 -0
- package/src/lib/fake-neon-api.ts +782 -0
- package/src/lib/test-utils.ts +83 -0
- package/src/v1.ts +32 -0
- package/tsconfig.json +4 -0
- package/tsdown.config.ts +20 -0
- package/vitest.config.ts +19 -0
- package/vitest.e2e.config.ts +29 -0
package/.env.example
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @neondatabase/env
|
|
2
|
+
|
|
3
|
+
Resolve and inject Neon connection strings for the branch selected by your `neon.ts` policy. Exposes `fetchEnv` / `parseEnv` functions plus a single CLI command: `neon-env run -- <cmd>`.
|
|
4
|
+
|
|
5
|
+
Builds on [`@neondatabase/config`](../config) — it reuses the `Config` policy type and the Neon API client.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @neondatabase/env
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Functions
|
|
14
|
+
|
|
15
|
+
The library functions are **filesystem- and env-agnostic**: `fetchEnv` requires an explicit `projectId` + `branchId`, and `parseEnv` requires an explicit `branchName`. (The `neon-env` CLI does the `.neon`/`NEON_*` resolution and passes these in.)
|
|
16
|
+
|
|
17
|
+
> `parseEnv` takes a branch **name**, not an id, because it makes no API call — it only needs the branch to evaluate your `neon.ts` policy, which switches on `branch.name`. The API-backed functions take a `branchId` (`br-…`) and read the name back from Neon.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import config from "../neon";
|
|
21
|
+
import { fetchEnv, parseEnv } from "@neondatabase/env/v1";
|
|
22
|
+
|
|
23
|
+
// Async — calls the Neon API for live connection strings. Use in build scripts / top-level await.
|
|
24
|
+
const env = await fetchEnv(config, { projectId: "patient-art-12345", branchId: "br-…" });
|
|
25
|
+
const db = drizzle(neon(env.postgres.databaseUrl), { schema });
|
|
26
|
+
|
|
27
|
+
// Sync — reads already-injected process.env and validates it (no network).
|
|
28
|
+
// Use in app bootstrap where async isn't available.
|
|
29
|
+
const env2 = parseEnv(config, process.env.NEON_BRANCH_NAME ?? "main");
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Both return the same namespaced `NeonEnv` shape: `postgres` is always present; `auth` and `dataApi` are included (and statically typed) when the evaluated branch policy enables them.
|
|
33
|
+
|
|
34
|
+
| Function | Description |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `fetchEnv(config, { projectId, branchId, ... })` | Async. Calls the Neon API for the given project + branch and returns live connection strings (and Auth/Data API values when enabled). `projectId` and `branchId` are required (`branchId` is a `br-…` id). |
|
|
37
|
+
| `parseEnv(config, branchName)` | Sync. Reads/validates the Neon env vars already present in `process.env`, evaluating the policy for `branchName`. Throws `PlatformError(EnvNotInjected)` listing missing vars when the env isn't populated. |
|
|
38
|
+
| `toEntries(env)` | Project a resolved `NeonEnv` into `{ KEY: value }` pairs for cross-process transport (named after the web `.entries()` convention; returns a `Record`). |
|
|
39
|
+
|
|
40
|
+
## CLI
|
|
41
|
+
|
|
42
|
+
One command — inject the env vars for your `neon.ts` branch into a dev command:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
neon-env run -- npm run dev
|
|
46
|
+
neon-env run -- pnpm dev
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`run` loads `neon.ts`, resolves the branch (via `--branch`, `NEON_BRANCH_ID`, or `.neon[/project.json]`), fetches the connection strings from Neon, and spawns the command with `DATABASE_URL` / `DATABASE_URL_UNPOOLED` (and `NEON_AUTH_BASE_URL` / `NEON_DATA_API_URL` when the policy enables them) injected on top of the inherited environment. Stdio is inherited so interactive dev servers keep working, and the parent exits with the child's exit code.
|
|
50
|
+
|
|
51
|
+
Flags: `--config <path>`, `--project-id`, `--branch`, `--api-key`, `--debug`.
|
|
52
|
+
|
|
53
|
+
## Env vars produced
|
|
54
|
+
|
|
55
|
+
| Key | From |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `DATABASE_URL` | pooled connection string |
|
|
58
|
+
| `DATABASE_URL_UNPOOLED` | direct connection string |
|
|
59
|
+
| `NEON_AUTH_BASE_URL` | Neon Auth integration (when `auth` is enabled) |
|
|
60
|
+
| `NEON_DATA_API_URL` | Data API integration (when `dataApi` is enabled) |
|
|
61
|
+
|
|
62
|
+
## Resolution
|
|
63
|
+
|
|
64
|
+
The **CLI** (`neon-env run`) resolves project + branch itself: `--project-id` / `--branch` flag → `NEON_PROJECT_ID` / `NEON_BRANCH_ID` env → `.neon[/project.json]` walked up from the working directory. The API key resolves via `--api-key` → `NEON_API_KEY` → `~/.config/neonctl/credentials.json`.
|
|
65
|
+
|
|
66
|
+
The **library functions** do none of this — pass `projectId` / `branchId` explicitly. This keeps `.neon` parsing in one place (the CLI / neonctl) and the functions pure.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { defineConfig } from "@neondatabase/config/v1";
|
|
2
|
+
import { describe, expect } from "vitest";
|
|
3
|
+
import { fetchEnv } from "../src/v1.js";
|
|
4
|
+
import {
|
|
5
|
+
bootstrapProject,
|
|
6
|
+
DEFAULT_REGION,
|
|
7
|
+
detectApiKeyScope,
|
|
8
|
+
e2eTest,
|
|
9
|
+
makeRealApi,
|
|
10
|
+
uniqueProjectName,
|
|
11
|
+
} from "./helpers.js";
|
|
12
|
+
|
|
13
|
+
describe("e2e — fetchEnv against real Neon API", () => {
|
|
14
|
+
e2eTest("returns Postgres env for selected branch", async ({ track }) => {
|
|
15
|
+
const scope = await detectApiKeyScope();
|
|
16
|
+
const api = makeRealApi();
|
|
17
|
+
const projectId =
|
|
18
|
+
scope.kind === "org-or-user"
|
|
19
|
+
? await bootstrapProject(api, {
|
|
20
|
+
name: uniqueProjectName("env"),
|
|
21
|
+
region: DEFAULT_REGION,
|
|
22
|
+
})
|
|
23
|
+
: scope.projectId;
|
|
24
|
+
if (scope.kind === "org-or-user") track(projectId);
|
|
25
|
+
const branchId = (await api.listBranches(projectId)).find(
|
|
26
|
+
(b) => b.isDefault,
|
|
27
|
+
)?.id;
|
|
28
|
+
if (!branchId) throw new Error("missing default branch");
|
|
29
|
+
const env = await fetchEnv(
|
|
30
|
+
defineConfig(() => ({})),
|
|
31
|
+
{ api, projectId, branchId },
|
|
32
|
+
);
|
|
33
|
+
expect(env.postgres.databaseUrl).toContain("postgresql://");
|
|
34
|
+
expect(env.postgres.databaseUrlUnpooled).toContain("postgresql://");
|
|
35
|
+
});
|
|
36
|
+
});
|
package/e2e/helpers.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createApiClient } from "@neondatabase/api-client";
|
|
3
|
+
import { createRealNeonApi, type NeonApi } from "@neondatabase/config/v1";
|
|
4
|
+
import { test } from "vitest";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Every e2e-created project is named `neon-ts-e2e-<uuid>`. Tests can register
|
|
8
|
+
* `track(id)` to opt into the per-test cleanup hook. The suite-level
|
|
9
|
+
* {@link sweepOrphans} additionally deletes leftovers from a previous failed run.
|
|
10
|
+
*/
|
|
11
|
+
const PROJECT_PREFIX = "neon-ts-e2e-";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default Neon region used by every e2e test that creates a project.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_REGION = "aws-us-east-2";
|
|
17
|
+
|
|
18
|
+
/** Generate a project name guaranteed not to collide with anything else in the org. */
|
|
19
|
+
export function uniqueProjectName(suffix?: string): string {
|
|
20
|
+
const id = randomUUID().slice(0, 8);
|
|
21
|
+
return suffix
|
|
22
|
+
? `${PROJECT_PREFIX}${id}-${suffix}`
|
|
23
|
+
: `${PROJECT_PREFIX}${id}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function requireApiKey(): string {
|
|
27
|
+
const key = process.env.NEON_API_KEY;
|
|
28
|
+
if (!key || key.trim() === "") {
|
|
29
|
+
throw new Error(
|
|
30
|
+
"NEON_API_KEY is not set. Create packages/env/.env (see .env.example) before running test:e2e.",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return key;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The same real NeonApi adapter the SDK uses internally — exercised end-to-end. */
|
|
37
|
+
export function makeRealApi(): NeonApi {
|
|
38
|
+
return createRealNeonApi({ apiKey: requireApiKey() });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a real Neon project via the NeonApi adapter directly.
|
|
43
|
+
*/
|
|
44
|
+
export async function bootstrapProject(
|
|
45
|
+
api: NeonApi,
|
|
46
|
+
args: { name: string; region: string },
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
const created = await api.createProject({
|
|
49
|
+
name: args.name,
|
|
50
|
+
regionId: args.region,
|
|
51
|
+
});
|
|
52
|
+
return created.id;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Lower-level Neon client. Used by cleanup and a few setup helpers. */
|
|
56
|
+
function makeRawClient(): ReturnType<typeof createApiClient> {
|
|
57
|
+
return createApiClient({ apiKey: requireApiKey() });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Discriminates the key currently configured. Project-scoped keys can't list projects;
|
|
62
|
+
* org/user-scoped keys can.
|
|
63
|
+
*/
|
|
64
|
+
export type ApiKeyScope =
|
|
65
|
+
| { kind: "org-or-user"; canCreate: true }
|
|
66
|
+
| { kind: "project"; projectId: string; canCreate: false };
|
|
67
|
+
|
|
68
|
+
let cachedScope: ApiKeyScope | undefined;
|
|
69
|
+
export async function detectApiKeyScope(): Promise<ApiKeyScope> {
|
|
70
|
+
if (cachedScope) return cachedScope;
|
|
71
|
+
const client = makeRawClient();
|
|
72
|
+
try {
|
|
73
|
+
await client.listProjects({ limit: 1 });
|
|
74
|
+
cachedScope = { kind: "org-or-user", canCreate: true };
|
|
75
|
+
return cachedScope;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
const status = (err as { response?: { status?: number } } | undefined)
|
|
78
|
+
?.response?.status;
|
|
79
|
+
if (status !== 401 && status !== 403) throw err;
|
|
80
|
+
}
|
|
81
|
+
const fixedProjectId = process.env.NEON_PROJECT_ID;
|
|
82
|
+
if (!fixedProjectId || fixedProjectId.trim() === "") {
|
|
83
|
+
throw new Error(
|
|
84
|
+
"API key cannot list projects (looks project-scoped) and NEON_PROJECT_ID is not set. " +
|
|
85
|
+
"Set NEON_PROJECT_ID in packages/env/.env to target a fixed project for the bounded e2e subset.",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
cachedScope = {
|
|
89
|
+
kind: "project",
|
|
90
|
+
projectId: fixedProjectId,
|
|
91
|
+
canCreate: false,
|
|
92
|
+
};
|
|
93
|
+
return cachedScope;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Delete a project, ignoring "already gone" errors so cleanup is idempotent. Refuses to
|
|
98
|
+
* delete anything that isn't prefixed with {@link PROJECT_PREFIX} so a mis-typed id can
|
|
99
|
+
* never wipe an unrelated project.
|
|
100
|
+
*/
|
|
101
|
+
async function deleteProject(projectId: string): Promise<void> {
|
|
102
|
+
const client = makeRawClient();
|
|
103
|
+
const project = await client.getProject(projectId).catch((err) => {
|
|
104
|
+
const status = (err as { response?: { status?: number } } | undefined)
|
|
105
|
+
?.response?.status;
|
|
106
|
+
if (status === 404 || status === 410) return null;
|
|
107
|
+
throw err;
|
|
108
|
+
});
|
|
109
|
+
if (!project) return;
|
|
110
|
+
if (!project.data.project.name.startsWith(PROJECT_PREFIX)) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Refusing to delete project ${projectId} ("${project.data.project.name}"): does not match the e2e prefix.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const maxAttempts = 12;
|
|
116
|
+
let delay = 500;
|
|
117
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
118
|
+
try {
|
|
119
|
+
await client.deleteProject(projectId);
|
|
120
|
+
return;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const status = (
|
|
123
|
+
err as { response?: { status?: number } } | undefined
|
|
124
|
+
)?.response?.status;
|
|
125
|
+
if (status === 404 || status === 410) return;
|
|
126
|
+
if (status !== 423 || attempt === maxAttempts) throw err;
|
|
127
|
+
await sleep(delay);
|
|
128
|
+
delay = Math.min(delay * 2, 5_000);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* List every project whose name starts with {@link PROJECT_PREFIX} and delete them.
|
|
135
|
+
*/
|
|
136
|
+
export async function sweepOrphans(): Promise<{ swept: string[] }> {
|
|
137
|
+
const scope = await detectApiKeyScope();
|
|
138
|
+
if (scope.kind === "project") return { swept: [] };
|
|
139
|
+
const client = makeRawClient();
|
|
140
|
+
const swept: string[] = [];
|
|
141
|
+
let cursor: string | undefined;
|
|
142
|
+
while (true) {
|
|
143
|
+
const res = await client.listProjects({
|
|
144
|
+
limit: 100,
|
|
145
|
+
...(cursor ? { cursor } : {}),
|
|
146
|
+
});
|
|
147
|
+
for (const project of res.data.projects) {
|
|
148
|
+
if (project.name.startsWith(PROJECT_PREFIX)) {
|
|
149
|
+
await deleteProject(project.id);
|
|
150
|
+
swept.push(project.id);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const next = (res.data as { pagination?: { next?: string } }).pagination
|
|
154
|
+
?.next;
|
|
155
|
+
if (!next || next === cursor) break;
|
|
156
|
+
cursor = next;
|
|
157
|
+
}
|
|
158
|
+
return { swept };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A vitest `test.extend` fixture that tracks every project id a test creates and deletes
|
|
163
|
+
* each one in the cleanup phase, even if the test failed mid-way.
|
|
164
|
+
*/
|
|
165
|
+
export const e2eTest = test.extend<{
|
|
166
|
+
track: (projectId: string) => void;
|
|
167
|
+
}>({
|
|
168
|
+
// biome-ignore lint/correctness/noEmptyPattern: vitest's fixture API requires this exact shape.
|
|
169
|
+
track: async ({}, use) => {
|
|
170
|
+
const created: string[] = [];
|
|
171
|
+
await use((projectId: string) => {
|
|
172
|
+
created.push(projectId);
|
|
173
|
+
});
|
|
174
|
+
for (const projectId of created) {
|
|
175
|
+
try {
|
|
176
|
+
await deleteProject(projectId);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error(
|
|
179
|
+
`[e2e cleanup] failed to delete ${projectId}: ${(err as Error).message}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
function sleep(ms: number): Promise<void> {
|
|
187
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
188
|
+
}
|
package/e2e/load-env.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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/env/.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
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
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/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@neondatabase/env",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Resolve and inject Neon connection strings for the branch selected by your neon.ts policy. fetchEnv / parseEnv plus a single `env run -- <cmd>` CLI.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"neon",
|
|
7
|
+
"database",
|
|
8
|
+
"postgres",
|
|
9
|
+
"env",
|
|
10
|
+
"dotenv",
|
|
11
|
+
"platform"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/neondatabase/neon-pkgs.git"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Neon",
|
|
20
|
+
"url": "https://neon.com"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import yargs from "yargs";
|
|
6
|
+
import { hideBin } from "yargs/helpers";
|
|
7
|
+
import { type CommandResult, runEnvRun } from "./lib/cli/commands.js";
|
|
8
|
+
|
|
9
|
+
const pkgVersion = readPackageVersion();
|
|
10
|
+
|
|
11
|
+
const argv = yargs(hideBin(process.argv))
|
|
12
|
+
.scriptName("neon-env")
|
|
13
|
+
.usage("$0 run -- <command> [options]")
|
|
14
|
+
.parserConfiguration({ "populate--": true })
|
|
15
|
+
.option("debug", {
|
|
16
|
+
type: "boolean",
|
|
17
|
+
default: false,
|
|
18
|
+
describe:
|
|
19
|
+
"Print stack traces and structured error details when something fails",
|
|
20
|
+
})
|
|
21
|
+
.command(
|
|
22
|
+
"run",
|
|
23
|
+
"Run a command with Neon env vars (from your neon.ts policy) injected into its environment. Use `--` to separate the command: `neon-env run -- npm run dev`.",
|
|
24
|
+
(y) =>
|
|
25
|
+
y
|
|
26
|
+
.option("config", {
|
|
27
|
+
type: "string",
|
|
28
|
+
describe:
|
|
29
|
+
"Path to neon.ts (defaults to walking up from cwd)",
|
|
30
|
+
})
|
|
31
|
+
.option("project-id", {
|
|
32
|
+
type: "string",
|
|
33
|
+
describe: "Override the .neon/project.json projectId",
|
|
34
|
+
})
|
|
35
|
+
.option("branch", {
|
|
36
|
+
type: "string",
|
|
37
|
+
describe:
|
|
38
|
+
"Override the .neon/project.json branchId / NEON_BRANCH_ID",
|
|
39
|
+
})
|
|
40
|
+
.option("api-key", {
|
|
41
|
+
type: "string",
|
|
42
|
+
describe: "Neon API key (defaults to NEON_API_KEY)",
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
.demandCommand(1, "Run `neon-env --help` to see the available commands.")
|
|
46
|
+
.strict()
|
|
47
|
+
.help()
|
|
48
|
+
.version(pkgVersion)
|
|
49
|
+
.parseSync();
|
|
50
|
+
|
|
51
|
+
const command = String(argv._[0]);
|
|
52
|
+
const cwd = process.cwd();
|
|
53
|
+
|
|
54
|
+
let result: CommandResult;
|
|
55
|
+
switch (command) {
|
|
56
|
+
case "run": {
|
|
57
|
+
const passthrough = Array.isArray(argv["--"])
|
|
58
|
+
? argv["--"].map(String)
|
|
59
|
+
: [];
|
|
60
|
+
result = await runEnvRun(
|
|
61
|
+
{
|
|
62
|
+
command: passthrough,
|
|
63
|
+
...(typeof argv.config === "string"
|
|
64
|
+
? { configPath: argv.config }
|
|
65
|
+
: {}),
|
|
66
|
+
...(typeof argv["project-id"] === "string"
|
|
67
|
+
? { projectId: argv["project-id"] }
|
|
68
|
+
: {}),
|
|
69
|
+
...(typeof argv.branch === "string"
|
|
70
|
+
? { branch: argv.branch }
|
|
71
|
+
: {}),
|
|
72
|
+
...(typeof argv["api-key"] === "string"
|
|
73
|
+
? { apiKey: argv["api-key"] }
|
|
74
|
+
: {}),
|
|
75
|
+
},
|
|
76
|
+
{ cwd },
|
|
77
|
+
);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
default:
|
|
81
|
+
result = {
|
|
82
|
+
exitCode: 1,
|
|
83
|
+
stdout: "",
|
|
84
|
+
stderr: `Unknown command: ${command}\n`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
89
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
90
|
+
if (argv.debug && result.exitCode !== 0 && result.debugInfo) {
|
|
91
|
+
process.stderr.write(`\n--- debug ---\n${result.debugInfo}\n`);
|
|
92
|
+
}
|
|
93
|
+
process.exit(result.exitCode);
|
|
94
|
+
|
|
95
|
+
function readPackageVersion(): string {
|
|
96
|
+
// The built CLI lives at `dist/cli.js`, so `package.json` is one directory up. When
|
|
97
|
+
// running from source (tsx, vitest), the file lives at `src/cli.ts` and `package.json`
|
|
98
|
+
// is again one directory up. Single resolution covers both layouts.
|
|
99
|
+
try {
|
|
100
|
+
const pkgUrl = new URL("../package.json", import.meta.url);
|
|
101
|
+
const raw = readFileSync(fileURLToPath(pkgUrl), "utf-8");
|
|
102
|
+
const parsed = JSON.parse(raw) as { version?: unknown };
|
|
103
|
+
return typeof parsed.version === "string" ? parsed.version : "0.0.0";
|
|
104
|
+
} catch {
|
|
105
|
+
return "0.0.0";
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
5
|
+
import { FakeNeonApi } from "../fake-neon-api.js";
|
|
6
|
+
import { makeTempRepo, stubCleanNeonEnv } from "../test-utils.js";
|
|
7
|
+
import { runEnvRun } from "./commands.js";
|
|
8
|
+
|
|
9
|
+
const CONFIG_SRC = new URL("../../../../config/src/v1.ts", import.meta.url)
|
|
10
|
+
.pathname;
|
|
11
|
+
|
|
12
|
+
const cleanups: Array<() => void> = [];
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
while (cleanups.length > 0) cleanups.shift()?.();
|
|
15
|
+
});
|
|
16
|
+
beforeEach(() => stubCleanNeonEnv());
|
|
17
|
+
|
|
18
|
+
function setup(files: Record<string, string | null>) {
|
|
19
|
+
const repo = makeTempRepo(files);
|
|
20
|
+
cleanups.push(repo.cleanup);
|
|
21
|
+
return repo.root;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function seededFake() {
|
|
25
|
+
const api = new FakeNeonApi();
|
|
26
|
+
const projectId = "proj-env-cli";
|
|
27
|
+
api.seedProject({
|
|
28
|
+
project: {
|
|
29
|
+
id: projectId,
|
|
30
|
+
name: "env-cli-test",
|
|
31
|
+
regionId: "aws-us-east-1",
|
|
32
|
+
pgVersion: 17,
|
|
33
|
+
},
|
|
34
|
+
branches: [
|
|
35
|
+
{ branch: { id: "br-main", name: "main", isDefault: true } },
|
|
36
|
+
],
|
|
37
|
+
});
|
|
38
|
+
return { api, projectId };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function policy() {
|
|
42
|
+
return `import { defineConfig } from "${CONFIG_SRC}";
|
|
43
|
+
export default defineConfig(() => ({}));`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("runEnvRun", () => {
|
|
47
|
+
test("injects DATABASE_URL into the spawned command", async () => {
|
|
48
|
+
const { api, projectId } = seededFake();
|
|
49
|
+
const root = setup({
|
|
50
|
+
"package.json": "{}",
|
|
51
|
+
".neon/project.json": JSON.stringify({
|
|
52
|
+
projectId,
|
|
53
|
+
branchId: "br-main",
|
|
54
|
+
}),
|
|
55
|
+
"neon.ts": policy(),
|
|
56
|
+
});
|
|
57
|
+
const outFile = join(
|
|
58
|
+
mkdtempSync(join(tmpdir(), "neon-env-out-")),
|
|
59
|
+
"url.txt",
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const result = await runEnvRun(
|
|
63
|
+
{
|
|
64
|
+
command: [
|
|
65
|
+
process.execPath,
|
|
66
|
+
"-e",
|
|
67
|
+
`require("node:fs").writeFileSync(${JSON.stringify(outFile)}, process.env.DATABASE_URL ?? "")`,
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{ cwd: root, api },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(result.exitCode).toBe(0);
|
|
74
|
+
expect(existsSync(outFile)).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns a non-zero exit code with usage when no command is given", async () => {
|
|
78
|
+
const { api } = seededFake();
|
|
79
|
+
const root = setup({ "package.json": "{}" });
|
|
80
|
+
const result = await runEnvRun({ command: [] }, { cwd: root, api });
|
|
81
|
+
expect(result.exitCode).not.toBe(0);
|
|
82
|
+
expect(result.stderr).toContain("neon-env run --");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("propagates the child's exit code", async () => {
|
|
86
|
+
const { api, projectId } = seededFake();
|
|
87
|
+
const root = setup({
|
|
88
|
+
"package.json": "{}",
|
|
89
|
+
".neon/project.json": JSON.stringify({
|
|
90
|
+
projectId,
|
|
91
|
+
branchId: "br-main",
|
|
92
|
+
}),
|
|
93
|
+
"neon.ts": policy(),
|
|
94
|
+
});
|
|
95
|
+
const result = await runEnvRun(
|
|
96
|
+
{ command: [process.execPath, "-e", "process.exit(3)"] },
|
|
97
|
+
{ cwd: root, api },
|
|
98
|
+
);
|
|
99
|
+
expect(result.exitCode).toBe(3);
|
|
100
|
+
});
|
|
101
|
+
});
|