magus-cli 7.12.2 → 7.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/package.json +4 -4
- package/src/__tests__/market-bases.test.ts +112 -0
- package/src/__tests__/market-config.test.ts +84 -0
- package/src/__tests__/market-conflicts.test.ts +175 -0
- package/src/__tests__/market-consent.test.ts +185 -0
- package/src/__tests__/market-content.test.ts +183 -0
- package/src/__tests__/market-delivery.test.ts +308 -0
- package/src/__tests__/market-fixture.ts +1167 -0
- package/src/__tests__/market-git.test.ts +130 -0
- package/src/__tests__/market-identity.test.ts +127 -0
- package/src/__tests__/market-install.test.ts +143 -0
- package/src/__tests__/market-layout.test.ts +206 -0
- package/src/__tests__/market-publish.test.ts +263 -0
- package/src/__tests__/market-r2-adapters.test.ts +190 -0
- package/src/__tests__/market-r2-company-cli.test.ts +350 -0
- package/src/__tests__/market-r2-company-unit.test.ts +224 -0
- package/src/__tests__/market-r2-local-items.test.ts +197 -0
- package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
- package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
- package/src/__tests__/market-r2-routing.test.ts +127 -0
- package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
- package/src/__tests__/market-r2b-display-path.test.ts +180 -0
- package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
- package/src/__tests__/market-r2b-local-items.test.ts +253 -0
- package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
- package/src/__tests__/market-readonly.test.ts +162 -0
- package/src/__tests__/market-repositories.test.ts +263 -0
- package/src/__tests__/market-results.test.ts +198 -0
- package/src/__tests__/market-router.test.ts +65 -0
- package/src/__tests__/market-safety.test.ts +357 -0
- package/src/__tests__/market-teammates.test.ts +304 -0
- package/src/cli/install.ts +64 -5
- package/src/cli/market-view.ts +329 -0
- package/src/cli/market.ts +664 -0
- package/src/cli/router.ts +69 -3
- package/src/cli/update-view.ts +56 -0
- package/src/cli/update.ts +111 -9
- package/src/data/marketplaces.ts +8 -2
- package/src/main.tsx +20 -2
- package/src/market-tui.tsx +21 -0
- package/src/services/active-closure.ts +71 -9
- package/src/services/claude-cli.ts +140 -10
- package/src/services/claude-settings.ts +19 -25
- package/src/services/doctor.ts +5 -5
- package/src/services/git-write.ts +615 -0
- package/src/services/github-cli.ts +309 -0
- package/src/services/local-marketplace.ts +112 -50
- package/src/services/market-bases.ts +191 -0
- package/src/services/market-change.ts +242 -0
- package/src/services/market-clone.ts +572 -0
- package/src/services/market-fs.ts +138 -0
- package/src/services/market-install.ts +172 -0
- package/src/services/market-layout.ts +311 -0
- package/src/services/market-local.ts +93 -0
- package/src/services/market-marker.ts +49 -0
- package/src/services/market-ops-content.ts +277 -0
- package/src/services/market-ops-plugin.ts +655 -0
- package/src/services/market-ops-publish.ts +1227 -0
- package/src/services/market-ops-repo.ts +916 -0
- package/src/services/market-ops-skill.ts +788 -0
- package/src/services/market-ops.ts +686 -0
- package/src/services/market-publish.ts +468 -0
- package/src/services/market-registry.ts +382 -0
- package/src/services/market-types.ts +268 -0
- package/src/services/marketplace-conflicts.ts +127 -0
- package/src/services/marketplace-refresh.ts +33 -3
- package/src/services/marketplace-sync.ts +253 -22
- package/src/services/plugin-dependencies.ts +17 -0
- package/src/services/plugin-manager.ts +1 -1
- package/src/services/plugin-mcp-config.ts +6 -9
- package/src/services/plugin-requires.ts +20 -8
- package/src/services/profile-absorb.ts +88 -3
- package/src/services/profile-adopt.ts +10 -1
- package/src/services/profiles.ts +64 -1
- package/src/services/resolver.ts +17 -5
- package/src/services/settings-manager.ts +2 -2
- package/src/services/skills-manager.ts +2 -2
- package/src/services/update-plan.ts +67 -8
- package/src/tui-runtime.tsx +142 -0
- package/src/tui.tsx +7 -124
- package/src/types/index.ts +7 -0
- package/src/ui/adapters/marketAdapter.ts +366 -0
- package/src/ui/adapters/pluginsAdapter.ts +45 -3
- package/src/ui/components/layout/ScreenLayout.tsx +43 -17
- package/src/ui/components/modals/ConfirmModal.tsx +145 -8
- package/src/ui/components/modals/HelpOverlay.tsx +10 -3
- package/src/ui/components/modals/ModalContainer.tsx +47 -5
- package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
- package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
- package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
- package/src/ui/components/primitives/SelectableRow.tsx +14 -1
- package/src/ui/components/primitives/ToneLine.tsx +25 -0
- package/src/ui/components/primitives/index.ts +5 -0
- package/src/ui/keymap.ts +21 -5
- package/src/ui/market/MarketApp.tsx +59 -0
- package/src/ui/market/MarketplaceScreen.tsx +280 -0
- package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
- package/src/ui/market/MarketplacesScreen.tsx +264 -0
- package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
- package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
- package/src/ui/market/marketKeymap.ts +243 -0
- package/src/ui/market/useMarketState.ts +694 -0
- package/src/ui/renderers/pluginRenderers.tsx +31 -3
- package/src/ui/renderers/pluginStatusText.ts +6 -0
- package/src/ui/screens/PluginsScreen.tsx +49 -5
- package/src/ui/screens/SkillsScreen.tsx +2 -2
- package/src/ui/state/AppContext.tsx +14 -2
- package/src/ui/state/types.ts +9 -2
- package/src/utils/config-dir.ts +29 -9
- package/src/utils/display-path.ts +33 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
classifyGitFailure,
|
|
6
|
+
gitEnv,
|
|
7
|
+
parseLsRemoteHead,
|
|
8
|
+
parseLsTree,
|
|
9
|
+
parseNameStatus,
|
|
10
|
+
unquoteCPath,
|
|
11
|
+
} from "../services/git-write.js";
|
|
12
|
+
import { classifyGhFailure } from "../services/github-cli.js";
|
|
13
|
+
import { MarketFixture, sourceModule, write } from "./market-fixture.js";
|
|
14
|
+
|
|
15
|
+
let f: MarketFixture | undefined;
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
f?.close();
|
|
18
|
+
f = undefined;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test.each([
|
|
22
|
+
["fatal: Could not resolve host: github.com", "no-network"],
|
|
23
|
+
[
|
|
24
|
+
"Permission denied (publickey).\nfatal: Could not read from remote repository.",
|
|
25
|
+
"auth",
|
|
26
|
+
],
|
|
27
|
+
["remote: Permission to acme/market denied to teammate.", "denied"],
|
|
28
|
+
["! [rejected] HEAD -> main (non-fast-forward)", "rejected-non-ff"],
|
|
29
|
+
[
|
|
30
|
+
"remote: error: GH006: Protected branch update failed for refs/heads/main.\n! [remote rejected] main (hook declined)",
|
|
31
|
+
"rejected-protected",
|
|
32
|
+
],
|
|
33
|
+
["fatal: unrelated problem", "other"],
|
|
34
|
+
] as const)("TEST-10 REQ-22 classifies git failure %s", (text, expected) =>
|
|
35
|
+
expect(classifyGitFailure(text, { timedOut: false })).toBe(expected),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
test("TEST-10 REQ-22 timeouts retain signing context and git never prompts", () => {
|
|
39
|
+
expect(classifyGitFailure("", { timedOut: true })).toBe("timeout");
|
|
40
|
+
expect(classifyGitFailure("", { timedOut: true, signing: true })).toBe(
|
|
41
|
+
"signing-timeout",
|
|
42
|
+
);
|
|
43
|
+
const original = {
|
|
44
|
+
GIT_TERMINAL_PROMPT: "1",
|
|
45
|
+
GCM_INTERACTIVE: "always",
|
|
46
|
+
HOME: "test-home",
|
|
47
|
+
};
|
|
48
|
+
const env = gitEnv(original);
|
|
49
|
+
expect({
|
|
50
|
+
HOME: env.HOME,
|
|
51
|
+
GIT_TERMINAL_PROMPT: env.GIT_TERMINAL_PROMPT,
|
|
52
|
+
GCM_INTERACTIVE: env.GCM_INTERACTIVE,
|
|
53
|
+
}).toEqual({
|
|
54
|
+
HOME: "test-home",
|
|
55
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
56
|
+
GCM_INTERACTIVE: "never",
|
|
57
|
+
});
|
|
58
|
+
expect(classifyGhFailure(4, "not logged in", false)).toBe("gh-auth");
|
|
59
|
+
expect(classifyGhFailure(1, "HTTP 404: Not Found", false)).toBe("not-found");
|
|
60
|
+
expect(classifyGhFailure(1, "Could not resolve host", false)).toBe(
|
|
61
|
+
"no-network",
|
|
62
|
+
);
|
|
63
|
+
expect(classifyGhFailure(null, "", true)).toBe("timeout");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("TEST-11 REQ-22 parses remote HEAD without assuming main", () => {
|
|
67
|
+
const oid = "a".repeat(40);
|
|
68
|
+
expect(
|
|
69
|
+
parseLsRemoteHead(`ref: refs/heads/trunk\tHEAD\n${oid}\tHEAD\n`),
|
|
70
|
+
).toEqual({ branch: "trunk", commit: oid });
|
|
71
|
+
expect(parseLsRemoteHead("")).toEqual({ branch: null, commit: null });
|
|
72
|
+
});
|
|
73
|
+
test("TEST-11 REQ-22 NUL-delimited paths keep tabs, newlines and Unicode", () => {
|
|
74
|
+
const paths = ["space name.md", "tab\tname.md", "line\nname.md", "café.md"];
|
|
75
|
+
const tree = paths
|
|
76
|
+
.map((path, i) => `100644 blob ${String(i + 1).repeat(40)}\t${path}\0`)
|
|
77
|
+
.join("");
|
|
78
|
+
expect(parseLsTree(tree).map((entry) => entry.path)).toEqual(paths);
|
|
79
|
+
expect(parseNameStatus("D\0old name.md\0A\0new\nname.md\0")).toEqual([
|
|
80
|
+
{ status: "D", path: "old name.md" },
|
|
81
|
+
{ status: "A", path: "new\nname.md" },
|
|
82
|
+
]);
|
|
83
|
+
expect(unquoteCPath('"a\\tb"')).toBe("a\tb");
|
|
84
|
+
expect(unquoteCPath('"caf\\303\\251.md"')).toBe("café.md");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("TEST-12 REQ-22 real hash-object ignores filters and writes no object/index", () => {
|
|
88
|
+
f = new MarketFixture();
|
|
89
|
+
const repo = f.seed();
|
|
90
|
+
write(join(repo.author, ".gitattributes"), "*.md filter=must-not-run\n");
|
|
91
|
+
f.git(repo.author, "config", "filter.must-not-run.clean", "exit 99");
|
|
92
|
+
const file = join(repo.author, "new name.md");
|
|
93
|
+
write(file, "same bytes\n");
|
|
94
|
+
const before = f.git(repo.author, "ls-files", "--stage");
|
|
95
|
+
const got = f.value<{ ok: boolean; value: string[] }>(
|
|
96
|
+
`const g = await import(${JSON.stringify(sourceModule("services/git-write.js"))}); console.log(JSON.stringify(await g.hashObject(${JSON.stringify(repo.author)}, [${JSON.stringify(file)}])));`,
|
|
97
|
+
);
|
|
98
|
+
expect(got.ok).toBe(true);
|
|
99
|
+
expect(got.value).toEqual([
|
|
100
|
+
f.git(repo.author, "hash-object", "--no-filters", "--", file),
|
|
101
|
+
]);
|
|
102
|
+
expect(f.git(repo.author, "ls-files", "--stage")).toBe(before);
|
|
103
|
+
expect(
|
|
104
|
+
f.run([f.gitBinary, "cat-file", "-e", got.value[0]!], repo.author).code,
|
|
105
|
+
).not.toBe(0);
|
|
106
|
+
}, 30_000);
|
|
107
|
+
|
|
108
|
+
test("TEST-12 REQ-31 disposable sync cleans untracked/ignored files and preserves history", () => {
|
|
109
|
+
f = new MarketFixture();
|
|
110
|
+
const repo = f.seed({ extras: { ".gitignore": "*.scratch\n" } });
|
|
111
|
+
f.register(repo);
|
|
112
|
+
f.ok(["fetch", "acme"]);
|
|
113
|
+
const working = f.working(repo);
|
|
114
|
+
write(join(working, "leftover.scratch"), "must not commit\n");
|
|
115
|
+
write(join(working, "untracked.txt"), "must not commit\n");
|
|
116
|
+
write(join(working, "README.md"), "dirty disposable copy\n");
|
|
117
|
+
write(join(repo.author, "new.md"), "teammate\n");
|
|
118
|
+
const head = f.push(repo);
|
|
119
|
+
const cloneHead = f.git(f.clone(repo), "rev-parse", "HEAD");
|
|
120
|
+
|
|
121
|
+
const r = f.market(["fetch", "acme"]);
|
|
122
|
+
|
|
123
|
+
expect(r.code).toBe(0);
|
|
124
|
+
expect(f.git(working, "rev-parse", "HEAD")).toBe(head);
|
|
125
|
+
expect(f.git(working, "rev-parse", "--is-shallow-repository")).toBe("false");
|
|
126
|
+
expect(existsSync(join(working, "leftover.scratch"))).toBe(false);
|
|
127
|
+
expect(existsSync(join(working, "untracked.txt"))).toBe(false);
|
|
128
|
+
expect(f.git(f.clone(repo), "rev-parse", "HEAD")).toBe(cloneHead);
|
|
129
|
+
f.assertOwned();
|
|
130
|
+
}, 30_000);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, symlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readMarker, withMarker } from "../services/market-marker.js";
|
|
5
|
+
import {
|
|
6
|
+
isTrustedIn,
|
|
7
|
+
normaliseSource,
|
|
8
|
+
sameSource,
|
|
9
|
+
} from "../services/market-registry.js";
|
|
10
|
+
import { MarketFixture, sourceModule, writeJson } from "./market-fixture.js";
|
|
11
|
+
|
|
12
|
+
let f: MarketFixture | undefined;
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
f?.close();
|
|
15
|
+
f = undefined;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("TEST-07 REQ-5 setting the company marker preserves unrelated manifest metadata", () => {
|
|
19
|
+
const manifest = {
|
|
20
|
+
name: "acme",
|
|
21
|
+
owner: { name: "Team" },
|
|
22
|
+
metadata: { pluginRoot: "./plugins", description: "Keep this" },
|
|
23
|
+
plugins: [],
|
|
24
|
+
};
|
|
25
|
+
const before = JSON.stringify(manifest);
|
|
26
|
+
const marked = withMarker(manifest);
|
|
27
|
+
|
|
28
|
+
expect(readMarker(manifest)).toBe(false);
|
|
29
|
+
expect(readMarker(marked)).toBe(true);
|
|
30
|
+
expect(marked.metadata).toEqual({
|
|
31
|
+
pluginRoot: "./plugins",
|
|
32
|
+
description: "Keep this",
|
|
33
|
+
magus: { schema: 1, company: true },
|
|
34
|
+
});
|
|
35
|
+
expect(JSON.stringify(manifest)).toBe(before);
|
|
36
|
+
expect(readMarker(null)).toBe(false);
|
|
37
|
+
expect(
|
|
38
|
+
readMarker({ metadata: { magus: { schema: 1, company: false } } }),
|
|
39
|
+
).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test.each([
|
|
43
|
+
["acme/market", "acme/market"],
|
|
44
|
+
["https://github.com/acme/market.git", "acme/market"],
|
|
45
|
+
["https://github.com/acme/market/", "acme/market"],
|
|
46
|
+
["git@github.com:acme/market.git", "acme/market"],
|
|
47
|
+
["ssh://git@github.com/acme/market.git", "acme/market"],
|
|
48
|
+
[
|
|
49
|
+
"https://git.example.invalid/Team/Repo.git",
|
|
50
|
+
"https://git.example.invalid/Team/Repo.git",
|
|
51
|
+
],
|
|
52
|
+
])("TEST-08 REQ-33 normalises source %s", (input, expected) =>
|
|
53
|
+
expect(normaliseSource(input)).toBe(expected),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
test("TEST-08 REQ-33 relative directories resolve against the project and real parent", () => {
|
|
57
|
+
f = new MarketFixture();
|
|
58
|
+
const directory = join(f.root, "repo-directory");
|
|
59
|
+
mkdirSync(directory);
|
|
60
|
+
symlinkSync(directory, join(f.project, "market-link"));
|
|
61
|
+
expect(normaliseSource("./market-link", f.project)).toBe(directory);
|
|
62
|
+
expect(normaliseSource("./not-created", f.project)).toBe(
|
|
63
|
+
join(f.project, "not-created"),
|
|
64
|
+
);
|
|
65
|
+
expect(normaliseSource(new URL(`file://${directory}`).href)).toBe(
|
|
66
|
+
new URL(`file://${directory}`).href,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("TEST-08 REQ-33 GitHub case is equivalent but paths and other URLs are exact", () => {
|
|
71
|
+
expect(sameSource("Acme/Claude-Market", "acme/claude-market")).toBe(true);
|
|
72
|
+
expect(
|
|
73
|
+
sameSource(
|
|
74
|
+
normaliseSource("https://github.com/Acme/Market.git"),
|
|
75
|
+
normaliseSource("git@github.com:acme/market.git"),
|
|
76
|
+
),
|
|
77
|
+
).toBe(true);
|
|
78
|
+
f = new MarketFixture();
|
|
79
|
+
expect(sameSource(join(f.root, "Repo"), join(f.root, "repo"))).toBe(false);
|
|
80
|
+
expect(
|
|
81
|
+
sameSource(
|
|
82
|
+
"https://git.example.invalid/Team/Repo",
|
|
83
|
+
"https://git.example.invalid/team/repo",
|
|
84
|
+
),
|
|
85
|
+
).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("TEST-09 REQ-5 a trusted name must match the registered source", () => {
|
|
89
|
+
const list = {
|
|
90
|
+
schema: 1,
|
|
91
|
+
marketplaces: { acme: { source: "acme/market", via: "connect" as const } },
|
|
92
|
+
};
|
|
93
|
+
expect(isTrustedIn(list, "acme", "Acme/Market")).toBe(true);
|
|
94
|
+
expect(isTrustedIn(list, "acme", "attacker/market")).toBe(false);
|
|
95
|
+
expect(isTrustedIn(list, "other", "acme/market")).toBe(false);
|
|
96
|
+
expect(isTrustedIn(list, "acme", null)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test.each([
|
|
100
|
+
{ marker: true, trust: false, sourceMatches: true, company: false },
|
|
101
|
+
{ marker: false, trust: true, sourceMatches: true, company: false },
|
|
102
|
+
{ marker: true, trust: true, sourceMatches: false, company: false },
|
|
103
|
+
{ marker: true, trust: true, sourceMatches: true, company: true },
|
|
104
|
+
])(
|
|
105
|
+
"TEST-09 REQ-5 REQ-28 company identity is marker AND source-bound trust: %j",
|
|
106
|
+
({ marker, trust, sourceMatches, company }) => {
|
|
107
|
+
f = new MarketFixture(true);
|
|
108
|
+
const repo = f.seed({ marker });
|
|
109
|
+
f.register(repo, { trust });
|
|
110
|
+
if (!sourceMatches)
|
|
111
|
+
writeJson(join(f.config, "magus-market", "trusted.json"), {
|
|
112
|
+
schema: 1,
|
|
113
|
+
marketplaces: { acme: { source: "different/market", via: "connect" } },
|
|
114
|
+
});
|
|
115
|
+
const before = f.snapshot();
|
|
116
|
+
|
|
117
|
+
const companies = f.value<Array<{ name: string }>>(
|
|
118
|
+
`const m = await import(${JSON.stringify(sourceModule("services/market-registry.js"))}); console.log(JSON.stringify(await m.listCompanyMarketplaces()));`,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(companies.map((m) => m.name)).toEqual(company ? ["acme"] : []);
|
|
122
|
+
expect(f.snapshot()).toEqual(before);
|
|
123
|
+
expect(f.journal()).toEqual([]);
|
|
124
|
+
f.assertOwned();
|
|
125
|
+
},
|
|
126
|
+
30_000,
|
|
127
|
+
);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
ID,
|
|
5
|
+
MANIFEST,
|
|
6
|
+
MarketFixture,
|
|
7
|
+
PLUGIN_MANIFEST,
|
|
8
|
+
readJson,
|
|
9
|
+
sourceModule,
|
|
10
|
+
writeJson,
|
|
11
|
+
} from "./market-fixture.js";
|
|
12
|
+
|
|
13
|
+
let f: MarketFixture | undefined;
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
f?.close();
|
|
16
|
+
f = undefined;
|
|
17
|
+
});
|
|
18
|
+
function advance(scopes: string[], target = "0.3.2") {
|
|
19
|
+
return f!.value<{
|
|
20
|
+
outcome: string;
|
|
21
|
+
loaded: string | null;
|
|
22
|
+
installs: Array<{
|
|
23
|
+
scope: string;
|
|
24
|
+
status: string;
|
|
25
|
+
to: string | null;
|
|
26
|
+
loaded: string | null;
|
|
27
|
+
fix?: string;
|
|
28
|
+
message?: string;
|
|
29
|
+
}>;
|
|
30
|
+
}>(
|
|
31
|
+
`const m = await import(${JSON.stringify(sourceModule("services/market-install.js"))}); console.log(JSON.stringify(await m.advanceViaCli(${JSON.stringify(ID)}, ${JSON.stringify(scopes)}, ${JSON.stringify(f!.project)}, ${JSON.stringify(target)})));`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
function bumpClone() {
|
|
35
|
+
const repo = f!.seed();
|
|
36
|
+
f!.register(repo);
|
|
37
|
+
return {
|
|
38
|
+
repo,
|
|
39
|
+
bump() {
|
|
40
|
+
const manifest = readJson(join(repo.author, MANIFEST));
|
|
41
|
+
manifest.plugins[0].version = "0.3.2";
|
|
42
|
+
writeJson(join(repo.author, MANIFEST), manifest);
|
|
43
|
+
const plugin = readJson(join(repo.author, PLUGIN_MANIFEST));
|
|
44
|
+
plugin.version = "0.3.2";
|
|
45
|
+
writeJson(join(repo.author, PLUGIN_MANIFEST), plugin);
|
|
46
|
+
f!.push(repo);
|
|
47
|
+
f!.git(f!.clone(repo), "pull", "--ff-only");
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test.each([
|
|
53
|
+
{ order: ["user", "project"] as const },
|
|
54
|
+
{ order: ["project", "user"] as const },
|
|
55
|
+
])(
|
|
56
|
+
"TEST-14 REQ-14 both registry orders converge after every scope moves: %j",
|
|
57
|
+
({ order }) => {
|
|
58
|
+
f = new MarketFixture();
|
|
59
|
+
const { bump } = bumpClone();
|
|
60
|
+
for (const scope of order) f.install(ID, scope);
|
|
61
|
+
bump();
|
|
62
|
+
|
|
63
|
+
const r = advance(["user", "project"]);
|
|
64
|
+
|
|
65
|
+
expect(r.outcome).toBe("ok");
|
|
66
|
+
expect(r.loaded).toBe("0.3.2");
|
|
67
|
+
expect(
|
|
68
|
+
r.installs.map((row) => ({
|
|
69
|
+
scope: row.scope,
|
|
70
|
+
status: row.status,
|
|
71
|
+
to: row.to,
|
|
72
|
+
loaded: row.loaded,
|
|
73
|
+
})),
|
|
74
|
+
).toEqual([
|
|
75
|
+
{ scope: "user", status: "updated", to: "0.3.2", loaded: "0.3.2" },
|
|
76
|
+
{ scope: "project", status: "updated", to: "0.3.2", loaded: "0.3.2" },
|
|
77
|
+
]);
|
|
78
|
+
f.assertOwned();
|
|
79
|
+
},
|
|
80
|
+
60_000,
|
|
81
|
+
);
|
|
82
|
+
test("TEST-14 REQ-14 a missing row becomes an installed row at the requested cwd", () => {
|
|
83
|
+
f = new MarketFixture();
|
|
84
|
+
const { bump } = bumpClone();
|
|
85
|
+
bump();
|
|
86
|
+
|
|
87
|
+
const r = advance(["project"]);
|
|
88
|
+
|
|
89
|
+
expect(r.outcome).toBe("ok");
|
|
90
|
+
expect(f.version(ID, "project")).toBe("0.3.2");
|
|
91
|
+
f.assertOwned();
|
|
92
|
+
}, 30_000);
|
|
93
|
+
test("TEST-14 REQ-14 a misdirected update fails its own row even when another row moved", () => {
|
|
94
|
+
f = new MarketFixture();
|
|
95
|
+
const { bump } = bumpClone();
|
|
96
|
+
const other = join(f.root, "other-checkout");
|
|
97
|
+
f.git(f.project, "worktree", "add", "-b", "other", other);
|
|
98
|
+
f.install(ID, "project", other);
|
|
99
|
+
f.install(ID, "project");
|
|
100
|
+
bump();
|
|
101
|
+
f.setModes({ misdirect: true });
|
|
102
|
+
|
|
103
|
+
const r = advance(["project"]);
|
|
104
|
+
|
|
105
|
+
expect(r.outcome).toBe("partial");
|
|
106
|
+
expect(r.installs[0]?.status).toBe("failed");
|
|
107
|
+
expect(r.installs[0]?.fix).toContain("magus update");
|
|
108
|
+
expect(f.version(ID, "project")).toBe("0.3.1");
|
|
109
|
+
f.assertOwned();
|
|
110
|
+
}, 60_000);
|
|
111
|
+
test("TEST-14 REQ-14 a moved row with an older effective loader is partial", () => {
|
|
112
|
+
f = new MarketFixture();
|
|
113
|
+
const { bump } = bumpClone();
|
|
114
|
+
const other = join(f.root, "other-checkout");
|
|
115
|
+
f.git(f.project, "worktree", "add", "-b", "other", other);
|
|
116
|
+
f.install(ID, "project", other);
|
|
117
|
+
f.install(ID, "project");
|
|
118
|
+
bump();
|
|
119
|
+
|
|
120
|
+
const r = advance(["project"]);
|
|
121
|
+
|
|
122
|
+
expect(r.installs[0]?.status).toBe("updated");
|
|
123
|
+
expect(r.loaded).toBe("0.3.1");
|
|
124
|
+
expect(r.outcome).toBe("partial");
|
|
125
|
+
expect(r.installs[0]?.loaded).toBe("0.3.1");
|
|
126
|
+
expect(f.version(ID, "project", other)).toBe("0.3.1");
|
|
127
|
+
f.assertOwned();
|
|
128
|
+
}, 60_000);
|
|
129
|
+
test("TEST-14 REQ-14 not-found errors stay failures without registry recovery", () => {
|
|
130
|
+
f = new MarketFixture();
|
|
131
|
+
const { bump } = bumpClone();
|
|
132
|
+
f.install();
|
|
133
|
+
bump();
|
|
134
|
+
f.setModes({ failInstall: "Plugin not found in marketplace" });
|
|
135
|
+
|
|
136
|
+
const r = advance(["user"]);
|
|
137
|
+
|
|
138
|
+
expect(r.outcome).toBe("partial");
|
|
139
|
+
expect(r.installs[0]?.status).toBe("failed");
|
|
140
|
+
expect(r.installs[0]?.fix).toContain("magus update");
|
|
141
|
+
expect(f.version()).toBe("0.3.1");
|
|
142
|
+
f.assertOwned();
|
|
143
|
+
}, 30_000);
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
type OwnerCandidate,
|
|
6
|
+
addPluginEntry,
|
|
7
|
+
compareSkillTrees,
|
|
8
|
+
currentVersion,
|
|
9
|
+
excludedByShape,
|
|
10
|
+
findSkillOwners,
|
|
11
|
+
inDomain,
|
|
12
|
+
nextVersion,
|
|
13
|
+
pluginDirFor,
|
|
14
|
+
pluginRelDir,
|
|
15
|
+
removePluginEntry,
|
|
16
|
+
sameFiles,
|
|
17
|
+
setEntryVersion,
|
|
18
|
+
treeHash,
|
|
19
|
+
validPackageName,
|
|
20
|
+
validSkillName,
|
|
21
|
+
} from "../services/market-layout.js";
|
|
22
|
+
|
|
23
|
+
describe("marketplace layout and versions", () => {
|
|
24
|
+
test.each([
|
|
25
|
+
[{ version: "1.2.3" }, {}, "1.2.3", "1.2.4"],
|
|
26
|
+
[{}, { version: "2.3.4" }, "2.3.4", "2.3.5"],
|
|
27
|
+
[{ version: "3.0.9" }, { version: "9.0.0" }, "3.0.9", "3.0.10"],
|
|
28
|
+
[{}, {}, null, "0.0.1"],
|
|
29
|
+
[null, null, null, "0.0.1"],
|
|
30
|
+
] as const)(
|
|
31
|
+
"TEST-01 REQ-12 uses plugin then entry version: %j / %j",
|
|
32
|
+
(plugin, entry, current, next) => {
|
|
33
|
+
expect(currentVersion(plugin, entry)).toBe(current);
|
|
34
|
+
expect(nextVersion(current)).toBe(next);
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
test.each(["1", "1.2", "v1.2.3", "1.2.3-beta", "invalid"])(
|
|
38
|
+
"TEST-01 REQ-12 does not guess a patch for %s",
|
|
39
|
+
(value) => expect(nextVersion(value)).toBeNull(),
|
|
40
|
+
);
|
|
41
|
+
test("TEST-01 REQ-12 changes manifest entries without mutating their input", () => {
|
|
42
|
+
const original = {
|
|
43
|
+
name: "acme",
|
|
44
|
+
metadata: { description: "Team" },
|
|
45
|
+
plugins: [{ name: "kit", source: "./kit", version: "1.0.0" }],
|
|
46
|
+
};
|
|
47
|
+
const before = JSON.stringify(original);
|
|
48
|
+
|
|
49
|
+
const updated = setEntryVersion(original, "kit", "1.0.1");
|
|
50
|
+
const added = addPluginEntry(original, {
|
|
51
|
+
name: "other",
|
|
52
|
+
source: "./other",
|
|
53
|
+
});
|
|
54
|
+
const removed = removePluginEntry(original, "kit");
|
|
55
|
+
|
|
56
|
+
expect(updated.plugins).toEqual([
|
|
57
|
+
{ name: "kit", source: "./kit", version: "1.0.1" },
|
|
58
|
+
]);
|
|
59
|
+
expect(added.plugins).toHaveLength(2);
|
|
60
|
+
expect(removed.plugins).toEqual([]);
|
|
61
|
+
expect(JSON.stringify(original)).toBe(before);
|
|
62
|
+
});
|
|
63
|
+
test.each([
|
|
64
|
+
["./plugins/kit", undefined, "plugins/kit"],
|
|
65
|
+
["kit", "./plugins", "plugins/kit"],
|
|
66
|
+
["kit", "plugins", "plugins/kit"],
|
|
67
|
+
[".", undefined, ""],
|
|
68
|
+
["./nested/../kit", undefined, "kit"],
|
|
69
|
+
["kit", undefined, null],
|
|
70
|
+
["plugins/kit", undefined, null],
|
|
71
|
+
["../outside", undefined, null],
|
|
72
|
+
["kit", "../outside", null],
|
|
73
|
+
[{ source: "github", repo: "someone/kit" }, undefined, null],
|
|
74
|
+
["https://example.invalid/kit.git", undefined, null],
|
|
75
|
+
] as const)(
|
|
76
|
+
"TEST-02 REQ-35 resolves source %j with pluginRoot %j",
|
|
77
|
+
(source, pluginRoot, expected) => {
|
|
78
|
+
const root = join(tmpdir(), "marketplace-root");
|
|
79
|
+
expect(pluginRelDir(source, pluginRoot)).toBe(expected);
|
|
80
|
+
expect(pluginDirFor(root, source, pluginRoot)).toBe(
|
|
81
|
+
expected === null ? null : join(root, expected),
|
|
82
|
+
);
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
test("TEST-02 REQ-35 rejects an absolute source even when it is below the root", () => {
|
|
86
|
+
const root = join(tmpdir(), "marketplace-root");
|
|
87
|
+
expect(pluginDirFor(root, join(root, "kit"))).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
test.each(["kit", "review-kit", "acme1"])(
|
|
90
|
+
"TEST-03 REQ-21 accepts package name %s",
|
|
91
|
+
(name) => {
|
|
92
|
+
expect(validPackageName(name)).toBe(true);
|
|
93
|
+
expect(validSkillName(name)).toBe(true);
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
test.each(["", ".", "..", "../review", "a/b", "a/../../review"])(
|
|
97
|
+
"TEST-03 REQ-21 rejects traversal name %j",
|
|
98
|
+
(name) => {
|
|
99
|
+
expect(validPackageName(name)).toBe(false);
|
|
100
|
+
expect(validSkillName(name)).toBe(false);
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("skill ownership and comparisons", () => {
|
|
106
|
+
test("TEST-04 REQ-25 returns both owners and excludes external plugin sources", () => {
|
|
107
|
+
const candidates: OwnerCandidate[] = [
|
|
108
|
+
{
|
|
109
|
+
marketplace: "acme",
|
|
110
|
+
plugins: [
|
|
111
|
+
{ name: "kit", dir: "plugins/kit" },
|
|
112
|
+
{ name: "external", dir: null },
|
|
113
|
+
],
|
|
114
|
+
paths: new Set([
|
|
115
|
+
"plugins/kit/skills/review/SKILL.md",
|
|
116
|
+
"skills/review/SKILL.md",
|
|
117
|
+
]),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
marketplace: "beta",
|
|
121
|
+
plugins: [{ name: "root-kit", dir: "" }],
|
|
122
|
+
paths: new Set(["skills/review/SKILL.md"]),
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
expect(findSkillOwners(candidates, "review").sort()).toEqual([
|
|
126
|
+
"kit@acme",
|
|
127
|
+
"root-kit@beta",
|
|
128
|
+
]);
|
|
129
|
+
expect(findSkillOwners(candidates, "missing")).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
test.each([
|
|
132
|
+
[null, "a", "a", "same"],
|
|
133
|
+
["old", "a", "a", "same"],
|
|
134
|
+
["a", "a", "a", "same"],
|
|
135
|
+
["a", "b", "a", "plugin-newer"],
|
|
136
|
+
["a", "a", "b", "fast"],
|
|
137
|
+
[null, "a", "b", "no-base"],
|
|
138
|
+
["a", "b", "c", "conflict"],
|
|
139
|
+
] as const)(
|
|
140
|
+
"TEST-05 REQ-11 compares base=%s theirs=%s ours=%s as %s",
|
|
141
|
+
(base, theirs, ours, state) => {
|
|
142
|
+
expect(compareSkillTrees(base, theirs, ours)).toBe(state);
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
test("TEST-05 REQ-30 file hashes ignore order but distinguish path, deletion and bytes", () => {
|
|
146
|
+
const a = [
|
|
147
|
+
{ path: "SKILL.md", oid: "a".repeat(40) },
|
|
148
|
+
{ path: "notes.md", oid: "b".repeat(40) },
|
|
149
|
+
];
|
|
150
|
+
const before = JSON.stringify(a);
|
|
151
|
+
|
|
152
|
+
expect(sameFiles(a, [...a].reverse())).toBe(true);
|
|
153
|
+
expect(treeHash(a)).toBe(treeHash([...a].reverse()));
|
|
154
|
+
expect(sameFiles(a, a.slice(0, 1))).toBe(false);
|
|
155
|
+
expect(treeHash(a)).not.toBe(
|
|
156
|
+
treeHash([{ ...a[0]!, oid: "c".repeat(40) }, a[1]!]),
|
|
157
|
+
);
|
|
158
|
+
expect(treeHash(a)).not.toBe(
|
|
159
|
+
treeHash([{ ...a[0]!, path: "renamed.md" }, a[1]!]),
|
|
160
|
+
);
|
|
161
|
+
expect(JSON.stringify(a)).toBe(before);
|
|
162
|
+
});
|
|
163
|
+
test.each([
|
|
164
|
+
["SKILL.md", false, null],
|
|
165
|
+
["references/rules.md", false, null],
|
|
166
|
+
[".env", false, "dotfile"],
|
|
167
|
+
["deep/.private/x", false, "dotfile"],
|
|
168
|
+
["node_modules/a/x", false, "node_modules"],
|
|
169
|
+
["deep/node_modules/a/x", false, "node_modules"],
|
|
170
|
+
["link.md", true, "symlink"],
|
|
171
|
+
] as const)("TEST-06 REQ-21 excludes %s by shape", (path, link, reason) => {
|
|
172
|
+
expect(excludedByShape(path, link)).toBe(reason);
|
|
173
|
+
expect(
|
|
174
|
+
inDomain(path, link ? "120000" : "100644", new Set(), new Set()),
|
|
175
|
+
).toBe(reason === null);
|
|
176
|
+
});
|
|
177
|
+
test("TEST-06 REQ-30 applies identical exclusions to files absent on one side", () => {
|
|
178
|
+
const base = [
|
|
179
|
+
{ path: "SKILL.md", oid: "a".repeat(40) },
|
|
180
|
+
{ path: ".gitkeep", oid: "b".repeat(40) },
|
|
181
|
+
{ path: "ignored.md", oid: "c".repeat(40) },
|
|
182
|
+
{ path: "destination.tmp", oid: "d".repeat(40) },
|
|
183
|
+
];
|
|
184
|
+
const filter = (paths: typeof base) =>
|
|
185
|
+
paths.filter((file) =>
|
|
186
|
+
inDomain(
|
|
187
|
+
file.path,
|
|
188
|
+
"100644",
|
|
189
|
+
new Set(["ignored.md"]),
|
|
190
|
+
new Set(["destination.tmp"]),
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
const ours = base.slice(0, 1);
|
|
194
|
+
expect(
|
|
195
|
+
compareSkillTrees(
|
|
196
|
+
treeHash(filter(base)),
|
|
197
|
+
treeHash(filter(base)),
|
|
198
|
+
treeHash(filter(ours)),
|
|
199
|
+
),
|
|
200
|
+
).toBe("same");
|
|
201
|
+
expect(inDomain("references/link", "120000", new Set(), new Set())).toBe(
|
|
202
|
+
false,
|
|
203
|
+
);
|
|
204
|
+
expect(inDomain("run.sh", "100755", new Set(), new Set())).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
});
|