claudeup 4.30.0 → 4.32.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 +98 -92
- package/package.json +4 -4
- package/src/__tests__/content-drift.test.ts +152 -0
- package/src/__tests__/dual-write-prevention.test.ts +151 -8
- package/src/__tests__/marketplace-refresh.test.ts +7 -2
- package/src/__tests__/moved-marketplace.test.ts +87 -0
- package/src/__tests__/profile-materializer.test.ts +87 -0
- package/src/__tests__/version-snapshot.test.ts +85 -0
- package/src/cli/install.ts +36 -5
- package/src/cli/router.ts +17 -9
- package/src/prerunner/index.ts +152 -6
- package/src/services/claude-cli.ts +31 -0
- package/src/services/claude-settings.ts +17 -1
- package/src/services/content-drift.ts +121 -0
- package/src/services/marketplace-refresh.ts +15 -3
- package/src/services/plugin-manager.ts +102 -2
- package/src/services/profile-materializer.ts +55 -4
- package/src/services/version-snapshot.ts +57 -8
- package/src/ui/renderers/pluginRenderers.tsx +20 -4
- package/src/ui/screens/PluginsScreen.tsx +82 -10
package/README.md
CHANGED
|
@@ -1,128 +1,134 @@
|
|
|
1
1
|
# claudeup
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Manage a Claude Code setup — plugins, MCP servers, skills, CLI tools and the
|
|
4
|
+
binaries plugins depend on — from a TUI, a CLI, or a committed team manifest.
|
|
4
5
|
|
|
5
|
-
##
|
|
6
|
+
## Install
|
|
6
7
|
|
|
7
8
|
```bash
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
# Using pnpx (no install)
|
|
12
|
-
pnpx claudeup
|
|
13
|
-
|
|
14
|
-
# Using npm
|
|
15
|
-
npm install -g claudeup
|
|
16
|
-
|
|
17
|
-
# Using Bun
|
|
18
|
-
bun add -g claudeup
|
|
9
|
+
bun add -g claudeup # recommended
|
|
10
|
+
npm install -g claudeup # also works
|
|
19
11
|
```
|
|
20
12
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
### 1. MCP Server Setup
|
|
24
|
-
Configure Model Context Protocol servers with a curated list:
|
|
25
|
-
- **File System** - Read/write files, search directories
|
|
26
|
-
- **Database** - SQLite, PostgreSQL connections
|
|
27
|
-
- **Developer Tools** - GitHub, GitLab, Chrome DevTools, Puppeteer
|
|
28
|
-
- **API & Web** - HTTP requests, Brave Search
|
|
29
|
-
- **Productivity** - Google Drive, Slack, Memory
|
|
30
|
-
- **AI & Intelligence** - Claude Context (semantic search), Sequential Thinking
|
|
31
|
-
|
|
32
|
-
### 2. Plugin Marketplaces
|
|
33
|
-
Add official and community plugin marketplaces:
|
|
34
|
-
- **Anthropic Official** - Official Claude Code plugins
|
|
35
|
-
- **MadAppGang** - Frontend, Backend, Code Analysis plugins
|
|
36
|
-
|
|
37
|
-
### 3. Manage Plugins
|
|
38
|
-
Install and configure plugins from added marketplaces:
|
|
39
|
-
- Frontend Development (React/TypeScript)
|
|
40
|
-
- Code Analysis (Deep investigation)
|
|
41
|
-
- Bun Backend (TypeScript backend)
|
|
42
|
-
- Orchestration (Multi-agent patterns)
|
|
43
|
-
- Agent Development (Create agents)
|
|
44
|
-
|
|
45
|
-
### 4. Status Line Configuration
|
|
46
|
-
Configure the Claude Code status line with presets:
|
|
47
|
-
- Minimal, Standard, Detailed
|
|
48
|
-
- Git-aware, Token-focused
|
|
49
|
-
- Time-tracking, Developer
|
|
50
|
-
|
|
51
|
-
## Usage
|
|
13
|
+
Updates go through claudeup itself, not the package manager:
|
|
52
14
|
|
|
53
15
|
```bash
|
|
54
|
-
|
|
55
|
-
claudeup
|
|
56
|
-
|
|
57
|
-
# Or with npx
|
|
58
|
-
npx claudeup
|
|
16
|
+
claudeup update
|
|
59
17
|
```
|
|
60
18
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
- `Escape/q` - Go back / Exit
|
|
65
|
-
- `?` - Show help
|
|
66
|
-
- `1-4` - Quick jump to screens
|
|
19
|
+
You get a self-contained binary for your platform (an `optionalDependencies`
|
|
20
|
+
package gated on `os`/`cpu`). On a platform without a prebuilt binary the
|
|
21
|
+
launcher falls back to running from source under Bun, so the install still works.
|
|
67
22
|
|
|
68
|
-
##
|
|
23
|
+
## Two ways to use it
|
|
69
24
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
pnpm install
|
|
25
|
+
**Interactive** — run `claudeup` with no arguments for the TUI. Good for
|
|
26
|
+
exploring what is available and toggling things on one machine.
|
|
73
27
|
|
|
74
|
-
|
|
75
|
-
|
|
28
|
+
**Declarative** — commit a `.claude/profiles.json` and run `claudeup install`.
|
|
29
|
+
Good for making a teammate's machine match yours. This is the
|
|
30
|
+
[team configuration guide](docs/team-configuration.md).
|
|
76
31
|
|
|
77
|
-
|
|
78
|
-
pnpm start:bun # With Bun
|
|
79
|
-
pnpm start # With Node
|
|
80
|
-
```
|
|
32
|
+
## Commands
|
|
81
33
|
|
|
82
|
-
|
|
34
|
+
```
|
|
35
|
+
claudeup Open the interactive TUI
|
|
36
|
+
|
|
37
|
+
install [profile] Install a profile's plugins, binaries, skills and
|
|
38
|
+
env, then activate it. No argument = every profile.
|
|
39
|
+
--check Report drift only, write nothing (CI gate)
|
|
40
|
+
--yes, -y Skip the confirmation prompt
|
|
41
|
+
--force Discard unsynced local edits instead of refusing
|
|
42
|
+
profile list Show every profile; ● marks the active one
|
|
43
|
+
profile show <name> Print a profile's fully resolved closure
|
|
44
|
+
profile switch <name> Repoint the active profile — offline, no reinstall
|
|
45
|
+
profile sync Promote local edits back into .claude/profiles.json
|
|
46
|
+
doctor [--fix] Check binary deps, profile symlinks, conventions
|
|
47
|
+
|
|
48
|
+
claude [args...] Check for plugin updates (1h cache), then run claude
|
|
49
|
+
--force, -f Force the update check
|
|
50
|
+
update Update claudeup itself
|
|
51
|
+
--version, -v Version + update check
|
|
52
|
+
--help, -h This list
|
|
53
|
+
```
|
|
83
54
|
|
|
84
|
-
claudeup
|
|
55
|
+
`claudeup doctor` exits non-zero when it finds a problem, so it works as a CI
|
|
56
|
+
check. So does `claudeup install --check`.
|
|
85
57
|
|
|
86
|
-
|
|
87
|
-
- `.claude/settings.local.json` - Local settings (MCP servers, allowMcp)
|
|
58
|
+
## What the TUI covers
|
|
88
59
|
|
|
89
|
-
|
|
60
|
+
| Screen | What it manages |
|
|
61
|
+
|---|---|
|
|
62
|
+
| Plugins | Install, enable, disable; shows version changes and newly installed plugins |
|
|
63
|
+
| Skills | Browse and install skills from configured skill repos |
|
|
64
|
+
| MCP | Add MCP servers from a curated catalog |
|
|
65
|
+
| Settings | Claude Code settings, from a catalog of known keys |
|
|
66
|
+
| Profiles | Saved plugin sets (the TUI's own, older profile concept) |
|
|
67
|
+
| CLI Tools | Install claudish, mnemex and friends via the right package manager |
|
|
68
|
+
| Git State | Gitignore conventions and repo hygiene |
|
|
69
|
+
| Alias | Shell alias + flag management for launching `claude` |
|
|
90
70
|
|
|
91
|
-
|
|
71
|
+
Navigate with `↑/↓` or `j/k`, `Enter` to select, `r` to refresh, `?` for help,
|
|
72
|
+
`q`/`Escape` to go back. Number keys `1`–`8` jump straight to a screen.
|
|
92
73
|
|
|
93
|
-
|
|
74
|
+
## Files claudeup touches
|
|
94
75
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
76
|
+
| Path | Owner | What claudeup does |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `.claude/profiles.json` | you, committed | reads it; `profile sync` writes to it |
|
|
79
|
+
| `.claude/_profiles/<name>/` | claudeup, gitignored | generated build output |
|
|
80
|
+
| `.claude/settings.json` | Claude Code | in profile mode, a symlink into `_profiles/` |
|
|
81
|
+
| `.claude/settings.local.json` | you, gitignored | env values collected during install |
|
|
82
|
+
| `.mcp.json` | Claude Code | in profile mode, a symlink into `_profiles/` |
|
|
83
|
+
| `~/.claude/plugins/*` | Claude Code | never written directly — always via the `claude` CLI |
|
|
100
84
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
85
|
+
claudeup never hand-edits Claude Code's own registries
|
|
86
|
+
(`installed_plugins.json`, `known_marketplaces.json`, the plugin cache). Those go
|
|
87
|
+
through `claude plugin ...` so Claude Code stays the single writer.
|
|
104
88
|
|
|
105
|
-
|
|
89
|
+
## Development
|
|
106
90
|
|
|
107
91
|
```bash
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
92
|
+
bun install
|
|
93
|
+
bun test # full suite
|
|
94
|
+
bun run typecheck
|
|
95
|
+
bun run lint
|
|
96
|
+
bun run src/main.tsx # run from source
|
|
97
|
+
bun run build:binaries # cross-compile all platform binaries + their packages
|
|
113
98
|
```
|
|
114
99
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
100
|
+
Tests run per-file isolated in CI (`bun run test:ci`) because `mock.module`
|
|
101
|
+
leaks across files in a shared process.
|
|
102
|
+
|
|
103
|
+
**Do not bump `@opentui` past 0.1.x.** 0.4.x breaks `bun build --compile`: it
|
|
104
|
+
resolves tree-sitter workers eagerly, bypassing the `OTUI_ASSET_ROOT` override,
|
|
105
|
+
so a bare `import "@opentui/core"` crashes at import time in the compiled
|
|
106
|
+
binary. Re-run the compile spike before changing that pin.
|
|
118
107
|
|
|
119
|
-
|
|
108
|
+
## Releasing
|
|
109
|
+
|
|
110
|
+
Tag-driven. `.github/workflows/claudeup-release.yml` fires on `tools/claudeup/v*`:
|
|
111
|
+
|
|
112
|
+
1. **verify** — install, typecheck, `test:ci`, version and optional-deps gates
|
|
113
|
+
2. **binaries** — a matrix building each target on its **native** runner
|
|
114
|
+
(`bun --compile` cannot cross-compile in CI because opentui ships a
|
|
115
|
+
per-platform native library)
|
|
116
|
+
3. **publish** — platform packages first, main package last, so its
|
|
117
|
+
`optionalDependencies` already resolve
|
|
120
118
|
|
|
121
119
|
```bash
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
# bump "version" AND the three optionalDependencies to match
|
|
121
|
+
bun run check:optional-deps
|
|
122
|
+
git commit -am "feat(claudeup): vX.Y.Z - description"
|
|
123
|
+
git tag -a tools/claudeup/vX.Y.Z -m "Release message"
|
|
124
|
+
git push origin main --tags
|
|
124
125
|
```
|
|
125
126
|
|
|
127
|
+
Auth is npm OIDC trusted publishing — the workflow references no secrets. Adding
|
|
128
|
+
a **new** platform target needs a one-time manual publish to create the package
|
|
129
|
+
name (OIDC can only authenticate against a package that already exists); see
|
|
130
|
+
[`docs/npm-trusted-publishing-setup.md`](../../docs/npm-trusted-publishing-setup.md).
|
|
131
|
+
|
|
126
132
|
## License
|
|
127
133
|
|
|
128
134
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.32.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.
|
|
68
|
-
"claudeup-darwin-x64": "4.
|
|
69
|
-
"claudeup-linux-x64": "4.
|
|
67
|
+
"claudeup-darwin-arm64": "4.32.0",
|
|
68
|
+
"claudeup-darwin-x64": "4.32.0",
|
|
69
|
+
"claudeup-linux-x64": "4.32.0"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content drift: a plugin whose files changed without its version changing.
|
|
3
|
+
*
|
|
4
|
+
* Hermetic — builds a real git marketplace under a temp CLAUDE_CONFIG_DIR.
|
|
5
|
+
* Real git is used deliberately: the detection IS a git question, and mocking
|
|
6
|
+
* it would only test the mock.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { promises as fs } from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
clearContentDriftCache,
|
|
16
|
+
hasContentDrift,
|
|
17
|
+
} from "../services/content-drift.js";
|
|
18
|
+
|
|
19
|
+
let configDir: string;
|
|
20
|
+
let prevConfigDir: string | undefined;
|
|
21
|
+
let repo: string;
|
|
22
|
+
|
|
23
|
+
const git = (args: string[]) =>
|
|
24
|
+
spawnSync("git", args, { cwd: repo, encoding: "utf-8" });
|
|
25
|
+
|
|
26
|
+
const head = () => git(["rev-parse", "HEAD"]).stdout.trim();
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
30
|
+
configDir = await fs.mkdtemp(path.join(os.tmpdir(), "drift-"));
|
|
31
|
+
process.env.CLAUDE_CONFIG_DIR = configDir;
|
|
32
|
+
clearContentDriftCache();
|
|
33
|
+
|
|
34
|
+
repo = path.join(configDir, "plugins", "marketplaces", "probemp");
|
|
35
|
+
await fs.mkdir(path.join(repo, "plugins", "alpha", "skills"), {
|
|
36
|
+
recursive: true,
|
|
37
|
+
});
|
|
38
|
+
await fs.mkdir(path.join(repo, "plugins", "beta"), { recursive: true });
|
|
39
|
+
await fs.writeFile(path.join(repo, "plugins", "alpha", "skills", "s.md"), "v1");
|
|
40
|
+
await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v1");
|
|
41
|
+
|
|
42
|
+
git(["init", "-q", "-b", "main"]);
|
|
43
|
+
git(["config", "user.email", "probe@example.invalid"]);
|
|
44
|
+
git(["config", "user.name", "probe"]);
|
|
45
|
+
git(["add", "-A"]);
|
|
46
|
+
git(["commit", "-q", "-m", "release 1.0.0"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
51
|
+
else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
|
|
52
|
+
await fs.rm(configDir, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("hasContentDrift", () => {
|
|
56
|
+
test("no drift when the plugin's files are untouched", async () => {
|
|
57
|
+
const sha = head();
|
|
58
|
+
expect(
|
|
59
|
+
await hasContentDrift({
|
|
60
|
+
marketplace: "probemp",
|
|
61
|
+
pluginName: "alpha",
|
|
62
|
+
installedSha: sha,
|
|
63
|
+
}),
|
|
64
|
+
).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("drift when the plugin's files change under an unchanged version", async () => {
|
|
68
|
+
const sha = head();
|
|
69
|
+
// Republish: same version, different content — what publish-dist.sh does.
|
|
70
|
+
await fs.writeFile(
|
|
71
|
+
path.join(repo, "plugins", "alpha", "skills", "s.md"),
|
|
72
|
+
"v2 — different content, same version",
|
|
73
|
+
);
|
|
74
|
+
git(["add", "-A"]);
|
|
75
|
+
git(["commit", "-q", "-m", "republish 1.0.0"]);
|
|
76
|
+
|
|
77
|
+
expect(
|
|
78
|
+
await hasContentDrift({
|
|
79
|
+
marketplace: "probemp",
|
|
80
|
+
pluginName: "alpha",
|
|
81
|
+
installedSha: sha,
|
|
82
|
+
}),
|
|
83
|
+
).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a change to ANOTHER plugin does not flag this one", async () => {
|
|
87
|
+
// The reason this compares the plugin subtree and not the marketplace
|
|
88
|
+
// HEAD: any commit to any plugin would otherwise flag every plugin.
|
|
89
|
+
const sha = head();
|
|
90
|
+
await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v2");
|
|
91
|
+
git(["add", "-A"]);
|
|
92
|
+
git(["commit", "-q", "-m", "unrelated change"]);
|
|
93
|
+
|
|
94
|
+
expect(
|
|
95
|
+
await hasContentDrift({
|
|
96
|
+
marketplace: "probemp",
|
|
97
|
+
pluginName: "alpha",
|
|
98
|
+
installedSha: sha,
|
|
99
|
+
}),
|
|
100
|
+
).toBe(false);
|
|
101
|
+
expect(
|
|
102
|
+
await hasContentDrift({
|
|
103
|
+
marketplace: "probemp",
|
|
104
|
+
pluginName: "beta",
|
|
105
|
+
installedSha: sha,
|
|
106
|
+
}),
|
|
107
|
+
).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("deleting a skill counts as drift", async () => {
|
|
111
|
+
// The live case: dev@magus 3.0.1 lost tui-progress/SKILL.md while still
|
|
112
|
+
// declaring 3.0.1, so the version compare reported "up to date".
|
|
113
|
+
const sha = head();
|
|
114
|
+
await fs.rm(path.join(repo, "plugins", "alpha", "skills", "s.md"));
|
|
115
|
+
git(["add", "-A"]);
|
|
116
|
+
git(["commit", "-q", "-m", "drop a skill"]);
|
|
117
|
+
|
|
118
|
+
expect(
|
|
119
|
+
await hasContentDrift({
|
|
120
|
+
marketplace: "probemp",
|
|
121
|
+
pluginName: "alpha",
|
|
122
|
+
installedSha: sha,
|
|
123
|
+
}),
|
|
124
|
+
).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("stays silent when it cannot answer honestly", async () => {
|
|
128
|
+
// No recorded sha, unknown sha (force-push/shallow clone), and unknown
|
|
129
|
+
// marketplace must all report false rather than nag for a reinstall.
|
|
130
|
+
expect(
|
|
131
|
+
await hasContentDrift({
|
|
132
|
+
marketplace: "probemp",
|
|
133
|
+
pluginName: "alpha",
|
|
134
|
+
installedSha: undefined,
|
|
135
|
+
}),
|
|
136
|
+
).toBe(false);
|
|
137
|
+
expect(
|
|
138
|
+
await hasContentDrift({
|
|
139
|
+
marketplace: "probemp",
|
|
140
|
+
pluginName: "alpha",
|
|
141
|
+
installedSha: "0".repeat(40),
|
|
142
|
+
}),
|
|
143
|
+
).toBe(false);
|
|
144
|
+
expect(
|
|
145
|
+
await hasContentDrift({
|
|
146
|
+
marketplace: "does-not-exist",
|
|
147
|
+
pluginName: "alpha",
|
|
148
|
+
installedSha: head(),
|
|
149
|
+
}),
|
|
150
|
+
).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -94,7 +94,10 @@ describe("PluginsScreen — no dual-write after CLI operations", () => {
|
|
|
94
94
|
|
|
95
95
|
// Mutable state shared between mock factories and test assertions.
|
|
96
96
|
let mockSaveGlobal: ReturnType<typeof mock>;
|
|
97
|
+
let mockSaveProject: ReturnType<typeof mock>;
|
|
98
|
+
let mockSaveLocal: ReturnType<typeof mock>;
|
|
97
99
|
let mockUpdatePlugin: ReturnType<typeof mock>;
|
|
100
|
+
let mockRepairPlugin: ReturnType<typeof mock>;
|
|
98
101
|
let mockIsClaudeAvailable: ReturnType<typeof mock>;
|
|
99
102
|
let mockGetAvailablePlugins: ReturnType<typeof mock>;
|
|
100
103
|
|
|
@@ -123,6 +126,8 @@ mock.module("../services/claude-settings.js", () => ({
|
|
|
123
126
|
getLocalEnabledPlugins: mock(() => Promise.resolve({})),
|
|
124
127
|
saveGlobalInstalledPluginVersion: (...args: unknown[]) =>
|
|
125
128
|
mockSaveGlobal(...args),
|
|
129
|
+
saveLocalInstalledPluginVersion: (...args: unknown[]) =>
|
|
130
|
+
mockSaveLocal(...args),
|
|
126
131
|
readGlobalSettings: mock(() => Promise.resolve({ hooks: {} })),
|
|
127
132
|
writeGlobalSettings: mock(() => Promise.resolve()),
|
|
128
133
|
}));
|
|
@@ -130,6 +135,7 @@ mock.module("../services/claude-settings.js", () => ({
|
|
|
130
135
|
mock.module("../services/claude-cli.js", () => ({
|
|
131
136
|
...actualClaudeCli,
|
|
132
137
|
updatePlugin: (...args: unknown[]) => mockUpdatePlugin(...args),
|
|
138
|
+
repairPlugin: (...args: unknown[]) => mockRepairPlugin(...args),
|
|
133
139
|
isClaudeAvailable: (...args: unknown[]) => mockIsClaudeAvailable(...args),
|
|
134
140
|
addMarketplace: mock(() => Promise.resolve()),
|
|
135
141
|
updateMarketplace: mock(() => Promise.resolve()),
|
|
@@ -139,6 +145,7 @@ mock.module("../services/plugin-manager.js", () => ({
|
|
|
139
145
|
...actualPluginManager,
|
|
140
146
|
getAvailablePlugins: (...args: unknown[]) =>
|
|
141
147
|
mockGetAvailablePlugins(...args),
|
|
148
|
+
saveInstalledPluginVersion: (...args: unknown[]) => mockSaveProject(...args),
|
|
142
149
|
clearMarketplaceCache: mock(() => undefined),
|
|
143
150
|
}));
|
|
144
151
|
|
|
@@ -173,26 +180,35 @@ mock.module("fs-extra", () => ({
|
|
|
173
180
|
// Now import the module under test (after mock.module registrations).
|
|
174
181
|
const { prerunClaude } = await import("../prerunner/index.js");
|
|
175
182
|
|
|
176
|
-
|
|
177
|
-
function makePlugin(overrides: Partial<{
|
|
178
|
-
id: string;
|
|
183
|
+
interface ScopeStatusLike {
|
|
179
184
|
enabled: boolean;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}> = {}): {
|
|
185
|
+
version?: string;
|
|
186
|
+
}
|
|
187
|
+
interface PluginLike {
|
|
184
188
|
id: string;
|
|
185
189
|
enabled: boolean;
|
|
186
190
|
hasUpdate: boolean;
|
|
191
|
+
contentStale?: boolean;
|
|
187
192
|
installedVersion: string;
|
|
188
193
|
version: string;
|
|
189
|
-
|
|
194
|
+
userScope?: ScopeStatusLike;
|
|
195
|
+
projectScope?: ScopeStatusLike;
|
|
196
|
+
localScope?: ScopeStatusLike;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Minimal PluginInfo-like object. The per-scope status matters: the prerunner
|
|
201
|
+
* updates the scopes that actually hold an outdated install, so a fixture with
|
|
202
|
+
* no scope status describes a plugin installed nowhere and is correctly skipped.
|
|
203
|
+
*/
|
|
204
|
+
function makePlugin(overrides: Partial<PluginLike> = {}): PluginLike {
|
|
190
205
|
return {
|
|
191
206
|
id: "test-plugin@magus",
|
|
192
207
|
enabled: true,
|
|
193
208
|
hasUpdate: true,
|
|
194
209
|
installedVersion: "1.0.0",
|
|
195
210
|
version: "1.1.0",
|
|
211
|
+
userScope: { enabled: true, version: "1.0.0" },
|
|
196
212
|
...overrides,
|
|
197
213
|
};
|
|
198
214
|
}
|
|
@@ -200,7 +216,10 @@ function makePlugin(overrides: Partial<{
|
|
|
200
216
|
describe("prerunner — saveGlobalInstalledPluginVersion call count", () => {
|
|
201
217
|
beforeEach(() => {
|
|
202
218
|
mockSaveGlobal = mock(() => Promise.resolve());
|
|
219
|
+
mockSaveProject = mock(() => Promise.resolve());
|
|
220
|
+
mockSaveLocal = mock(() => Promise.resolve());
|
|
203
221
|
mockUpdatePlugin = mock(() => Promise.resolve());
|
|
222
|
+
mockRepairPlugin = mock(() => Promise.resolve());
|
|
204
223
|
mockIsClaudeAvailable = mock(() => Promise.resolve(true));
|
|
205
224
|
mockGetAvailablePlugins = mock(() =>
|
|
206
225
|
Promise.resolve([makePlugin()]),
|
|
@@ -219,6 +238,130 @@ describe("prerunner — saveGlobalInstalledPluginVersion call count", () => {
|
|
|
219
238
|
expect(mockSaveGlobal).toHaveBeenCalledTimes(1);
|
|
220
239
|
});
|
|
221
240
|
|
|
241
|
+
/**
|
|
242
|
+
* The prerunner used to call `updatePlugin(id, "user")` unconditionally.
|
|
243
|
+
* `hasUpdate` is computed from the CURRENT PROJECT's resolved version, but
|
|
244
|
+
* installs live per project in installed_plugins.json — 495 of 496 magus
|
|
245
|
+
* installs on the reporter's machine are project-scoped. So it detected
|
|
246
|
+
* project drift, updated user scope, reported success, and left the project
|
|
247
|
+
* untouched, re-detecting the identical drift on every subsequent run.
|
|
248
|
+
*/
|
|
249
|
+
it("updates the PROJECT scope when that is where the outdated install is", async () => {
|
|
250
|
+
mockGetAvailablePlugins = mock(() =>
|
|
251
|
+
Promise.resolve([
|
|
252
|
+
makePlugin({
|
|
253
|
+
userScope: undefined,
|
|
254
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
255
|
+
}),
|
|
256
|
+
]),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
await prerunClaude(["--help"], { force: true });
|
|
260
|
+
|
|
261
|
+
expect(mockUpdatePlugin).toHaveBeenCalledTimes(1);
|
|
262
|
+
expect(mockUpdatePlugin.mock.calls[0]?.[1]).toBe("project");
|
|
263
|
+
expect(mockSaveProject).toHaveBeenCalledTimes(1);
|
|
264
|
+
expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("updates every scope that is outdated, not just the first", async () => {
|
|
268
|
+
mockGetAvailablePlugins = mock(() =>
|
|
269
|
+
Promise.resolve([
|
|
270
|
+
makePlugin({
|
|
271
|
+
userScope: { enabled: true, version: "1.0.0" },
|
|
272
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
273
|
+
localScope: { enabled: true, version: "1.1.0" }, // already current
|
|
274
|
+
}),
|
|
275
|
+
]),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
await prerunClaude(["--help"], { force: true });
|
|
279
|
+
|
|
280
|
+
const scopes = mockUpdatePlugin.mock.calls
|
|
281
|
+
.map((c: unknown[]) => c[1])
|
|
282
|
+
.sort();
|
|
283
|
+
expect(scopes).toEqual(["project", "user"]);
|
|
284
|
+
expect(mockSaveLocal).toHaveBeenCalledTimes(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A content-stale plugin has no version bump to install over, and
|
|
289
|
+
* `claude plugin install` no-ops on "already installed" (verified against the
|
|
290
|
+
* real marketplace in autotest/plugin-system/probe-05). The prerunner must
|
|
291
|
+
* therefore repair — uninstall+install — not update.
|
|
292
|
+
*/
|
|
293
|
+
it("repairs a content-stale plugin instead of updating it", async () => {
|
|
294
|
+
mockGetAvailablePlugins = mock(() =>
|
|
295
|
+
Promise.resolve([
|
|
296
|
+
makePlugin({
|
|
297
|
+
hasUpdate: false,
|
|
298
|
+
contentStale: true,
|
|
299
|
+
installedVersion: "1.0.0",
|
|
300
|
+
version: "1.0.0",
|
|
301
|
+
userScope: undefined,
|
|
302
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
303
|
+
}),
|
|
304
|
+
]),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
await prerunClaude(["--help"], { force: true });
|
|
308
|
+
|
|
309
|
+
expect(mockRepairPlugin).toHaveBeenCalledTimes(1);
|
|
310
|
+
expect(mockRepairPlugin.mock.calls[0]?.[1]).toBe("project");
|
|
311
|
+
expect(mockUpdatePlugin).toHaveBeenCalledTimes(0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("prefers a real version update over a repair when both apply", async () => {
|
|
315
|
+
// A pending version bump reinstalls the files anyway, so repairing as well
|
|
316
|
+
// would double the work and the uninstall window.
|
|
317
|
+
mockGetAvailablePlugins = mock(() =>
|
|
318
|
+
Promise.resolve([
|
|
319
|
+
makePlugin({ hasUpdate: true, contentStale: true }),
|
|
320
|
+
]),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
await prerunClaude(["--help"], { force: true });
|
|
324
|
+
|
|
325
|
+
expect(mockUpdatePlugin).toHaveBeenCalledTimes(1);
|
|
326
|
+
expect(mockRepairPlugin).toHaveBeenCalledTimes(0);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("does not repair when the CLI is unavailable", async () => {
|
|
330
|
+
mockIsClaudeAvailable = mock(() => Promise.resolve(false));
|
|
331
|
+
mockGetAvailablePlugins = mock(() =>
|
|
332
|
+
Promise.resolve([
|
|
333
|
+
makePlugin({
|
|
334
|
+
hasUpdate: false,
|
|
335
|
+
contentStale: true,
|
|
336
|
+
version: "1.0.0",
|
|
337
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
338
|
+
}),
|
|
339
|
+
]),
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
await prerunClaude(["--help"], { force: true });
|
|
343
|
+
|
|
344
|
+
expect(mockRepairPlugin).toHaveBeenCalledTimes(0);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("does nothing when no scope holds an outdated install", async () => {
|
|
348
|
+
// hasUpdate can be true from a stale settings base while every real
|
|
349
|
+
// install is already current. Updating "somewhere" would be a guess.
|
|
350
|
+
mockGetAvailablePlugins = mock(() =>
|
|
351
|
+
Promise.resolve([
|
|
352
|
+
makePlugin({
|
|
353
|
+
userScope: { enabled: true, version: "1.1.0" },
|
|
354
|
+
projectScope: undefined,
|
|
355
|
+
}),
|
|
356
|
+
]),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
await prerunClaude(["--help"], { force: true });
|
|
360
|
+
|
|
361
|
+
expect(mockUpdatePlugin).toHaveBeenCalledTimes(0);
|
|
362
|
+
expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
|
|
363
|
+
});
|
|
364
|
+
|
|
222
365
|
it("does NOT call saveGlobalInstalledPluginVersion when CLI is unavailable", async () => {
|
|
223
366
|
// CLI unavailable — we must NOT write phantom state.
|
|
224
367
|
mockIsClaudeAvailable = mock(() => Promise.resolve(false));
|
|
@@ -214,14 +214,19 @@ describe("refreshRegisteredMarketplaces — selection & skips", () => {
|
|
|
214
214
|
expect(r.skipped).toEqual(["local"]);
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
-
it("
|
|
217
|
+
it("reports a marketplace with autoUpdate === false separately from ordinary skips", async () => {
|
|
218
|
+
// Not lumped into `skipped`: opting out here freezes the catalog, so the
|
|
219
|
+
// marketplace's plugins read as up to date forever. The caller has to be
|
|
220
|
+
// able to tell the user, which it cannot do if this looks like "no clone
|
|
221
|
+
// on disk". A real magus clone sat 11 days behind exactly this way.
|
|
218
222
|
installClone("magus");
|
|
219
223
|
publishV2();
|
|
220
224
|
configured = { magus: gh() };
|
|
221
225
|
autoUpdate = { magus: false };
|
|
222
226
|
const r = await refreshRegisteredMarketplaces();
|
|
223
227
|
expect(r.refreshed).toEqual([]);
|
|
224
|
-
expect(r.
|
|
228
|
+
expect(r.autoUpdateDisabled).toEqual(["magus"]);
|
|
229
|
+
expect(r.skipped).toEqual([]);
|
|
225
230
|
});
|
|
226
231
|
|
|
227
232
|
it("honors the skip set (already handled this run)", async () => {
|