claudeup 4.42.1 → 5.0.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/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.42.1",
3
+ "version": "5.0.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.1",
68
- "claudeup-darwin-x64": "4.42.1",
69
- "claudeup-linux-x64": "4.42.1"
67
+ "claudeup-darwin-arm64": "5.0.1",
68
+ "claudeup-darwin-x64": "5.0.1",
69
+ "claudeup-linux-x64": "5.0.1"
70
70
  }
71
71
  }
@@ -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
+ });
@@ -87,6 +87,11 @@ describe("loadProjectDotenv", () => {
87
87
  expect(r.warnings).toHaveLength(1);
88
88
  expect(r.warnings[0]).toContain(".env");
89
89
  expect(r.warnings[0]).toContain("named pipe");
90
+ // The warning must give the real reason — the open blocks with no writer.
91
+ // It must NOT claim reading would steal another process's secrets: a
92
+ // 1Password pipe re-serves on every open, so that rationale is false.
93
+ expect(r.warnings[0]).toContain("blocks");
94
+ expect(r.warnings[0]).not.toContain("consume");
90
95
  });
91
96
 
92
97
  it("skips a .env that is a FIFO directly, and warns", () => {
@@ -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[] = [
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Regression tests for the `claudeup update` APPLY path.
3
+ *
4
+ * Every defect a multi-model review found in this feature lived here, in the
5
+ * half that had no coverage, while the pure planner beside it had forty tests.
6
+ * These lock the apply path's contract so that gap cannot silently reopen.
7
+ */
8
+
9
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
10
+ import { type PluginApplyDeps, applyPlugins } from "../cli/update.js";
11
+ import type { PluginScope } from "../services/claude-cli.js";
12
+ import type { PluginUpdateItem } from "../services/update-plan.js";
13
+
14
+ const PROJECT = "/tmp/project";
15
+
16
+ /** Records every call, and lets a test dictate what the registry reads back. */
17
+ function stubDeps(
18
+ installedAfter: string | null | ((scope: PluginScope) => string | null),
19
+ ) {
20
+ const saved: Array<{ id: string; version: string; scope: PluginScope }> = [];
21
+ const updated: Array<{ id: string; scope: PluginScope }> = [];
22
+ const repaired: Array<{ id: string; scope: PluginScope }> = [];
23
+
24
+ const deps: PluginApplyDeps = {
25
+ update: mock(async (id: string, scope: PluginScope) => {
26
+ updated.push({ id, scope });
27
+ }),
28
+ repair: mock(async (id: string, scope: PluginScope) => {
29
+ repaired.push({ id, scope });
30
+ }),
31
+ readInstalled: mock(async (_id: string, scope: PluginScope) =>
32
+ typeof installedAfter === "function"
33
+ ? installedAfter(scope)
34
+ : installedAfter,
35
+ ),
36
+ saveInstalled: mock(
37
+ async (id: string, version: string, scope: PluginScope) => {
38
+ saved.push({ id, version, scope });
39
+ },
40
+ ),
41
+ };
42
+ return { deps, saved, updated, repaired };
43
+ }
44
+
45
+ function item(over: Partial<PluginUpdateItem>): PluginUpdateItem {
46
+ return {
47
+ pluginId: "dev@magus",
48
+ pinned: "latest",
49
+ installed: "1.0.0",
50
+ available: "2.0.0",
51
+ target: "2.0.0",
52
+ action: "update",
53
+ scopes: ["project"],
54
+ ...over,
55
+ };
56
+ }
57
+
58
+ let logs: string[];
59
+ beforeEach(() => {
60
+ logs = [];
61
+ // biome-ignore lint/suspicious/noExplicitAny: test double
62
+ console.log = ((...a: unknown[]) => logs.push(a.join(" "))) as any;
63
+ // biome-ignore lint/suspicious/noExplicitAny: test double
64
+ console.warn = ((...a: unknown[]) => logs.push(a.join(" "))) as any;
65
+ });
66
+
67
+ describe("what gets RECORDED is what got installed", () => {
68
+ test("records the version read back, not the version we asked for", async () => {
69
+ // `claude plugin install` accepts no version — it installs the newest.
70
+ // Recording `target` instead of the read-back made the registry claim a
71
+ // version that was never on disk.
72
+ const { deps, saved } = stubDeps("9.9.9");
73
+ await applyPlugins([item({ target: "2.0.0" })], PROJECT, deps);
74
+ expect(saved).toEqual([
75
+ { id: "dev@magus", version: "9.9.9", scope: "project" },
76
+ ]);
77
+ });
78
+
79
+ test("an exact pin the installer could not honour is reported, not hidden", async () => {
80
+ const { deps, saved } = stubDeps("9.9.9");
81
+ const result = await applyPlugins(
82
+ [item({ pinned: "4.6.1", target: "4.6.1" })],
83
+ PROJECT,
84
+ deps,
85
+ );
86
+ // The pin is NOT written as if satisfied...
87
+ expect(saved).toEqual([
88
+ { id: "dev@magus", version: "9.9.9", scope: "project" },
89
+ ]);
90
+ // ...and the shortfall is surfaced to the caller.
91
+ expect(result.mismatches).toEqual([
92
+ {
93
+ pluginId: "dev@magus",
94
+ pinned: "4.6.1",
95
+ actual: "9.9.9",
96
+ scope: "project",
97
+ },
98
+ ]);
99
+ });
100
+
101
+ test("a satisfied exact pin produces no mismatch", async () => {
102
+ const { deps } = stubDeps("4.6.1");
103
+ const result = await applyPlugins(
104
+ [item({ pinned: "4.6.1", target: "4.6.1" })],
105
+ PROJECT,
106
+ deps,
107
+ );
108
+ expect(result.mismatches).toEqual([]);
109
+ });
110
+
111
+ test("a 'latest' pin never counts as a mismatch", async () => {
112
+ const { deps } = stubDeps("9.9.9");
113
+ const result = await applyPlugins(
114
+ [item({ pinned: "latest", target: "2.0.0" })],
115
+ PROJECT,
116
+ deps,
117
+ );
118
+ expect(result.mismatches).toEqual([]);
119
+ });
120
+
121
+ test("nothing is recorded when the registry has no version to read back", async () => {
122
+ // Writing a guess here would be the same fabrication in another costume.
123
+ const { deps, saved } = stubDeps(null);
124
+ await applyPlugins([item({})], PROJECT, deps);
125
+ expect(saved).toEqual([]);
126
+ });
127
+ });
128
+
129
+ describe("scope handling", () => {
130
+ test("a fresh install goes to project scope only", async () => {
131
+ const { deps, updated } = stubDeps("2.0.0");
132
+ await applyPlugins(
133
+ [item({ action: "install", installed: null, scopes: [] })],
134
+ PROJECT,
135
+ deps,
136
+ );
137
+ expect(updated).toEqual([{ id: "dev@magus", scope: "project" }]);
138
+ });
139
+
140
+ test("every outdated scope is updated AND recorded separately", async () => {
141
+ const { deps, updated, saved } = stubDeps((scope) =>
142
+ scope === "user" ? "2.0.0" : "1.5.0",
143
+ );
144
+ await applyPlugins([item({ scopes: ["user", "project"] })], PROJECT, deps);
145
+ expect(updated).toEqual([
146
+ { id: "dev@magus", scope: "user" },
147
+ { id: "dev@magus", scope: "project" },
148
+ ]);
149
+ // Each scope records ITS OWN read-back, never one shared value.
150
+ expect(saved).toEqual([
151
+ { id: "dev@magus", version: "2.0.0", scope: "user" },
152
+ { id: "dev@magus", version: "1.5.0", scope: "project" },
153
+ ]);
154
+ });
155
+
156
+ test("a repair uses repairPlugin, not a plain install", async () => {
157
+ // A content-stale plugin has no version bump, so `install` is a no-op that
158
+ // reports success — only uninstall+install refreshes the files.
159
+ const { deps, repaired, updated } = stubDeps("1.0.0");
160
+ await applyPlugins([item({ action: "repair" })], PROJECT, deps);
161
+ expect(repaired).toEqual([{ id: "dev@magus", scope: "project" }]);
162
+ expect(updated).toEqual([]);
163
+ });
164
+ });
165
+
166
+ describe("what is skipped", () => {
167
+ test("'current' and 'unknown' items are never touched", async () => {
168
+ const { deps, updated, saved } = stubDeps("1.0.0");
169
+ await applyPlugins(
170
+ [
171
+ item({ action: "current", scopes: [] }),
172
+ item({ pluginId: "b@magus", action: "unknown", scopes: [] }),
173
+ ],
174
+ PROJECT,
175
+ deps,
176
+ );
177
+ expect(updated).toEqual([]);
178
+ expect(saved).toEqual([]);
179
+ });
180
+
181
+ test("an 'unknown' plugin is not installed on a guess", async () => {
182
+ // It is unknown because the catalog could not be reached. Acting on that
183
+ // would be picking a version at random.
184
+ const { deps, updated } = stubDeps("1.0.0");
185
+ await applyPlugins(
186
+ [item({ action: "unknown", target: null, scopes: ["project"] })],
187
+ PROJECT,
188
+ deps,
189
+ );
190
+ expect(updated).toEqual([]);
191
+ });
192
+ });
193
+
194
+ describe("failure handling", () => {
195
+ test("a failing plugin is reported, and the rest still run", async () => {
196
+ const { deps, updated } = stubDeps("2.0.0");
197
+ deps.update = mock(async (id: string, scope: PluginScope) => {
198
+ if (id === "bad@magus") throw new Error("network down");
199
+ updated.push({ id, scope });
200
+ });
201
+
202
+ const result = await applyPlugins(
203
+ [item({ pluginId: "bad@magus" }), item({ pluginId: "good@magus" })],
204
+ PROJECT,
205
+ deps,
206
+ );
207
+
208
+ expect(result.failed).toEqual(["bad@magus"]);
209
+ expect(result.ok).toBe(1);
210
+ expect(updated).toEqual([{ id: "good@magus", scope: "project" }]);
211
+ });
212
+
213
+ test("a scope that threw records nothing for that plugin", async () => {
214
+ // Half-applied state must not be recorded as if it succeeded.
215
+ const { deps, saved } = stubDeps("2.0.0");
216
+ deps.update = mock(async () => {
217
+ throw new Error("boom");
218
+ });
219
+ const result = await applyPlugins([item({})], PROJECT, deps);
220
+ expect(saved).toEqual([]);
221
+ expect(result.ok).toBe(0);
222
+ });
223
+ });