claudeup 4.18.0 → 4.22.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__/alias-adopt.test.ts +364 -0
- package/src/__tests__/alias-parser.test.ts +92 -0
- package/src/__tests__/alias-shell-writer.test.ts +7 -0
- package/src/__tests__/alias-store.test.ts +77 -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-refresh.test.ts +267 -0
- package/src/__tests__/plugin-requires.test.ts +133 -0
- package/src/__tests__/plugin-setup.test.ts +118 -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/alias-flags.ts +11 -1
- 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/alias-shell-writer.ts +382 -8
- package/src/services/alias-store.ts +60 -0
- 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-setup.ts +61 -4
- 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 +16 -11
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/modals/ConfirmModal.tsx +1 -1
- package/src/ui/screens/AliasScreen.tsx +491 -359
- package/src/ui/screens/CliToolsScreen.tsx +11 -3
- package/src/ui/screens/PluginsScreen.tsx +3 -1
- package/src/ui/screens/index.ts +0 -1
- package/src/ui/state/reducer.ts +6 -102
- package/src/ui/state/types.ts +8 -35
- package/src/utils/command-utils.ts +23 -0
- package/src/utils/file-locking.ts +144 -0
- package/src/data/alias-flags.js +0 -196
- 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 -764
- package/src/services/alias-store.js +0 -77
- 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 -444
- 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 -346
- 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/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 -1008
- 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 -766
- 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 -543
- 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,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
|
+
});
|
|
@@ -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
|
+
});
|