@neondatabase/env 0.0.0 → 0.1.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 (46) hide show
  1. package/LICENSE.md +178 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +61 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/config/dist/lib/neon-api.d.ts +264 -0
  6. package/dist/config/dist/lib/neon-api.d.ts.map +1 -0
  7. package/dist/config/dist/lib/types.d.ts +184 -0
  8. package/dist/config/dist/lib/types.d.ts.map +1 -0
  9. package/dist/config/dist/v1.d.ts +4 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +3 -0
  12. package/dist/lib/cli/commands.d.ts +46 -0
  13. package/dist/lib/cli/commands.d.ts.map +1 -0
  14. package/dist/lib/cli/commands.js +181 -0
  15. package/dist/lib/cli/commands.js.map +1 -0
  16. package/dist/lib/cli/resolve-context.d.ts +35 -0
  17. package/dist/lib/cli/resolve-context.d.ts.map +1 -0
  18. package/dist/lib/cli/resolve-context.js +90 -0
  19. package/dist/lib/cli/resolve-context.js.map +1 -0
  20. package/dist/lib/env.d.ts +194 -0
  21. package/dist/lib/env.d.ts.map +1 -0
  22. package/dist/lib/env.js +263 -0
  23. package/dist/lib/env.js.map +1 -0
  24. package/dist/v1.d.ts +2 -0
  25. package/dist/v1.js +2 -0
  26. package/package.json +72 -21
  27. package/.env.example +0 -5
  28. package/e2e/env.e2e.test.ts +0 -36
  29. package/e2e/helpers.ts +0 -188
  30. package/e2e/load-env.ts +0 -29
  31. package/e2e/setup.ts +0 -24
  32. package/src/cli.ts +0 -107
  33. package/src/index.ts +0 -5
  34. package/src/lib/cli/commands.test.ts +0 -101
  35. package/src/lib/cli/commands.ts +0 -267
  36. package/src/lib/cli/resolve-context.test.ts +0 -242
  37. package/src/lib/cli/resolve-context.ts +0 -142
  38. package/src/lib/env.test.ts +0 -172
  39. package/src/lib/env.ts +0 -610
  40. package/src/lib/fake-neon-api.ts +0 -782
  41. package/src/lib/test-utils.ts +0 -83
  42. package/src/v1.ts +0 -32
  43. package/tsconfig.json +0 -4
  44. package/tsdown.config.ts +0 -20
  45. package/vitest.config.ts +0 -19
  46. package/vitest.e2e.config.ts +0 -29
