claudeup 4.42.0 → 4.42.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/bin/claudeup.js +16 -3
- package/package.json +4 -4
- package/scripts/build-binaries.ts +12 -1
- package/src/__tests__/dotenv.test.ts +190 -0
- package/src/main.tsx +18 -0
- package/src/services/dotenv.ts +176 -0
package/bin/claudeup.js
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* optionalDependency (claudeup-<platform>-<arch>) — that binary embeds the Bun
|
|
7
7
|
* runtime, so it runs without Bun installed. Falls back to running from source
|
|
8
8
|
* via Bun for dev checkouts and unsupported platforms.
|
|
9
|
+
*
|
|
10
|
+
* Set CLAUDEUP_NO_BINARY=1 to skip the prebuilt binary and force the source
|
|
11
|
+
* path. That escape hatch exists because the launcher PREFERS the binary: when
|
|
12
|
+
* a shipped binary cannot start on a given machine, without it claudeup is a
|
|
13
|
+
* hard block rather than a slow start.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
import { spawnSync } from "node:child_process";
|
|
@@ -18,7 +23,8 @@ const require = createRequire(import.meta.url);
|
|
|
18
23
|
const args = process.argv.slice(2);
|
|
19
24
|
const { platform, arch } = process;
|
|
20
25
|
|
|
21
|
-
// 1. Prefer the prebuilt platform binary.
|
|
26
|
+
// 1. Prefer the prebuilt platform binary, unless the source path is forced.
|
|
27
|
+
const forceSource = process.env.CLAUDEUP_NO_BINARY === "1";
|
|
22
28
|
const pkgName = `claudeup-${platform}-${arch}`;
|
|
23
29
|
let binaryPath = null;
|
|
24
30
|
try {
|
|
@@ -29,9 +35,16 @@ try {
|
|
|
29
35
|
// optional dep not installed for this platform — fall through
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
if (binaryPath) {
|
|
38
|
+
if (binaryPath && !forceSource) {
|
|
33
39
|
const result = spawnSync(binaryPath, args, { stdio: "inherit" });
|
|
34
|
-
|
|
40
|
+
// A binary that could not be executed AT ALL (ENOENT, EACCES, bad arch)
|
|
41
|
+
// leaves status null, and `status ?? 0` then reported SUCCESS for a run that
|
|
42
|
+
// never happened — the worst answer available. Only trust the status when the
|
|
43
|
+
// process actually ran; otherwise say so and fall through to the source path.
|
|
44
|
+
if (!result.error) process.exit(result.status ?? 0);
|
|
45
|
+
console.error(
|
|
46
|
+
`claudeup: prebuilt binary could not start (${result.error.message}); falling back to source.`,
|
|
47
|
+
);
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
// 2. Fallback: run from source via Bun.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.42.
|
|
3
|
+
"version": "4.42.1",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"claudeup-darwin-arm64": "4.42.
|
|
68
|
-
"claudeup-darwin-x64": "4.42.
|
|
69
|
-
"claudeup-linux-x64": "4.42.
|
|
67
|
+
"claudeup-darwin-arm64": "4.42.1",
|
|
68
|
+
"claudeup-darwin-x64": "4.42.1",
|
|
69
|
+
"claudeup-linux-x64": "4.42.1"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -14,6 +14,16 @@
|
|
|
14
14
|
* only the one matching the host os/cpu, and bin/claudeup.js execs it.
|
|
15
15
|
*
|
|
16
16
|
* Hard constraint: claudeup pins @opentui 0.1.x — 0.4.x breaks --compile.
|
|
17
|
+
*
|
|
18
|
+
* Hard constraint: the two --no-compile-autoload flags below must stay. A Bun
|
|
19
|
+
* standalone executable autoloads .env and bunfig.toml from its CURRENT
|
|
20
|
+
* DIRECTORY by default, and claudeup reads neither on purpose. Bun 1.4.0 dies
|
|
21
|
+
* in the .env path when that file is a symlink pointing at a FIFO — exactly how
|
|
22
|
+
* 1Password serves secrets into a git worktree — exiting 1 with nothing on
|
|
23
|
+
* stdout or stderr, because the crash lands before any JS runs and therefore
|
|
24
|
+
* before any error handler exists. A FIFO also hands its bytes to whoever opens
|
|
25
|
+
* it first, so an autoloading claudeup can swallow the secrets a dev server was
|
|
26
|
+
* waiting for. Dropping the flags re-arms both failures.
|
|
17
27
|
*/
|
|
18
28
|
|
|
19
29
|
import { $ } from "bun";
|
|
@@ -53,7 +63,8 @@ for (const t of TARGETS) {
|
|
|
53
63
|
await mkdir(path.join(outDir, "bin"), { recursive: true });
|
|
54
64
|
|
|
55
65
|
console.log(`Building ${pkgName} (${t.bunTarget})…`);
|
|
56
|
-
|
|
66
|
+
// The --no-compile-autoload flags are load-bearing; see the header.
|
|
67
|
+
await $`bun build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig --target=${t.bunTarget} ${entry} --outfile ${binPath}`;
|
|
57
68
|
|
|
58
69
|
// Platform package: os/cpu-restricted, ships only the binary, declares NO
|
|
59
70
|
// `bin` (the main package's launcher resolves and execs bin/claudeup).
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { loadProjectDotenv, parseDotenv } from "../services/dotenv";
|
|
7
|
+
|
|
8
|
+
function mkfifo(path: string): void {
|
|
9
|
+
const r = spawnSync("mkfifo", [path]);
|
|
10
|
+
if (r.status !== 0) {
|
|
11
|
+
throw new Error(`mkfifo failed: ${r.stderr?.toString() ?? "unknown"}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("loadProjectDotenv", () => {
|
|
16
|
+
let dir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
dir = await mkdtemp(join(tmpdir(), "claudeup-dotenv-"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await rm(dir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("loads a regular .env", async () => {
|
|
27
|
+
await writeFile(join(dir, ".env"), "FOO=bar\nBAZ=qux\n");
|
|
28
|
+
const env: Record<string, string | undefined> = {};
|
|
29
|
+
|
|
30
|
+
const r = loadProjectDotenv(dir, env);
|
|
31
|
+
|
|
32
|
+
expect(env.FOO).toBe("bar");
|
|
33
|
+
expect(env.BAZ).toBe("qux");
|
|
34
|
+
expect(r.loaded).toEqual([".env"]);
|
|
35
|
+
expect(r.warnings).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("never overwrites a variable already in the environment", async () => {
|
|
39
|
+
await writeFile(join(dir, ".env"), "FOO=from-file\n");
|
|
40
|
+
const env: Record<string, string | undefined> = { FOO: "from-shell" };
|
|
41
|
+
|
|
42
|
+
const r = loadProjectDotenv(dir, env);
|
|
43
|
+
|
|
44
|
+
expect(env.FOO).toBe("from-shell");
|
|
45
|
+
expect(r.applied).not.toContain("FOO");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("lets .env.local win over .env", async () => {
|
|
49
|
+
await writeFile(join(dir, ".env"), "FOO=base\nONLY_BASE=yes\n");
|
|
50
|
+
await writeFile(join(dir, ".env.local"), "FOO=override\n");
|
|
51
|
+
const env: Record<string, string | undefined> = {};
|
|
52
|
+
|
|
53
|
+
const r = loadProjectDotenv(dir, env);
|
|
54
|
+
|
|
55
|
+
expect(env.FOO).toBe("override");
|
|
56
|
+
expect(env.ONLY_BASE).toBe("yes");
|
|
57
|
+
expect(r.loaded).toEqual([".env", ".env.local"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("says nothing when there is no .env at all", () => {
|
|
61
|
+
const env: Record<string, string | undefined> = {};
|
|
62
|
+
|
|
63
|
+
const r = loadProjectDotenv(dir, env);
|
|
64
|
+
|
|
65
|
+
expect(r.loaded).toEqual([]);
|
|
66
|
+
expect(r.warnings).toEqual([]);
|
|
67
|
+
expect(env).toEqual({});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// The regression this whole module exists for. claudeup 4.41.0 shipped a
|
|
71
|
+
// binary whose embedded Bun autoloaded .env and aborted on this exact shape:
|
|
72
|
+
// exit 1, nothing on stdout, nothing on stderr, in every git worktree where
|
|
73
|
+
// 1Password serves secrets through a pipe.
|
|
74
|
+
//
|
|
75
|
+
// Note the failure mode if this ever regresses: opening a FIFO with no writer
|
|
76
|
+
// BLOCKS, so a broken loader hangs here rather than failing an assertion.
|
|
77
|
+
// A timeout on this test is the same bug, reported differently.
|
|
78
|
+
it("skips a .env that is a symlink to a FIFO, and warns", async () => {
|
|
79
|
+
mkfifo(join(dir, "secrets-pipe"));
|
|
80
|
+
await symlink(join(dir, "secrets-pipe"), join(dir, ".env"));
|
|
81
|
+
const env: Record<string, string | undefined> = {};
|
|
82
|
+
|
|
83
|
+
const r = loadProjectDotenv(dir, env);
|
|
84
|
+
|
|
85
|
+
expect(r.loaded).toEqual([]);
|
|
86
|
+
expect(env).toEqual({});
|
|
87
|
+
expect(r.warnings).toHaveLength(1);
|
|
88
|
+
expect(r.warnings[0]).toContain(".env");
|
|
89
|
+
expect(r.warnings[0]).toContain("named pipe");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("skips a .env that is a FIFO directly, and warns", () => {
|
|
93
|
+
mkfifo(join(dir, ".env"));
|
|
94
|
+
const env: Record<string, string | undefined> = {};
|
|
95
|
+
|
|
96
|
+
const r = loadProjectDotenv(dir, env);
|
|
97
|
+
|
|
98
|
+
expect(r.loaded).toEqual([]);
|
|
99
|
+
expect(r.warnings).toHaveLength(1);
|
|
100
|
+
expect(r.warnings[0]).toContain("named pipe");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("follows a symlink that points at a regular file", async () => {
|
|
104
|
+
await writeFile(join(dir, "real-env"), "FOO=bar\n");
|
|
105
|
+
await symlink(join(dir, "real-env"), join(dir, ".env"));
|
|
106
|
+
const env: Record<string, string | undefined> = {};
|
|
107
|
+
|
|
108
|
+
const r = loadProjectDotenv(dir, env);
|
|
109
|
+
|
|
110
|
+
expect(env.FOO).toBe("bar");
|
|
111
|
+
expect(r.loaded).toEqual([".env"]);
|
|
112
|
+
expect(r.warnings).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("warns instead of throwing on a dangling symlink", async () => {
|
|
116
|
+
await symlink(join(dir, "nothing-here"), join(dir, ".env"));
|
|
117
|
+
const env: Record<string, string | undefined> = {};
|
|
118
|
+
|
|
119
|
+
const r = loadProjectDotenv(dir, env);
|
|
120
|
+
|
|
121
|
+
expect(r.loaded).toEqual([]);
|
|
122
|
+
expect(r.warnings).toHaveLength(1);
|
|
123
|
+
expect(r.warnings[0]).toContain("does not resolve");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("skips a .env that is a directory, and warns", async () => {
|
|
127
|
+
await mkdir(join(dir, ".env"));
|
|
128
|
+
const env: Record<string, string | undefined> = {};
|
|
129
|
+
|
|
130
|
+
const r = loadProjectDotenv(dir, env);
|
|
131
|
+
|
|
132
|
+
expect(r.loaded).toEqual([]);
|
|
133
|
+
expect(r.warnings[0]).toContain("directory");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("loads what it can from a file with unparseable lines", async () => {
|
|
137
|
+
await writeFile(
|
|
138
|
+
join(dir, ".env"),
|
|
139
|
+
"this is not a pair\nFOO=bar\n=novalue\n",
|
|
140
|
+
);
|
|
141
|
+
const env: Record<string, string | undefined> = {};
|
|
142
|
+
|
|
143
|
+
const r = loadProjectDotenv(dir, env);
|
|
144
|
+
|
|
145
|
+
expect(env.FOO).toBe("bar");
|
|
146
|
+
expect(r.warnings).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("parseDotenv", () => {
|
|
151
|
+
it("ignores comments and blank lines", () => {
|
|
152
|
+
expect(parseDotenv("# note\n\nFOO=bar\n")).toEqual({ FOO: "bar" });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("strips an export prefix", () => {
|
|
156
|
+
expect(parseDotenv("export FOO=bar\n")).toEqual({ FOO: "bar" });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("strips surrounding quotes", () => {
|
|
160
|
+
expect(parseDotenv(`FOO="bar"\nBAZ='qux'\n`)).toEqual({
|
|
161
|
+
FOO: "bar",
|
|
162
|
+
BAZ: "qux",
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("expands escapes in double quotes but not single quotes", () => {
|
|
167
|
+
expect(parseDotenv(`A="one\\ntwo"\nB='one\\ntwo'\n`)).toEqual({
|
|
168
|
+
A: "one\ntwo",
|
|
169
|
+
B: "one\\ntwo",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("drops a trailing comment from an unquoted value", () => {
|
|
174
|
+
expect(parseDotenv("FOO=bar # why\n")).toEqual({ FOO: "bar" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("keeps a # that is part of a quoted value", () => {
|
|
178
|
+
expect(parseDotenv(`FOO="bar # why"\n`)).toEqual({ FOO: "bar # why" });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("keeps an = that appears inside a value", () => {
|
|
182
|
+
expect(parseDotenv("URL=postgres://u:p@h/db?a=b\n")).toEqual({
|
|
183
|
+
URL: "postgres://u:p@h/db?a=b",
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("rejects a key that is not a valid identifier", () => {
|
|
188
|
+
expect(parseDotenv("not-a-key=x\nOK=y\n")).toEqual({ OK: "y" });
|
|
189
|
+
});
|
|
190
|
+
});
|
package/src/main.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { createRoot } from "@opentui/react";
|
|
|
6
6
|
// a dynamic require("../package.json") is not resolvable inside the bunfs root.
|
|
7
7
|
import pkg from "../package.json";
|
|
8
8
|
import { route } from "./cli/router.js";
|
|
9
|
+
import { loadProjectDotenv } from "./services/dotenv.js";
|
|
9
10
|
import { App } from "./ui/App.js";
|
|
10
11
|
import { setThemeMode } from "./ui/theme-mode.js";
|
|
11
12
|
|
|
@@ -17,6 +18,23 @@ export const VERSION = (pkg as { version: string }).version;
|
|
|
17
18
|
async function main(): Promise<void> {
|
|
18
19
|
const args = process.argv.slice(2);
|
|
19
20
|
|
|
21
|
+
// Load the project's .env ourselves. Bun's standalone autoload is compiled OFF
|
|
22
|
+
// (scripts/build-binaries.ts) because it killed claudeup outright wherever
|
|
23
|
+
// .env was a symlink to a FIFO — the shape 1Password uses — exiting 1 with no
|
|
24
|
+
// output at all. services/dotenv.ts does the same job as an ordinary guarded
|
|
25
|
+
// read: regular files only, and every failure reported rather than fatal.
|
|
26
|
+
try {
|
|
27
|
+
for (const warning of loadProjectDotenv().warnings) {
|
|
28
|
+
console.warn(`claudeup: ${warning}`);
|
|
29
|
+
}
|
|
30
|
+
} catch (error) {
|
|
31
|
+
// Belt and braces. loadProjectDotenv is total, but no future edit to it may
|
|
32
|
+
// ever be allowed to stop claudeup from starting.
|
|
33
|
+
console.warn(
|
|
34
|
+
`claudeup: .env could not be loaded: ${error instanceof Error ? error.message : String(error)}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
// Dispatch non-interactive subcommands (claude, update, install, …) and
|
|
21
39
|
// top-level flags (--version/--help). A bare invocation falls through to
|
|
22
40
|
// the interactive TUI below.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Load the project's .env into the process environment — deliberately, in our
|
|
7
|
+
* own code, with every failure downgraded to a warning.
|
|
8
|
+
*
|
|
9
|
+
* Bun's standalone-executable autoload used to do this for free. It is compiled
|
|
10
|
+
* OFF now (see scripts/build-binaries.ts): Bun 1.4.0 aborts inside that autoload
|
|
11
|
+
* when .env is a symlink pointing at a FIFO — exactly how 1Password serves
|
|
12
|
+
* secrets into a git worktree — exiting 1 with nothing on stdout or stderr,
|
|
13
|
+
* before any JavaScript runs and therefore before any error handler exists.
|
|
14
|
+
* claudeup simply vanished in every such directory.
|
|
15
|
+
*
|
|
16
|
+
* Two rules this loader keeps that the autoload did not:
|
|
17
|
+
*
|
|
18
|
+
* 1. Only regular files are read. A FIFO, socket or device is skipped, never
|
|
19
|
+
* opened. That dodges the crash, and it is right on its own terms: a FIFO
|
|
20
|
+
* hands its bytes to whoever opens it FIRST, so reading one would swallow
|
|
21
|
+
* the secrets a dev server or `op run` was waiting for.
|
|
22
|
+
* 2. Nothing here is fatal. A missing, unreadable or malformed .env produces a
|
|
23
|
+
* warning and claudeup carries on. Managing ~/.claude must never depend on
|
|
24
|
+
* the state of whatever directory you happen to be standing in.
|
|
25
|
+
*
|
|
26
|
+
* A real environment variable always beats a file, matching dotenv convention;
|
|
27
|
+
* a later file beats an earlier one.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Files loaded, in order. A later file overrides an earlier one. */
|
|
31
|
+
const ENV_FILES = [".env", ".env.local"] as const;
|
|
32
|
+
|
|
33
|
+
export interface DotenvOutcome {
|
|
34
|
+
/** Files actually parsed, in the order applied. */
|
|
35
|
+
loaded: string[];
|
|
36
|
+
/** Names newly set. A pre-existing variable is never overwritten. */
|
|
37
|
+
applied: string[];
|
|
38
|
+
/** One line per file skipped or failed. Print these; never throw them. */
|
|
39
|
+
warnings: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse dotenv text. Best-effort by design: an unparseable line is skipped
|
|
44
|
+
* rather than raised, because a broken .env must not be able to stop claudeup.
|
|
45
|
+
* Values spanning several lines are not supported.
|
|
46
|
+
*/
|
|
47
|
+
export function parseDotenv(text: string): Record<string, string> {
|
|
48
|
+
const out: Record<string, string> = {};
|
|
49
|
+
|
|
50
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
51
|
+
const line = rawLine.trim();
|
|
52
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
53
|
+
|
|
54
|
+
const eq = line.indexOf("=");
|
|
55
|
+
if (eq <= 0) continue;
|
|
56
|
+
|
|
57
|
+
let key = line.slice(0, eq).trim();
|
|
58
|
+
if (key.startsWith("export ")) key = key.slice("export ".length).trim();
|
|
59
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
60
|
+
|
|
61
|
+
let value = line.slice(eq + 1).trim();
|
|
62
|
+
const quote = value[0];
|
|
63
|
+
const quoted =
|
|
64
|
+
(quote === '"' || quote === "'") &&
|
|
65
|
+
value.length > 1 &&
|
|
66
|
+
value.endsWith(quote);
|
|
67
|
+
|
|
68
|
+
if (quoted) {
|
|
69
|
+
value = value.slice(1, -1);
|
|
70
|
+
// Escapes are a double-quote feature; single quotes stay literal.
|
|
71
|
+
if (quote === '"') {
|
|
72
|
+
value = value
|
|
73
|
+
.replace(/\\n/g, "\n")
|
|
74
|
+
.replace(/\\r/g, "\r")
|
|
75
|
+
.replace(/\\t/g, "\t");
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
// An unquoted value ends at the first " #" comment.
|
|
79
|
+
const hash = value.indexOf(" #");
|
|
80
|
+
if (hash !== -1) value = value.slice(0, hash).trimEnd();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
out[key] = value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What a non-regular entry actually is, for a warning a human can act on. */
|
|
90
|
+
function describeKind(info: Stats): string {
|
|
91
|
+
if (info.isFIFO()) {
|
|
92
|
+
return "it is a named pipe; reading it would consume another process's secrets";
|
|
93
|
+
}
|
|
94
|
+
if (info.isSocket()) return "it is a socket, not a regular file";
|
|
95
|
+
if (info.isDirectory()) return "it is a directory, not a regular file";
|
|
96
|
+
if (info.isBlockDevice() || info.isCharacterDevice()) {
|
|
97
|
+
return "it is a device, not a regular file";
|
|
98
|
+
}
|
|
99
|
+
return "it is not a regular file";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function reason(error: unknown): string {
|
|
103
|
+
return error instanceof Error ? error.message : String(error);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read .env and .env.local from `cwd` into `env`. Never throws.
|
|
108
|
+
*
|
|
109
|
+
* Returns what was loaded and, in `warnings`, every reason a file was skipped or
|
|
110
|
+
* failed — the caller decides how to surface them.
|
|
111
|
+
*/
|
|
112
|
+
export function loadProjectDotenv(
|
|
113
|
+
cwd: string = process.cwd(),
|
|
114
|
+
env: Record<string, string | undefined> = process.env,
|
|
115
|
+
): DotenvOutcome {
|
|
116
|
+
const outcome: DotenvOutcome = { loaded: [], applied: [], warnings: [] };
|
|
117
|
+
|
|
118
|
+
// Merge every file BEFORE touching `env`. Applying file by file would make
|
|
119
|
+
// the "never overwrite" rule below fire against .env's own values, so .env
|
|
120
|
+
// would silently beat .env.local — the opposite of the intended precedence.
|
|
121
|
+
const merged: Record<string, string> = {};
|
|
122
|
+
|
|
123
|
+
for (const name of ENV_FILES) {
|
|
124
|
+
const file = path.join(cwd, name);
|
|
125
|
+
|
|
126
|
+
// lstat first, so a symlink is recognised as one: the warning should name
|
|
127
|
+
// the file the user sees, while the decision is made about its target.
|
|
128
|
+
let info: Stats;
|
|
129
|
+
try {
|
|
130
|
+
info = lstatSync(file);
|
|
131
|
+
} catch {
|
|
132
|
+
continue; // Absent. That is the ordinary case, not a problem.
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (info.isSymbolicLink()) {
|
|
136
|
+
try {
|
|
137
|
+
info = statSync(file);
|
|
138
|
+
} catch {
|
|
139
|
+
outcome.warnings.push(`skipped ${name} — the symlink does not resolve`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!info.isFile()) {
|
|
145
|
+
outcome.warnings.push(`skipped ${name} — ${describeKind(info)}`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let text: string;
|
|
150
|
+
try {
|
|
151
|
+
text = readFileSync(file, "utf8");
|
|
152
|
+
} catch (error) {
|
|
153
|
+
outcome.warnings.push(`could not read ${name} — ${reason(error)}`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let parsed: Record<string, string>;
|
|
158
|
+
try {
|
|
159
|
+
parsed = parseDotenv(text);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
outcome.warnings.push(`could not parse ${name} — ${reason(error)}`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
outcome.loaded.push(name);
|
|
166
|
+
Object.assign(merged, parsed); // A later file beats an earlier one.
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
170
|
+
if (env[key] !== undefined) continue; // A real variable always wins.
|
|
171
|
+
env[key] = value;
|
|
172
|
+
outcome.applied.push(key);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return outcome;
|
|
176
|
+
}
|