@app-vitals/agent-runtime 1.5.0 → 1.7.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/baked-marketplaces-reporter.ts +51 -0
- package/baked-marketplaces-reporter.unit.test.ts +77 -0
- package/boot.ts +31 -7
- package/entrypoint.ts +18 -1
- package/package.json +3 -3
- package/setup.integration.test.ts +236 -0
- package/setup.ts +169 -24
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baked marketplace reporter — at boot, lists the marketplace directories
|
|
3
|
+
* baked into the image (BAKED_MARKETPLACES_ROOT) and reports their names to
|
|
4
|
+
* admin: PUT /agents/:agentId/baked-marketplaces (overwrite). Read-only
|
|
5
|
+
* visibility: admin stores the list so the UI can show what the image ships.
|
|
6
|
+
*
|
|
7
|
+
* Fire-and-forget: never throws; non-2xx and thrown errors are swallowed with
|
|
8
|
+
* console.warn. The payload is the directory basenames only.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { basename } from "node:path";
|
|
12
|
+
import { BAKED_MARKETPLACES_ROOT, discoverBakedMarketplaces } from "./setup.ts";
|
|
13
|
+
|
|
14
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
15
|
+
|
|
16
|
+
interface ReportBakedMarketplacesOptions {
|
|
17
|
+
apiUrl: string;
|
|
18
|
+
agentId: string;
|
|
19
|
+
apiKey: string;
|
|
20
|
+
root?: string;
|
|
21
|
+
fetchFn?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function reportBakedMarketplaces(
|
|
25
|
+
opts: ReportBakedMarketplacesOptions,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const url = `${opts.apiUrl}/agents/${opts.agentId}/baked-marketplaces`;
|
|
28
|
+
try {
|
|
29
|
+
const names = discoverBakedMarketplaces(
|
|
30
|
+
opts.root ?? BAKED_MARKETPLACES_ROOT,
|
|
31
|
+
).map((dir) => basename(dir));
|
|
32
|
+
const res = await (opts.fetchFn ?? fetch)(url, {
|
|
33
|
+
method: "PUT",
|
|
34
|
+
headers: {
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify({ names }),
|
|
39
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
console.warn(
|
|
43
|
+
`[baked-marketplaces-reporter] PUT ${url} returned ${res.status} — swallowing`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.warn(
|
|
48
|
+
`[baked-marketplaces-reporter] PUT ${url} failed: ${String(err)} — swallowing`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for reportBakedMarketplaces: reads the baked marketplace dirs,
|
|
3
|
+
* PUTs the names once, and never throws.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { reportBakedMarketplaces } from "./baked-marketplaces-reporter.ts";
|
|
11
|
+
|
|
12
|
+
const roots: string[] = [];
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
for (const r of roots.splice(0)) rmSync(r, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function bakedRoot(names: string[]): string {
|
|
18
|
+
const root = mkdtempSync(join(tmpdir(), "baked-"));
|
|
19
|
+
roots.push(root);
|
|
20
|
+
for (const n of names) {
|
|
21
|
+
mkdirSync(join(root, n, ".claude-plugin"), { recursive: true });
|
|
22
|
+
writeFileSync(join(root, n, ".claude-plugin", "marketplace.json"), "{}");
|
|
23
|
+
}
|
|
24
|
+
return root;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function harness(response: Response | Error = new Response("{}")) {
|
|
28
|
+
const calls: { url: string; init: RequestInit | undefined }[] = [];
|
|
29
|
+
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
30
|
+
calls.push({ url: String(url), init });
|
|
31
|
+
if (response instanceof Error) throw response;
|
|
32
|
+
return response;
|
|
33
|
+
}) as typeof fetch;
|
|
34
|
+
return { calls, fetchFn };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const base = { apiUrl: "http://admin", agentId: "a1", apiKey: "k" };
|
|
38
|
+
|
|
39
|
+
describe("reportBakedMarketplaces", () => {
|
|
40
|
+
test("PUTs the basenames once with the agent key", async () => {
|
|
41
|
+
const root = bakedRoot(["vitals-os", "shipwright"]);
|
|
42
|
+
const { calls, fetchFn } = harness();
|
|
43
|
+
await reportBakedMarketplaces({ ...base, root, fetchFn });
|
|
44
|
+
expect(calls).toHaveLength(1);
|
|
45
|
+
expect(calls[0].url).toBe("http://admin/agents/a1/baked-marketplaces");
|
|
46
|
+
expect(calls[0].init?.method).toBe("PUT");
|
|
47
|
+
expect(new Headers(calls[0].init?.headers).get("authorization")).toBe(
|
|
48
|
+
"Bearer k",
|
|
49
|
+
);
|
|
50
|
+
const body = JSON.parse(calls[0].init?.body as string);
|
|
51
|
+
expect([...body.names].sort()).toEqual(["shipwright", "vitals-os"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("reports an empty list when the root is missing", async () => {
|
|
55
|
+
const { calls, fetchFn } = harness();
|
|
56
|
+
await reportBakedMarketplaces({
|
|
57
|
+
...base,
|
|
58
|
+
root: "/nonexistent-baked-root",
|
|
59
|
+
fetchFn,
|
|
60
|
+
});
|
|
61
|
+
expect(JSON.parse(calls[0].init?.body as string)).toEqual({ names: [] });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("swallows a non-2xx response", async () => {
|
|
65
|
+
const { fetchFn } = harness(new Response("no", { status: 500 }));
|
|
66
|
+
await expect(
|
|
67
|
+
reportBakedMarketplaces({ ...base, root: bakedRoot(["x"]), fetchFn }),
|
|
68
|
+
).resolves.toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("swallows a thrown fetch error", async () => {
|
|
72
|
+
const { fetchFn } = harness(new Error("boom"));
|
|
73
|
+
await expect(
|
|
74
|
+
reportBakedMarketplaces({ ...base, root: bakedRoot(["x"]), fetchFn }),
|
|
75
|
+
).resolves.toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
package/boot.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from "./agent-slack-membership-ref.ts";
|
|
40
40
|
import { agentTrialExpiryRef } from "./agent-trial-expiry-ref.ts";
|
|
41
41
|
import { applyEnvToProcess } from "./anthropic-federation-guard.ts";
|
|
42
|
+
import { reportBakedMarketplaces } from "./baked-marketplaces-reporter.ts";
|
|
42
43
|
import { createChatPoller } from "./chat-poller.ts";
|
|
43
44
|
import {
|
|
44
45
|
HttpChatTokenReporter,
|
|
@@ -259,6 +260,20 @@ export async function startAgent(
|
|
|
259
260
|
})
|
|
260
261
|
: new NoopGitHubInstallationsReporter();
|
|
261
262
|
|
|
263
|
+
// PMK-1.4: report the image's baked marketplace names to admin once at boot
|
|
264
|
+
// (overwrite, read-only visibility). Never throws; skipped when not wired.
|
|
265
|
+
if (
|
|
266
|
+
config.shipwright.apiUrl &&
|
|
267
|
+
config.shipwright.apiKey &&
|
|
268
|
+
config.shipwright.agentId
|
|
269
|
+
) {
|
|
270
|
+
void reportBakedMarketplaces({
|
|
271
|
+
apiUrl: config.shipwright.apiUrl,
|
|
272
|
+
agentId: config.shipwright.agentId,
|
|
273
|
+
apiKey: config.shipwright.apiKey,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
262
277
|
// Always-loaded-context stamp (context-stamp.ts): fingerprints the workspace
|
|
263
278
|
// CLAUDE.md + its imports + no-`paths` rules + the plugin version, read once
|
|
264
279
|
// per run so cron-run rows can be grouped by what the model was given. The
|
|
@@ -523,13 +538,22 @@ export async function startAgent(
|
|
|
523
538
|
});
|
|
524
539
|
|
|
525
540
|
// Install agent-specific plugins from bundle (non-fatal)
|
|
526
|
-
if (bundle.plugins?.length) {
|
|
527
|
-
await installPlugins(
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
541
|
+
if (bundle.plugins?.length || bundle.marketplaces?.length) {
|
|
542
|
+
await installPlugins(
|
|
543
|
+
undefined,
|
|
544
|
+
undefined,
|
|
545
|
+
bundle.plugins,
|
|
546
|
+
undefined,
|
|
547
|
+
undefined,
|
|
548
|
+
undefined,
|
|
549
|
+
undefined,
|
|
550
|
+
undefined,
|
|
551
|
+
bundle.marketplaces,
|
|
552
|
+
).catch((err) =>
|
|
553
|
+
console.warn(
|
|
554
|
+
"[config-sync] agent plugin install failed (non-fatal):",
|
|
555
|
+
(err as Error).message,
|
|
556
|
+
),
|
|
533
557
|
);
|
|
534
558
|
}
|
|
535
559
|
|
package/entrypoint.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import type { AgentPlugin } from "@app-vitals/agent-admin";
|
|
22
22
|
import type { AgentConfigClient } from "./agent-config-client.ts";
|
|
23
|
+
import type { RemoteMarketplace } from "./setup.ts";
|
|
23
24
|
|
|
24
25
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
25
26
|
|
|
@@ -43,6 +44,12 @@ export interface EntrypointDeps {
|
|
|
43
44
|
execFn?: ExecFn,
|
|
44
45
|
cwd?: string,
|
|
45
46
|
plugins?: AgentPlugin[],
|
|
47
|
+
shipwrightRepoRootOverride?: string,
|
|
48
|
+
pluginManifestPath?: string,
|
|
49
|
+
extraMarketplaceDirs?: string[],
|
|
50
|
+
extraMarketplacesRoot?: string,
|
|
51
|
+
env?: Record<string, string | undefined>,
|
|
52
|
+
remoteMarketplaces?: RemoteMarketplace[],
|
|
46
53
|
) => Promise<void>;
|
|
47
54
|
spawnAgentServer: (cmd: string, args: string[]) => void;
|
|
48
55
|
exit: (code: number) => void;
|
|
@@ -130,7 +137,17 @@ export async function runEntrypoint(deps: EntrypointDeps): Promise<void> {
|
|
|
130
137
|
await runMiseStartup(agentHome);
|
|
131
138
|
|
|
132
139
|
// Step 7: Install plugins
|
|
133
|
-
await installPlugins(
|
|
140
|
+
await installPlugins(
|
|
141
|
+
undefined,
|
|
142
|
+
undefined,
|
|
143
|
+
config.plugins,
|
|
144
|
+
undefined,
|
|
145
|
+
undefined,
|
|
146
|
+
undefined,
|
|
147
|
+
undefined,
|
|
148
|
+
undefined,
|
|
149
|
+
config.marketplaces,
|
|
150
|
+
);
|
|
134
151
|
|
|
135
152
|
// Step 8: Spawn agent server
|
|
136
153
|
spawnAgentServer("bun", ["run", join(import.meta.dir, "..", "index.ts")]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"./workspace-paths": "./workspace-paths.ts"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@app-vitals/agent-admin": "1.
|
|
44
|
-
"@app-vitals/agent-lib": "1.
|
|
43
|
+
"@app-vitals/agent-admin": "1.7.0",
|
|
44
|
+
"@app-vitals/agent-lib": "1.7.0",
|
|
45
45
|
"@octokit/auth-app": "^8.2.0",
|
|
46
46
|
"@sentry/bun": "^10.63.0",
|
|
47
47
|
"@slack/bolt": "^5.0.0",
|
|
@@ -1590,3 +1590,239 @@ describe("installPlugins stale shipwright marketplace healing", () => {
|
|
|
1590
1590
|
expect(calls).toEqual([["plugin", "marketplace", "add", root]]);
|
|
1591
1591
|
});
|
|
1592
1592
|
});
|
|
1593
|
+
|
|
1594
|
+
// ---------------------------------------------------------------------------
|
|
1595
|
+
// installPlugins — remote marketplaces (PMK-1.3)
|
|
1596
|
+
// ---------------------------------------------------------------------------
|
|
1597
|
+
|
|
1598
|
+
describe("installPlugins — remote marketplaces", () => {
|
|
1599
|
+
const ROW = { name: "mkt", source: "owner/mkt", ref: "v1" };
|
|
1600
|
+
const NO_REPO = () => join(testHome, "no-repo");
|
|
1601
|
+
const registryPath = () => join(testHome, "known_marketplaces.json");
|
|
1602
|
+
const manifestPath = () => join(testHome, "installed_plugins.json");
|
|
1603
|
+
|
|
1604
|
+
const writeRegistry = (registry: unknown) => {
|
|
1605
|
+
mkdirSync(testHome, { recursive: true });
|
|
1606
|
+
writeFileSync(registryPath(), JSON.stringify(registry));
|
|
1607
|
+
};
|
|
1608
|
+
|
|
1609
|
+
// A marketplace dir the registry can point at.
|
|
1610
|
+
const installDir = (withManifest: boolean): string => {
|
|
1611
|
+
const dir = join(testHome, "marketplaces-cache", "mkt");
|
|
1612
|
+
mkdirSync(join(dir, ".claude-plugin"), { recursive: true });
|
|
1613
|
+
if (withManifest) {
|
|
1614
|
+
writeFileSync(
|
|
1615
|
+
join(dir, ".claude-plugin", "marketplace.json"),
|
|
1616
|
+
JSON.stringify({ name: "mkt" }),
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
return dir;
|
|
1620
|
+
};
|
|
1621
|
+
|
|
1622
|
+
const githubEntry = (
|
|
1623
|
+
repo: string,
|
|
1624
|
+
ref: string | undefined,
|
|
1625
|
+
installLocation: string,
|
|
1626
|
+
) => ({
|
|
1627
|
+
source: { source: "github", repo, ...(ref ? { ref } : {}) },
|
|
1628
|
+
installLocation,
|
|
1629
|
+
lastUpdated: "2026-11-09T00:00:00.000Z",
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
const run = async (
|
|
1633
|
+
exec: (
|
|
1634
|
+
cmd: string,
|
|
1635
|
+
args: string[],
|
|
1636
|
+
opts: { cwd: string },
|
|
1637
|
+
) => Promise<{ stdout: string; exitCode: number }>,
|
|
1638
|
+
rows: Array<{ name: string; source: string; ref?: string | null }>,
|
|
1639
|
+
extraDirs: string[] = NO_EXTRA_MARKETPLACES,
|
|
1640
|
+
plugins: AgentPlugin[] = [],
|
|
1641
|
+
) =>
|
|
1642
|
+
installPlugins(
|
|
1643
|
+
exec,
|
|
1644
|
+
testHome,
|
|
1645
|
+
plugins,
|
|
1646
|
+
NO_REPO(),
|
|
1647
|
+
manifestPath(),
|
|
1648
|
+
extraDirs,
|
|
1649
|
+
undefined,
|
|
1650
|
+
{},
|
|
1651
|
+
rows,
|
|
1652
|
+
);
|
|
1653
|
+
|
|
1654
|
+
const recorder = (exitFor: (args: string[]) => number = () => 0) => {
|
|
1655
|
+
const calls: string[][] = [];
|
|
1656
|
+
const exec = async (_cmd: string, args: string[]) => {
|
|
1657
|
+
calls.push(args);
|
|
1658
|
+
return { stdout: "", exitCode: exitFor(args) };
|
|
1659
|
+
};
|
|
1660
|
+
return { calls, exec };
|
|
1661
|
+
};
|
|
1662
|
+
|
|
1663
|
+
it("with no rows the call sequence is unchanged", async () => {
|
|
1664
|
+
const { calls, exec } = recorder();
|
|
1665
|
+
await run(exec, [], NO_EXTRA_MARKETPLACES, [SHIPWRIGHT_ROW]);
|
|
1666
|
+
expect(calls).toEqual([
|
|
1667
|
+
["plugin", "install", "shipwright@shipwright"],
|
|
1668
|
+
["plugin", "update", "shipwright@shipwright"],
|
|
1669
|
+
]);
|
|
1670
|
+
});
|
|
1671
|
+
|
|
1672
|
+
it("fresh home: baked adds, remote add <source>#<ref>, update, then plugins", async () => {
|
|
1673
|
+
const baked = join(testHome, "baked", "baked-mkt");
|
|
1674
|
+
const { calls, exec } = recorder();
|
|
1675
|
+
await run(exec, [ROW], [baked], [SHIPWRIGHT_ROW]);
|
|
1676
|
+
expect(calls).toEqual([
|
|
1677
|
+
["plugin", "marketplace", "add", baked],
|
|
1678
|
+
["plugin", "marketplace", "add", "owner/mkt#v1"],
|
|
1679
|
+
["plugin", "marketplace", "update", "mkt"],
|
|
1680
|
+
["plugin", "install", "shipwright@shipwright"],
|
|
1681
|
+
["plugin", "update", "shipwright@shipwright"],
|
|
1682
|
+
]);
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
it("adds without a ref suffix when the row has no ref", async () => {
|
|
1686
|
+
const { calls, exec } = recorder();
|
|
1687
|
+
await run(exec, [{ name: "mkt", source: "owner/mkt", ref: null }]);
|
|
1688
|
+
expect(calls[0]).toEqual(["plugin", "marketplace", "add", "owner/mkt"]);
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
it("same source and ref: only update", async () => {
|
|
1692
|
+
writeRegistry({ mkt: githubEntry("owner/mkt", "v1", installDir(true)) });
|
|
1693
|
+
const { calls, exec } = recorder();
|
|
1694
|
+
await run(exec, [ROW]);
|
|
1695
|
+
expect(calls).toEqual([["plugin", "marketplace", "update", "mkt"]]);
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
it("different ref: remove, add, update", async () => {
|
|
1699
|
+
writeRegistry({ mkt: githubEntry("owner/mkt", "v0", installDir(true)) });
|
|
1700
|
+
const { calls, exec } = recorder();
|
|
1701
|
+
await run(exec, [ROW]);
|
|
1702
|
+
expect(calls).toEqual([
|
|
1703
|
+
["plugin", "marketplace", "remove", "mkt"],
|
|
1704
|
+
["plugin", "marketplace", "add", "owner/mkt#v1"],
|
|
1705
|
+
["plugin", "marketplace", "update", "mkt"],
|
|
1706
|
+
]);
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
it("different source: remove, add, update", async () => {
|
|
1710
|
+
writeRegistry({ mkt: githubEntry("other/mkt", "v1", installDir(true)) });
|
|
1711
|
+
const { calls, exec } = recorder();
|
|
1712
|
+
await run(exec, [ROW]);
|
|
1713
|
+
expect(calls.map((c) => c[2])).toEqual(["remove", "add", "update"]);
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
it("installLocation without marketplace.json: remove, add, update", async () => {
|
|
1717
|
+
writeRegistry({ mkt: githubEntry("owner/mkt", "v1", installDir(false)) });
|
|
1718
|
+
const { calls, exec } = recorder();
|
|
1719
|
+
await run(exec, [ROW]);
|
|
1720
|
+
expect(calls.map((c) => c[2])).toEqual(["remove", "add", "update"]);
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
it("a git URL source matches its registered form", async () => {
|
|
1724
|
+
writeRegistry({
|
|
1725
|
+
mkt: {
|
|
1726
|
+
source: {
|
|
1727
|
+
source: "git",
|
|
1728
|
+
url: "https://github.com/Owner/mkt.git",
|
|
1729
|
+
ref: "v1",
|
|
1730
|
+
},
|
|
1731
|
+
installLocation: installDir(true),
|
|
1732
|
+
},
|
|
1733
|
+
});
|
|
1734
|
+
const { calls, exec } = recorder();
|
|
1735
|
+
await run(exec, [{ ...ROW, source: "https://github.com/owner/mkt" }]);
|
|
1736
|
+
expect(calls).toEqual([["plugin", "marketplace", "update", "mkt"]]);
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
it("a baked dir of the same name suppresses the row with a warning", async () => {
|
|
1740
|
+
const baked = join(testHome, "baked", "mkt");
|
|
1741
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
1742
|
+
const { calls, exec } = recorder();
|
|
1743
|
+
await run(exec, [ROW], [baked]);
|
|
1744
|
+
expect(calls).toEqual([["plugin", "marketplace", "add", baked]]);
|
|
1745
|
+
expect(warn.mock.calls.flat().join("\n")).toContain("baked marketplace");
|
|
1746
|
+
warn.mockRestore();
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
it("warns on a manifest name mismatch", async () => {
|
|
1750
|
+
// The CLI registers under the manifest name, not the row name.
|
|
1751
|
+
writeRegistry({
|
|
1752
|
+
actual: githubEntry("owner/mkt", "v1", installDir(true)),
|
|
1753
|
+
});
|
|
1754
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
1755
|
+
const { exec } = recorder();
|
|
1756
|
+
await run(exec, [ROW]);
|
|
1757
|
+
expect(warn.mock.calls.flat().join("\n")).toContain("named actual");
|
|
1758
|
+
warn.mockRestore();
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
it("failed add, remove and update warn and boot continues", async () => {
|
|
1762
|
+
writeRegistry({ mkt: githubEntry("owner/mkt", "v0", installDir(true)) });
|
|
1763
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
1764
|
+
const { calls, exec } = recorder((args) =>
|
|
1765
|
+
args[0] === "plugin" && args[1] === "marketplace" ? 1 : 0,
|
|
1766
|
+
);
|
|
1767
|
+
await run(exec, [ROW], NO_EXTRA_MARKETPLACES, [SHIPWRIGHT_ROW]);
|
|
1768
|
+
const text = warn.mock.calls.flat().join("\n");
|
|
1769
|
+
expect(text).toContain("marketplace remove mkt exited 1");
|
|
1770
|
+
expect(text).toContain("marketplace add owner/mkt#v1 exited 1");
|
|
1771
|
+
expect(text).toContain("marketplace update mkt exited 1");
|
|
1772
|
+
expect(calls.at(-1)).toEqual(["plugin", "update", "shipwright@shipwright"]);
|
|
1773
|
+
warn.mockRestore();
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
it("never removes registrations the agent does not manage", async () => {
|
|
1777
|
+
writeRegistry({
|
|
1778
|
+
unmanaged: githubEntry("x/y", undefined, installDir(false)),
|
|
1779
|
+
mkt: githubEntry("owner/mkt", "v1", installDir(true)),
|
|
1780
|
+
});
|
|
1781
|
+
const { calls, exec } = recorder();
|
|
1782
|
+
await run(exec, [ROW]);
|
|
1783
|
+
expect(calls.some((c) => c.includes("unmanaged"))).toBe(false);
|
|
1784
|
+
expect(calls.some((c) => c[2] === "remove")).toBe(false);
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
it("keeps the baked stale-registration heal working alongside rows", async () => {
|
|
1788
|
+
const baked = join(testHome, "baked", "heal-mkt");
|
|
1789
|
+
writeRegistry({
|
|
1790
|
+
"heal-mkt": {
|
|
1791
|
+
source: { source: "directory", path: "/nonexistent-app" },
|
|
1792
|
+
installLocation: "/nonexistent-app",
|
|
1793
|
+
},
|
|
1794
|
+
});
|
|
1795
|
+
const { calls, exec } = recorder();
|
|
1796
|
+
await run(exec, [ROW], [baked]);
|
|
1797
|
+
expect(calls.slice(0, 2)).toEqual([
|
|
1798
|
+
["plugin", "marketplace", "remove", "heal-mkt"],
|
|
1799
|
+
["plugin", "marketplace", "add", baked],
|
|
1800
|
+
]);
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
it("two consecutive boots are idempotent", async () => {
|
|
1804
|
+
// Fake CLI that persists registrations the way `claude` does.
|
|
1805
|
+
const cliExec = async (_cmd: string, args: string[]) => {
|
|
1806
|
+
const [, sub, action, target] = args;
|
|
1807
|
+
if (sub === "marketplace") {
|
|
1808
|
+
const reg = existsSync(registryPath())
|
|
1809
|
+
? JSON.parse(readFileSync(registryPath(), "utf8"))
|
|
1810
|
+
: {};
|
|
1811
|
+
if (action === "add" && target?.startsWith("owner/")) {
|
|
1812
|
+
const [repo, ref] = target.split("#");
|
|
1813
|
+
reg.mkt = githubEntry(repo, ref, installDir(true));
|
|
1814
|
+
} else if (action === "remove") {
|
|
1815
|
+
delete reg[target as string];
|
|
1816
|
+
}
|
|
1817
|
+
writeFileSync(registryPath(), JSON.stringify(reg));
|
|
1818
|
+
}
|
|
1819
|
+
return { stdout: "", exitCode: 0 };
|
|
1820
|
+
};
|
|
1821
|
+
const firstCalls: string[][] = [];
|
|
1822
|
+
const secondCalls: string[][] = [];
|
|
1823
|
+
await run(async (c, a) => (firstCalls.push(a), cliExec(c, a)), [ROW]);
|
|
1824
|
+
await run(async (c, a) => (secondCalls.push(a), cliExec(c, a)), [ROW]);
|
|
1825
|
+
expect(firstCalls.map((c) => c[2])).toEqual(["add", "update"]);
|
|
1826
|
+
expect(secondCalls).toEqual([["plugin", "marketplace", "update", "mkt"]]);
|
|
1827
|
+
});
|
|
1828
|
+
});
|
package/setup.ts
CHANGED
|
@@ -382,30 +382,168 @@ export function findStalePluginSpecs(
|
|
|
382
382
|
});
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
/** A remote marketplace row from the agent config bundle. */
|
|
386
|
+
export interface RemoteMarketplace {
|
|
387
|
+
name: string;
|
|
388
|
+
source: string;
|
|
389
|
+
ref?: string | null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Name of the built-in marketplace: its manifest name, else `shipwright`. */
|
|
393
|
+
function builtinMarketplaceName(repoRoot: string): string {
|
|
394
|
+
try {
|
|
395
|
+
const manifest = JSON.parse(
|
|
396
|
+
readFileSync(
|
|
397
|
+
join(repoRoot, ".claude-plugin", "marketplace.json"),
|
|
398
|
+
"utf8",
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
if (typeof manifest?.name === "string") return manifest.name;
|
|
402
|
+
} catch {}
|
|
403
|
+
return "shipwright";
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
interface RegisteredMarketplace {
|
|
407
|
+
source?: { repo?: unknown; url?: unknown; path?: unknown; ref?: unknown };
|
|
408
|
+
installLocation?: unknown;
|
|
409
|
+
}
|
|
410
|
+
|
|
385
411
|
/**
|
|
386
412
|
* Reads the Claude CLI's known-marketplaces registry
|
|
387
|
-
* (`known_marketplaces.json`, a sibling of `installed_plugins.json`)
|
|
388
|
-
* returns
|
|
389
|
-
* `undefined` for a missing or unparsable registry or an unknown name, and
|
|
413
|
+
* (`known_marketplaces.json`, a sibling of `installed_plugins.json`).
|
|
414
|
+
* Defensive: returns an empty registry for a missing or unparsable file, and
|
|
390
415
|
* warns when the file exists but cannot be read.
|
|
391
416
|
*/
|
|
392
|
-
function
|
|
417
|
+
function readMarketplaceRegistry(
|
|
393
418
|
registryPath: string,
|
|
394
|
-
|
|
395
|
-
)
|
|
396
|
-
if (!existsSync(registryPath)) return undefined;
|
|
419
|
+
): Record<string, RegisteredMarketplace | null> {
|
|
420
|
+
if (!existsSync(registryPath)) return {};
|
|
397
421
|
try {
|
|
398
|
-
const registry = JSON.parse(readFileSync(registryPath, "utf8"))
|
|
399
|
-
|
|
400
|
-
{ installLocation?: unknown } | null
|
|
401
|
-
>;
|
|
402
|
-
const location = registry?.[name]?.installLocation;
|
|
403
|
-
return typeof location === "string" ? location : undefined;
|
|
422
|
+
const registry = JSON.parse(readFileSync(registryPath, "utf8"));
|
|
423
|
+
return registry && typeof registry === "object" ? registry : {};
|
|
404
424
|
} catch (err) {
|
|
405
425
|
console.warn(
|
|
406
426
|
`[agent] could not read ${registryPath} (nothing to heal): ${(err as Error).message}`,
|
|
407
427
|
);
|
|
408
|
-
return
|
|
428
|
+
return {};
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The install location the registry records for `name`, if any. */
|
|
433
|
+
function readRegisteredMarketplaceLocation(
|
|
434
|
+
registryPath: string,
|
|
435
|
+
name: string,
|
|
436
|
+
): string | undefined {
|
|
437
|
+
const location = readMarketplaceRegistry(registryPath)[name]?.installLocation;
|
|
438
|
+
return typeof location === "string" ? location : undefined;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Normalises a marketplace source (`owner/repo` or git URL) for comparison. */
|
|
442
|
+
function normalizeMarketplaceSource(source: string): string {
|
|
443
|
+
return source
|
|
444
|
+
.trim()
|
|
445
|
+
.replace(/^https:\/\/github\.com\//i, "")
|
|
446
|
+
.replace(/\/+$/, "")
|
|
447
|
+
.replace(/\.git$/i, "")
|
|
448
|
+
.toLowerCase();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Whether a registry entry was added from the same source and ref as `row`. */
|
|
452
|
+
function registeredMatchesRow(
|
|
453
|
+
entry: RegisteredMarketplace | null | undefined,
|
|
454
|
+
row: RemoteMarketplace,
|
|
455
|
+
): boolean {
|
|
456
|
+
const src = entry?.source;
|
|
457
|
+
const registeredSource = [src?.repo, src?.url, src?.path].find(
|
|
458
|
+
(v): v is string => typeof v === "string",
|
|
459
|
+
);
|
|
460
|
+
if (registeredSource === undefined) return false;
|
|
461
|
+
const registeredRef = typeof src?.ref === "string" ? src.ref : null;
|
|
462
|
+
return (
|
|
463
|
+
normalizeMarketplaceSource(registeredSource) ===
|
|
464
|
+
normalizeMarketplaceSource(row.source) &&
|
|
465
|
+
registeredRef === (row.ref ?? null)
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Runs `claude plugin marketplace <args>`; a non-zero exit warns, never throws. */
|
|
470
|
+
async function runMarketplaceCommand(
|
|
471
|
+
execFn: ExecFn,
|
|
472
|
+
cwd: string,
|
|
473
|
+
args: string[],
|
|
474
|
+
): Promise<boolean> {
|
|
475
|
+
const result = await execFn("claude", ["plugin", "marketplace", ...args], {
|
|
476
|
+
cwd,
|
|
477
|
+
});
|
|
478
|
+
if (result.exitCode !== 0) {
|
|
479
|
+
console.warn(
|
|
480
|
+
`[agent] claude plugin marketplace ${args.join(" ")} exited ${result.exitCode}: ${result.stdout}`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
return result.exitCode === 0;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Reconciles one remote marketplace row (from the agent config bundle) to its
|
|
488
|
+
* desired state, then updates it:
|
|
489
|
+
* 1. a baked/built-in marketplace of the same name wins (warn, skip)
|
|
490
|
+
* 2. registered with another source/ref, or with an installLocation that has
|
|
491
|
+
* no marketplace.json -> remove first
|
|
492
|
+
* 3. not registered -> add `<source>[#ref]`
|
|
493
|
+
* 4. update
|
|
494
|
+
* Only the row's own name is ever touched. Failures warn and continue.
|
|
495
|
+
*/
|
|
496
|
+
async function reconcileRemoteMarketplace(
|
|
497
|
+
row: RemoteMarketplace,
|
|
498
|
+
managedLocalNames: Set<string>,
|
|
499
|
+
registryPath: string,
|
|
500
|
+
execFn: ExecFn,
|
|
501
|
+
cwd: string,
|
|
502
|
+
): Promise<void> {
|
|
503
|
+
if (managedLocalNames.has(row.name)) {
|
|
504
|
+
console.warn(
|
|
505
|
+
`[agent] remote marketplace ${row.name} ignored: a baked marketplace with the same name takes precedence`,
|
|
506
|
+
);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const entry = readMarketplaceRegistry(registryPath)[row.name];
|
|
511
|
+
let registered = entry !== undefined && entry !== null;
|
|
512
|
+
if (registered) {
|
|
513
|
+
const location =
|
|
514
|
+
typeof entry?.installLocation === "string"
|
|
515
|
+
? entry.installLocation
|
|
516
|
+
: undefined;
|
|
517
|
+
const dangling =
|
|
518
|
+
location === undefined ||
|
|
519
|
+
!existsSync(join(location, ".claude-plugin", "marketplace.json"));
|
|
520
|
+
if (dangling || !registeredMatchesRow(entry, row)) {
|
|
521
|
+
console.log(
|
|
522
|
+
`[agent] marketplace ${row.name} is registered with a different source/ref or no marketplace.json — removing before re-adding ${row.source}`,
|
|
523
|
+
);
|
|
524
|
+
await runMarketplaceCommand(execFn, cwd, ["remove", row.name]);
|
|
525
|
+
registered = false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (!registered) {
|
|
530
|
+
const spec = row.ref ? `${row.source}#${row.ref}` : row.source;
|
|
531
|
+
await runMarketplaceCommand(execFn, cwd, ["add", spec]);
|
|
532
|
+
}
|
|
533
|
+
await runMarketplaceCommand(execFn, cwd, ["update", row.name]);
|
|
534
|
+
|
|
535
|
+
// The CLI registers a marketplace under its manifest name, so a mismatch
|
|
536
|
+
// leaves the registry without an entry for the row's name.
|
|
537
|
+
const after = readMarketplaceRegistry(registryPath);
|
|
538
|
+
if (after[row.name] === undefined) {
|
|
539
|
+
const actual = Object.entries(after).find(([, e]) =>
|
|
540
|
+
registeredMatchesRow(e, row),
|
|
541
|
+
)?.[0];
|
|
542
|
+
if (actual !== undefined) {
|
|
543
|
+
console.warn(
|
|
544
|
+
`[agent] remote marketplace row ${row.name} cloned a manifest named ${actual} — plugin specs must use @${actual}`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
409
547
|
}
|
|
410
548
|
}
|
|
411
549
|
|
|
@@ -446,6 +584,7 @@ export async function installPlugins(
|
|
|
446
584
|
extraMarketplaceDirs?: string[],
|
|
447
585
|
extraMarketplacesRoot: string = BAKED_MARKETPLACES_ROOT,
|
|
448
586
|
env: Record<string, string | undefined> = process.env,
|
|
587
|
+
remoteMarketplaces: RemoteMarketplace[] = [],
|
|
449
588
|
): Promise<void> {
|
|
450
589
|
const shipwrightRepoRoot =
|
|
451
590
|
shipwrightRepoRootOverride ?? resolveShipwrightRepoRoot(env);
|
|
@@ -477,16 +616,7 @@ export async function installPlugins(
|
|
|
477
616
|
console.log(
|
|
478
617
|
`[agent] marketplace ${name} is registered at ${registered} with no marketplace.json — removing before re-adding ${dir}`,
|
|
479
618
|
);
|
|
480
|
-
|
|
481
|
-
"claude",
|
|
482
|
-
["plugin", "marketplace", "remove", name],
|
|
483
|
-
{ cwd },
|
|
484
|
-
);
|
|
485
|
-
if (remove.exitCode !== 0) {
|
|
486
|
-
console.warn(
|
|
487
|
-
`[agent] claude plugin marketplace remove ${name} exited ${remove.exitCode}: ${remove.stdout}`,
|
|
488
|
-
);
|
|
489
|
-
}
|
|
619
|
+
await runMarketplaceCommand(execFn, cwd, ["remove", name]);
|
|
490
620
|
}
|
|
491
621
|
const extraAdd = await execFn(
|
|
492
622
|
"claude",
|
|
@@ -526,6 +656,21 @@ export async function installPlugins(
|
|
|
526
656
|
}
|
|
527
657
|
}
|
|
528
658
|
|
|
659
|
+
// Reconcile remote marketplaces (config bundle rows) after every local one
|
|
660
|
+
// is registered, before any plugin install.
|
|
661
|
+
const managedLocalNames = new Set(extraDirs.map((dir) => basename(dir)));
|
|
662
|
+
if (hasRepoMarketplace)
|
|
663
|
+
managedLocalNames.add(builtinMarketplaceName(shipwrightRepoRoot));
|
|
664
|
+
for (const row of remoteMarketplaces) {
|
|
665
|
+
await reconcileRemoteMarketplace(
|
|
666
|
+
row,
|
|
667
|
+
managedLocalNames,
|
|
668
|
+
registryPath,
|
|
669
|
+
execFn,
|
|
670
|
+
cwd,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
529
674
|
const allSpecs = agentPlugins.map((p) => `${p.plugin}@${p.marketplace}`);
|
|
530
675
|
|
|
531
676
|
// Uninstall any plugin whose recorded installPath no longer exists. This
|