@app-vitals/agent-runtime 0.16.1 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-runtime",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
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": "0.16.1",
44
- "@app-vitals/agent-lib": "0.16.1",
43
+ "@app-vitals/agent-admin": "0.16.2",
44
+ "@app-vitals/agent-lib": "0.16.2",
45
45
  "@octokit/auth-app": "^8.2.0",
46
46
  "@sentry/bun": "^10.63.0",
47
47
  "@slack/bolt": "^5.0.0",
@@ -894,6 +894,19 @@ describe("findStalePluginSpecs", () => {
894
894
  // these tests depend on ambient sandbox/image state.
895
895
  const NO_EXTRA_MARKETPLACES: string[] = [];
896
896
 
897
+ const SHIPWRIGHT_ROW: AgentPlugin = {
898
+ plugin: "shipwright",
899
+ marketplace: "shipwright",
900
+ };
901
+
902
+ // A repo root carrying a local marketplace (coding image); platform images ship none.
903
+ const repoRoot = (): string => {
904
+ const root = join(testHome, "repo-root");
905
+ mkdirSync(join(root, ".claude-plugin"), { recursive: true });
906
+ writeFileSync(join(root, ".claude-plugin", "marketplace.json"), "{}");
907
+ return root;
908
+ };
909
+
897
910
  describe("installPlugins", () => {
898
911
  it("registers local marketplace then installs shipwright@shipwright using the shipwright marketplace", async () => {
899
912
  const calls: Array<{ cmd: string; args: string[] }> = [];
@@ -909,20 +922,15 @@ describe("installPlugins", () => {
909
922
  await installPlugins(
910
923
  mockExec,
911
924
  testHome,
912
- [],
913
- "/repo/root",
925
+ [SHIPWRIGHT_ROW],
926
+ repoRoot(),
914
927
  join(testHome, "nonexistent.json"),
915
928
  NO_EXTRA_MARKETPLACES,
916
929
  );
917
930
 
918
931
  // marketplace add + 1 install + 1 update = 3 calls (no stale paths)
919
932
  expect(calls).toHaveLength(3);
920
- expect(calls[0].args).toEqual([
921
- "plugin",
922
- "marketplace",
923
- "add",
924
- "/repo/root",
925
- ]);
933
+ expect(calls[0].args).toEqual(["plugin", "marketplace", "add", repoRoot()]);
926
934
  expect(calls[1].args).toEqual([
927
935
  "plugin",
928
936
  "install",
@@ -935,7 +943,7 @@ describe("installPlugins", () => {
935
943
  ]);
936
944
  });
937
945
 
938
- it("installs agent-specific plugins after default plugins", async () => {
946
+ it("installs row plugins in row order", async () => {
939
947
  const calls: Array<{ cmd: string; args: string[] }> = [];
940
948
  const mockExec = async (
941
949
  cmd: string,
@@ -947,6 +955,7 @@ describe("installPlugins", () => {
947
955
  };
948
956
 
949
957
  const agentPlugins: AgentPlugin[] = [
958
+ SHIPWRIGHT_ROW,
950
959
  { marketplace: "my-marketplace", plugin: "custom-plugin" },
951
960
  { marketplace: "other-market", plugin: "another-plugin" },
952
961
  ];
@@ -955,7 +964,7 @@ describe("installPlugins", () => {
955
964
  mockExec,
956
965
  testHome,
957
966
  agentPlugins,
958
- "/repo/root",
967
+ repoRoot(),
959
968
  join(testHome, "nonexistent.json"),
960
969
  NO_EXTRA_MARKETPLACES,
961
970
  );
@@ -963,14 +972,9 @@ describe("installPlugins", () => {
963
972
  // 1 marketplace add + 3 installs + 3 updates = 7 calls
964
973
  expect(calls).toHaveLength(7);
965
974
 
966
- expect(calls[0].args).toEqual([
967
- "plugin",
968
- "marketplace",
969
- "add",
970
- "/repo/root",
971
- ]);
975
+ expect(calls[0].args).toEqual(["plugin", "marketplace", "add", repoRoot()]);
972
976
 
973
- // installs: default first, then agent-specific
977
+ // installs in row order
974
978
  expect(calls[1].args).toEqual([
975
979
  "plugin",
976
980
  "install",
@@ -987,7 +991,7 @@ describe("installPlugins", () => {
987
991
  "another-plugin@other-market",
988
992
  ]);
989
993
 
990
- // updates: default first, then agent-specific
994
+ // updates in row order
991
995
  expect(calls[4].args).toEqual([
992
996
  "plugin",
993
997
  "update",
@@ -1005,7 +1009,7 @@ describe("installPlugins", () => {
1005
1009
  ]);
1006
1010
  });
1007
1011
 
1008
- it("empty agentPlugins installs only default plugins", async () => {
1012
+ it("no plugin rows installs nothing (platform agents)", async () => {
1009
1013
  const calls: Array<{ args: string[] }> = [];
1010
1014
  const mockExec = async (
1011
1015
  _cmd: string,
@@ -1020,15 +1024,12 @@ describe("installPlugins", () => {
1020
1024
  mockExec,
1021
1025
  testHome,
1022
1026
  [],
1023
- "/repo/root",
1027
+ "/repo/root-without-marketplace",
1024
1028
  join(testHome, "nonexistent.json"),
1025
1029
  NO_EXTRA_MARKETPLACES,
1026
1030
  );
1027
1031
 
1028
- // marketplace add + 1 install + 1 update = 3 calls
1029
- expect(calls).toHaveLength(3);
1030
- expect(calls[1].args).toContain("shipwright@shipwright");
1031
- expect(calls[2].args).toContain("shipwright@shipwright");
1032
+ expect(calls).toEqual([]);
1032
1033
  });
1033
1034
 
1034
1035
  it("is non-fatal when the claude binary isn't available", async () => {
@@ -1040,8 +1041,8 @@ describe("installPlugins", () => {
1040
1041
  installPlugins(
1041
1042
  throwingExec,
1042
1043
  testHome,
1043
- [],
1044
- "/repo/root",
1044
+ [SHIPWRIGHT_ROW],
1045
+ repoRoot(),
1045
1046
  join(testHome, "nonexistent.json"),
1046
1047
  NO_EXTRA_MARKETPLACES,
1047
1048
  ),
@@ -1067,8 +1068,8 @@ describe("installPlugins", () => {
1067
1068
  installPlugins(
1068
1069
  mockExec,
1069
1070
  testHome,
1070
- [],
1071
- "/repo/root",
1071
+ [SHIPWRIGHT_ROW],
1072
+ repoRoot(),
1072
1073
  join(testHome, "nonexistent.json"),
1073
1074
  NO_EXTRA_MARKETPLACES,
1074
1075
  ),
@@ -1097,8 +1098,8 @@ describe("installPlugins", () => {
1097
1098
  installPlugins(
1098
1099
  mockExec,
1099
1100
  testHome,
1100
- [],
1101
- "/repo/root",
1101
+ [SHIPWRIGHT_ROW],
1102
+ repoRoot(),
1102
1103
  join(testHome, "nonexistent.json"),
1103
1104
  NO_EXTRA_MARKETPLACES,
1104
1105
  ),
@@ -1123,8 +1124,8 @@ describe("installPlugins", () => {
1123
1124
  installPlugins(
1124
1125
  mockExec,
1125
1126
  testHome,
1126
- [],
1127
- "/repo/root",
1127
+ [SHIPWRIGHT_ROW],
1128
+ repoRoot(),
1128
1129
  join(testHome, "nonexistent.json"),
1129
1130
  NO_EXTRA_MARKETPLACES,
1130
1131
  ),
@@ -1163,8 +1164,8 @@ describe("installPlugins", () => {
1163
1164
  await installPlugins(
1164
1165
  mockExec,
1165
1166
  testHome,
1166
- [],
1167
- "/repo/root",
1167
+ [SHIPWRIGHT_ROW],
1168
+ repoRoot(),
1168
1169
  manifestPath,
1169
1170
  NO_EXTRA_MARKETPLACES,
1170
1171
  );
@@ -1224,20 +1225,15 @@ describe("installPlugins", () => {
1224
1225
  await installPlugins(
1225
1226
  mockExec,
1226
1227
  testHome,
1227
- [],
1228
- "/repo/root",
1228
+ [SHIPWRIGHT_ROW],
1229
+ repoRoot(),
1229
1230
  manifestPath,
1230
1231
  NO_EXTRA_MARKETPLACES,
1231
1232
  );
1232
1233
 
1233
1234
  // marketplace add + uninstall (failed) = 2 calls; install and update are skipped
1234
1235
  expect(calls).toHaveLength(2);
1235
- expect(calls[0].args).toEqual([
1236
- "plugin",
1237
- "marketplace",
1238
- "add",
1239
- "/repo/root",
1240
- ]);
1236
+ expect(calls[0].args).toEqual(["plugin", "marketplace", "add", repoRoot()]);
1241
1237
  expect(calls[1].args).toEqual([
1242
1238
  "plugin",
1243
1239
  "uninstall",
@@ -1277,8 +1273,8 @@ describe("installPlugins", () => {
1277
1273
  await installPlugins(
1278
1274
  mockExec,
1279
1275
  testHome,
1280
- [],
1281
- "/repo/root",
1276
+ [SHIPWRIGHT_ROW],
1277
+ repoRoot(),
1282
1278
  manifestPath,
1283
1279
  NO_EXTRA_MARKETPLACES,
1284
1280
  );
@@ -1392,8 +1388,8 @@ describe("installPlugins — extra marketplace discovery", () => {
1392
1388
  await installPlugins(
1393
1389
  mockExec,
1394
1390
  testHome,
1395
- [],
1396
- "/repo/root",
1391
+ [SHIPWRIGHT_ROW],
1392
+ repoRoot(),
1397
1393
  join(testHome, "nonexistent.json"),
1398
1394
  undefined, // use default discovery — pass conventionRoot via extraMarketplacesRoot
1399
1395
  conventionRoot,
@@ -1403,7 +1399,7 @@ describe("installPlugins — extra marketplace discovery", () => {
1403
1399
  const addCalls = calls.filter((c) => c.args[1] === "marketplace");
1404
1400
  expect(addCalls.length).toBeGreaterThanOrEqual(2);
1405
1401
  expect(addCalls[0].args[3]).toBe(extraMarketDir);
1406
- expect(addCalls[1].args[3]).toBe("/repo/root");
1402
+ expect(addCalls[1].args[3]).toBe(repoRoot());
1407
1403
 
1408
1404
  // All install calls must come after all marketplace add calls
1409
1405
  const installIdx = calls.findIndex((c) => c.args[1] === "install");
@@ -1444,8 +1440,8 @@ describe("installPlugins — extra marketplace discovery", () => {
1444
1440
  await installPlugins(
1445
1441
  mockExec,
1446
1442
  testHome,
1447
- [],
1448
- "/repo/root",
1443
+ [SHIPWRIGHT_ROW],
1444
+ repoRoot(),
1449
1445
  join(testHome, "nonexistent.json"),
1450
1446
  undefined,
1451
1447
  conventionRoot,
@@ -1457,7 +1453,7 @@ describe("installPlugins — extra marketplace discovery", () => {
1457
1453
  const addDirs = addCalls.map((c) => c.args[3]);
1458
1454
  expect(addDirs).toContain(validDir);
1459
1455
  expect(addDirs).not.toContain(invalidDir);
1460
- expect(addDirs).toContain("/repo/root");
1456
+ expect(addDirs).toContain(repoRoot());
1461
1457
  });
1462
1458
 
1463
1459
  it("is backward compatible when no extra marketplace dirs exist", async () => {
@@ -1478,8 +1474,8 @@ describe("installPlugins — extra marketplace discovery", () => {
1478
1474
  await installPlugins(
1479
1475
  mockExec,
1480
1476
  testHome,
1481
- [],
1482
- "/repo/root",
1477
+ [SHIPWRIGHT_ROW],
1478
+ repoRoot(),
1483
1479
  join(testHome, "nonexistent.json"),
1484
1480
  undefined,
1485
1481
  conventionRoot,
@@ -1488,7 +1484,7 @@ describe("installPlugins — extra marketplace discovery", () => {
1488
1484
  // Only the /repo/root marketplace add — same as before this feature
1489
1485
  const addCalls = calls.filter((c) => c.args[1] === "marketplace");
1490
1486
  expect(addCalls).toHaveLength(1);
1491
- expect(addCalls[0].args[3]).toBe("/repo/root");
1487
+ expect(addCalls[0].args[3]).toBe(repoRoot());
1492
1488
  // marketplace add + install + update = 3 calls total
1493
1489
  expect(calls).toHaveLength(3);
1494
1490
  });
package/setup.ts CHANGED
@@ -57,12 +57,6 @@ export function resolveShipwrightRepoRoot(
57
57
  return env.SHIPWRIGHT_REPO_ROOT || DEFAULT_SHIPWRIGHT_REPO_ROOT;
58
58
  }
59
59
 
60
- // Local marketplace name — matches the `name` field in .claude-plugin/marketplace.json.
61
- const SHIPWRIGHT_MARKETPLACE = "shipwright";
62
-
63
- // Default plugin installed on all Shipwright agents.
64
- const DEFAULT_PLUGINS: readonly string[] = ["shipwright"];
65
-
66
60
  // The persona templates a type's templatesDir must provide or it is not usable.
67
61
  // Everything else (VOICE, mise, requirements, BOOTSTRAP) falls back per file to
68
62
  // the built-in workspace/ dir, and SPECIALTY.md.template is optional (ARD-6.2).
@@ -399,15 +393,15 @@ export function findStalePluginSpecs(
399
393
  }
400
394
 
401
395
  /**
402
- * Installs the shipwright default plugins plus any agent-specific plugins
403
- * from the config bundle. All commands are idempotent (exit 0 when already
396
+ * Installs the plugins in the agent's own AgentPlugin rows (seeded from the
397
+ * specialty manifest at create time). No plugin is installed by default. All commands are idempotent (exit 0 when already
404
398
  * present / already at latest), so reboots are a silent no-op.
405
399
  *
406
400
  * Non-fatal — a missing `claude` binary or network blip logs and returns
407
401
  * without throwing, so startup is never blocked.
408
402
  *
409
- * Install order: defaults first, then agent-specific plugins.
410
- * Update order: same.
403
+ * The built-in marketplace is only registered when `shipwrightRepoRoot` has a
404
+ * `.claude-plugin/marketplace.json` (the platform image ships none).
411
405
  *
412
406
  * Marketplace registration order:
413
407
  * 1. Extra baked marketplaces (discovered from `extraMarketplacesRoot` or
@@ -463,24 +457,22 @@ export async function installPlugins(
463
457
 
464
458
  // Register the local (built-in) shipwright marketplace so `claude plugin install`
465
459
  // can resolve plugins by name. Idempotent — safe to call on every startup.
466
- const add = await execFn(
467
- "claude",
468
- ["plugin", "marketplace", "add", shipwrightRepoRoot],
469
- { cwd },
470
- );
471
- if (add.exitCode !== 0) {
472
- console.warn(
473
- `[agent] claude plugin marketplace add exited ${add.exitCode}: ${add.stdout}`,
460
+ if (
461
+ existsSync(join(shipwrightRepoRoot, ".claude-plugin", "marketplace.json"))
462
+ ) {
463
+ const add = await execFn(
464
+ "claude",
465
+ ["plugin", "marketplace", "add", shipwrightRepoRoot],
466
+ { cwd },
474
467
  );
468
+ if (add.exitCode !== 0) {
469
+ console.warn(
470
+ `[agent] claude plugin marketplace add exited ${add.exitCode}: ${add.stdout}`,
471
+ );
472
+ }
475
473
  }
476
474
 
477
- const defaultPluginSpecs = DEFAULT_PLUGINS.map(
478
- (name) => `${name}@${SHIPWRIGHT_MARKETPLACE}`,
479
- );
480
- const agentPluginSpecs = agentPlugins.map(
481
- (p) => `${p.plugin}@${p.marketplace}`,
482
- );
483
- const allSpecs = [...defaultPluginSpecs, ...agentPluginSpecs];
475
+ const allSpecs = agentPlugins.map((p) => `${p.plugin}@${p.marketplace}`);
484
476
 
485
477
  // Uninstall any plugin whose recorded installPath no longer exists. This
486
478
  // clears stale manifest entries left over from a different-HOME container
@@ -502,7 +494,7 @@ export async function installPlugins(
502
494
  }
503
495
  }
504
496
 
505
- // Install all plugins (defaults then agent-specific).
497
+ // Install all plugins .
506
498
  // Skip any stale spec whose uninstall failed — install is idempotent and
507
499
  // would silently succeed without fixing the stale path, hiding the breakage.
508
500
  for (const spec of allSpecs) {
@@ -517,7 +509,7 @@ export async function installPlugins(
517
509
  }
518
510
  }
519
511
 
520
- // Update all plugins (defaults then agent-specific).
512
+ // Update all plugins .
521
513
  // Skip specs whose uninstall failed for the same reason as the install loop.
522
514
  for (const spec of allSpecs) {
523
515
  if (failedUninstalls.has(spec)) continue;
@@ -4,7 +4,7 @@
4
4
  * Pure logic tests: no I/O, no real binaries, injected exec doubles only.
5
5
  */
6
6
 
7
- import { describe, expect, it } from "bun:test";
7
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
8
8
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
@@ -22,56 +22,64 @@ import {
22
22
  // these tests depend on ambient sandbox/image state.
23
23
  const NO_EXTRA_MARKETPLACES: string[] = [];
24
24
 
25
- describe("installPlugins — local marketplace", () => {
26
- it("registers the marketplace before installing plugins", async () => {
27
- const calls: Array<{ cmd: string; args: string[] }> = [];
28
- const mockExec = async (
29
- cmd: string,
30
- args: string[],
31
- _opts: { cwd: string },
32
- ) => {
33
- calls.push({ cmd, args });
34
- return { stdout: "", exitCode: 0 };
35
- };
25
+ const SHIPWRIGHT_ROW = { plugin: "shipwright", marketplace: "shipwright" };
26
+
27
+ // A repo root that carries a local marketplace (a coding image); the platform
28
+ // image has none.
29
+ const makeMarketplaceRoot = (): string => {
30
+ const root = mkdtempSync(join(tmpdir(), "mkt-root-"));
31
+ mkdirSync(join(root, ".claude-plugin"), { recursive: true });
32
+ writeFileSync(join(root, ".claude-plugin", "marketplace.json"), "{}");
33
+ return root;
34
+ };
35
+
36
+ const recordCalls = () => {
37
+ const calls: Array<{ cmd: string; args: string[] }> = [];
38
+ const exec = async (cmd: string, args: string[], _opts: { cwd: string }) => {
39
+ calls.push({ cmd, args });
40
+ return { stdout: "", exitCode: 0 };
41
+ };
42
+ return { calls, exec };
43
+ };
36
44
 
37
- await installPlugins(
38
- mockExec,
45
+ describe("installPlugins — local marketplace", () => {
46
+ let root: string;
47
+ beforeEach(() => {
48
+ root = makeMarketplaceRoot();
49
+ });
50
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
51
+
52
+ const run = (
53
+ exec: ReturnType<typeof recordCalls>["exec"],
54
+ plugins: Array<{ plugin: string; marketplace: string }>,
55
+ repoRoot = root,
56
+ extra: string[] = NO_EXTRA_MARKETPLACES,
57
+ ) =>
58
+ installPlugins(
59
+ exec,
39
60
  "/tmp/cwd",
40
- [],
41
- "/repo/root",
61
+ plugins,
62
+ repoRoot,
42
63
  "/tmp/nonexistent-manifest.json",
43
- NO_EXTRA_MARKETPLACES,
64
+ extra,
44
65
  );
45
66
 
46
- // First call must be marketplace add
47
- expect(calls[0].args).toEqual([
48
- "plugin",
49
- "marketplace",
50
- "add",
51
- "/repo/root",
52
- ]);
67
+ it("registers the marketplace before installing plugins", async () => {
68
+ const { calls, exec } = recordCalls();
69
+ await run(exec, [SHIPWRIGHT_ROW]);
70
+ expect(calls[0].args).toEqual(["plugin", "marketplace", "add", root]);
53
71
  });
54
72
 
55
- it("installs and updates shipwright@shipwright using the shipwright marketplace name", async () => {
56
- const calls: Array<{ cmd: string; args: string[] }> = [];
57
- const mockExec = async (
58
- cmd: string,
59
- args: string[],
60
- _opts: { cwd: string },
61
- ) => {
62
- calls.push({ cmd, args });
63
- return { stdout: "", exitCode: 0 };
64
- };
65
-
66
- await installPlugins(
67
- mockExec,
68
- "/tmp/cwd",
69
- [],
70
- "/repo/root",
71
- "/tmp/nonexistent-manifest.json",
72
- NO_EXTRA_MARKETPLACES,
73
- );
73
+ it("installs nothing when the agent has no plugin rows", async () => {
74
+ const { calls, exec } = recordCalls();
75
+ await run(exec, []);
76
+ expect(calls.filter((c) => c.args[1] === "install")).toEqual([]);
77
+ expect(calls.filter((c) => c.args[1] === "update")).toEqual([]);
78
+ });
74
79
 
80
+ it("installs and updates shipwright@shipwright for a coding agent's shipwright row", async () => {
81
+ const { calls, exec } = recordCalls();
82
+ await run(exec, [SHIPWRIGHT_ROW]);
75
83
  // marketplace add + install + update = 3 calls
76
84
  expect(calls).toHaveLength(3);
77
85
  expect(calls[1].args).toEqual([
@@ -87,29 +95,14 @@ describe("installPlugins — local marketplace", () => {
87
95
  });
88
96
 
89
97
  it("agent plugins use their own marketplace, not the local one", async () => {
90
- const calls: Array<{ cmd: string; args: string[] }> = [];
91
- const mockExec = async (
92
- cmd: string,
93
- args: string[],
94
- _opts: { cwd: string },
95
- ) => {
96
- calls.push({ cmd, args });
97
- return { stdout: "", exitCode: 0 };
98
- };
99
-
100
- await installPlugins(
101
- mockExec,
102
- "/tmp/cwd",
103
- [{ plugin: "my-plugin", marketplace: "org/my-marketplace" }],
104
- "/repo/root",
105
- "/tmp/nonexistent-manifest.json",
106
- NO_EXTRA_MARKETPLACES,
107
- );
108
-
98
+ const { calls, exec } = recordCalls();
99
+ await run(exec, [
100
+ SHIPWRIGHT_ROW,
101
+ { plugin: "my-plugin", marketplace: "org/my-marketplace" },
102
+ ]);
109
103
  // marketplace add + 2 installs + 2 updates = 5 calls
110
104
  expect(calls).toHaveLength(5);
111
- const specs = calls.slice(1).map((c) => c.args[2]);
112
- expect(specs).toEqual([
105
+ expect(calls.slice(1).map((c) => c.args[2])).toEqual([
113
106
  "shipwright@shipwright",
114
107
  "my-plugin@org/my-marketplace",
115
108
  "shipwright@shipwright",
@@ -117,128 +110,30 @@ describe("installPlugins — local marketplace", () => {
117
110
  ]);
118
111
  });
119
112
 
120
- it("passes the repoRoot to marketplace add", async () => {
121
- const calls: Array<{ cmd: string; args: string[] }> = [];
122
- const mockExec = async (
123
- cmd: string,
124
- args: string[],
125
- _opts: { cwd: string },
126
- ) => {
127
- calls.push({ cmd, args });
128
- return { stdout: "", exitCode: 0 };
129
- };
130
-
131
- await installPlugins(
132
- mockExec,
133
- "/tmp/cwd",
134
- [],
135
- "/custom/repo/root",
136
- "/tmp/nonexistent-manifest.json",
137
- NO_EXTRA_MARKETPLACES,
138
- );
139
-
140
- expect(calls[0].args).toEqual([
141
- "plugin",
142
- "marketplace",
143
- "add",
144
- "/custom/repo/root",
145
- ]);
113
+ it("skips the local marketplace add when the repo root has no marketplace", async () => {
114
+ const { calls, exec } = recordCalls();
115
+ await run(exec, [], "/repo/root");
116
+ expect(calls).toEqual([]);
146
117
  });
147
118
 
148
- it("registers extra marketplaces before the /app marketplace", async () => {
149
- const calls: Array<{ cmd: string; args: string[] }> = [];
150
- const mockExec = async (
151
- cmd: string,
152
- args: string[],
153
- _opts: { cwd: string },
154
- ) => {
155
- calls.push({ cmd, args });
156
- return { stdout: "", exitCode: 0 };
157
- };
158
-
159
- // Pass a non-existent root so discoverBakedMarketplaces can't find dirs —
160
- // we inject via extraMarketplaceDirs directly instead.
161
- await installPlugins(
162
- mockExec,
163
- "/tmp/cwd",
164
- [],
165
- "/repo/root",
166
- "/tmp/nonexistent-manifest.json",
167
- ["/opt/shipwright/marketplaces/my-marketplace"],
168
- );
169
-
170
- // Extra marketplace add must come BEFORE the /repo/root marketplace add
119
+ it("registers extra marketplaces before the local marketplace", async () => {
120
+ const { calls, exec } = recordCalls();
121
+ await run(exec, [], root, ["/opt/shipwright/marketplaces/my-marketplace"]);
171
122
  const addCalls = calls.filter((c) => c.args[1] === "marketplace");
172
123
  expect(addCalls[0].args[3]).toBe(
173
124
  "/opt/shipwright/marketplaces/my-marketplace",
174
125
  );
175
- expect(addCalls[1].args[3]).toBe("/repo/root");
176
- });
177
-
178
- it("is a no-op when no extra marketplace dirs exist", async () => {
179
- const calls: Array<{ cmd: string; args: string[] }> = [];
180
- const mockExec = async (
181
- cmd: string,
182
- args: string[],
183
- _opts: { cwd: string },
184
- ) => {
185
- calls.push({ cmd, args });
186
- return { stdout: "", exitCode: 0 };
187
- };
188
-
189
- await installPlugins(
190
- mockExec,
191
- "/tmp/cwd",
192
- [],
193
- "/repo/root",
194
- "/tmp/nonexistent-manifest.json",
195
- NO_EXTRA_MARKETPLACES,
196
- );
197
-
198
- // Only the /repo/root marketplace add — no extra adds
199
- const addCalls = calls.filter((c) => c.args[1] === "marketplace");
200
- expect(addCalls).toHaveLength(1);
201
- expect(addCalls[0].args[3]).toBe("/repo/root");
126
+ expect(addCalls[1].args[3]).toBe(root);
202
127
  });
203
128
 
204
129
  it("never reads the real BAKED_MARKETPLACES_ROOT when extraMarketplaceDirs is injected (isolation regression)", async () => {
205
- // Regression test for T-002: previously, tests that omitted the
206
- // extraMarketplaceDirs arg silently fell through to
207
- // discoverBakedMarketplaces(BAKED_MARKETPLACES_ROOT), which performs a
208
- // real existsSync/readdirSync against /opt/shipwright/marketplaces. On any
209
- // image/sandbox that bakes a marketplace there (this sandbox does), that
210
- // extra ambient `marketplace add` call would silently appear before the
211
- // expected calls and break exact-match assertions below.
212
- //
213
- // This test asserts the injected empty array wins deterministically,
214
- // regardless of what (if anything) actually exists on disk at
215
- // /opt/shipwright/marketplaces in the environment running the suite.
216
- const calls: Array<{ cmd: string; args: string[] }> = [];
217
- const mockExec = async (
218
- cmd: string,
219
- args: string[],
220
- _opts: { cwd: string },
221
- ) => {
222
- calls.push({ cmd, args });
223
- return { stdout: "", exitCode: 0 };
224
- };
225
-
226
- await installPlugins(
227
- mockExec,
228
- "/tmp/cwd",
229
- [],
230
- "/repo/root",
231
- "/tmp/nonexistent-manifest.json",
232
- NO_EXTRA_MARKETPLACES,
233
- );
234
-
235
- // Exactly one marketplace add (the built-in shipwright one) — no extra
236
- // marketplace add sneaks in from real fs discovery, no matter what's
237
- // actually present on disk at BAKED_MARKETPLACES_ROOT in this environment.
130
+ // The injected empty array must win over real fs discovery of
131
+ // /opt/shipwright/marketplaces, whatever the sandbox has baked there.
132
+ const { calls, exec } = recordCalls();
133
+ await run(exec, [SHIPWRIGHT_ROW]);
238
134
  const addCalls = calls.filter((c) => c.args[1] === "marketplace");
239
135
  expect(addCalls).toHaveLength(1);
240
- expect(addCalls[0].args[3]).toBe("/repo/root");
241
- // Full call count is deterministic: marketplace add + install + update = 3.
136
+ expect(addCalls[0].args[3]).toBe(root);
242
137
  expect(calls).toHaveLength(3);
243
138
  });
244
139
  });
@@ -368,19 +263,24 @@ describe("SHIPWRIGHT_REPO_ROOT override (PCB-1.1)", () => {
368
263
  "/tmp/nonexistent-baked-root",
369
264
  env,
370
265
  );
371
- return calls[0][3];
266
+ return calls[0]?.[3];
372
267
  };
373
268
 
374
269
  it("installPlugins registers the marketplace from SHIPWRIGHT_REPO_ROOT when set", async () => {
375
- expect(await registerRoot({ SHIPWRIGHT_REPO_ROOT: "/opt/custom" })).toBe(
376
- "/opt/custom",
377
- );
270
+ const custom = makeMarketplaceRoot();
271
+ try {
272
+ expect(await registerRoot({ SHIPWRIGHT_REPO_ROOT: custom })).toBe(custom);
273
+ } finally {
274
+ rmSync(custom, { recursive: true, force: true });
275
+ }
378
276
  });
379
277
 
380
278
  it("installPlugins keeps the computed default when unset or empty", async () => {
381
279
  const unset = await registerRoot({});
382
- expect(unset).toBe(resolveShipwrightRepoRoot({}));
383
- expect(unset).not.toBe("/opt/custom");
280
+ // Only registered when the computed default root carries a marketplace.
281
+ expect(unset === undefined || unset === resolveShipwrightRepoRoot({})).toBe(
282
+ true,
283
+ );
384
284
  expect(await registerRoot({ SHIPWRIGHT_REPO_ROOT: "" })).toBe(unset);
385
285
  });
386
286