claudeup 6.2.1 → 6.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/bin/claudeup.js +6 -1
- package/package.json +6 -6
- package/src/__tests__/alias-adopt.test.ts +331 -329
- package/src/__tests__/alias-parser.test.ts +384 -376
- package/src/__tests__/alias-shell-writer.test.ts +638 -625
- package/src/__tests__/alias-store.test.ts +143 -143
- package/src/__tests__/catalog-cache-store.test.ts +265 -265
- package/src/__tests__/catalog-notice.test.ts +123 -123
- package/src/__tests__/cli-apply-seams.test.ts +12 -8
- package/src/__tests__/cli-router.test.ts +132 -3
- package/src/__tests__/cli-update-view.test.ts +27 -8
- package/src/__tests__/community-staleness.test.ts +0 -2
- package/src/__tests__/component-badge.test.ts +4 -4
- package/src/__tests__/content-drift.test.ts +5 -1
- package/src/__tests__/conventions-integration.test.ts +774 -689
- package/src/__tests__/conventions-manager.test.ts +1216 -1152
- package/src/__tests__/doctor-bins.test.ts +14 -6
- package/src/__tests__/doctor.test.ts +438 -425
- package/src/__tests__/dual-write-prevention.test.ts +277 -282
- package/src/__tests__/enabled-not-installed.test.ts +71 -63
- package/src/__tests__/file-locking.test.ts +196 -196
- package/src/__tests__/gap-fill-versions.test.ts +318 -311
- package/src/__tests__/github-budget.test.ts +170 -164
- package/src/__tests__/gitignore-detector.test.ts +160 -152
- package/src/__tests__/gitignore-fixer.test.ts +285 -247
- package/src/__tests__/gitignore-prerun.test.ts +57 -57
- package/src/__tests__/gitignore-resolver.test.ts +173 -146
- package/src/__tests__/gitignore-service.test.ts +121 -119
- package/src/__tests__/install-command.test.ts +3 -3
- package/src/__tests__/install-plan.test.ts +13 -7
- package/src/__tests__/manifest.test.ts +16 -5
- package/src/__tests__/markdown-renderer.test.ts +0 -1
- package/src/__tests__/marketplace-badge.test.ts +111 -90
- package/src/__tests__/marketplace-version.test.ts +64 -61
- package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
- package/src/__tests__/plugin-requires.test.ts +18 -4
- package/src/__tests__/plugin-setup.test.ts +12 -12
- package/src/__tests__/profile-command.test.ts +1 -7
- package/src/__tests__/profile-materializer.test.ts +31 -8
- package/src/__tests__/profile-sync.test.ts +23 -5
- package/src/__tests__/registry-version-resolution.test.ts +61 -47
- package/src/__tests__/resolve-executable.test.ts +1 -1
- package/src/__tests__/resolver.test.ts +40 -15
- package/src/__tests__/scope-action.test.ts +52 -50
- package/src/__tests__/scope-squares.test.tsx +4 -5
- package/src/__tests__/skill-install-files.test.ts +111 -103
- package/src/__tests__/symlink-manager.test.ts +27 -10
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/update-apply.test.ts +1 -3
- package/src/__tests__/useGitignoreModal.test.ts +69 -61
- package/src/__tests__/version-snapshot.test.ts +181 -165
- package/src/cli/ansi.ts +23 -15
- package/src/cli/doctor.ts +10 -9
- package/src/cli/install.ts +3 -3
- package/src/cli/profile.ts +5 -4
- package/src/cli/prompt.ts +0 -1
- package/src/cli/router.ts +69 -2
- package/src/cli/update-view.ts +11 -6
- package/src/cli/update.ts +9 -4
- package/src/cli/upgrade.ts +6 -4
- package/src/data/alias-flags.ts +260 -260
- package/src/data/gitignore-defaults.ts +20 -20
- package/src/data/gitignore-reasons.ts +86 -92
- package/src/data/gitignore-templates.ts +14 -14
- package/src/data/predefined-profiles.ts +8 -9
- package/src/data/skill-repos.ts +43 -35
- package/src/main.tsx +29 -6
- package/src/opentui.d.ts +1 -3
- package/src/prerunner/index.ts +7 -8
- package/src/services/alias-settings.ts +20 -23
- package/src/services/alias-shell-writer.ts +800 -803
- package/src/services/alias-store.ts +77 -77
- package/src/services/claude-settings.ts +27 -18
- package/src/services/content-drift.ts +13 -3
- package/src/services/conventions-manager.ts +692 -617
- package/src/services/doctor.ts +464 -403
- package/src/services/gitignore-detector.ts +113 -103
- package/src/services/gitignore-fixer.ts +204 -189
- package/src/services/gitignore-prerun.ts +26 -26
- package/src/services/gitignore-resolver.ts +162 -144
- package/src/services/gitignore-service.ts +120 -117
- package/src/services/install-plan.ts +4 -4
- package/src/services/manifest.ts +1 -1
- package/src/services/marketplace-catalog-git.ts +5 -2
- package/src/services/marketplace-fetcher.ts +241 -235
- package/src/services/plugin-manager.ts +17 -20
- package/src/services/plugin-mcp-config.ts +2 -2
- package/src/services/plugin-requires.ts +9 -6
- package/src/services/plugin-setup.ts +7 -9
- package/src/services/plugin-version-check.ts +4 -4
- package/src/services/profile-materializer.ts +9 -5
- package/src/services/profile-sync.ts +12 -4
- package/src/services/profiles.ts +3 -3
- package/src/services/resolver.ts +10 -6
- package/src/services/settings-manager.ts +82 -46
- package/src/services/skills-manager.ts +11 -9
- package/src/services/symlink-manager.ts +2 -5
- package/src/services/toolchain.ts +3 -3
- package/src/services/update-cache.ts +1 -1
- package/src/services/update-engine.ts +4 -4
- package/src/services/update-plan.ts +9 -11
- package/src/services/version-snapshot.ts +88 -88
- package/src/types/gitignore.ts +28 -28
- package/src/ui/App.tsx +36 -36
- package/src/ui/adapters/settingsAdapter.ts +2 -2
- package/src/ui/components/CategoryHeader.tsx +1 -1
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +3 -1
- package/src/ui/components/ScrollableList.tsx +2 -1
- package/src/ui/components/StyledText.tsx +1 -1
- package/src/ui/components/layout/FooterHints.tsx +5 -3
- package/src/ui/components/layout/Panel.tsx +2 -2
- package/src/ui/components/layout/ScreenLayout.tsx +5 -4
- package/src/ui/components/modals/InputModal.tsx +7 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -3
- package/src/ui/components/modals/SelectModal.tsx +1 -1
- package/src/ui/components/primitives/DetailSection.tsx +1 -1
- package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
- package/src/ui/hooks/index.ts +0 -1
- package/src/ui/hooks/useGitignoreModal.ts +1 -5
- package/src/ui/hooks/useMismatchModal.ts +4 -10
- package/src/ui/renderers/cliToolRenderers.tsx +2 -2
- package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +3 -9
- package/src/ui/renderers/profileRenderers.tsx +9 -8
- package/src/ui/renderers/settingsRenderers.tsx +4 -4
- package/src/ui/renderers/skillRenderers.tsx +14 -14
- package/src/ui/screens/AliasScreen.tsx +31 -34
- package/src/ui/screens/CliToolsScreen.tsx +14 -13
- package/src/ui/screens/EnvVarsScreen.tsx +8 -8
- package/src/ui/screens/GitignoreScreen.tsx +15 -14
- package/src/ui/screens/McpRegistryScreen.tsx +10 -7
- package/src/ui/screens/McpScreen.tsx +10 -10
- package/src/ui/screens/PluginsScreen.tsx +17 -21
- package/src/ui/screens/ProfilesScreen.tsx +25 -24
- package/src/ui/screens/SkillsScreen.tsx +16 -14
- package/src/ui/state/AppContext.tsx +4 -8
- package/src/ui/state/DimensionsContext.tsx +3 -2
- package/src/ui/theme-mode.ts +20 -38
- package/src/ui/theme-resolve.ts +148 -0
- package/src/utils/clipboard.ts +5 -5
- package/src/utils/command-utils.ts +2 -2
- package/src/utils/config-dir.ts +1 -3
- package/src/utils/file-locking.ts +88 -86
- package/src/ui/hooks/useAsyncData.ts +0 -127
- package/src/ui/state/AnimationContext.tsx +0 -76
|
@@ -1,18 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeAll,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
it,
|
|
8
|
+
} from "bun:test";
|
|
2
9
|
import fs from "node:fs";
|
|
3
10
|
import os from "node:os";
|
|
4
11
|
import path from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
clearMarketplaceCache,
|
|
7
|
-
fetchMarketplacePlugins,
|
|
8
|
-
getMarketplaceFetchFailures,
|
|
9
|
-
resolveMarketplacePlugins,
|
|
10
|
-
} from "../services/marketplace-fetcher";
|
|
11
12
|
import { resetGitHubBudget } from "../services/github-budget";
|
|
12
13
|
import type {
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
LocalMarketplace,
|
|
15
|
+
LocalMarketplacePlugin,
|
|
15
16
|
} from "../services/local-marketplace";
|
|
17
|
+
import {
|
|
18
|
+
clearMarketplaceCache,
|
|
19
|
+
fetchMarketplacePlugins,
|
|
20
|
+
getMarketplaceFetchFailures,
|
|
21
|
+
resolveMarketplacePlugins,
|
|
22
|
+
} from "../services/marketplace-fetcher";
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* Point the marketplace-clone lookup at an EMPTY temp directory.
|
|
@@ -27,15 +34,16 @@ let prevConfigDir: string | undefined;
|
|
|
27
34
|
let emptyConfigDir: string;
|
|
28
35
|
|
|
29
36
|
beforeAll(() => {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
emptyConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "mp-fallback-"));
|
|
38
|
+
prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
39
|
+
process.env.CLAUDE_CONFIG_DIR = emptyConfigDir;
|
|
33
40
|
});
|
|
34
41
|
|
|
35
42
|
afterAll(() => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
// biome-ignore lint/performance/noDelete: assigning undefined to process.env stores the string "undefined"; only delete unsets the variable
|
|
44
|
+
if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
45
|
+
else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
|
|
46
|
+
fs.rmSync(emptyConfigDir, { recursive: true, force: true });
|
|
39
47
|
});
|
|
40
48
|
|
|
41
49
|
/**
|
|
@@ -52,136 +60,136 @@ afterAll(() => {
|
|
|
52
60
|
*/
|
|
53
61
|
|
|
54
62
|
function buildLocalMarketplace(
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
name: string,
|
|
64
|
+
plugins: LocalMarketplacePlugin[],
|
|
57
65
|
): LocalMarketplace {
|
|
58
|
-
|
|
66
|
+
return { name, description: "", plugins };
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
// Session-level cache could otherwise carry over a previous test's
|
|
72
|
+
// successful fetch and short-circuit the fallback path.
|
|
73
|
+
clearMarketplaceCache();
|
|
74
|
+
// The per-host cooldown is deliberately NOT cleared by clearMarketplaceCache
|
|
75
|
+
// — it reflects GitHub's state, not ours — so a 429 in one test would
|
|
76
|
+
// otherwise gate every fetch in the next for the full backoff.
|
|
77
|
+
resetGitHubBudget();
|
|
78
|
+
});
|
|
71
79
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
80
|
+
it("falls back to the local cache when the remote fetch returns empty", async () => {
|
|
81
|
+
const local = new Map<string, LocalMarketplace>([
|
|
82
|
+
[
|
|
83
|
+
"magus",
|
|
84
|
+
buildLocalMarketplace("magus", [
|
|
85
|
+
{
|
|
86
|
+
name: "code-analysis",
|
|
87
|
+
version: "5.3.0",
|
|
88
|
+
description: "Codebase investigation tools",
|
|
89
|
+
category: "development",
|
|
90
|
+
author: { name: "Jack Rudenko" },
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "dev",
|
|
94
|
+
version: "2.7.0",
|
|
95
|
+
description: "Dev workflow",
|
|
96
|
+
},
|
|
97
|
+
]),
|
|
98
|
+
],
|
|
99
|
+
]);
|
|
92
100
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
101
|
+
// An invalid repo string short-circuits `fetchMarketplacePlugins` to
|
|
102
|
+
// `[]` without any network call, simulating the offline case.
|
|
103
|
+
const resolved = await resolveMarketplacePlugins(
|
|
104
|
+
"magus",
|
|
105
|
+
"not a valid repo string",
|
|
106
|
+
local,
|
|
107
|
+
);
|
|
100
108
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
expect(resolved.plugins).toHaveLength(2);
|
|
110
|
+
expect(resolved.plugins.map((p) => p.name).sort()).toEqual([
|
|
111
|
+
"code-analysis",
|
|
112
|
+
"dev",
|
|
113
|
+
]);
|
|
114
|
+
const codeAnalysis = resolved.plugins.find(
|
|
115
|
+
(p) => p.name === "code-analysis",
|
|
116
|
+
);
|
|
117
|
+
expect(codeAnalysis?.version).toBe("5.3.0");
|
|
118
|
+
expect(codeAnalysis?.description).toBe("Codebase investigation tools");
|
|
119
|
+
expect(codeAnalysis?.category).toBe("development");
|
|
120
|
+
expect(codeAnalysis?.author?.name).toBe("Jack Rudenko");
|
|
121
|
+
});
|
|
114
122
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
123
|
+
it("labels the fallback answer as local-clone, not remote", async () => {
|
|
124
|
+
// The regression this guards: the fallback plugins were returned in the
|
|
125
|
+
// same shape as a successful remote fetch, so the caller compared installed
|
|
126
|
+
// versions against a clone nothing refreshes and reported "up to date".
|
|
127
|
+
// The source must travel with the data.
|
|
128
|
+
const local = new Map<string, LocalMarketplace>([
|
|
129
|
+
[
|
|
130
|
+
"magus",
|
|
131
|
+
buildLocalMarketplace("magus", [
|
|
132
|
+
{ name: "terminal", version: "4.1.4", description: "" },
|
|
133
|
+
]),
|
|
134
|
+
],
|
|
135
|
+
]);
|
|
136
|
+
const resolved = await resolveMarketplacePlugins(
|
|
137
|
+
"magus",
|
|
138
|
+
"not a valid repo string",
|
|
139
|
+
local,
|
|
140
|
+
);
|
|
133
141
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
142
|
+
expect(resolved.source).toBe("local-clone");
|
|
143
|
+
expect(resolved.failure).toBeDefined();
|
|
144
|
+
expect(resolved.failure?.kind).toBe("invalid-repo");
|
|
145
|
+
expect(resolved.failure?.marketplace).toBe("magus");
|
|
146
|
+
});
|
|
139
147
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
it("returns empty when both remote and cache are empty", async () => {
|
|
149
|
+
const local = new Map<string, LocalMarketplace>();
|
|
150
|
+
const resolved = await resolveMarketplacePlugins(
|
|
151
|
+
"magus",
|
|
152
|
+
"not a valid repo string",
|
|
153
|
+
local,
|
|
154
|
+
);
|
|
155
|
+
expect(resolved.plugins).toEqual([]);
|
|
156
|
+
expect(resolved.source).toBe("none");
|
|
157
|
+
expect(resolved.failure).toBeDefined();
|
|
158
|
+
});
|
|
151
159
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
it("returns empty when remote fails AND cache exists but has no plugins", async () => {
|
|
161
|
+
const local = new Map<string, LocalMarketplace>([
|
|
162
|
+
["magus", buildLocalMarketplace("magus", [])],
|
|
163
|
+
]);
|
|
164
|
+
const resolved = await resolveMarketplacePlugins(
|
|
165
|
+
"magus",
|
|
166
|
+
"not a valid repo string",
|
|
167
|
+
local,
|
|
168
|
+
);
|
|
169
|
+
expect(resolved.plugins).toEqual([]);
|
|
170
|
+
expect(resolved.source).toBe("none");
|
|
171
|
+
});
|
|
164
172
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
173
|
+
it("does not consult the cache when the remote returns plugins", async () => {
|
|
174
|
+
// We can't easily exercise the success branch without a live network,
|
|
175
|
+
// but we can verify that a cache entry for a DIFFERENT marketplace
|
|
176
|
+
// doesn't bleed into the requested one.
|
|
177
|
+
const local = new Map<string, LocalMarketplace>([
|
|
178
|
+
[
|
|
179
|
+
"other-marketplace",
|
|
180
|
+
buildLocalMarketplace("other-marketplace", [
|
|
181
|
+
{ name: "should-not-leak", version: "0.0.1", description: "" },
|
|
182
|
+
]),
|
|
183
|
+
],
|
|
184
|
+
]);
|
|
185
|
+
const resolved = await resolveMarketplacePlugins(
|
|
186
|
+
"magus",
|
|
187
|
+
"not a valid repo string",
|
|
188
|
+
local,
|
|
189
|
+
);
|
|
190
|
+
expect(resolved.plugins).toEqual([]);
|
|
191
|
+
expect(resolved.source).toBe("none");
|
|
192
|
+
});
|
|
185
193
|
});
|
|
186
194
|
|
|
187
195
|
/**
|
|
@@ -192,121 +200,123 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
192
200
|
* to guess, and all of them guessed "fine".
|
|
193
201
|
*/
|
|
194
202
|
describe("fetchMarketplacePlugins — failures are values", () => {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
203
|
+
beforeEach(() => {
|
|
204
|
+
clearMarketplaceCache();
|
|
205
|
+
resetGitHubBudget();
|
|
206
|
+
});
|
|
199
207
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
208
|
+
it("classifies a rate limit distinctly from a network problem", async () => {
|
|
209
|
+
const original = globalThis.fetch;
|
|
210
|
+
globalThis.fetch = (async () =>
|
|
211
|
+
new Response("rate limited", {
|
|
212
|
+
status: 429,
|
|
213
|
+
statusText: "Too Many Requests",
|
|
214
|
+
})) as typeof globalThis.fetch;
|
|
215
|
+
try {
|
|
216
|
+
const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
217
|
+
expect(result.plugins).toEqual([]);
|
|
218
|
+
expect(result.failure?.kind).toBe("rate-limited");
|
|
219
|
+
expect(result.failure?.httpStatus).toBe(429);
|
|
220
|
+
expect(result.failure?.detail).toContain("rate limit");
|
|
221
|
+
} finally {
|
|
222
|
+
globalThis.fetch = original;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
217
225
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
226
|
+
it("records the failure so the UI can report it without re-fetching", async () => {
|
|
227
|
+
const original = globalThis.fetch;
|
|
228
|
+
let calls = 0;
|
|
229
|
+
globalThis.fetch = (async () => {
|
|
230
|
+
calls++;
|
|
231
|
+
return new Response("nope", { status: 500, statusText: "Server Error" });
|
|
232
|
+
}) as typeof globalThis.fetch;
|
|
233
|
+
try {
|
|
234
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
227
235
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
236
|
+
const failures = getMarketplaceFetchFailures();
|
|
237
|
+
expect(failures.map((f) => f.marketplace)).toContain("magus");
|
|
238
|
+
expect(failures.find((f) => f.marketplace === "magus")?.kind).toBe(
|
|
239
|
+
"http",
|
|
240
|
+
);
|
|
231
241
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
242
|
+
// Second call must be served from the negative cache. Re-attempting on
|
|
243
|
+
// every screen mount is what cost 30-45s of "Loading..." per tab switch.
|
|
244
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
245
|
+
expect(calls).toBe(1);
|
|
246
|
+
} finally {
|
|
247
|
+
globalThis.fetch = original;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
240
250
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
251
|
+
it("reports no failures when the fetch succeeds", async () => {
|
|
252
|
+
const original = globalThis.fetch;
|
|
253
|
+
globalThis.fetch = (async () =>
|
|
254
|
+
Response.json({
|
|
255
|
+
metadata: { version: "9.0.3" },
|
|
256
|
+
plugins: [{ name: "terminal", version: "4.1.6" }],
|
|
257
|
+
})) as typeof globalThis.fetch;
|
|
258
|
+
try {
|
|
259
|
+
const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
260
|
+
expect(result.failure).toBeUndefined();
|
|
261
|
+
expect(result.plugins).toHaveLength(1);
|
|
262
|
+
expect(getMarketplaceFetchFailures()).toEqual([]);
|
|
263
|
+
} finally {
|
|
264
|
+
globalThis.fetch = original;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
257
267
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
268
|
+
it("does not send a request while the rate-limit window is still open", async () => {
|
|
269
|
+
// Deliberate: an explicit refresh clears OUR caches but not GitHub's limit.
|
|
270
|
+
// Retrying inside a known window cannot succeed, and on some endpoints a
|
|
271
|
+
// rejected request extends the penalty — so the screen waits and counts down
|
|
272
|
+
// instead. `clearMarketplaceCache()` must not be a way around that.
|
|
273
|
+
const original = globalThis.fetch;
|
|
274
|
+
let calls = 0;
|
|
275
|
+
globalThis.fetch = (async () => {
|
|
276
|
+
calls++;
|
|
277
|
+
return new Response("nope", { status: 429 });
|
|
278
|
+
}) as typeof globalThis.fetch;
|
|
279
|
+
try {
|
|
280
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
281
|
+
expect(calls).toBe(1);
|
|
272
282
|
|
|
273
|
-
|
|
274
|
-
|
|
283
|
+
clearMarketplaceCache();
|
|
284
|
+
const second = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
275
285
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
286
|
+
expect(calls).toBe(1); // no second request went out
|
|
287
|
+
expect(second.failure?.kind).toBe("rate-limited");
|
|
288
|
+
expect(second.failure?.retryAt).toBeGreaterThan(Date.now());
|
|
289
|
+
} finally {
|
|
290
|
+
globalThis.fetch = original;
|
|
291
|
+
}
|
|
292
|
+
});
|
|
283
293
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
294
|
+
it("clears the recorded failure once the window passes and the fetch succeeds", async () => {
|
|
295
|
+
const original = globalThis.fetch;
|
|
296
|
+
globalThis.fetch = (async () =>
|
|
297
|
+
new Response("nope", { status: 429 })) as typeof globalThis.fetch;
|
|
298
|
+
try {
|
|
299
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
300
|
+
expect(getMarketplaceFetchFailures()).toHaveLength(1);
|
|
291
301
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
302
|
+
// The cooldown expiring is what unblocks the retry — this stands in for the
|
|
303
|
+
// clock reaching `retryAt`, which is when the screen refetches by itself.
|
|
304
|
+
resetGitHubBudget();
|
|
305
|
+
clearMarketplaceCache();
|
|
306
|
+
globalThis.fetch = (async () =>
|
|
307
|
+
Response.json({
|
|
308
|
+
plugins: [{ name: "terminal", version: "4.2.0" }],
|
|
309
|
+
})) as typeof globalThis.fetch;
|
|
300
310
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
311
|
+
const recovered = await fetchMarketplacePlugins(
|
|
312
|
+
"magus",
|
|
313
|
+
"MadAppGang/magus",
|
|
314
|
+
);
|
|
315
|
+
expect(recovered.failure).toBeUndefined();
|
|
316
|
+
expect(recovered.plugins).toHaveLength(1);
|
|
317
|
+
expect(getMarketplaceFetchFailures()).toEqual([]);
|
|
318
|
+
} finally {
|
|
319
|
+
globalThis.fetch = original;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
312
322
|
});
|
|
@@ -92,11 +92,19 @@ describe("parsePluginRequires — setup block (the established convention)", ()
|
|
|
92
92
|
test.each([
|
|
93
93
|
[
|
|
94
94
|
"github.com/MadAppGang/tmux-mcp@v1.7.1",
|
|
95
|
-
{
|
|
95
|
+
{
|
|
96
|
+
name: "tmux-mcp",
|
|
97
|
+
module: "github.com/MadAppGang/tmux-mcp",
|
|
98
|
+
version: "v1.7.1",
|
|
99
|
+
},
|
|
96
100
|
],
|
|
97
101
|
[
|
|
98
102
|
"github.com/MadAppGang/tmux-mcp/v2",
|
|
99
|
-
{
|
|
103
|
+
{
|
|
104
|
+
name: "tmux-mcp",
|
|
105
|
+
module: "github.com/MadAppGang/tmux-mcp/v2",
|
|
106
|
+
version: undefined,
|
|
107
|
+
},
|
|
100
108
|
],
|
|
101
109
|
[
|
|
102
110
|
"github.com/x/v2tool@v1.0.0",
|
|
@@ -105,7 +113,11 @@ describe("parsePluginRequires — setup block (the established convention)", ()
|
|
|
105
113
|
])("go spec %s → name/module/version", (spec, expected) => {
|
|
106
114
|
const r = parsePluginRequires({ setup: { go: [spec] } });
|
|
107
115
|
expect(
|
|
108
|
-
r.bin!.map((b) => ({
|
|
116
|
+
r.bin!.map((b) => ({
|
|
117
|
+
name: b.name,
|
|
118
|
+
module: b.module,
|
|
119
|
+
version: b.version,
|
|
120
|
+
})),
|
|
109
121
|
).toEqual([expected]);
|
|
110
122
|
});
|
|
111
123
|
|
|
@@ -132,7 +144,9 @@ describe("parsePluginRequires — setup block (the established convention)", ()
|
|
|
132
144
|
test("explicit requires.bin overrides a setup entry of the same name", () => {
|
|
133
145
|
const r = parsePluginRequires({
|
|
134
146
|
setup: { required: ["tmux"] }, // → brew
|
|
135
|
-
requires: {
|
|
147
|
+
requires: {
|
|
148
|
+
bin: [{ name: "tmux", via: "go", module: "example.com/tmux" }],
|
|
149
|
+
},
|
|
136
150
|
});
|
|
137
151
|
const tmux = r.bin!.find((b) => b.name === "tmux")!;
|
|
138
152
|
expect(tmux.via).toBe("go"); // requires.bin won
|
|
@@ -6,15 +6,15 @@
|
|
|
6
6
|
* and `required` setup keys.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
10
|
+
import * as actualChildProcess from "node:child_process";
|
|
11
|
+
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
11
12
|
import { tmpdir } from "node:os";
|
|
12
13
|
import path from "node:path";
|
|
13
14
|
// Real modules captured before the mock.module() overrides, so the mocks spread
|
|
14
15
|
// the full surface instead of stripping exports (e.g. resolveExecutable) that
|
|
15
16
|
// bun's global, non-restored mock.module would otherwise remove for later files.
|
|
16
17
|
import * as actualCommandUtils from "../utils/command-utils.js";
|
|
17
|
-
import * as actualChildProcess from "node:child_process";
|
|
18
18
|
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
// Mocks — must be set up BEFORE any import from plugin-setup
|
|
@@ -120,9 +120,9 @@ describe("extractGoBinaryName", () => {
|
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
it("handles module with nested path and version", () => {
|
|
123
|
-
expect(
|
|
124
|
-
|
|
125
|
-
)
|
|
123
|
+
expect(extractGoBinaryName("github.com/org/repo/cmd/mycli@v2.0.0")).toBe(
|
|
124
|
+
"mycli",
|
|
125
|
+
);
|
|
126
126
|
});
|
|
127
127
|
|
|
128
128
|
it("handles empty string gracefully", () => {
|
|
@@ -367,9 +367,9 @@ describe("checkMissingDeps — go packages", () => {
|
|
|
367
367
|
|
|
368
368
|
describe("extractGoVersion", () => {
|
|
369
369
|
it("returns the pinned version", () => {
|
|
370
|
-
expect(
|
|
371
|
-
|
|
372
|
-
)
|
|
370
|
+
expect(extractGoVersion("github.com/MadAppGang/tmux-mcp@v1.6.2")).toBe(
|
|
371
|
+
"v1.6.2",
|
|
372
|
+
);
|
|
373
373
|
});
|
|
374
374
|
it("returns null for @latest (a moving target)", () => {
|
|
375
375
|
expect(
|
|
@@ -380,9 +380,9 @@ describe("extractGoVersion", () => {
|
|
|
380
380
|
expect(extractGoVersion("github.com/user/tool")).toBeNull();
|
|
381
381
|
});
|
|
382
382
|
it("reads the pin behind a /v2 module path", () => {
|
|
383
|
-
expect(
|
|
384
|
-
|
|
385
|
-
)
|
|
383
|
+
expect(extractGoVersion("github.com/MadAppGang/tmux-mcp/v2@v2.0.0")).toBe(
|
|
384
|
+
"v2.0.0",
|
|
385
|
+
);
|
|
386
386
|
});
|
|
387
387
|
it("a /v2 module path with no pin is unpinned, not pinned to v2", () => {
|
|
388
388
|
expect(extractGoVersion("github.com/MadAppGang/tmux-mcp/v2")).toBeNull();
|