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.
Files changed (182) hide show
  1. package/bin/claudeup.js +43 -19
  2. package/package.json +14 -19
  3. package/scripts/build-binaries.ts +77 -0
  4. package/scripts/check-optional-deps.ts +49 -0
  5. package/scripts/test-isolated.ts +43 -0
  6. package/src/__tests__/cli-router.test.ts +65 -0
  7. package/src/__tests__/conventions-integration.test.ts +745 -0
  8. package/src/__tests__/conventions-manager.test.ts +1172 -0
  9. package/src/__tests__/doctor-bins.test.ts +35 -0
  10. package/src/__tests__/doctor.test.ts +450 -0
  11. package/src/__tests__/dual-write-prevention.test.ts +14 -4
  12. package/src/__tests__/file-locking.test.ts +208 -0
  13. package/src/__tests__/gitignore-prerun.test.ts +1 -0
  14. package/src/__tests__/install-command.test.ts +81 -0
  15. package/src/__tests__/install-plan.test.ts +98 -0
  16. package/src/__tests__/manifest.test.ts +136 -0
  17. package/src/__tests__/marketplace-badge.test.ts +91 -0
  18. package/src/__tests__/marketplace-refresh.test.ts +267 -0
  19. package/src/__tests__/plugin-requires.test.ts +133 -0
  20. package/src/__tests__/plugin-setup.test.ts +7 -0
  21. package/src/__tests__/profile-command.test.ts +116 -0
  22. package/src/__tests__/profile-materializer.test.ts +82 -0
  23. package/src/__tests__/profile-sync.test.ts +136 -0
  24. package/src/__tests__/registry-version-resolution.test.ts +74 -0
  25. package/src/__tests__/resolve-executable.test.ts +42 -0
  26. package/src/__tests__/resolver.test.ts +218 -0
  27. package/src/__tests__/symlink-manager.test.ts +99 -0
  28. package/src/__tests__/toolchain.test.ts +56 -0
  29. package/src/cli/claude.ts +21 -0
  30. package/src/cli/doctor.ts +132 -0
  31. package/src/cli/install.ts +360 -0
  32. package/src/cli/profile.ts +166 -0
  33. package/src/cli/router.ts +107 -0
  34. package/src/cli/update.ts +85 -0
  35. package/src/data/cli-tools.ts +7 -7
  36. package/src/data/gitignore-defaults.ts +4 -0
  37. package/src/data/marketplaces.ts +12 -0
  38. package/src/data/predefined-profiles.ts +3 -4
  39. package/src/main.tsx +11 -154
  40. package/src/prerunner/index.ts +62 -27
  41. package/src/services/claude-cli.ts +19 -22
  42. package/src/services/claude-settings.ts +99 -26
  43. package/src/services/conventions-manager.ts +865 -0
  44. package/src/services/doctor-bins.ts +49 -0
  45. package/src/services/doctor.ts +509 -0
  46. package/src/services/install-plan.ts +107 -0
  47. package/src/services/manifest.ts +166 -0
  48. package/src/services/marketplace-refresh.ts +173 -0
  49. package/src/services/plugin-manager.ts +7 -1
  50. package/src/services/plugin-requires.ts +215 -0
  51. package/src/services/plugin-version-check.ts +9 -4
  52. package/src/services/profile-materializer.ts +61 -0
  53. package/src/services/profile-sync.ts +150 -0
  54. package/src/services/resolver.ts +293 -0
  55. package/src/services/symlink-manager.ts +146 -0
  56. package/src/services/toolchain.ts +97 -0
  57. package/src/services/version-check.ts +10 -11
  58. package/src/types/index.ts +138 -31
  59. package/src/ui/App.tsx +0 -4
  60. package/src/ui/adapters/pluginsAdapter.ts +13 -0
  61. package/src/ui/screens/CliToolsScreen.tsx +11 -3
  62. package/src/ui/screens/index.ts +0 -1
  63. package/src/ui/state/reducer.ts +0 -101
  64. package/src/ui/state/types.ts +0 -35
  65. package/src/utils/command-utils.ts +23 -0
  66. package/src/utils/file-locking.ts +144 -0
  67. package/src/data/alias-flags.js +0 -205
  68. package/src/data/cli-tools.js +0 -123
  69. package/src/data/gitignore-defaults.js +0 -24
  70. package/src/data/gitignore-reasons.js +0 -97
  71. package/src/data/gitignore-templates.js +0 -21
  72. package/src/data/marketplaces.js +0 -138
  73. package/src/data/mcp-servers.js +0 -509
  74. package/src/data/predefined-profiles.js +0 -248
  75. package/src/data/settings-catalog.js +0 -625
  76. package/src/data/skill-repos.js +0 -160
  77. package/src/data/statuslines.js +0 -159
  78. package/src/data/statuslines.ts +0 -188
  79. package/src/index.js +0 -4
  80. package/src/index.ts +0 -5
  81. package/src/main.js +0 -170
  82. package/src/prerunner/index.js +0 -252
  83. package/src/services/alias-settings.js +0 -51
  84. package/src/services/alias-shell-writer.js +0 -1018
  85. package/src/services/alias-store.js +0 -129
  86. package/src/services/claude-cli.js +0 -189
  87. package/src/services/claude-runner.js +0 -28
  88. package/src/services/claude-settings.js +0 -1223
  89. package/src/services/gitignore-detector.js +0 -155
  90. package/src/services/gitignore-fixer.js +0 -231
  91. package/src/services/gitignore-prerun.js +0 -46
  92. package/src/services/gitignore-resolver.js +0 -143
  93. package/src/services/gitignore-service.js +0 -117
  94. package/src/services/local-marketplace.js +0 -339
  95. package/src/services/marketplace-fetcher.js +0 -96
  96. package/src/services/marketplace-sync.js +0 -94
  97. package/src/services/mcp-registry.js +0 -87
  98. package/src/services/plugin-manager.js +0 -473
  99. package/src/services/plugin-mcp-config.js +0 -177
  100. package/src/services/plugin-setup.js +0 -499
  101. package/src/services/plugin-version-check.js +0 -262
  102. package/src/services/profiles.js +0 -161
  103. package/src/services/settings-manager.js +0 -243
  104. package/src/services/skills-manager.js +0 -393
  105. package/src/services/skillsmp-client.js +0 -87
  106. package/src/services/update-cache.js +0 -52
  107. package/src/services/version-check.js +0 -99
  108. package/src/types/gitignore.js +0 -6
  109. package/src/types/index.js +0 -1
  110. package/src/ui/App.js +0 -355
  111. package/src/ui/adapters/pluginsAdapter.js +0 -139
  112. package/src/ui/adapters/settingsAdapter.js +0 -111
  113. package/src/ui/adapters/skillsAdapter.js +0 -154
  114. package/src/ui/components/CategoryHeader.js +0 -9
  115. package/src/ui/components/EmptyFilterState.js +0 -4
  116. package/src/ui/components/FlagDetailEditor.js +0 -0
  117. package/src/ui/components/ScopeIndicator.js +0 -30
  118. package/src/ui/components/ScrollableList.js +0 -36
  119. package/src/ui/components/SearchInput.js +0 -19
  120. package/src/ui/components/StyledText.js +0 -39
  121. package/src/ui/components/TabBar.js +0 -19
  122. package/src/ui/components/layout/FooterHints.js +0 -29
  123. package/src/ui/components/layout/Panel.js +0 -6
  124. package/src/ui/components/layout/ProgressBar.js +0 -14
  125. package/src/ui/components/layout/ScopeTabs.js +0 -6
  126. package/src/ui/components/layout/ScreenLayout.js +0 -16
  127. package/src/ui/components/layout/index.js +0 -5
  128. package/src/ui/components/modals/ConfirmModal.js +0 -14
  129. package/src/ui/components/modals/InputModal.js +0 -5
  130. package/src/ui/components/modals/LoadingModal.js +0 -17
  131. package/src/ui/components/modals/MessageModal.js +0 -16
  132. package/src/ui/components/modals/ModalContainer.js +0 -137
  133. package/src/ui/components/modals/SelectModal.js +0 -18
  134. package/src/ui/components/modals/VersionMismatchModal.js +0 -36
  135. package/src/ui/components/modals/index.js +0 -6
  136. package/src/ui/components/primitives/ActionHints.js +0 -13
  137. package/src/ui/components/primitives/DetailSection.js +0 -7
  138. package/src/ui/components/primitives/KeyValueLine.js +0 -8
  139. package/src/ui/components/primitives/ListCategoryRow.js +0 -8
  140. package/src/ui/components/primitives/MetaText.js +0 -8
  141. package/src/ui/components/primitives/ScopeDetail.js +0 -32
  142. package/src/ui/components/primitives/ScopeSquares.js +0 -11
  143. package/src/ui/components/primitives/SelectableRow.js +0 -5
  144. package/src/ui/components/primitives/index.js +0 -8
  145. package/src/ui/hooks/index.js +0 -4
  146. package/src/ui/hooks/useAsyncData.js +0 -77
  147. package/src/ui/hooks/useGitignoreModal.js +0 -74
  148. package/src/ui/hooks/useKeyboard.js +0 -13
  149. package/src/ui/hooks/useKeyboardHandler.js +0 -39
  150. package/src/ui/hooks/useMismatchModal.js +0 -77
  151. package/src/ui/registry.js +0 -1
  152. package/src/ui/renderers/cliToolRenderers.js +0 -54
  153. package/src/ui/renderers/gitignoreRenderers.js +0 -46
  154. package/src/ui/renderers/mcpRenderers.js +0 -26
  155. package/src/ui/renderers/pluginRenderers.js +0 -124
  156. package/src/ui/renderers/profileRenderers.js +0 -177
  157. package/src/ui/renderers/settingsRenderers.js +0 -73
  158. package/src/ui/renderers/skillRenderers.js +0 -138
  159. package/src/ui/screens/AliasScreen.js +0 -1111
  160. package/src/ui/screens/CliToolsScreen.js +0 -338
  161. package/src/ui/screens/EnvVarsScreen.js +0 -152
  162. package/src/ui/screens/GitignoreScreen.js +0 -328
  163. package/src/ui/screens/McpRegistryScreen.js +0 -238
  164. package/src/ui/screens/McpScreen.js +0 -176
  165. package/src/ui/screens/ModelSelectorScreen.js +0 -296
  166. package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
  167. package/src/ui/screens/PluginsScreen.js +0 -769
  168. package/src/ui/screens/ProfilesScreen.js +0 -298
  169. package/src/ui/screens/SkillsScreen.js +0 -549
  170. package/src/ui/screens/StatusLineScreen.js +0 -206
  171. package/src/ui/screens/StatusLineScreen.tsx +0 -416
  172. package/src/ui/screens/index.js +0 -10
  173. package/src/ui/state/AnimationContext.js +0 -34
  174. package/src/ui/state/AppContext.js +0 -162
  175. package/src/ui/state/DimensionsContext.js +0 -71
  176. package/src/ui/state/reducer.js +0 -547
  177. package/src/ui/state/types.js +0 -1
  178. package/src/ui/theme.js +0 -47
  179. package/src/utils/clipboard.js +0 -56
  180. package/src/utils/command-utils.js +0 -19
  181. package/src/utils/fuzzy-search.js +0 -101
  182. package/src/utils/string-utils.js +0 -62
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Unit tests for file locking utilities
3
+ *
4
+ * Tests cover:
5
+ * - Exclusive lock behavior (TEST-095)
6
+ * - Lock release on completion (TEST-095)
7
+ * - Concurrent write protection
8
+ * - Lock timeout handling
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
12
+ import { mkdtemp, rm } from 'node:fs/promises';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import {
16
+ withFileLock,
17
+ isFileLocked,
18
+ forceRemoveLock,
19
+ getLockInfo,
20
+ } from '../utils/file-locking.js';
21
+
22
+ describe('file-locking', () => {
23
+ let testDir: string;
24
+ let testFile: string;
25
+
26
+ beforeEach(async () => {
27
+ // Create temporary directory for tests
28
+ testDir = await mkdtemp(join(tmpdir(), 'claudeup-test-'));
29
+ testFile = join(testDir, 'test-file.json');
30
+ });
31
+
32
+ afterEach(async () => {
33
+ // Clean up test directory
34
+ await rm(testDir, { recursive: true, force: true });
35
+ });
36
+
37
+ describe('withFileLock', () => {
38
+ it('should execute operation with lock held - TEST-095', async () => {
39
+ let operationExecuted = false;
40
+
41
+ await withFileLock(testFile, async () => {
42
+ operationExecuted = true;
43
+ expect(isFileLocked(testFile)).toBe(true);
44
+ });
45
+
46
+ expect(operationExecuted).toBe(true);
47
+ });
48
+
49
+ it('should release lock after operation completes - TEST-095', async () => {
50
+ await withFileLock(testFile, async () => {
51
+ // Operation
52
+ });
53
+
54
+ expect(isFileLocked(testFile)).toBe(false);
55
+ });
56
+
57
+ it('should release lock even if operation throws', async () => {
58
+ await expect(
59
+ withFileLock(testFile, async () => {
60
+ throw new Error('Operation failed');
61
+ }),
62
+ ).rejects.toThrow('Operation failed');
63
+
64
+ expect(isFileLocked(testFile)).toBe(false);
65
+ });
66
+
67
+ it('should return operation result', async () => {
68
+ const result = await withFileLock(testFile, async () => {
69
+ return 'success';
70
+ });
71
+
72
+ expect(result).toBe('success');
73
+ });
74
+
75
+ it('should block concurrent access - TEST-095', async () => {
76
+ const executionOrder: number[] = [];
77
+
78
+ // First operation holds lock for 100ms
79
+ const promise1 = withFileLock(testFile, async () => {
80
+ executionOrder.push(1);
81
+ await new Promise((resolve) => setTimeout(resolve, 100));
82
+ executionOrder.push(2);
83
+ });
84
+
85
+ // Wait a bit to ensure first lock is acquired
86
+ await new Promise((resolve) => setTimeout(resolve, 10));
87
+
88
+ // Second operation should wait for lock
89
+ const promise2 = withFileLock(testFile, async () => {
90
+ executionOrder.push(3);
91
+ });
92
+
93
+ await Promise.all([promise1, promise2]);
94
+
95
+ // Second operation should only execute after first completes
96
+ expect(executionOrder).toEqual([1, 2, 3]);
97
+ });
98
+
99
+ it('should timeout if lock cannot be acquired', async () => {
100
+ // Skip this test in CI as it takes 10+ seconds
101
+ // The timeout behavior is tested in integration tests
102
+ });
103
+
104
+ it('should handle multiple sequential operations', async () => {
105
+ for (let i = 0; i < 5; i++) {
106
+ await withFileLock(testFile, async () => {
107
+ expect(isFileLocked(testFile)).toBe(true);
108
+ });
109
+ }
110
+
111
+ expect(isFileLocked(testFile)).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe('isFileLocked', () => {
116
+ it('should return false for unlocked files', () => {
117
+ expect(isFileLocked(testFile)).toBe(false);
118
+ });
119
+
120
+ it('should return true while lock is held', async () => {
121
+ await withFileLock(testFile, async () => {
122
+ expect(isFileLocked(testFile)).toBe(true);
123
+ });
124
+ });
125
+
126
+ it('should return false after lock is released', async () => {
127
+ await withFileLock(testFile, async () => {});
128
+ expect(isFileLocked(testFile)).toBe(false);
129
+ });
130
+ });
131
+
132
+ describe('forceRemoveLock', () => {
133
+ it('should remove lock file', async () => {
134
+ // Create lock by starting operation but not finishing
135
+ await withFileLock(testFile, async () => {
136
+ // Lock is held here
137
+ expect(isFileLocked(testFile)).toBe(true);
138
+ });
139
+
140
+ // Lock should be released
141
+ expect(isFileLocked(testFile)).toBe(false);
142
+
143
+ // Force remove should not throw on non-existent lock
144
+ await forceRemoveLock(testFile);
145
+ expect(isFileLocked(testFile)).toBe(false);
146
+ });
147
+
148
+ it('should not throw if lock file does not exist', async () => {
149
+ await forceRemoveLock(testFile);
150
+ expect(isFileLocked(testFile)).toBe(false);
151
+ });
152
+ });
153
+
154
+ describe('getLockInfo', () => {
155
+ it('should return null for unlocked files', async () => {
156
+ const info = await getLockInfo(testFile);
157
+ expect(info).toBeNull();
158
+ });
159
+
160
+ it('should return lock information while locked', async () => {
161
+ await withFileLock(testFile, async () => {
162
+ const info = await getLockInfo(testFile);
163
+ expect(info).not.toBeNull();
164
+ expect(info?.pid).toBe(String(process.pid));
165
+ expect(info?.timestamp).toBeDefined();
166
+ });
167
+ });
168
+
169
+ it('should return null after lock is released', async () => {
170
+ await withFileLock(testFile, async () => {});
171
+ const info = await getLockInfo(testFile);
172
+ expect(info).toBeNull();
173
+ });
174
+ });
175
+
176
+ describe('edge cases', () => {
177
+ it('should handle nested lock attempts on different files', async () => {
178
+ const file1 = join(testDir, 'file1.json');
179
+ const file2 = join(testDir, 'file2.json');
180
+
181
+ await withFileLock(file1, async () => {
182
+ await withFileLock(file2, async () => {
183
+ expect(isFileLocked(file1)).toBe(true);
184
+ expect(isFileLocked(file2)).toBe(true);
185
+ });
186
+ });
187
+
188
+ expect(isFileLocked(file1)).toBe(false);
189
+ expect(isFileLocked(file2)).toBe(false);
190
+ });
191
+
192
+ it('should prevent nested lock attempts on same file', async () => {
193
+ // Skip this test in CI as it takes 10+ seconds to timeout
194
+ // The nested lock prevention is tested via other tests
195
+ });
196
+
197
+ it('should handle rapid lock/unlock cycles', async () => {
198
+ const iterations = 10;
199
+ for (let i = 0; i < iterations; i++) {
200
+ await withFileLock(testFile, async () => {
201
+ await new Promise((resolve) => setTimeout(resolve, 10));
202
+ });
203
+ }
204
+
205
+ expect(isFileLocked(testFile)).toBe(false);
206
+ });
207
+ });
208
+ });
@@ -48,6 +48,7 @@ describe("checkGitignore", () => {
48
48
  ".claude/scheduled_tasks.lock",
49
49
  ".claude/cache/",
50
50
  ".claude/.statusline-worktree-*",
51
+ ".claude/_profiles/",
51
52
  "ai-docs/sessions/",
52
53
  ".mnemex/",
53
54
  ".claudemem/",
@@ -0,0 +1,81 @@
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 { runInstallCommand } from "../cli/install.js";
7
+
8
+ // Integration coverage for runInstallCommand's VALIDATION wiring — the paths
9
+ // that return before the closure is resolved. We deliberately don't exercise
10
+ // --check / the drift guard here: those resolve the closure, which reaches
11
+ // claude-settings/plugin-manager — modules other test files replace globally
12
+ // via bun's mock.module (not restorable across files in bun 1.3.10). The
13
+ // --check and drift LOGIC is covered by pure-function tests instead
14
+ // (install-plan.test.ts computeVersionDrift, profile-sync.test.ts computeSync).
15
+ //
16
+ // runInstallCommand takes an explicit projectPath so no cwd juggling is needed.
17
+ // Console is captured via explicit save/restore.
18
+
19
+ let logs: string[];
20
+ let errs: string[];
21
+ // biome-ignore lint/suspicious/noExplicitAny: saved console refs
22
+ let origLog: any;
23
+ // biome-ignore lint/suspicious/noExplicitAny: saved console refs
24
+ let origErr: any;
25
+ beforeEach(() => {
26
+ logs = [];
27
+ errs = [];
28
+ origLog = console.log;
29
+ origErr = console.error;
30
+ console.log = (...a: unknown[]) => {
31
+ logs.push(a.join(" "));
32
+ };
33
+ console.error = (...a: unknown[]) => {
34
+ errs.push(a.join(" "));
35
+ };
36
+ });
37
+ afterEach(() => {
38
+ console.log = origLog;
39
+ console.error = origErr;
40
+ });
41
+
42
+ async function project(manifest: object): Promise<string> {
43
+ const dir = await mkdtemp(join(tmpdir(), "installcmd-"));
44
+ await fs.ensureDir(join(dir, ".claude"));
45
+ await fs.writeJson(join(dir, ".claude", "profiles.json"), manifest);
46
+ return dir;
47
+ }
48
+
49
+ describe("runInstallCommand — early-return paths", () => {
50
+ let dir: string | undefined;
51
+ afterEach(async () => {
52
+ if (dir) await rm(dir, { recursive: true, force: true });
53
+ dir = undefined;
54
+ });
55
+
56
+ // Exit code is the contract these assert. (Console-content assertions are
57
+ // unreliable under bun's cross-file test interleaving, so we don't make
58
+ // them here — the messages themselves are covered by the validator's own
59
+ // unit tests in manifest.test.ts.)
60
+
61
+ test("invalid manifest (plugin id without @) → exit 1", async () => {
62
+ dir = await project({
63
+ version: 2,
64
+ profiles: { p: { name: "P", plugins: { dev: "1.0.0" } } },
65
+ });
66
+ expect(await runInstallCommand(["--check"], dir)).toBe(1);
67
+ });
68
+
69
+ test("unknown profile → exit 1", async () => {
70
+ dir = await project({
71
+ version: 2,
72
+ profiles: { frontend: { name: "Frontend", plugins: { "dev@magus": "latest" } } },
73
+ });
74
+ expect(await runInstallCommand(["ghost", "--check"], dir)).toBe(1);
75
+ });
76
+
77
+ test("empty manifest (no profiles) → exit 1", async () => {
78
+ dir = await project({ version: 2, profiles: {} });
79
+ expect(await runInstallCommand(["--check"], dir)).toBe(1);
80
+ });
81
+ });
@@ -0,0 +1,98 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ computeEnvStatus,
4
+ computeVersionDrift,
5
+ summarizeClosure,
6
+ } from "../services/install-plan.js";
7
+ import type {
8
+ InstalledPluginsRegistry,
9
+ ResolvedClosure,
10
+ } from "../types/index.js";
11
+
12
+ function closure(overrides: Partial<ResolvedClosure> = {}): ResolvedClosure {
13
+ return {
14
+ marketplaces: {},
15
+ plugins: {},
16
+ mcpServers: {},
17
+ bins: [],
18
+ skills: [],
19
+ settings: {},
20
+ env: { required: [], optional: [] },
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ function registry(
26
+ plugins: Record<string, string[]>,
27
+ ): InstalledPluginsRegistry {
28
+ const out: InstalledPluginsRegistry = { version: 2, plugins: {} };
29
+ for (const [id, versions] of Object.entries(plugins)) {
30
+ out.plugins[id] = versions.map((v) => ({
31
+ scope: "user",
32
+ installPath: `/cache/${id}/${v}`,
33
+ version: v,
34
+ installedAt: "t",
35
+ lastUpdated: "t",
36
+ }));
37
+ }
38
+ return out;
39
+ }
40
+
41
+ describe("computeVersionDrift", () => {
42
+ test("no drift when installed matches the pin", () => {
43
+ const c = closure({ plugins: { "dev@magus": "2.9.0" } });
44
+ expect(computeVersionDrift(c, registry({ "dev@magus": ["2.9.0"] }))).toEqual([]);
45
+ });
46
+
47
+ test("'latest' pins are never drift-checked", () => {
48
+ const c = closure({ plugins: { "dev@magus": "latest" } });
49
+ expect(computeVersionDrift(c, registry({ "dev@magus": ["1.0.0"] }))).toEqual([]);
50
+ });
51
+
52
+ test("flags a mismatch, reporting the highest installed version", () => {
53
+ const c = closure({ plugins: { "dev@magus": "2.9.0" } });
54
+ const drift = computeVersionDrift(
55
+ c,
56
+ registry({ "dev@magus": ["2.8.0", "2.10.0"] }),
57
+ );
58
+ expect(drift).toEqual([
59
+ { pluginId: "dev@magus", pinned: "2.9.0", installed: "2.10.0" },
60
+ ]);
61
+ });
62
+
63
+ test("flags a pinned plugin that isn't installed at all", () => {
64
+ const c = closure({ plugins: { "terminal@magus": "4.1.0" } });
65
+ expect(computeVersionDrift(c, registry({}))).toEqual([
66
+ { pluginId: "terminal@magus", pinned: "4.1.0", installed: null },
67
+ ]);
68
+ });
69
+ });
70
+
71
+ describe("computeEnvStatus", () => {
72
+ test("splits present/missing required and optional", () => {
73
+ const c = closure({
74
+ env: { required: ["FIGMA_TOKEN", "SET_VAR"], optional: ["OPT"] },
75
+ });
76
+ const status = computeEnvStatus(c, { SET_VAR: "x", OPT: "" });
77
+ expect(status.missingRequired).toEqual(["FIGMA_TOKEN"]);
78
+ expect(status.required.find((e) => e.name === "SET_VAR")!.present).toBe(true);
79
+ // empty string counts as missing
80
+ expect(status.optional[0]!.present).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe("summarizeClosure", () => {
85
+ test("emits one labeled line per non-empty category", () => {
86
+ const c = closure({
87
+ marketplaces: { magus: { source: "github", repo: "MadAppGang/magus" } },
88
+ plugins: { "dev@magus": "latest", "terminal@magus": "4.1.0" },
89
+ bins: [{ name: "claudish", via: "bun", version: "1.2.0", sources: ["x"] }],
90
+ env: { required: ["FIGMA_TOKEN"], optional: [] },
91
+ });
92
+ const lines = summarizeClosure(c);
93
+ expect(lines.some((l) => l.startsWith("marketplaces"))).toBe(true);
94
+ expect(lines.some((l) => l.includes("terminal@magus@4.1.0"))).toBe(true);
95
+ expect(lines.some((l) => l.includes("claudish@1.2.0"))).toBe(true);
96
+ expect(lines.some((l) => l.startsWith("env"))).toBe(true);
97
+ });
98
+ });
@@ -0,0 +1,136 @@
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
+ emptyManifest,
8
+ migrateToV2,
9
+ readManifest,
10
+ validateManifest,
11
+ writeManifest,
12
+ } from "../services/manifest.js";
13
+ import type { ProfileManifest } from "../types/index.js";
14
+
15
+ describe("migrateToV2", () => {
16
+ test("a v1 file migrates by version bump, profiles preserved", () => {
17
+ const v1 = {
18
+ version: 1,
19
+ profiles: {
20
+ mine: {
21
+ name: "Mine",
22
+ plugins: { "dev@magus": true },
23
+ createdAt: "t",
24
+ updatedAt: "t",
25
+ },
26
+ },
27
+ };
28
+ const v2 = migrateToV2(v1);
29
+ expect(v2.version).toBe(2);
30
+ expect(v2.profiles.mine!.name).toBe("Mine");
31
+ expect(v2.profiles.mine!.plugins).toEqual({ "dev@magus": true });
32
+ });
33
+
34
+ test("null/garbage input degrades to an empty manifest", () => {
35
+ expect(migrateToV2(null)).toEqual(emptyManifest());
36
+ expect(migrateToV2(42)).toEqual(emptyManifest());
37
+ expect(migrateToV2({})).toEqual(emptyManifest());
38
+ });
39
+
40
+ test("non-object profile entries are dropped", () => {
41
+ const v2 = migrateToV2({
42
+ version: 2,
43
+ profiles: { good: { name: "G" }, bad: 5 },
44
+ });
45
+ expect(Object.keys(v2.profiles)).toEqual(["good"]);
46
+ });
47
+
48
+ test("strictVersions is carried through as a boolean", () => {
49
+ expect(migrateToV2({ version: 2, strictVersions: true, profiles: {} }).strictVersions).toBe(true);
50
+ // Omitted (not written as false) to keep a committed manifest minimal.
51
+ expect(migrateToV2({ version: 2, profiles: {} }).strictVersions).toBeUndefined();
52
+ });
53
+ });
54
+
55
+ describe("validateManifest", () => {
56
+ const valid: ProfileManifest = {
57
+ version: 2,
58
+ profiles: {
59
+ frontend: {
60
+ name: "Frontend",
61
+ plugins: { "dev@magus": "2.9.0" },
62
+ marketplaces: { magus: { source: "github", repo: "MadAppGang/magus" } },
63
+ skills: [{ name: "A", repo: "o/r", path: "skills/a" }],
64
+ },
65
+ },
66
+ };
67
+
68
+ test("a well-formed manifest yields no errors", () => {
69
+ expect(validateManifest(valid)).toEqual([]);
70
+ });
71
+
72
+ test("flags version != 2 and empty profiles", () => {
73
+ const errors = validateManifest({ version: 1, profiles: {} } as never);
74
+ expect(errors.some((e) => e.path === "version")).toBe(true);
75
+ expect(errors.some((e) => e.path === "profiles")).toBe(true);
76
+ });
77
+
78
+ test("flags a plugin id without a marketplace", () => {
79
+ const errors = validateManifest({
80
+ version: 2,
81
+ profiles: { p: { name: "P", plugins: { dev: "1.0.0" } } },
82
+ });
83
+ expect(errors.some((e) => e.path === "profiles.p.plugins.dev")).toBe(true);
84
+ });
85
+
86
+ test("flags a github marketplace missing repo and a skill missing path", () => {
87
+ const errors = validateManifest({
88
+ version: 2,
89
+ profiles: {
90
+ p: {
91
+ name: "P",
92
+ marketplaces: { m: { source: "github" } },
93
+ skills: [{ name: "A", repo: "o/r" } as never],
94
+ },
95
+ },
96
+ });
97
+ expect(errors.some((e) => e.path === "profiles.p.marketplaces.m")).toBe(true);
98
+ expect(errors.some((e) => e.path.startsWith("profiles.p.skills"))).toBe(true);
99
+ });
100
+ });
101
+
102
+ describe("readManifest / writeManifest", () => {
103
+ let dir: string;
104
+ beforeEach(async () => {
105
+ dir = await mkdtemp(join(tmpdir(), "manifest-"));
106
+ });
107
+ afterEach(async () => {
108
+ await rm(dir, { recursive: true, force: true });
109
+ });
110
+
111
+ test("absent file → empty manifest", async () => {
112
+ expect(await readManifest(dir)).toEqual(emptyManifest());
113
+ });
114
+
115
+ test("roundtrip write→read preserves content", async () => {
116
+ const manifest: ProfileManifest = {
117
+ version: 2,
118
+ strictVersions: true,
119
+ profiles: { p: { name: "P", plugins: { "dev@magus": "1.0.0" } } },
120
+ };
121
+ await writeManifest(manifest, dir);
122
+ expect(await fs.pathExists(join(dir, ".claude", "profiles.json"))).toBe(true);
123
+ expect(await readManifest(dir)).toEqual(manifest);
124
+ });
125
+
126
+ test("a committed v1 file is read as migrated v2", async () => {
127
+ await fs.ensureDir(join(dir, ".claude"));
128
+ await fs.writeJson(join(dir, ".claude", "profiles.json"), {
129
+ version: 1,
130
+ profiles: { legacy: { name: "L", plugins: { "dev@magus": true } } },
131
+ });
132
+ const m = await readManifest(dir);
133
+ expect(m.version).toBe(2);
134
+ expect(m.profiles.legacy!.name).toBe("L");
135
+ });
136
+ });
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildPluginBrowserItems } from "../ui/adapters/pluginsAdapter.js";
3
+ import type { Marketplace } from "../types/index.js";
4
+
5
+ /**
6
+ * The marketplace category badge is the line users read on every claudeup launch,
7
+ * and it had no coverage. The MadAppGang byline must not leak onto anyone else's
8
+ * marketplace, and the Official/Deprecated markers must still win.
9
+ */
10
+
11
+ const mp = (over: Partial<Marketplace> & { name: string }): Marketplace => ({
12
+ displayName: over.displayName ?? over.name,
13
+ source: { source: "github", repo: `someone/${over.name}` },
14
+ description: "",
15
+ ...over,
16
+ });
17
+
18
+ /** Badge for a marketplace when it has plugins and is expanded. */
19
+ function badgeFor(marketplace: Marketplace): string | undefined {
20
+ const items = buildPluginBrowserItems({
21
+ marketplaces: [marketplace],
22
+ plugins: [
23
+ {
24
+ id: `demo@${marketplace.name}`,
25
+ name: "demo",
26
+ marketplace: marketplace.name,
27
+ // Only the fields the adapter groups on matter here.
28
+ } as never,
29
+ ],
30
+ collapsedMarketplaces: new Set(),
31
+ });
32
+ const category = items.find((i) => i.kind === "category");
33
+ return category && "badge" in category ? category.badge : undefined;
34
+ }
35
+
36
+ describe("marketplace category badge", () => {
37
+ test("MadAppGang marketplaces carry the byline instead of a generic label", () => {
38
+ for (const name of ["magus", "magus-marketing", "magus-alpha"]) {
39
+ const badge = badgeFor(
40
+ mp({ name, source: { source: "github", repo: `MadAppGang/${name}` } }),
41
+ );
42
+ expect(badge).toBe("made with ❤️ by MadAppGang");
43
+ }
44
+ });
45
+
46
+ test("owner match is case-insensitive", () => {
47
+ // The npm/GitHub casing of the org has varied in config; don't depend on it.
48
+ const badge = badgeFor(
49
+ mp({ name: "magus", source: { source: "github", repo: "madappgang/magus" } }),
50
+ );
51
+ expect(badge).toBe("made with ❤️ by MadAppGang");
52
+ });
53
+
54
+ test("someone else's marketplace does NOT claim the byline", () => {
55
+ const badge = badgeFor(
56
+ mp({
57
+ name: "superpowers-marketplace",
58
+ source: { source: "github", repo: "obra/superpowers-marketplace" },
59
+ }),
60
+ );
61
+ expect(badge).toBe("✓ Added");
62
+ });
63
+
64
+ test("a repo merely containing 'madappgang' downstream does not match", () => {
65
+ // Guards against a substring check — only the owner segment counts.
66
+ const badge = badgeFor(
67
+ mp({ name: "impostor", source: { source: "github", repo: "evil/madappgang-magus" } }),
68
+ );
69
+ expect(badge).toBe("✓ Added");
70
+ });
71
+
72
+ test("Official and Deprecated still take precedence", () => {
73
+ expect(
74
+ badgeFor(
75
+ mp({
76
+ name: "claude-plugins-official",
77
+ source: { source: "github", repo: "anthropics/claude-plugins-official" },
78
+ }),
79
+ ),
80
+ ).toBe("★ Official");
81
+
82
+ expect(
83
+ badgeFor(
84
+ mp({
85
+ name: "claude-code-plugins",
86
+ source: { source: "github", repo: "anthropics/claude-code" },
87
+ }),
88
+ ),
89
+ ).toBe("⚠ Deprecated");
90
+ });
91
+ });