@@ -1,267 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import {
5
- ConfigLoadError,
6
- ErrorCode,
7
- loadConfigFromFile,
8
- MissingContextError,
9
- type NeonApi,
10
- PlatformError,
11
- } from "@neondatabase/config/v1";
12
- import { fetchEnv, toEntries } from "../env.js";
13
- import { resolveContext } from "./resolve-context.js";
14
-
15
- /** File `env run` reads to layer one-time auth keys. Matches the Vercel/Next.js convention. */
16
- const DEFAULT_ENV_FILE = ".env.local";
17
-
18
- /**
19
- * Cross-cutting environment a CLI command is allowed to touch. Injected so tests can drive
20
- * the handler with a custom NeonApi and a controlled `cwd` without spawning child
21
- * processes.
22
- */
23
- export interface CommandEnv {
24
- cwd: string;
25
- /**
26
- * When set, used directly as the NeonApi. When omitted, the real adapter is built from
27
- * `options.apiKey ?? NEON_API_KEY` inside `fetchEnv`.
28
- */
29
- api?: NeonApi;
30
- }
31
-
32
- export interface CommandResult {
33
- /** Process exit code. `0` for success, non-zero for failure. */
34
- exitCode: number;
35
- /** Text intended for stdout. */
36
- stdout: string;
37
- /** Text intended for stderr (human-readable status / error messages). */
38
- stderr: string;
39
- /** Optional structured debug payload — printed only when `--debug` is passed. */
40
- debugInfo?: string;
41
- }
42
-
43
- export interface EnvRunCommandOptions {
44
- /** The user command to spawn (after `--`). The first element is the executable. */
45
- command: string[];
46
- configPath?: string;
47
- projectId?: string;
48
- branch?: string;
49
- apiKey?: string;
50
- }
51
-
52
- /**
53
- * Implementation of `neon-env run -- <cmd...>`. Loads `neon.ts`, fetches the env from
54
- * Neon, then spawns the user-supplied command with the env vars injected on top of the
55
- * inherited `process.env`. Stdio is inherited so interactive dev servers keep working.
56
- * The parent process exits with the child's exit code.
57
- */
58
- export async function runEnvRun(
59
- options: EnvRunCommandOptions,
60
- ctx: CommandEnv,
61
- ): Promise<CommandResult> {
62
- if (options.command.length === 0) {
63
- return failure(
64
- [
65
- "`env run` requires a command to spawn.",
66
- "Usage: neon-env run -- <command> [args...]",
67
- "Example: neon-env run -- npm run dev",
68
- ].join("\n"),
69
- );
70
- }
71
-
72
- // The CLI owns project/branch resolution (flags → NEON_* env → .neon file) so the
73
- // library functions stay filesystem/env-agnostic.
74
- const resolved = resolveContext({
75
- cwd: ctx.cwd,
76
- ...(options.projectId ? { projectId: options.projectId } : {}),
77
- ...(options.branch ? { branch: options.branch } : {}),
78
- });
79
- if (!resolved.ok) {
80
- return failure(
81
- [
82
- "`env run` could not resolve the Neon project and branch:",
83
- ...resolved.missing.map((m) => ` - ${m}`),
84
- ].join("\n"),
85
- 3,
86
- );
87
- }
88
-
89
- let injected: Record<string, string>;
90
- try {
91
- const env = await loadConfigAndFetchEnv(options, ctx, resolved.context);
92
- injected = toEntries(env);
93
- } catch (err) {
94
- return handleError(err);
95
- }
96
-
97
- const [executable, ...args] = options.command;
98
- const exitCode = await spawnAndWait(executable, args, {
99
- cwd: ctx.cwd,
100
- env: { ...process.env, ...injected },
101
- });
102
- return { exitCode, stdout: "", stderr: "" };
103
- }
104
-
105
- /**
106
- * Load `neon.ts`, then call `fetchEnv` with the explicitly-resolved project + branch.
107
- * Layers any one-time Auth keys from `.env.local` (next to the config file) into the env
108
- * source so re-runs keep round-tripping values the Neon API only returns once at
109
- * integration-creation time.
110
- */
111
- async function loadConfigAndFetchEnv(
112
- options: EnvRunCommandOptions,
113
- ctx: CommandEnv,
114
- resolved: { projectId: string; branchId: string },
115
- ): Promise<Awaited<ReturnType<typeof fetchEnv>>> {
116
- const { config, resolvedPath } = await loadConfigFromFile({
117
- ...(options.configPath ? { path: options.configPath } : {}),
118
- cwd: ctx.cwd,
119
- });
120
- const envFileSource = join(dirname(resolvedPath), DEFAULT_ENV_FILE);
121
- const fileEnv = existsSync(envFileSource)
122
- ? parseEnvFile(readFileSync(envFileSource, "utf-8"))
123
- : {};
124
- return fetchEnv(config, {
125
- projectId: resolved.projectId,
126
- branchId: resolved.branchId,
127
- env: { ...process.env, ...fileEnv },
128
- ...(ctx.api ? { api: ctx.api } : {}),
129
- ...(options.apiKey ? { apiKey: options.apiKey } : {}),
130
- });
131
- }
132
-
133
- /**
134
- * Spawn a child process with stdio inherited so dev servers stay interactive. Resolves
135
- * with the child's exit code (treating signal terminations as code 1 so the CLI surfaces
136
- * a non-zero exit consistently).
137
- */
138
- function spawnAndWait(
139
- command: string,
140
- args: string[],
141
- options: { cwd: string; env: Record<string, string | undefined> },
142
- ): Promise<number> {
143
- return new Promise((resolve) => {
144
- const child = spawn(command, args, {
145
- cwd: options.cwd,
146
- env: options.env,
147
- stdio: "inherit",
148
- });
149
- child.on("error", (err) => {
150
- process.stderr.write(
151
- `neon-env run: failed to spawn '${command}': ${err.message}\n`,
152
- );
153
- resolve(1);
154
- });
155
- child.on("exit", (code, signal) => {
156
- if (typeof code === "number") {
157
- resolve(code);
158
- return;
159
- }
160
- if (signal) {
161
- process.stderr.write(
162
- `neon-env run: child terminated by signal ${signal}\n`,
163
- );
164
- resolve(1);
165
- return;
166
- }
167
- resolve(1);
168
- });
169
- });
170
- }
171
-
172
- function parseEnvFile(body: string): NodeJS.ProcessEnv {
173
- const out: NodeJS.ProcessEnv = {};
174
- for (const line of body.split("\n")) {
175
- const parsed = parseEnvLine(line);
176
- if (parsed) out[parsed.key] = parsed.value;
177
- }
178
- return out;
179
- }
180
-
181
- function parseEnvLine(line: string): { key: string; value: string } | null {
182
- const match = line.match(
183
- /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/,
184
- );
185
- const key = match?.[1];
186
- const rawValue = match?.[2];
187
- if (key === undefined || rawValue === undefined) return null;
188
- return { key, value: unescapeEnvValue(rawValue.trim()) };
189
- }
190
-
191
- function unescapeEnvValue(value: string): string {
192
- if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
193
- return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
194
- }
195
- if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
196
- return value.slice(1, -1);
197
- }
198
- return value;
199
- }
200
-
201
- /**
202
- * Stable exit code per `PlatformError` code. Mirrors the table in the config package so
203
- * shell pipelines can branch on the specific failure mode without parsing free text.
204
- */
205
- const EXIT_CODE_BY_PLATFORM_ERROR_CODE: Readonly<Record<string, number>> = {
206
- [ErrorCode.MissingApiKey]: 1,
207
- [ErrorCode.Unauthorized]: 6,
208
- [ErrorCode.Forbidden]: 7,
209
- [ErrorCode.NotFound]: 8,
210
- [ErrorCode.RateLimited]: 9,
211
- [ErrorCode.NetworkError]: 10,
212
- [ErrorCode.ServerError]: 11,
213
- [ErrorCode.Locked]: 11,
214
- [ErrorCode.InternalError]: 99,
215
- };
216
-
217
- function handleError(err: unknown): CommandResult {
218
- if (err instanceof MissingContextError)
219
- return errorResult(err, `Missing context: ${err.message}`, 3);
220
- if (err instanceof ConfigLoadError)
221
- return errorResult(err, `Failed to load config: ${err.message}`, 4);
222
- if (err instanceof PlatformError) {
223
- const exitCode = EXIT_CODE_BY_PLATFORM_ERROR_CODE[err.code];
224
- if (exitCode !== undefined)
225
- return errorResult(err, err.message, exitCode);
226
- return errorResult(err, `[${err.code}] ${err.message}`, 5);
227
- }
228
- if (err instanceof Error) return errorResult(err, err.message, 1);
229
- return failure(String(err), 1);
230
- }
231
-
232
- function errorResult(
233
- err: unknown,
234
- message: string,
235
- exitCode: number,
236
- ): CommandResult {
237
- const result: CommandResult = {
238
- exitCode,
239
- stdout: "",
240
- stderr: `${message}\n`,
241
- };
242
- const debug = buildDebugInfo(err);
243
- if (debug) result.debugInfo = debug;
244
- return result;
245
- }
246
-
247
- function buildDebugInfo(err: unknown): string | undefined {
248
- if (!(err instanceof Error)) return undefined;
249
- const lines: string[] = [];
250
- if (err instanceof PlatformError) {
251
- lines.push(`code : ${err.code}`);
252
- if (Object.keys(err.details).length > 0) {
253
- lines.push(`details : ${JSON.stringify(err.details, null, 2)}`);
254
- }
255
- }
256
- if (err.cause instanceof Error) {
257
- lines.push(`cause : ${err.cause.name}: ${err.cause.message}`);
258
- }
259
- if (err.stack) {
260
- lines.push(err.stack);
261
- }
262
- return lines.length > 0 ? lines.join("\n") : undefined;
263
- }
264
-
265
- function failure(message: string, exitCode = 1): CommandResult {
266
- return { exitCode, stdout: "", stderr: `${message}\n` };
267
- }
@@ -1,242 +0,0 @@
1
- import { afterEach, describe, expect, test } from "vitest";
2
- import { makeTempRepo } from "../test-utils.js";
3
- import { resolveContext } from "./resolve-context.js";
4
-
5
- const cleanups: Array<() => void> = [];
6
- afterEach(() => {
7
- while (cleanups.length > 0) cleanups.shift()?.();
8
- });
9
-
10
- function setup(files: Record<string, string | null>) {
11
- const repo = makeTempRepo(files);
12
- cleanups.push(repo.cleanup);
13
- return repo.root;
14
- }
15
-
16
- /** An env with every NEON_* key explicitly unset, so the test controls precedence. */
17
- const EMPTY_ENV: NodeJS.ProcessEnv = {};
18
-
19
- describe("resolveContext — precedence", () => {
20
- test("explicit options win over env and file", () => {
21
- const root = setup({
22
- "package.json": "{}",
23
- ".neon/project.json": JSON.stringify({
24
- projectId: "proj-file",
25
- branchId: "br-file",
26
- }),
27
- });
28
- const result = resolveContext({
29
- cwd: root,
30
- projectId: "proj-opt",
31
- branch: "br-opt",
32
- env: {
33
- NEON_PROJECT_ID: "proj-env",
34
- NEON_BRANCH_ID: "br-env",
35
- },
36
- });
37
- expect(result).toEqual({
38
- ok: true,
39
- context: { projectId: "proj-opt", branchId: "br-opt" },
40
- });
41
- });
42
-
43
- test("env wins over the .neon file when no option is given", () => {
44
- const root = setup({
45
- "package.json": "{}",
46
- ".neon/project.json": JSON.stringify({
47
- projectId: "proj-file",
48
- branchId: "br-file",
49
- }),
50
- });
51
- const result = resolveContext({
52
- cwd: root,
53
- env: {
54
- NEON_PROJECT_ID: "proj-env",
55
- NEON_BRANCH_ID: "br-env",
56
- },
57
- });
58
- expect(result).toMatchObject({
59
- ok: true,
60
- context: { projectId: "proj-env", branchId: "br-env" },
61
- });
62
- });
63
-
64
- test("falls back to .neon/project.json when no option or env", () => {
65
- const root = setup({
66
- "package.json": "{}",
67
- ".neon/project.json": JSON.stringify({
68
- projectId: "proj-file",
69
- branchId: "br-file",
70
- branchName: "main",
71
- }),
72
- });
73
- const result = resolveContext({ cwd: root, env: EMPTY_ENV });
74
- expect(result).toEqual({
75
- ok: true,
76
- context: {
77
- projectId: "proj-file",
78
- branchId: "br-file",
79
- branchName: "main",
80
- },
81
- });
82
- });
83
-
84
- test("NEON_BRANCH_NAME populates branchName", () => {
85
- const root = setup({
86
- "package.json": "{}",
87
- ".neon/project.json": JSON.stringify({
88
- projectId: "p",
89
- branchId: "br-1",
90
- }),
91
- });
92
- const result = resolveContext({
93
- cwd: root,
94
- env: { NEON_BRANCH_NAME: "feature-x" },
95
- });
96
- expect(result).toMatchObject({
97
- ok: true,
98
- context: { branchName: "feature-x" },
99
- });
100
- });
101
- });
102
-
103
- describe("resolveContext — .neon file discovery", () => {
104
- test("reads the bare `.neon` neonctl-convention file", () => {
105
- const root = setup({
106
- "package.json": "{}",
107
- ".neon": JSON.stringify({
108
- projectId: "p-bare",
109
- branchId: "br-bare",
110
- }),
111
- });
112
- const result = resolveContext({ cwd: root, env: EMPTY_ENV });
113
- expect(result).toMatchObject({
114
- ok: true,
115
- context: { projectId: "p-bare", branchId: "br-bare" },
116
- });
117
- });
118
-
119
- test("prefers .neon/project.json over a bare .neon in the same dir", () => {
120
- const root = setup({
121
- "package.json": "{}",
122
- ".neon/project.json": JSON.stringify({
123
- projectId: "p-dir",
124
- branchId: "br-dir",
125
- }),
126
- });
127
- // (A bare `.neon` file cannot coexist with a `.neon/` directory, so the
128
- // preference is exercised structurally by the dir form resolving first.)
129
- const result = resolveContext({ cwd: root, env: EMPTY_ENV });
130
- expect(result).toMatchObject({
131
- ok: true,
132
- context: { projectId: "p-dir" },
133
- });
134
- });
135
-
136
- test("walks up from a nested dir to a workspace-root .neon", () => {
137
- const root = setup({
138
- "package.json": "{}",
139
- ".neon/project.json": JSON.stringify({
140
- projectId: "p-root",
141
- branchId: "br-root",
142
- }),
143
- "packages/db/package.json": "{}",
144
- });
145
- const result = resolveContext({
146
- cwd: `${root}/packages/db`,
147
- env: EMPTY_ENV,
148
- });
149
- expect(result).toMatchObject({
150
- ok: true,
151
- context: { projectId: "p-root", branchId: "br-root" },
152
- });
153
- });
154
-
155
- test("stops the upward walk at the .git boundary", () => {
156
- // `.neon` lives ABOVE the repo root; the walk must not escape past `.git`.
157
- const outer = setup({
158
- ".neon/project.json": JSON.stringify({
159
- projectId: "p-outer",
160
- branchId: "br-outer",
161
- }),
162
- });
163
- // Seed an inner repo with its own `.git` and no context file.
164
- const innerRepo = makeTempRepo({ "package.json": "{}" });
165
- cleanups.push(innerRepo.cleanup);
166
- // The inner repo is a separate temp dir, so walking from it never reaches `outer`.
167
- const result = resolveContext({ cwd: innerRepo.root, env: EMPTY_ENV });
168
- expect(result.ok).toBe(false);
169
- void outer;
170
- });
171
-
172
- test("treats malformed .neon JSON as absent", () => {
173
- const root = setup({
174
- "package.json": "{}",
175
- ".neon/project.json": "{ not valid json",
176
- });
177
- const result = resolveContext({ cwd: root, env: EMPTY_ENV });
178
- expect(result.ok).toBe(false);
179
- });
180
-
181
- test("ignores empty-string fields in the .neon file", () => {
182
- const root = setup({
183
- "package.json": "{}",
184
- ".neon/project.json": JSON.stringify({
185
- projectId: "",
186
- branchId: "br-1",
187
- }),
188
- });
189
- const result = resolveContext({ cwd: root, env: EMPTY_ENV });
190
- // projectId empty → unresolved; branchId present.
191
- expect(result.ok).toBe(false);
192
- });
193
- });
194
-
195
- describe("resolveContext — missing fields", () => {
196
- test("reports both missing when nothing resolves", () => {
197
- const root = setup({ "package.json": "{}" });
198
- const result = resolveContext({ cwd: root, env: EMPTY_ENV });
199
- expect(result.ok).toBe(false);
200
- if (result.ok) throw new Error("expected failure");
201
- expect(result.missing).toHaveLength(2);
202
- expect(result.missing[0]).toContain("project id");
203
- expect(result.missing[1]).toContain("branch");
204
- });
205
-
206
- test("reports only branch missing when projectId resolves", () => {
207
- const root = setup({ "package.json": "{}" });
208
- const result = resolveContext({
209
- cwd: root,
210
- projectId: "p",
211
- env: EMPTY_ENV,
212
- });
213
- expect(result.ok).toBe(false);
214
- if (result.ok) throw new Error("expected failure");
215
- expect(result.missing).toHaveLength(1);
216
- expect(result.missing[0]).toContain("branch");
217
- });
218
-
219
- test("reports only project missing when branch resolves", () => {
220
- const root = setup({ "package.json": "{}" });
221
- const result = resolveContext({
222
- cwd: root,
223
- branch: "br-1",
224
- env: EMPTY_ENV,
225
- });
226
- expect(result.ok).toBe(false);
227
- if (result.ok) throw new Error("expected failure");
228
- expect(result.missing).toHaveLength(1);
229
- expect(result.missing[0]).toContain("project id");
230
- });
231
-
232
- test("trims whitespace-only values to missing", () => {
233
- const root = setup({ "package.json": "{}" });
234
- const result = resolveContext({
235
- cwd: root,
236
- projectId: " ",
237
- branch: " ",
238
- env: EMPTY_ENV,
239
- });
240
- expect(result.ok).toBe(false);
241
- });
242
- });
@@ -1,142 +0,0 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, resolve } from "node:path";
4
-
5
- /**
6
- * Resolved project + branch context for the `neon-env` CLI. The CLI owns this resolution
7
- * (flags → `NEON_*` env → `.neon[/project.json]` file) so the `@neondatabase/env` library
8
- * functions can stay filesystem- and env-agnostic.
9
- */
10
- export interface ResolvedContext {
11
- projectId: string;
12
- branchId: string;
13
- /** Branch name for `parseEnv`-style policy evaluation, when known. */
14
- branchName?: string;
15
- }
16
-
17
- export interface ResolveContextOptions {
18
- projectId?: string;
19
- branch?: string;
20
- cwd: string;
21
- env?: NodeJS.ProcessEnv;
22
- }
23
-
24
- /**
25
- * Resolve `projectId` and `branch` for a CLI invocation. Precedence (each wins over the
26
- * next): explicit flag → `NEON_*` env var → `.neon[/project.json]` walked up from `cwd`.
27
- *
28
- * Returns the resolved values plus a list of human-readable reasons for any field that
29
- * could not be resolved (so the caller can render one combined error).
30
- */
31
- export function resolveContext(
32
- options: ResolveContextOptions,
33
- ): { ok: true; context: ResolvedContext } | { ok: false; missing: string[] } {
34
- const env = options.env ?? process.env;
35
- const file = findNeonFile(options.cwd);
36
-
37
- const projectId =
38
- nonEmpty(options.projectId) ??
39
- nonEmpty(env.NEON_PROJECT_ID) ??
40
- file?.projectId;
41
-
42
- const branchId =
43
- nonEmpty(options.branch) ??
44
- nonEmpty(env.NEON_BRANCH_ID) ??
45
- file?.branchId;
46
-
47
- const branchName = nonEmpty(env.NEON_BRANCH_NAME) ?? file?.branchName;
48
-
49
- const missing: string[] = [];
50
- if (!projectId) {
51
- missing.push(
52
- "project id — pass `--project-id`, set `NEON_PROJECT_ID`, or add `projectId` to `.neon/project.json` (run `npx neonctl link`).",
53
- );
54
- }
55
- if (!branchId) {
56
- missing.push(
57
- "branch — pass `--branch`, set `NEON_BRANCH_ID`, or add `branchId` to `.neon/project.json` (run `npx neonctl checkout <branch>`).",
58
- );
59
- }
60
- if (!projectId || !branchId) return { ok: false, missing };
61
-
62
- return {
63
- ok: true,
64
- context: {
65
- projectId,
66
- branchId,
67
- ...(branchName ? { branchName } : {}),
68
- },
69
- };
70
- }
71
-
72
- interface NeonFile {
73
- projectId?: string;
74
- branchId?: string;
75
- branchName?: string;
76
- }
77
-
78
- /**
79
- * Walk up from `cwd` looking for `.neon/project.json` (preferred) or `.neon` (neonctl
80
- * convention). Stops at the first `.git` directory or the home directory. Read-only.
81
- */
82
- function findNeonFile(cwd: string): NeonFile | null {
83
- let current = resolve(cwd);
84
- const stop = resolve(homedir());
85
- let lastSeen: string | null = null;
86
-
87
- while (true) {
88
- const parsed =
89
- readNeonFileAt(resolve(current, ".neon", "project.json")) ??
90
- readNeonFileAt(resolve(current, ".neon"));
91
- if (parsed) return parsed;
92
-
93
- if (current === stop) return null;
94
- if (existsSync(resolve(current, ".git"))) return null;
95
-
96
- const parent = dirname(current);
97
- if (parent === current || parent === lastSeen) return null;
98
- lastSeen = current;
99
- current = parent;
100
- }
101
- }
102
-
103
- function readNeonFileAt(path: string): NeonFile | null {
104
- if (!isFile(path)) return null;
105
- let raw: string;
106
- try {
107
- raw = readFileSync(path, "utf-8");
108
- } catch {
109
- return null;
110
- }
111
- let parsed: unknown;
112
- try {
113
- parsed = JSON.parse(raw);
114
- } catch {
115
- return null;
116
- }
117
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
118
- return null;
119
- const obj = parsed as Record<string, unknown>;
120
- const out: NeonFile = {};
121
- if (typeof obj.projectId === "string" && obj.projectId !== "")
122
- out.projectId = obj.projectId;
123
- if (typeof obj.branchId === "string" && obj.branchId !== "")
124
- out.branchId = obj.branchId;
125
- if (typeof obj.branchName === "string" && obj.branchName !== "")
126
- out.branchName = obj.branchName;
127
- return out;
128
- }
129
-
130
- function isFile(path: string): boolean {
131
- try {
132
- return statSync(path).isFile();
133
- } catch {
134
- return false;
135
- }
136
- }
137
-
138
- function nonEmpty(value: string | undefined): string | undefined {
139
- if (typeof value !== "string") return undefined;
140
- const trimmed = value.trim();
141
- return trimmed === "" ? undefined : trimmed;
142
- }