claudeup 4.42.0 → 5.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/README.md CHANGED
@@ -13,9 +13,13 @@ npm install -g claudeup # also works
13
13
  Updates go through claudeup itself, not the package manager:
14
14
 
15
15
  ```bash
16
- claudeup update
16
+ claudeup upgrade
17
17
  ```
18
18
 
19
+ `upgrade` advances **claudeup**. `update` advances **your dependencies** — the
20
+ plugins, CLI tools and skills your profile declares. Same split as every package
21
+ manager, so the two are never confused.
22
+
19
23
  You get a self-contained binary for your platform (an `optionalDependencies`
20
24
  package gated on `os`/`cpu`). On a platform without a prebuilt binary the
21
25
  launcher falls back to running from source under Bun, so the install still works.
@@ -35,23 +39,89 @@ Good for making a teammate's machine match yours. This is the
35
39
  claudeup Open the interactive TUI
36
40
 
37
41
  install [profile] Install a profile's plugins, binaries, skills and
38
- env, then activate it. No argument = every profile.
42
+ env at the manifest's PINNED versions, then activate
43
+ it. No argument = every profile.
39
44
  --check Report drift only, write nothing (CI gate)
40
45
  --yes, -y Skip the confirmation prompt
41
46
  --force Discard unsynced local edits instead of refusing
47
+ update [profile] Bring the ACTIVE profile up to date: install what it
48
+ declares but this machine lacks, and advance every
49
+ "latest" pin to the newest published version.
50
+ --check Report only, write nothing (exits 1 if anything is
51
+ missing or known to be behind)
52
+ --yes, -y Skip the confirmation prompt
42
53
  profile list Show every profile; ● marks the active one
43
54
  profile show <name> Print a profile's fully resolved closure
44
55
  profile switch <name> Repoint the active profile — offline, no reinstall
56
+ profile init Write .claude/profiles.json from this project's setup
45
57
  profile sync Promote local edits back into .claude/profiles.json
46
58
  doctor [--fix] Check binary deps, profile symlinks, conventions
47
59
 
48
60
  claude [args...] Check for plugin updates (1h cache), then run claude
49
61
  --force, -f Force the update check
50
- update Update claudeup itself
62
+ upgrade Update claudeup itself
51
63
  --version, -v Version + update check
52
64
  --help, -h This list
