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 +73 -3
- package/bin/claudeup.js +16 -3
- package/package.json +4 -4
- package/scripts/build-binaries.ts +12 -1
- package/src/__tests__/cli-router.test.ts +46 -0
- package/src/__tests__/dotenv.test.ts +190 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/toolchain.test.ts +31 -0
- package/src/__tests__/update-apply.test.ts +223 -0
- package/src/__tests__/update-plan.test.ts +631 -0
- package/src/cli/bootstrap.ts +140 -0
- package/src/cli/install.ts +65 -83
- package/src/cli/profile.ts +41 -9
- package/src/cli/prompt.ts +42 -0
- package/src/cli/router.ts +23 -9
- package/src/cli/update.ts +459 -71
- package/src/cli/upgrade.ts +89 -0
- package/src/main.tsx +18 -0
- package/src/prerunner/index.ts +1 -22
- package/src/services/dotenv.ts +176 -0
- package/src/services/marketplace-sync.ts +37 -6
- package/src/services/plugin-manager.ts +50 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/resolver.ts +1 -1
- package/src/services/toolchain.ts +35 -0
- package/src/services/update-plan.ts +385 -0
- package/src/ui/App.tsx +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
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": "
|
|
68
|
-
"claudeup-darwin-x64": "
|
|
69
|
-
"claudeup-linux-x64": "
|
|
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
|
-
|
|
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[] = [
|