claudeup 4.19.0 → 4.23.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/bin/claudeup.js +43 -19
- package/package.json +14 -19
- package/scripts/build-binaries.ts +77 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +43 -0
- package/src/__tests__/cli-router.test.ts +65 -0
- package/src/__tests__/conventions-integration.test.ts +745 -0
- package/src/__tests__/conventions-manager.test.ts +1172 -0
- package/src/__tests__/doctor-bins.test.ts +35 -0
- package/src/__tests__/doctor.test.ts +450 -0
- package/src/__tests__/dual-write-prevention.test.ts +14 -4
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/gitignore-prerun.test.ts +1 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +98 -0
- package/src/__tests__/manifest.test.ts +136 -0
- package/src/__tests__/marketplace-badge.test.ts +91 -0
- package/src/__tests__/marketplace-refresh.test.ts +267 -0
- package/src/__tests__/plugin-requires.test.ts +133 -0
- package/src/__tests__/plugin-setup.test.ts +7 -0
- package/src/__tests__/profile-command.test.ts +116 -0
- package/src/__tests__/profile-materializer.test.ts +82 -0
- package/src/__tests__/profile-sync.test.ts +136 -0
- package/src/__tests__/registry-version-resolution.test.ts +74 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +218 -0
- package/src/__tests__/symlink-manager.test.ts +99 -0
- package/src/__tests__/toolchain.test.ts +56 -0
- package/src/cli/claude.ts +21 -0
- package/src/cli/doctor.ts +132 -0
- package/src/cli/install.ts +360 -0
- package/src/cli/profile.ts +166 -0
- package/src/cli/router.ts +107 -0
- package/src/cli/update.ts +85 -0
- package/src/data/cli-tools.ts +7 -7
- package/src/data/gitignore-defaults.ts +4 -0
- package/src/data/marketplaces.ts +12 -0
- package/src/data/predefined-profiles.ts +3 -4
- package/src/main.tsx +11 -154
- package/src/prerunner/index.ts +62 -27
- package/src/services/claude-cli.ts +19 -22
- package/src/services/claude-settings.ts +99 -26
- package/src/services/conventions-manager.ts +865 -0
- package/src/services/doctor-bins.ts +49 -0
- package/src/services/doctor.ts +509 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/manifest.ts +166 -0
- package/src/services/marketplace-refresh.ts +173 -0
- package/src/services/plugin-manager.ts +7 -1
- package/src/services/plugin-requires.ts +215 -0
- package/src/services/plugin-version-check.ts +9 -4
- package/src/services/profile-materializer.ts +61 -0
- package/src/services/profile-sync.ts +150 -0
- package/src/services/resolver.ts +293 -0
- package/src/services/symlink-manager.ts +146 -0
- package/src/services/toolchain.ts +97 -0
- package/src/services/version-check.ts +10 -11
- package/src/types/index.ts +138 -31
- package/src/ui/App.tsx +0 -4
- package/src/ui/adapters/pluginsAdapter.ts +13 -0
- package/src/ui/screens/CliToolsScreen.tsx +11 -3
- package/src/ui/screens/index.ts +0 -1
- package/src/ui/state/reducer.ts +0 -101
- package/src/ui/state/types.ts +0 -35
- package/src/utils/command-utils.ts +23 -0
- package/src/utils/file-locking.ts +144 -0
- package/src/data/alias-flags.js +0 -205
- package/src/data/cli-tools.js +0 -123
- package/src/data/gitignore-defaults.js +0 -24
- package/src/data/gitignore-reasons.js +0 -97
- package/src/data/gitignore-templates.js +0 -21
- package/src/data/marketplaces.js +0 -138
- package/src/data/mcp-servers.js +0 -509
- package/src/data/predefined-profiles.js +0 -248
- package/src/data/settings-catalog.js +0 -625
- package/src/data/skill-repos.js +0 -160
- package/src/data/statuslines.js +0 -159
- package/src/data/statuslines.ts +0 -188
- package/src/index.js +0 -4
- package/src/index.ts +0 -5
- package/src/main.js +0 -170
- package/src/prerunner/index.js +0 -252
- package/src/services/alias-settings.js +0 -51
- package/src/services/alias-shell-writer.js +0 -1018
- package/src/services/alias-store.js +0 -129
- package/src/services/claude-cli.js +0 -189
- package/src/services/claude-runner.js +0 -28
- package/src/services/claude-settings.js +0 -1223
- package/src/services/gitignore-detector.js +0 -155
- package/src/services/gitignore-fixer.js +0 -231
- package/src/services/gitignore-prerun.js +0 -46
- package/src/services/gitignore-resolver.js +0 -143
- package/src/services/gitignore-service.js +0 -117
- package/src/services/local-marketplace.js +0 -339
- package/src/services/marketplace-fetcher.js +0 -96
- package/src/services/marketplace-sync.js +0 -94
- package/src/services/mcp-registry.js +0 -87
- package/src/services/plugin-manager.js +0 -473
- package/src/services/plugin-mcp-config.js +0 -177
- package/src/services/plugin-setup.js +0 -499
- package/src/services/plugin-version-check.js +0 -262
- package/src/services/profiles.js +0 -161
- package/src/services/settings-manager.js +0 -243
- package/src/services/skills-manager.js +0 -393
- package/src/services/skillsmp-client.js +0 -87
- package/src/services/update-cache.js +0 -52
- package/src/services/version-check.js +0 -99
- package/src/types/gitignore.js +0 -6
- package/src/types/index.js +0 -1
- package/src/ui/App.js +0 -355
- package/src/ui/adapters/pluginsAdapter.js +0 -139
- package/src/ui/adapters/settingsAdapter.js +0 -111
- package/src/ui/adapters/skillsAdapter.js +0 -154
- package/src/ui/components/CategoryHeader.js +0 -9
- package/src/ui/components/EmptyFilterState.js +0 -4
- package/src/ui/components/FlagDetailEditor.js +0 -0
- package/src/ui/components/ScopeIndicator.js +0 -30
- package/src/ui/components/ScrollableList.js +0 -36
- package/src/ui/components/SearchInput.js +0 -19
- package/src/ui/components/StyledText.js +0 -39
- package/src/ui/components/TabBar.js +0 -19
- package/src/ui/components/layout/FooterHints.js +0 -29
- package/src/ui/components/layout/Panel.js +0 -6
- package/src/ui/components/layout/ProgressBar.js +0 -14
- package/src/ui/components/layout/ScopeTabs.js +0 -6
- package/src/ui/components/layout/ScreenLayout.js +0 -16
- package/src/ui/components/layout/index.js +0 -5
- package/src/ui/components/modals/ConfirmModal.js +0 -14
- package/src/ui/components/modals/InputModal.js +0 -5
- package/src/ui/components/modals/LoadingModal.js +0 -17
- package/src/ui/components/modals/MessageModal.js +0 -16
- package/src/ui/components/modals/ModalContainer.js +0 -137
- package/src/ui/components/modals/SelectModal.js +0 -18
- package/src/ui/components/modals/VersionMismatchModal.js +0 -36
- package/src/ui/components/modals/index.js +0 -6
- package/src/ui/components/primitives/ActionHints.js +0 -13
- package/src/ui/components/primitives/DetailSection.js +0 -7
- package/src/ui/components/primitives/KeyValueLine.js +0 -8
- package/src/ui/components/primitives/ListCategoryRow.js +0 -8
- package/src/ui/components/primitives/MetaText.js +0 -8
- package/src/ui/components/primitives/ScopeDetail.js +0 -32
- package/src/ui/components/primitives/ScopeSquares.js +0 -11
- package/src/ui/components/primitives/SelectableRow.js +0 -5
- package/src/ui/components/primitives/index.js +0 -8
- package/src/ui/hooks/index.js +0 -4
- package/src/ui/hooks/useAsyncData.js +0 -77
- package/src/ui/hooks/useGitignoreModal.js +0 -74
- package/src/ui/hooks/useKeyboard.js +0 -13
- package/src/ui/hooks/useKeyboardHandler.js +0 -39
- package/src/ui/hooks/useMismatchModal.js +0 -77
- package/src/ui/registry.js +0 -1
- package/src/ui/renderers/cliToolRenderers.js +0 -54
- package/src/ui/renderers/gitignoreRenderers.js +0 -46
- package/src/ui/renderers/mcpRenderers.js +0 -26
- package/src/ui/renderers/pluginRenderers.js +0 -124
- package/src/ui/renderers/profileRenderers.js +0 -177
- package/src/ui/renderers/settingsRenderers.js +0 -73
- package/src/ui/renderers/skillRenderers.js +0 -138
- package/src/ui/screens/AliasScreen.js +0 -1111
- package/src/ui/screens/CliToolsScreen.js +0 -338
- package/src/ui/screens/EnvVarsScreen.js +0 -152
- package/src/ui/screens/GitignoreScreen.js +0 -328
- package/src/ui/screens/McpRegistryScreen.js +0 -238
- package/src/ui/screens/McpScreen.js +0 -176
- package/src/ui/screens/ModelSelectorScreen.js +0 -296
- package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
- package/src/ui/screens/PluginsScreen.js +0 -769
- package/src/ui/screens/ProfilesScreen.js +0 -298
- package/src/ui/screens/SkillsScreen.js +0 -549
- package/src/ui/screens/StatusLineScreen.js +0 -206
- package/src/ui/screens/StatusLineScreen.tsx +0 -416
- package/src/ui/screens/index.js +0 -10
- package/src/ui/state/AnimationContext.js +0 -34
- package/src/ui/state/AppContext.js +0 -162
- package/src/ui/state/DimensionsContext.js +0 -71
- package/src/ui/state/reducer.js +0 -547
- package/src/ui/state/types.js +0 -1
- package/src/ui/theme.js +0 -47
- package/src/utils/clipboard.js +0 -56
- package/src/utils/command-utils.js +0 -19
- package/src/utils/fuzzy-search.js +0 -101
- package/src/utils/string-utils.js +0 -62
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for refreshRegisteredMarketplaces — the non-destructive marketplace clone
|
|
3
|
+
* refresh. These use REAL git repositories on purpose: the entire point of the
|
|
4
|
+
* rework is a real git property — `git pull --ff-only` advances a clone but NEVER
|
|
5
|
+
* deletes it on failure (unlike `claude plugin marketplace update`, which the repo
|
|
6
|
+
* deliberately bans; see marketplace-refresh.ts). Only a real pull can prove that,
|
|
7
|
+
* so the "clone survives a failed pull" cases here are the load-bearing ones.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
// --- Mock the marketplace registry (the only claudeup dep of the refresher) ---
|
|
16
|
+
let configured: Record<string, { source: { source: string; repo?: string } }>;
|
|
17
|
+
let autoUpdate: Record<string, boolean | undefined>;
|
|
18
|
+
|
|
19
|
+
mock.module("../services/claude-settings.js", () => ({
|
|
20
|
+
getConfiguredMarketplaces: async () => configured,
|
|
21
|
+
getMarketplaceAutoUpdate: async (name: string) => autoUpdate[name],
|
|
22
|
+
// The refresher resolves clone paths via this helper; point it at our temp
|
|
23
|
+
// home so no real ~/.claude marketplace is ever touched. (Bun's os.homedir()
|
|
24
|
+
// ignores runtime $HOME changes, so mocking the helper is the reliable seam.)
|
|
25
|
+
getGlobalClaudeDir: () => path.join(tmp, ".claude"),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const { refreshRegisteredMarketplaces, fastForwardCloneIfPresent } =
|
|
29
|
+
await import("../services/marketplace-refresh.js");
|
|
30
|
+
|
|
31
|
+
const gh = (repo = "MadAppGang/magus") => ({
|
|
32
|
+
source: { source: "github", repo },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// git with a deterministic identity, no signing, `main` as default branch — so the
|
|
36
|
+
// suite doesn't depend on the developer's global git config.
|
|
37
|
+
const GIT_CFG = [
|
|
38
|
+
"-c",
|
|
39
|
+
"user.email=test@example.com",
|
|
40
|
+
"-c",
|
|
41
|
+
"user.name=test",
|
|
42
|
+
"-c",
|
|
43
|
+
"commit.gpgsign=false",
|
|
44
|
+
"-c",
|
|
45
|
+
"init.defaultBranch=main",
|
|
46
|
+
];
|
|
47
|
+
function git(cwd: string, ...args: string[]): string {
|
|
48
|
+
return execFileSync("git", [...GIT_CFG, ...args], { cwd, encoding: "utf8" });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let tmp: string;
|
|
52
|
+
let mpRoot: string; // <tmp>/.claude/plugins/marketplaces (via mocked getGlobalClaudeDir)
|
|
53
|
+
let origin: string; // bare origin repo standing in for the GitHub remote
|
|
54
|
+
|
|
55
|
+
function writePluginJson(dir: string, version: string, pin: string) {
|
|
56
|
+
const p = path.join(dir, "plugins", "terminal");
|
|
57
|
+
fs.mkdirSync(p, { recursive: true });
|
|
58
|
+
fs.writeFileSync(
|
|
59
|
+
path.join(p, "plugin.json"),
|
|
60
|
+
JSON.stringify({ version, setup: { go: [pin] } }),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
function readPin(dir: string): { version: string; pin: string } {
|
|
64
|
+
const j = JSON.parse(
|
|
65
|
+
fs.readFileSync(
|
|
66
|
+
path.join(dir, "plugins", "terminal", "plugin.json"),
|
|
67
|
+
"utf8",
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
return { version: j.version, pin: j.setup.go[0] };
|
|
71
|
+
}
|
|
72
|
+
/** Clone origin into the marketplace dir for `name`, at origin's current HEAD. */
|
|
73
|
+
function installClone(name: string): string {
|
|
74
|
+
const dir = path.join(mpRoot, name);
|
|
75
|
+
git(tmp, "clone", origin, dir);
|
|
76
|
+
return dir;
|
|
77
|
+
}
|
|
78
|
+
/** Advance origin to a v2 (pinned) release, so installed clones become stale. */
|
|
79
|
+
function publishV2() {
|
|
80
|
+
const pub = path.join(tmp, "pub");
|
|
81
|
+
git(tmp, "clone", origin, pub);
|
|
82
|
+
writePluginJson(pub, "4.1.2", "x@v1.6.2");
|
|
83
|
+
git(pub, "add", "-A");
|
|
84
|
+
git(pub, "commit", "-m", "v2 pinned");
|
|
85
|
+
git(pub, "push", "origin", "HEAD:main");
|
|
86
|
+
fs.rmSync(pub, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "mp-refresh-"));
|
|
91
|
+
mpRoot = path.join(tmp, ".claude", "plugins", "marketplaces");
|
|
92
|
+
fs.mkdirSync(mpRoot, { recursive: true });
|
|
93
|
+
|
|
94
|
+
// Bare origin seeded at v1 (unpinned).
|
|
95
|
+
origin = path.join(tmp, "origin.git");
|
|
96
|
+
const seed = path.join(tmp, "seed");
|
|
97
|
+
git(tmp, "init", "--bare", origin);
|
|
98
|
+
git(tmp, "clone", origin, seed);
|
|
99
|
+
writePluginJson(seed, "4.0.3", "x@latest");
|
|
100
|
+
git(seed, "add", "-A");
|
|
101
|
+
git(seed, "commit", "-m", "v1");
|
|
102
|
+
git(seed, "push", "origin", "HEAD:main");
|
|
103
|
+
fs.rmSync(seed, { recursive: true, force: true });
|
|
104
|
+
|
|
105
|
+
configured = {};
|
|
106
|
+
autoUpdate = {};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("refreshRegisteredMarketplaces — the fix (CLAIM #1)", () => {
|
|
114
|
+
it("fast-forwards a stale clone so a newly pinned setup.go becomes visible", async () => {
|
|
115
|
+
const dir = installClone("magus");
|
|
116
|
+
expect(readPin(dir).pin).toBe("x@latest"); // stale on-disk clone
|
|
117
|
+
publishV2(); // a release lands upstream
|
|
118
|
+
|
|
119
|
+
configured = { magus: gh() };
|
|
120
|
+
const r = await refreshRegisteredMarketplaces();
|
|
121
|
+
|
|
122
|
+
expect(r.refreshed).toEqual(["magus"]);
|
|
123
|
+
expect(r.failed).toEqual([]);
|
|
124
|
+
// The whole point: the version-aware installer now sees the pin on disk.
|
|
125
|
+
expect(readPin(dir)).toEqual({ version: "4.1.2", pin: "x@v1.6.2" });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("refreshes when autoUpdate is undefined (default on)", async () => {
|
|
129
|
+
installClone("magus");
|
|
130
|
+
publishV2();
|
|
131
|
+
configured = { magus: gh() };
|
|
132
|
+
const r = await refreshRegisteredMarketplaces();
|
|
133
|
+
expect(r.refreshed).toEqual(["magus"]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("refreshRegisteredMarketplaces — non-destructive on failure (the rework's reason)", () => {
|
|
138
|
+
it("leaves the clone intact when the remote is unreachable (never deleted)", async () => {
|
|
139
|
+
const dir = installClone("magus");
|
|
140
|
+
git(
|
|
141
|
+
dir,
|
|
142
|
+
"remote",
|
|
143
|
+
"set-url",
|
|
144
|
+
"origin",
|
|
145
|
+
path.join(tmp, "does-not-exist.git"),
|
|
146
|
+
);
|
|
147
|
+
configured = { magus: gh() };
|
|
148
|
+
|
|
149
|
+
const r = await refreshRegisteredMarketplaces();
|
|
150
|
+
|
|
151
|
+
expect(r.failed).toEqual(["magus"]);
|
|
152
|
+
expect(fs.existsSync(dir)).toBe(true); // NOT deleted — the destructive mode is gone
|
|
153
|
+
expect(readPin(dir).pin).toBe("x@latest"); // content untouched
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("fails safely when history diverged (ff-only refuses to merge), clone intact", async () => {
|
|
157
|
+
const dir = installClone("magus");
|
|
158
|
+
// A local commit the upstream doesn't have → not fast-forwardable.
|
|
159
|
+
writePluginJson(dir, "4.0.3", "x@local");
|
|
160
|
+
git(dir, "add", "-A");
|
|
161
|
+
git(dir, "commit", "-m", "local divergence");
|
|
162
|
+
publishV2(); // upstream also moves → histories diverge
|
|
163
|
+
|
|
164
|
+
configured = { magus: gh() };
|
|
165
|
+
const r = await refreshRegisteredMarketplaces();
|
|
166
|
+
|
|
167
|
+
expect(r.failed).toEqual(["magus"]);
|
|
168
|
+
expect(fs.existsSync(dir)).toBe(true);
|
|
169
|
+
expect(readPin(dir).pin).toBe("x@local"); // local work preserved
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("refreshes healthy clones even when another fails (parallel, isolated)", async () => {
|
|
173
|
+
const good = installClone("good");
|
|
174
|
+
const bad = installClone("bad");
|
|
175
|
+
git(bad, "remote", "set-url", "origin", path.join(tmp, "nope.git"));
|
|
176
|
+
publishV2();
|
|
177
|
+
|
|
178
|
+
configured = { good: gh(), bad: gh("c/d") };
|
|
179
|
+
const r = await refreshRegisteredMarketplaces();
|
|
180
|
+
|
|
181
|
+
expect(r.refreshed).toEqual(["good"]);
|
|
182
|
+
expect(r.failed).toEqual(["bad"]);
|
|
183
|
+
expect(fs.existsSync(bad)).toBe(true);
|
|
184
|
+
expect(readPin(good).pin).toBe("x@v1.6.2");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("refreshRegisteredMarketplaces — selection & skips", () => {
|
|
189
|
+
it("skips a clone with uncommitted local changes (never clobbers them)", async () => {
|
|
190
|
+
const dir = installClone("magus");
|
|
191
|
+
publishV2();
|
|
192
|
+
// Uncommitted edit to a tracked file.
|
|
193
|
+
writePluginJson(dir, "4.0.3", "x@dirty");
|
|
194
|
+
configured = { magus: gh() };
|
|
195
|
+
|
|
196
|
+
const r = await refreshRegisteredMarketplaces();
|
|
197
|
+
|
|
198
|
+
expect(r.skipped).toEqual(["magus"]);
|
|
199
|
+
expect(r.refreshed).toEqual([]);
|
|
200
|
+
expect(readPin(dir).pin).toBe("x@dirty"); // preserved, not fast-forwarded
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("skips a registered marketplace whose clone is not on disk", async () => {
|
|
204
|
+
configured = { magus: gh() }; // never installed
|
|
205
|
+
const r = await refreshRegisteredMarketplaces();
|
|
206
|
+
expect(r.skipped).toEqual(["magus"]);
|
|
207
|
+
expect(r.failed).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("skips non-github sources", async () => {
|
|
211
|
+
configured = { local: { source: { source: "local" } } };
|
|
212
|
+
const r = await refreshRegisteredMarketplaces();
|
|
213
|
+
expect(r.refreshed).toEqual([]);
|
|
214
|
+
expect(r.skipped).toEqual(["local"]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("skips a marketplace with autoUpdate === false", async () => {
|
|
218
|
+
installClone("magus");
|
|
219
|
+
publishV2();
|
|
220
|
+
configured = { magus: gh() };
|
|
221
|
+
autoUpdate = { magus: false };
|
|
222
|
+
const r = await refreshRegisteredMarketplaces();
|
|
223
|
+
expect(r.refreshed).toEqual([]);
|
|
224
|
+
expect(r.skipped).toEqual(["magus"]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("honors the skip set (already handled this run)", async () => {
|
|
228
|
+
installClone("magus");
|
|
229
|
+
installClone("other");
|
|
230
|
+
publishV2();
|
|
231
|
+
configured = { magus: gh(), other: gh("x/y") };
|
|
232
|
+
|
|
233
|
+
const r = await refreshRegisteredMarketplaces(["magus"]);
|
|
234
|
+
|
|
235
|
+
expect(r.refreshed).toEqual(["other"]);
|
|
236
|
+
expect(r.refreshed).not.toContain("magus");
|
|
237
|
+
expect(r.skipped).not.toContain("magus"); // skip = not touched at all
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("fastForwardCloneIfPresent — single-clone helper (shared with claude-cli recovery)", () => {
|
|
242
|
+
it("returns 'absent' when no clone is on disk (caller then clones via add)", async () => {
|
|
243
|
+
expect(await fastForwardCloneIfPresent("magus")).toBe("absent");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("returns 'refreshed' and advances a stale clone", async () => {
|
|
247
|
+
const dir = installClone("magus");
|
|
248
|
+
publishV2();
|
|
249
|
+
expect(await fastForwardCloneIfPresent("magus")).toBe("refreshed");
|
|
250
|
+
expect(readPin(dir).pin).toBe("x@v1.6.2");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("returns 'failed' (never throws, clone intact) when the remote is unreachable", async () => {
|
|
254
|
+
const dir = installClone("magus");
|
|
255
|
+
git(dir, "remote", "set-url", "origin", path.join(tmp, "gone.git"));
|
|
256
|
+
expect(await fastForwardCloneIfPresent("magus")).toBe("failed");
|
|
257
|
+
expect(fs.existsSync(dir)).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("returns 'skipped' for a dirty clone (never clobbers local edits)", async () => {
|
|
261
|
+
const dir = installClone("magus");
|
|
262
|
+
publishV2();
|
|
263
|
+
writePluginJson(dir, "4.0.3", "x@dirty");
|
|
264
|
+
expect(await fastForwardCloneIfPresent("magus")).toBe("skipped");
|
|
265
|
+
expect(readPin(dir).pin).toBe("x@dirty");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import {
|
|
7
|
+
parsePluginRequires,
|
|
8
|
+
readPluginRequires,
|
|
9
|
+
} from "../services/plugin-requires.js";
|
|
10
|
+
|
|
11
|
+
describe("parsePluginRequires", () => {
|
|
12
|
+
test("parses a valid requires.bin and fills defaults", () => {
|
|
13
|
+
const requires = parsePluginRequires({
|
|
14
|
+
name: "terminal",
|
|
15
|
+
requires: {
|
|
16
|
+
bin: [
|
|
17
|
+
{ name: "tmux", via: "brew", formula: "tmux", check: "tmux -V" },
|
|
18
|
+
{ name: "tmux-mcp", via: "bun", package: "tmux-mcp" },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
expect(requires.bin).toHaveLength(2);
|
|
23
|
+
const tmux = requires.bin!.find((b) => b.name === "tmux")!;
|
|
24
|
+
expect(tmux.via).toBe("brew");
|
|
25
|
+
expect(tmux.check).toBe("tmux -V");
|
|
26
|
+
const mcp = requires.bin!.find((b) => b.name === "tmux-mcp")!;
|
|
27
|
+
// check defaulted from the name
|
|
28
|
+
expect(mcp.check).toBe("tmux-mcp --version");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("drops entries with no name or an unknown installer", () => {
|
|
32
|
+
const requires = parsePluginRequires({
|
|
33
|
+
requires: {
|
|
34
|
+
bin: [
|
|
35
|
+
{ via: "bun" }, // no name
|
|
36
|
+
{ name: "x", via: "cargo" }, // unknown installer
|
|
37
|
+
{ name: "ok", via: "npm" },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
expect(requires.bin).toHaveLength(1);
|
|
42
|
+
expect(requires.bin![0]!.name).toBe("ok");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("no requires / non-array bin → empty object", () => {
|
|
46
|
+
expect(parsePluginRequires({ name: "x" })).toEqual({});
|
|
47
|
+
expect(parsePluginRequires({ requires: {} })).toEqual({});
|
|
48
|
+
expect(parsePluginRequires({ requires: { bin: "nope" } })).toEqual({});
|
|
49
|
+
expect(parsePluginRequires(null)).toEqual({});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("parsePluginRequires — setup block (the established convention)", () => {
|
|
54
|
+
test("terminal's setup: go module + required system tool", () => {
|
|
55
|
+
// The real plugins/terminal/plugin.json shape.
|
|
56
|
+
const r = parsePluginRequires({
|
|
57
|
+
name: "terminal",
|
|
58
|
+
setup: {
|
|
59
|
+
go: ["github.com/MadAppGang/tmux-mcp@latest"],
|
|
60
|
+
required: ["tmux"],
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const mcp = r.bin!.find((b) => b.name === "tmux-mcp")!;
|
|
64
|
+
expect(mcp.via).toBe("go");
|
|
65
|
+
expect(mcp.module).toBe("github.com/MadAppGang/tmux-mcp");
|
|
66
|
+
expect(mcp.version).toBe("latest");
|
|
67
|
+
const tmux = r.bin!.find((b) => b.name === "tmux")!;
|
|
68
|
+
expect(tmux.via).toBe("brew");
|
|
69
|
+
expect(tmux.formula).toBe("tmux");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("browser-use's setup: pip packages", () => {
|
|
73
|
+
const r = parsePluginRequires({
|
|
74
|
+
name: "browser-use",
|
|
75
|
+
setup: { pip: ["browser-use", "mcp"] },
|
|
76
|
+
});
|
|
77
|
+
expect(r.bin!.map((b) => b.name).sort()).toEqual(["browser-use", "mcp"]);
|
|
78
|
+
expect(r.bin!.every((b) => b.via === "pip")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("bun/npm specs split name@version", () => {
|
|
82
|
+
const r = parsePluginRequires({
|
|
83
|
+
setup: { bun: ["claudish@1.2.0"], npm: ["@scope/tool@3.0.0"] },
|
|
84
|
+
});
|
|
85
|
+
const claudish = r.bin!.find((b) => b.name === "claudish")!;
|
|
86
|
+
expect(claudish.version).toBe("1.2.0");
|
|
87
|
+
const scoped = r.bin!.find((b) => b.name === "tool")!;
|
|
88
|
+
expect(scoped.package).toBe("@scope/tool");
|
|
89
|
+
expect(scoped.version).toBe("3.0.0");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("explicit requires.bin overrides a setup entry of the same name", () => {
|
|
93
|
+
const r = parsePluginRequires({
|
|
94
|
+
setup: { required: ["tmux"] }, // → brew
|
|
95
|
+
requires: { bin: [{ name: "tmux", via: "go", module: "example.com/tmux" }] },
|
|
96
|
+
});
|
|
97
|
+
const tmux = r.bin!.find((b) => b.name === "tmux")!;
|
|
98
|
+
expect(tmux.via).toBe("go"); // requires.bin won
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("readPluginRequires", () => {
|
|
103
|
+
let dir: string;
|
|
104
|
+
beforeEach(async () => {
|
|
105
|
+
dir = await mkdtemp(join(tmpdir(), "plugreq-"));
|
|
106
|
+
});
|
|
107
|
+
afterEach(async () => {
|
|
108
|
+
await rm(dir, { recursive: true, force: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("reads plugin.json at the plugin root", async () => {
|
|
112
|
+
await fs.writeJson(join(dir, "plugin.json"), {
|
|
113
|
+
name: "p",
|
|
114
|
+
requires: { bin: [{ name: "tmux", via: "brew" }] },
|
|
115
|
+
});
|
|
116
|
+
const r = await readPluginRequires(dir);
|
|
117
|
+
expect(r.bin?.[0]?.name).toBe("tmux");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("falls back to .claude-plugin/plugin.json", async () => {
|
|
121
|
+
await fs.ensureDir(join(dir, ".claude-plugin"));
|
|
122
|
+
await fs.writeJson(join(dir, ".claude-plugin", "plugin.json"), {
|
|
123
|
+
name: "p",
|
|
124
|
+
requires: { bin: [{ name: "mnemex", via: "bun" }] },
|
|
125
|
+
});
|
|
126
|
+
const r = await readPluginRequires(dir);
|
|
127
|
+
expect(r.bin?.[0]?.name).toBe("mnemex");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("missing manifest → empty", async () => {
|
|
131
|
+
expect(await readPluginRequires(dir)).toEqual({});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
10
|
+
// Real modules captured before the mock.module() overrides, so the mocks spread
|
|
11
|
+
// the full surface instead of stripping exports (e.g. resolveExecutable) that
|
|
12
|
+
// bun's global, non-restored mock.module would otherwise remove for later files.
|
|
13
|
+
import * as actualCommandUtils from "../utils/command-utils.js";
|
|
14
|
+
import * as actualChildProcess from "node:child_process";
|
|
10
15
|
|
|
11
16
|
// ---------------------------------------------------------------------------
|
|
12
17
|
// Mocks — must be set up BEFORE any import from plugin-setup
|
|
@@ -21,6 +26,7 @@ let execResults: Map<
|
|
|
21
26
|
|
|
22
27
|
// Mock command-utils BEFORE importing plugin-setup
|
|
23
28
|
mock.module("../utils/command-utils.js", () => ({
|
|
29
|
+
...actualCommandUtils,
|
|
24
30
|
which: async (cmd: string) => {
|
|
25
31
|
return availableBinaries.has(cmd) ? `/usr/bin/${cmd}` : null;
|
|
26
32
|
},
|
|
@@ -28,6 +34,7 @@ mock.module("../utils/command-utils.js", () => ({
|
|
|
28
34
|
|
|
29
35
|
// Mock child_process execFile — promisify wraps this callback-based API
|
|
30
36
|
mock.module("node:child_process", () => ({
|
|
37
|
+
...actualChildProcess,
|
|
31
38
|
execFile: (
|
|
32
39
|
cmd: string,
|
|
33
40
|
args: string[],
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import {
|
|
7
|
+
listProfiles,
|
|
8
|
+
showProfile,
|
|
9
|
+
switchProfile,
|
|
10
|
+
} from "../cli/profile.js";
|
|
11
|
+
import { activeProfile } from "../services/symlink-manager.js";
|
|
12
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
13
|
+
|
|
14
|
+
let logs: string[];
|
|
15
|
+
let errs: string[];
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
logs = [];
|
|
19
|
+
errs = [];
|
|
20
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
21
|
+
console.log = mock((...a: unknown[]) => logs.push(a.join(" "))) as any;
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
23
|
+
console.error = mock((...a: unknown[]) => errs.push(a.join(" "))) as any;
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => mock.restore());
|
|
26
|
+
|
|
27
|
+
async function setupProject(
|
|
28
|
+
manifest: ProfileManifest,
|
|
29
|
+
materialize: string[] = [],
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
const dir = await mkdtemp(join(tmpdir(), "profcmd-"));
|
|
32
|
+
await fs.ensureDir(join(dir, ".claude"));
|
|
33
|
+
await fs.writeJson(join(dir, ".claude", "profiles.json"), manifest);
|
|
34
|
+
for (const name of materialize) {
|
|
35
|
+
const pd = join(dir, ".claude", "_profiles", name);
|
|
36
|
+
await fs.ensureDir(pd);
|
|
37
|
+
await fs.writeJson(join(pd, "settings.json"), { which: name });
|
|
38
|
+
await fs.writeJson(join(pd, "mcp.json"), { mcpServers: {} });
|
|
39
|
+
}
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const twoProfiles: ProfileManifest = {
|
|
44
|
+
version: 2,
|
|
45
|
+
profiles: {
|
|
46
|
+
frontend: { name: "Frontend", plugins: { "dev@magus": "latest" } },
|
|
47
|
+
backend: { name: "Backend", plugins: { "dev@magus": "2.9.0" } },
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("profile list", () => {
|
|
52
|
+
let dir: string;
|
|
53
|
+
afterEach(async () => {
|
|
54
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("lists profiles, marks (not installed) and the active one", async () => {
|
|
58
|
+
dir = await setupProject(twoProfiles, ["frontend"]);
|
|
59
|
+
await switchProfile("frontend", dir); // activate
|
|
60
|
+
logs = [];
|
|
61
|
+
const code = await listProfiles(dir);
|
|
62
|
+
expect(code).toBe(0);
|
|
63
|
+
const out = logs.join("\n");
|
|
64
|
+
expect(out).toContain("frontend");
|
|
65
|
+
expect(out).toContain("Backend");
|
|
66
|
+
expect(out).toContain("(not installed)"); // backend isn't materialized
|
|
67
|
+
expect(out).toContain("Active: frontend");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("profile show", () => {
|
|
72
|
+
let dir: string;
|
|
73
|
+
afterEach(async () => {
|
|
74
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("prints a resolved closure summary", async () => {
|
|
78
|
+
dir = await setupProject(twoProfiles);
|
|
79
|
+
const code = await showProfile("backend", dir);
|
|
80
|
+
expect(code).toBe(0);
|
|
81
|
+
expect(logs.join("\n")).toContain("dev@magus@2.9.0");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("unknown profile → exit 1", async () => {
|
|
85
|
+
dir = await setupProject(twoProfiles);
|
|
86
|
+
expect(await showProfile("ghost", dir)).toBe(1);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("profile switch", () => {
|
|
91
|
+
let dir: string;
|
|
92
|
+
afterEach(async () => {
|
|
93
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("switches when materialized and records the active profile", async () => {
|
|
97
|
+
dir = await setupProject(twoProfiles, ["frontend", "backend"]);
|
|
98
|
+
expect(await switchProfile("backend", dir)).toBe(0);
|
|
99
|
+
expect(await activeProfile(dir)).toBe("backend");
|
|
100
|
+
// gitignore got the _profiles entry
|
|
101
|
+
expect(await fs.readFile(join(dir, ".gitignore"), "utf8")).toContain(
|
|
102
|
+
".claude/_profiles/",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("refuses to switch to a profile that isn't installed", async () => {
|
|
107
|
+
dir = await setupProject(twoProfiles, ["frontend"]);
|
|
108
|
+
expect(await switchProfile("backend", dir)).toBe(1);
|
|
109
|
+
expect(errs.join("\n")).toContain("claudeup install backend");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("unknown profile → exit 1", async () => {
|
|
113
|
+
dir = await setupProject(twoProfiles);
|
|
114
|
+
expect(await switchProfile("ghost", dir)).toBe(1);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import {
|
|
7
|
+
buildProfileMcp,
|
|
8
|
+
buildProfileSettings,
|
|
9
|
+
materializeProfile,
|
|
10
|
+
} from "../services/profile-materializer.js";
|
|
11
|
+
import { profileDir } from "../services/symlink-manager.js";
|
|
12
|
+
import type { ResolvedClosure } from "../types/index.js";
|
|
13
|
+
|
|
14
|
+
function closure(overrides: Partial<ResolvedClosure> = {}): ResolvedClosure {
|
|
15
|
+
return {
|
|
16
|
+
marketplaces: {},
|
|
17
|
+
plugins: {},
|
|
18
|
+
mcpServers: {},
|
|
19
|
+
bins: [],
|
|
20
|
+
skills: [],
|
|
21
|
+
settings: {},
|
|
22
|
+
env: { required: [], optional: [] },
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("buildProfileSettings", () => {
|
|
28
|
+
test("enables every resolved plugin and merges declared settings", () => {
|
|
29
|
+
const s = buildProfileSettings(
|
|
30
|
+
closure({
|
|
31
|
+
plugins: { "dev@magus": "2.9.0", "terminal@magus": "latest" },
|
|
32
|
+
settings: { enableAllProjectMcpServers: true },
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
expect(s.enabledPlugins).toEqual({
|
|
36
|
+
"dev@magus": true,
|
|
37
|
+
"terminal@magus": true,
|
|
38
|
+
});
|
|
39
|
+
expect(s.enableAllProjectMcpServers).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("buildProfileMcp", () => {
|
|
44
|
+
test("wraps the closure's mcpServers", () => {
|
|
45
|
+
const m = buildProfileMcp(
|
|
46
|
+
closure({ mcpServers: { "chrome-devtools": { command: "npx" } } }),
|
|
47
|
+
);
|
|
48
|
+
expect(m.mcpServers["chrome-devtools"]).toEqual({ command: "npx" });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("materializeProfile", () => {
|
|
53
|
+
let project: string;
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
project = await mkdtemp(join(tmpdir(), "materialize-"));
|
|
56
|
+
});
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
await rm(project, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("writes settings.json, mcp.json, and an empty skills/ dir", async () => {
|
|
62
|
+
const dir = await materializeProfile(
|
|
63
|
+
"frontend",
|
|
64
|
+
closure({
|
|
65
|
+
plugins: { "dev@magus": "latest" },
|
|
66
|
+
mcpServers: { x: { command: "y" } },
|
|
67
|
+
}),
|
|
68
|
+
project,
|
|
69
|
+
);
|
|
70
|
+
expect(dir).toBe(profileDir("frontend", project));
|
|
71
|
+
expect((await fs.readJson(join(dir, "settings.json"))).enabledPlugins["dev@magus"]).toBe(true);
|
|
72
|
+
expect((await fs.readJson(join(dir, "mcp.json"))).mcpServers.x.command).toBe("y");
|
|
73
|
+
expect(await fs.pathExists(join(dir, "skills"))).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("is idempotent (re-materialize overwrites cleanly)", async () => {
|
|
77
|
+
await materializeProfile("p", closure({ plugins: { "a@m": "latest" } }), project);
|
|
78
|
+
const dir = await materializeProfile("p", closure({ plugins: { "b@m": "latest" } }), project);
|
|
79
|
+
const settings = await fs.readJson(join(dir, "settings.json"));
|
|
80
|
+
expect(settings.enabledPlugins).toEqual({ "b@m": true });
|
|
81
|
+
});
|
|
82
|
+
});
|