53
65
  ```
54
66
 
67
+ ### install vs update
68
+
69
+ They differ in one thing: whether the pins are allowed to move.
70
+
71
+ | | `install` | `update` |
72
+ |---|---|---|
73
+ | Versions | exactly what the manifest pins | advances every `"latest"` pin |
74
+ | Scope | every profile (or the one named) | the **active** profile |
75
+ | Also does | materializes + activates the profile | nothing to the manifest |
76
+
77
+ Both install whatever the profile declares and this machine lacks, so `update`
78
+ covers the "I ticked it but never installed it" case on its own.
79
+
80
+ **This is dependency management, not a package manager.** There is no resolver,
81
+ no lockfile and no version negotiation. The job is: everything the profile
82
+ declares is installed, and current.
83
+
84
+ That matters for exact pins. `claude plugin install` takes no version argument —
85
+ it installs whatever the marketplace publishes now — so a pin naming anything
86
+ else cannot be satisfied. `update` plans against the version it can actually
87
+ get, notes the shortfall, and moves on:
88
+
89
+ ```
90
+ dev@magus = 9.9.9 (manifest pins 4.6.1; the marketplace offers only 9.9.9)
91
+ ```
92
+
93
+ It records what actually landed, never the pin, and it does **not** fail over
94
+ the difference — an unsatisfiable pin is a standing property of the manifest,
95
+ not something a given run did wrong. Pin drift is gated where it always was:
96
+ `claudeup install --check` with `strictVersions` in the manifest.
97
+
98
+ **`update` only ever moves forward.** Nothing here can downgrade — the installer
99
+ takes no version — so an install that is already ahead of what the marketplace
100
+ publishes is left alone rather than dragged back. A plan that tried would emit a
101
+ step that runs, reports success, changes nothing, and reappears on every
102
+ subsequent run.
103
+
104
+ `--check` fails on things that are **missing or known to be behind**. It
105
+ deliberately does not fail on the two items that get re-fetched every run
106
+ regardless: an already-installed skill (a manifest skill ref carries no content
107
+ hash, so there is nothing to compare) and a present unpinned binary (no version
108
+ probe exists, only present/absent). A gate that can never pass gets switched
109
+ off, so it only fires on real drift.
110
+
111
+ Neither command needs a manifest to exist first. In a repo without
112
+ `.claude/profiles.json`, both offer to write one describing the project as it
113
+ stands — that is `claudeup profile init`, which you can also run on its own.
114
+ Adoption reads the project's own `.claude/settings.json`; if that enables
115
+ nothing it falls back to your user-scope settings and says so, because the file
116
+ is meant to be committed and one machine's global set is not a team contract.
117
+ Machine-only keys are stripped, and any settings value naming a path under your
118
+ home directory is flagged for review.
119
+
120
+ `--yes` alone will **not** create a manifest. It means "don't ask me about the
121
+ command I ran", and someone running `claudeup update --yes` in CI did not ask to
122
+ author their team's committed contract. Creating it needs a human at the prompt,
123
+ or `claudeup profile init`.
124
+
55
125
  `claudeup doctor` exits non-zero when it finds a problem, so it works as a CI
56
126
  check. So does `claudeup install --check`.
57
127
 
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": "5.0.0",
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": "5.0.0",
68
+ "claudeup-darwin-x64": "5.0.0",
69
+ "claudeup-linux-x64": "5.0.0"
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).
@@ -63,3 +63,49 @@ describe("route() dispatch", () => {
63
63
  expect(typeof mod.route).toBe("function");
64
64
  });
65
65
  });
66
+
67
+ describe("update / upgrade split", () => {
68
+ // `update` used to self-update claudeup. It now means what it means in every
69
+ // package manager — bring THIS PROJECT's dependencies up to date — and
70
+ // self-update moved to `upgrade`. Both halves are asserted here because a
71
+ // silent regression either way sends the user to the wrong command.
72
+
73
+ test("`upgrade` is the self-update command", async () => {
74
+ const mod = await import("../cli/upgrade.js");
75
+ expect(typeof mod.runUpgradeCommand).toBe("function");
76
+ });
77
+
78
+ test("`update` is the dependency command and takes args", async () => {
79
+ const mod = await import("../cli/update.js");
80
+ expect(typeof mod.runUpdateCommand).toBe("function");
81
+ // It takes `args`. The old self-update took none, and `Function.length`
82
+ // stops counting at the first defaulted parameter — so `projectPath`
83
+ // does not show up here, but a regression to the no-arg form would.
84
+ expect(mod.runUpdateCommand.length).toBe(1);
85
+ expect(mod.runUpdateCommand.length).toBeGreaterThan(
86
+ (await import("../cli/upgrade.js")).runUpgradeCommand.length,
87
+ );
88
+ });
89
+
90
+ test("--version points at `claudeup upgrade`, not `claudeup update`", async () => {
91
+ await route(["--version"], "1.0.0");
92
+ const out = logs.join("\n");
93
+ // Only assert when an update was actually offered; the check hits the
94
+ // network and may report nothing.
95
+ if (out.includes("Update available")) {
96
+ expect(out).toContain("claudeup upgrade");
97
+ expect(out).not.toContain("Run: claudeup update");
98
+ }
99
+ });
100
+
101
+ test("help documents both, and describes update as the dependency command", async () => {
102
+ await route(["--help"], "1.0.0");
103
+ const help = logs.join("\n");
104
+ expect(help).toContain("update [profile]");
105
+ expect(help).toContain("upgrade");
106
+ expect(help).toContain("Update claudeup ITSELF");
107
+ expect(help).toContain("profile init");
108
+ // The old wording sent people to `update` for a self-update.
109
+ expect(help).not.toContain("update Update claudeup itself");
110
+ });
111
+ });
@@ -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
+ });
@@ -0,0 +1,177 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildAdoptedProfile,
4
+ findMachineSpecificPaths,
5
+ } from "../services/profile-adopt.js";
6
+
7
+ const NOW = "2026-08-27T00:00:00.000Z";
8
+
9
+ describe("buildAdoptedProfile", () => {
10
+ test("adopts enabled plugins as 'latest', never as the installed version", () => {
11
+ // Pinning whatever a machine drifted to would turn one developer's
12
+ // accident into the team's contract.
13
+ const entry = buildAdoptedProfile({
14
+ enabledPlugins: { "dev@magus": true, "mnemex@magus": true },
15
+ settings: {},
16
+ mcpServers: {},
17
+ now: NOW,
18
+ });
19
+ expect(entry.plugins).toEqual({
20
+ "dev@magus": "latest",
21
+ "mnemex@magus": "latest",
22
+ });
23
+ });
24
+
25
+ test("skips disabled plugins", () => {
26
+ const entry = buildAdoptedProfile({
27
+ enabledPlugins: { "dev@magus": true, "gtd@magus": false },
28
+ settings: {},
29
+ mcpServers: {},
30
+ now: NOW,
31
+ });
32
+ expect(Object.keys(entry.plugins ?? {})).toEqual(["dev@magus"]);
33
+ });
34
+
35
+ test("skips an id with no marketplace — it cannot be reinstalled elsewhere", () => {
36
+ const entry = buildAdoptedProfile({
37
+ enabledPlugins: { "dev@magus": true, "some-local-thing": true },
38
+ settings: {},
39
+ mcpServers: {},
40
+ now: NOW,
41
+ });
42
+ expect(Object.keys(entry.plugins ?? {})).toEqual(["dev@magus"]);
43
+ });
44
+
45
+ test("derives the marketplace each plugin id names", () => {
46
+ const entry = buildAdoptedProfile({
47
+ enabledPlugins: { "dev@magus": true },
48
+ settings: {},
49
+ mcpServers: {},
50
+ now: NOW,
51
+ });
52
+ expect(entry.marketplaces?.magus).toEqual({
53
+ source: "github",
54
+ repo: "MadAppGang/magus",
55
+ });
56
+ });
57
+
58
+ test("drops enabledPlugins from settings — the plugins map expresses it", () => {
59
+ // Carrying both would let them disagree; materialization regenerates
60
+ // enabledPlugins from the plugins map.
61
+ const entry = buildAdoptedProfile({
62
+ enabledPlugins: { "dev@magus": true },
63
+ settings: {
64
+ enabledPlugins: { "dev@magus": true },
65
+ enableAllProjectMcpServers: true,
66
+ },
67
+ mcpServers: {},
68
+ now: NOW,
69
+ });
70
+ expect(entry.settings).toEqual({ enableAllProjectMcpServers: true });
71
+ });
72
+
73
+ test("carries MCP servers over verbatim", () => {
74
+ const servers = {
75
+ "chrome-devtools": { command: "npx", args: ["-y", "x"] },
76
+ };
77
+ const entry = buildAdoptedProfile({
78
+ enabledPlugins: { "dev@magus": true },
79
+ settings: {},
80
+ mcpServers: servers,
81
+ now: NOW,
82
+ });
83
+ expect(entry.mcpServers).toEqual(servers);
84
+ });
85
+
86
+ test("omits empty settings and mcpServers rather than writing empty objects", () => {
87
+ const entry = buildAdoptedProfile({
88
+ enabledPlugins: { "dev@magus": true },
89
+ settings: {},
90
+ mcpServers: {},
91
+ now: NOW,
92
+ });
93
+ expect(entry.settings).toBeUndefined();
94
+ expect(entry.mcpServers).toBeUndefined();
95
+ });
96
+
97
+ test("strips installedPluginVersions — that is machine state, not a contract", () => {
98
+ // Committing it publishes one developer's install state as the team's
99
+ // contract, and it is stale the moment anyone updates.
100
+ const entry = buildAdoptedProfile({
101
+ enabledPlugins: { "dev@magus": true },
102
+ settings: {
103
+ installedPluginVersions: { "dev@magus": "4.6.1" },
104
+ enableAllProjectMcpServers: true,
105
+ },
106
+ mcpServers: {},
107
+ now: NOW,
108
+ });
109
+ expect(entry.settings).toEqual({ enableAllProjectMcpServers: true });
110
+ });
111
+
112
+ test("omits settings entirely when only machine keys were present", () => {
113
+ const entry = buildAdoptedProfile({
114
+ enabledPlugins: { "dev@magus": true },
115
+ settings: {
116
+ enabledPlugins: { "dev@magus": true },
117
+ installedPluginVersions: { "dev@magus": "4.6.1" },
118
+ },
119
+ mcpServers: {},
120
+ now: NOW,
121
+ });
122
+ expect(entry.settings).toBeUndefined();
123
+ });
124
+
125
+ test("does not mutate the caller's settings object", () => {
126
+ const settings = {
127
+ installedPluginVersions: { "dev@magus": "4.6.1" },
128
+ other: 1,
129
+ };
130
+ buildAdoptedProfile({
131
+ enabledPlugins: { "dev@magus": true },
132
+ settings,
133
+ mcpServers: {},
134
+ now: NOW,
135
+ });
136
+ expect(settings.installedPluginVersions).toEqual({ "dev@magus": "4.6.1" });
137
+ });
138
+
139
+ test("is deterministic when the clock is injected", () => {
140
+ const input = {
141
+ enabledPlugins: { "dev@magus": true },
142
+ settings: {},
143
+ mcpServers: {},
144
+ now: NOW,
145
+ };
146
+ expect(buildAdoptedProfile(input)).toEqual(buildAdoptedProfile(input));
147
+ expect(buildAdoptedProfile(input).createdAt).toBe(NOW);
148
+ });
149
+ });
150
+
151
+ describe("findMachineSpecificPaths", () => {
152
+ const HOME = "/Users/someone";
153
+
154
+ test("flags a home-directory path nested in an array", () => {
155
+ // Not stripped — an additionalDirectories list is often genuinely wanted —
156
+ // but surfaced, because it will not resolve on a teammate's machine.
157
+ expect(
158
+ findMachineSpecificPaths(
159
+ { permissions: { additionalDirectories: ["../sibling", `${HOME}/x`] } },
160
+ HOME,
161
+ ),
162
+ ).toEqual([`permissions.additionalDirectories[1] = ${HOME}/x`]);
163
+ });
164
+
165
+ test("relative paths are not flagged", () => {
166
+ expect(
167
+ findMachineSpecificPaths(
168
+ { permissions: { additionalDirectories: ["../magus", "./local"] } },
169
+ HOME,
170
+ ),
171
+ ).toEqual([]);
172
+ });
173
+
174
+ test("returns nothing rather than guessing when HOME is unset", () => {
175
+ expect(findMachineSpecificPaths({ a: `${HOME}/x` }, "")).toEqual([]);
176
+ });
177
+ });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  binInstallCommand,
4
+ binUpgradeCommand,
4
5
  detectToolchains,
5
6
  } from "../services/toolchain.js";
6
7
  import type { ResolvedBin } from "../types/index.js";
@@ -31,6 +32,36 @@ describe("binInstallCommand", () => {
31
32
  });
32
33
  });
33
34
 
35
+ describe("binUpgradeCommand", () => {
36
+ test("brew and pip do NOT reuse their install command", () => {
37
+ // This is the whole reason the function exists: `brew install` on an
38
+ // installed formula prints "already installed" and changes nothing, and
39
+ // `pip install` without --upgrade does the same. Reusing them would let
40
+ // `claudeup update` report success while advancing nothing.
41
+ expect(binUpgradeCommand({ name: "tmux", via: "brew", formula: "tmux" })).toBe(
42
+ "brew upgrade tmux",
43
+ );
44
+ expect(binUpgradeCommand({ name: "aider", via: "pip", package: "aider" })).toBe(
45
+ "pip install --upgrade aider",
46
+ );
47
+ });
48
+
49
+ test("bun and npm state @latest explicitly, so the printed command reads true", () => {
50
+ expect(binUpgradeCommand({ name: "claudish", via: "bun" })).toBe(
51
+ "bun install -g claudish@latest",
52
+ );
53
+ expect(binUpgradeCommand({ name: "x", via: "npm", package: "x" })).toBe(
54
+ "npm install -g x@latest",
55
+ );
56
+ });
57
+
58
+ test("go installs the module at latest", () => {
59
+ expect(
60
+ binUpgradeCommand({ name: "t", via: "go", module: "github.com/x/t" }),
61
+ ).toBe("go install github.com/x/t@latest");
62
+ });
63
+ });
64
+
34
65
  describe("detectToolchains", () => {
35
66
  test("groups bins by installer and reports requiredBy + presence", async () => {
36
67
  const bins: ResolvedBin[] = [