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.
Files changed (111) hide show
  1. package/README.md +21 -0
  2. package/package.json +4 -4
  3. package/src/__tests__/market-bases.test.ts +112 -0
  4. package/src/__tests__/market-config.test.ts +84 -0
  5. package/src/__tests__/market-conflicts.test.ts +175 -0
  6. package/src/__tests__/market-consent.test.ts +185 -0
  7. package/src/__tests__/market-content.test.ts +183 -0
  8. package/src/__tests__/market-delivery.test.ts +308 -0
  9. package/src/__tests__/market-fixture.ts +1167 -0
  10. package/src/__tests__/market-git.test.ts +130 -0
  11. package/src/__tests__/market-identity.test.ts +127 -0
  12. package/src/__tests__/market-install.test.ts +143 -0
  13. package/src/__tests__/market-layout.test.ts +206 -0
  14. package/src/__tests__/market-publish.test.ts +263 -0
  15. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  16. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  17. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  18. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  19. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  20. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  21. package/src/__tests__/market-r2-routing.test.ts +127 -0
  22. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  23. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  24. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  25. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  26. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  27. package/src/__tests__/market-readonly.test.ts +162 -0
  28. package/src/__tests__/market-repositories.test.ts +263 -0
  29. package/src/__tests__/market-results.test.ts +198 -0
  30. package/src/__tests__/market-router.test.ts +65 -0
  31. package/src/__tests__/market-safety.test.ts +357 -0
  32. package/src/__tests__/market-teammates.test.ts +304 -0
  33. package/src/cli/install.ts +64 -5
  34. package/src/cli/market-view.ts +329 -0
  35. package/src/cli/market.ts +664 -0
  36. package/src/cli/router.ts +69 -3
  37. package/src/cli/update-view.ts +56 -0
  38. package/src/cli/update.ts +111 -9
  39. package/src/data/marketplaces.ts +8 -2
  40. package/src/main.tsx +20 -2
  41. package/src/market-tui.tsx +21 -0
  42. package/src/services/active-closure.ts +71 -9
  43. package/src/services/claude-cli.ts +140 -10
  44. package/src/services/claude-settings.ts +19 -25
  45. package/src/services/doctor.ts +5 -5
  46. package/src/services/git-write.ts +615 -0
  47. package/src/services/github-cli.ts +309 -0
  48. package/src/services/local-marketplace.ts +112 -50
  49. package/src/services/market-bases.ts +191 -0
  50. package/src/services/market-change.ts +242 -0
  51. package/src/services/market-clone.ts +572 -0
  52. package/src/services/market-fs.ts +138 -0
  53. package/src/services/market-install.ts +172 -0
  54. package/src/services/market-layout.ts +311 -0
  55. package/src/services/market-local.ts +93 -0
  56. package/src/services/market-marker.ts +49 -0
  57. package/src/services/market-ops-content.ts +277 -0
  58. package/src/services/market-ops-plugin.ts +655 -0
  59. package/src/services/market-ops-publish.ts +1227 -0
  60. package/src/services/market-ops-repo.ts +916 -0
  61. package/src/services/market-ops-skill.ts +788 -0
  62. package/src/services/market-ops.ts +686 -0
  63. package/src/services/market-publish.ts +468 -0
  64. package/src/services/market-registry.ts +382 -0
  65. package/src/services/market-types.ts +268 -0
  66. package/src/services/marketplace-conflicts.ts +127 -0
  67. package/src/services/marketplace-refresh.ts +33 -3
  68. package/src/services/marketplace-sync.ts +253 -22
  69. package/src/services/plugin-dependencies.ts +17 -0
  70. package/src/services/plugin-manager.ts +1 -1
  71. package/src/services/plugin-mcp-config.ts +6 -9
  72. package/src/services/plugin-requires.ts +20 -8
  73. package/src/services/profile-absorb.ts +88 -3
  74. package/src/services/profile-adopt.ts +10 -1
  75. package/src/services/profiles.ts +64 -1
  76. package/src/services/resolver.ts +17 -5
  77. package/src/services/settings-manager.ts +2 -2
  78. package/src/services/skills-manager.ts +2 -2
  79. package/src/services/update-plan.ts +67 -8
  80. package/src/tui-runtime.tsx +142 -0
  81. package/src/tui.tsx +7 -124
  82. package/src/types/index.ts +7 -0
  83. package/src/ui/adapters/marketAdapter.ts +366 -0
  84. package/src/ui/adapters/pluginsAdapter.ts +45 -3
  85. package/src/ui/components/layout/ScreenLayout.tsx +43 -17
  86. package/src/ui/components/modals/ConfirmModal.tsx +145 -8
  87. package/src/ui/components/modals/HelpOverlay.tsx +10 -3
  88. package/src/ui/components/modals/ModalContainer.tsx +47 -5
  89. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  90. package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
  91. package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
  92. package/src/ui/components/primitives/SelectableRow.tsx +14 -1
  93. package/src/ui/components/primitives/ToneLine.tsx +25 -0
  94. package/src/ui/components/primitives/index.ts +5 -0
  95. package/src/ui/keymap.ts +21 -5
  96. package/src/ui/market/MarketApp.tsx +59 -0
  97. package/src/ui/market/MarketplaceScreen.tsx +280 -0
  98. package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
  99. package/src/ui/market/MarketplacesScreen.tsx +264 -0
  100. package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
  101. package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
  102. package/src/ui/market/marketKeymap.ts +243 -0
  103. package/src/ui/market/useMarketState.ts +694 -0
  104. package/src/ui/renderers/pluginRenderers.tsx +31 -3
  105. package/src/ui/renderers/pluginStatusText.ts +6 -0
  106. package/src/ui/screens/PluginsScreen.tsx +49 -5
  107. package/src/ui/screens/SkillsScreen.tsx +2 -2
  108. package/src/ui/state/AppContext.tsx +14 -2
  109. package/src/ui/state/types.ts +9 -2
  110. package/src/utils/config-dir.ts +29 -9
  111. 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
+ });