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 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
- process.exit(result.status ?? 0);
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.0",
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.0",
68
- "claudeup-darwin-x64": "4.42.0",
69
- "claudeup-linux-x64": "4.42.0"
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
- await $`bun build --compile --target=${t.bunTarget} ${entry} --outfile ${binPath}`;
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
+ }