magus-cli 7.11.7 → 7.12.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.
@@ -0,0 +1,334 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ argsToFlagValuesWithLeftovers,
4
+ findAdoptableAlias,
5
+ parseAliasFromRc,
6
+ parseAliasLine,
7
+ parseManagedBlock,
8
+ renderAlias,
9
+ } from "../services/alias-shell-writer";
10
+ import type { AliasConfig, FlagValue } from "../services/alias-store";
11
+
12
+ const assignment = "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1";
13
+
14
+ function combinedFlags(): Record<string, FlagValue> {
15
+ return {
16
+ "agent-teams": { kind: "boolean", enabled: true },
17
+ "teammate-mode": { kind: "select", enabled: true, value: "tmux" },
18
+ autocompact: { kind: "text", enabled: true, value: "200k" },
19
+ };
20
+ }
21
+
22
+ const forms: Array<{
23
+ name: string;
24
+ envLauncher: boolean;
25
+ line: (body: string) => string;
26
+ }> = [
27
+ { name: "POSIX", envLauncher: false, line: (body) => `alias crew='${body}'` },
28
+ // The env launcher is a body form; the parser contract uses POSIX alias lines.
29
+ {
30
+ name: "POSIX with env launcher",
31
+ envLauncher: true,
32
+ line: (body) => `alias crew='${body}'`,
33
+ },
34
+ ];
35
+
36
+ function commandBody(
37
+ envLauncher: boolean,
38
+ env: string[],
39
+ argv: string[],
40
+ ): string {
41
+ return [
42
+ ...(envLauncher && env.length > 0 ? ["env"] : []),
43
+ ...env,
44
+ "claude",
45
+ ...argv,
46
+ ].join(" ");
47
+ }
48
+
49
+ describe("managed assignment-prefixed alias parsing", () => {
50
+ const examples = [
51
+ {
52
+ name: "POSIX known assignment",
53
+ line: `alias crew='${assignment} claude --teammate-mode tmux --autocompact 200k'`,
54
+ args: [assignment, "--teammate-mode", "tmux", "--autocompact", "200k"],
55
+ },
56
+ {
57
+ name: "POSIX alias with env launcher",
58
+ line: `alias crew='env ${assignment} claude --teammate-mode tmux --autocompact 200k'`,
59
+ args: [assignment, "--teammate-mode", "tmux", "--autocompact", "200k"],
60
+ },
61
+ {
62
+ name: "POSIX unknown before known assignment",
63
+ line: `alias crew='EXTRA=keep ${assignment} claude --autocompact 200k'`,
64
+ args: ["EXTRA=keep", assignment, "--autocompact", "200k"],
65
+ },
66
+ {
67
+ name: "POSIX env launcher with unknown after known assignment",
68
+ line: `alias crew='env ${assignment} EXTRA=a=b claude --teammate-mode tmux'`,
69
+ args: [assignment, "EXTRA=a=b", "--teammate-mode", "tmux"],
70
+ },
71
+ ];
72
+
73
+ for (const example of examples) {
74
+ it(`TEST-15 REQ-11 preserves env-first tokens for ${example.name}`, () => {
75
+ const block = [
76
+ "# >>> magus managed alias >>>",
77
+ example.line,
78
+ "# <<< magus managed alias <<<",
79
+ "",
80
+ ].join("\n");
81
+ const expected = { aliasName: "crew", args: [...example.args] };
82
+
83
+ expect(parseAliasLine(example.line)).toEqual(expected);
84
+ expect(parseManagedBlock(block)).toEqual(expected);
85
+ });
86
+ }
87
+ });
88
+
89
+ describe("assignment mapping and leftovers", () => {
90
+ const mappingCases: Array<{
91
+ name: string;
92
+ args: string[];
93
+ flags: () => Record<string, FlagValue>;
94
+ leftovers: string[];
95
+ }> = [
96
+ {
97
+ name: "known assignment and all new argv options",
98
+ args: [assignment, "--teammate-mode", "tmux", "--autocompact", "200k"],
99
+ flags: combinedFlags,
100
+ leftovers: [],
101
+ },
102
+ {
103
+ name: "unknown assignment before the known one",
104
+ args: [
105
+ "EXTRA=keep",
106
+ assignment,
107
+ "--teammate-mode",
108
+ "tmux",
109
+ "--autocompact",
110
+ "200k",
111
+ ],
112
+ flags: combinedFlags,
113
+ leftovers: ["EXTRA=keep"],
114
+ },
115
+ {
116
+ name: "unknown assignments after the known one",
117
+ args: [
118
+ assignment,
119
+ "EMPTY=",
120
+ "EXTRA=a=b",
121
+ "--teammate-mode",
122
+ "tmux",
123
+ "--autocompact",
124
+ "200k",
125
+ ],
126
+ flags: combinedFlags,
127
+ leftovers: ["EMPTY=", "EXTRA=a=b"],
128
+ },
129
+ {
130
+ name: "known env name with a different value",
131
+ args: ["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0", "--ide"],
132
+ flags: () => ({ ide: { kind: "boolean", enabled: true } }),
133
+ leftovers: ["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0"],
134
+ },
135
+ ];
136
+
137
+ for (const example of mappingCases) {
138
+ it(`TEST-16 REQ-12 retains every unmatched token for ${example.name}`, () => {
139
+ const args = [...example.args];
140
+
141
+ expect(argsToFlagValuesWithLeftovers(args)).toEqual({
142
+ flags: example.flags(),
143
+ leftovers: [...example.leftovers],
144
+ });
145
+ });
146
+ }
147
+
148
+ it("TEST-17 REQ-12 keeps an assignment after an argv flag as a leftover rather than enabling teams", () => {
149
+ const args = ["--ide", assignment];
150
+
151
+ expect(argsToFlagValuesWithLeftovers(args)).toEqual({
152
+ flags: { ide: { kind: "boolean", enabled: true } },
153
+ leftovers: [assignment],
154
+ });
155
+ });
156
+
157
+ it("TEST-17 REQ-12 retains an argv assignment without losing an earlier recognized env assignment", () => {
158
+ const args = [assignment, "--ide", "EXTRA=keep"];
159
+
160
+ expect(argsToFlagValuesWithLeftovers(args)).toEqual({
161
+ flags: {
162
+ "agent-teams": { kind: "boolean", enabled: true },
163
+ ide: { kind: "boolean", enabled: true },
164
+ },
165
+ leftovers: ["EXTRA=keep"],
166
+ });
167
+ });
168
+
169
+ it("TEST-17 REQ-12 leaves an assignment used as a text value inside that text flag", () => {
170
+ const args = ["--append-system-prompt", assignment];
171
+
172
+ expect(argsToFlagValuesWithLeftovers(args)).toEqual({
173
+ flags: {
174
+ "append-system-prompt": {
175
+ kind: "text",
176
+ enabled: true,
177
+ value: assignment,
178
+ },
179
+ },
180
+ leftovers: [],
181
+ });
182
+ });
183
+ });
184
+
185
+ describe("safe adoption of assignment-prefixed aliases", () => {
186
+ const losslessCases: Array<{
187
+ name: string;
188
+ env: string[];
189
+ argv: string[];
190
+ flags: () => Record<string, FlagValue>;
191
+ }> = [
192
+ {
193
+ name: "context size only",
194
+ env: [],
195
+ argv: ["--autocompact", "200k"],
196
+ flags: () => ({
197
+ autocompact: { kind: "text", enabled: true, value: "200k" },
198
+ }),
199
+ },
200
+ {
201
+ name: "agent teams only",
202
+ env: [assignment],
203
+ argv: [],
204
+ flags: () => ({
205
+ "agent-teams": { kind: "boolean", enabled: true },
206
+ }),
207
+ },
208
+ {
209
+ name: "all new options",
210
+ env: [assignment],
211
+ argv: ["--teammate-mode", "tmux", "--autocompact", "200k"],
212
+ flags: combinedFlags,
213
+ },
214
+ ];
215
+
216
+ const unknownCases: Array<{
217
+ name: string;
218
+ env: string[];
219
+ argv: string[];
220
+ flags: () => Record<string, FlagValue>;
221
+ leftovers: string[];
222
+ }> = [
223
+ {
224
+ name: "an unknown assignment alone",
225
+ env: ["EXTRA=keep"],
226
+ argv: ["--ide"],
227
+ flags: () => ({ ide: { kind: "boolean", enabled: true } }),
228
+ leftovers: ["EXTRA=keep"],
229
+ },
230
+ {
231
+ name: "an empty-valued unknown assignment before known teams",
232
+ env: ["EMPTY=", assignment],
233
+ argv: ["--teammate-mode", "tmux", "--autocompact", "200k"],
234
+ flags: combinedFlags,
235
+ leftovers: ["EMPTY="],
236
+ },
237
+ {
238
+ name: "an unknown assignment containing another equals sign after known teams",
239
+ env: [assignment, "EXTRA=a=b"],
240
+ argv: ["--teammate-mode", "tmux", "--autocompact", "200k"],
241
+ flags: combinedFlags,
242
+ leftovers: ["EXTRA=a=b"],
243
+ },
244
+ {
245
+ name: "a known assignment name with an unsupported value",
246
+ env: ["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0"],
247
+ argv: ["--ide"],
248
+ flags: () => ({ ide: { kind: "boolean", enabled: true } }),
249
+ leftovers: ["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0"],
250
+ },
251
+ ];
252
+
253
+ for (const form of forms) {
254
+ for (const example of losslessCases) {
255
+ it(`TEST-18 REQ-11 REQ-13 adopts ${example.name} losslessly in ${form.name}`, () => {
256
+ const line = form.line(
257
+ commandBody(form.envLauncher, example.env, example.argv),
258
+ );
259
+ const rc = `# personal aliases\n${line}\n# end aliases\n`;
260
+ const expectedFlags = example.flags();
261
+
262
+ const adopted = findAdoptableAlias(rc);
263
+
264
+ expect(adopted).toEqual({
265
+ name: "crew",
266
+ flags: expectedFlags,
267
+ lineRange: [1, 1],
268
+ leftovers: [],
269
+ lossless: true,
270
+ rawLine: line,
271
+ others: [],
272
+ });
273
+ if (adopted === null) {
274
+ throw new Error("Expected a lossless adoptable alias");
275
+ }
276
+ const config: AliasConfig = {
277
+ aliasName: adopted.name,
278
+ flags: adopted.flags,
279
+ };
280
+ expect(parseAliasFromRc(renderAlias(config, "bash").block)).toEqual({
281
+ aliasName: "crew",
282
+ flags: expectedFlags,
283
+ });
284
+ });
285
+ }
286
+
287
+ for (const example of unknownCases) {
288
+ it(`TEST-19 REQ-11 REQ-12 REQ-13 preserves ${example.name} as a leftover in ${form.name}`, () => {
289
+ const line = form.line(
290
+ commandBody(form.envLauncher, example.env, example.argv),
291
+ );
292
+ const rc = `# personal aliases\n${line}\n# end aliases\n`;
293
+
294
+ const adopted = findAdoptableAlias(rc);
295
+
296
+ expect(adopted).toEqual({
297
+ name: "crew",
298
+ flags: example.flags(),
299
+ lineRange: [1, 1],
300
+ leftovers: [...example.leftovers],
301
+ lossless: false,
302
+ rawLine: line,
303
+ others: [],
304
+ });
305
+ });
306
+ }
307
+
308
+ it(`TEST-20 REQ-13 REQ-14 refuses lossless adoption of orphan teammate-mode in ${form.name}`, () => {
309
+ const line = form.line("claude --teammate-mode tmux");
310
+
311
+ const adopted = findAdoptableAlias(`${line}\n`);
312
+
313
+ expect(adopted).toEqual({
314
+ name: "crew",
315
+ flags: {
316
+ "teammate-mode": { kind: "select", enabled: true, value: "tmux" },
317
+ },
318
+ lineRange: [0, 0],
319
+ leftovers: [],
320
+ lossless: false,
321
+ rawLine: line,
322
+ others: [],
323
+ });
324
+ });
325
+
326
+ it(`TEST-21 REQ-11 rejects an assignment-prefixed non-claude alias in ${form.name}`, () => {
327
+ const prefix = form.envLauncher ? "env " : "";
328
+ const line = form.line(`${prefix + assignment} other-command --ide`);
329
+
330
+ expect(parseAliasLine(line)).toBeNull();
331
+ expect(findAdoptableAlias(`${line}\n`)).toBeNull();
332
+ });
333
+ }
334
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ type ShellKind,
4
+ renderAlias,
5
+ renderArgsAsTokens,
6
+ renderEnv,
7
+ } from "../services/alias-shell-writer";
8
+ import {
9
+ type AliasConfig,
10
+ type FlagValue,
11
+ defaultAliasConfig,
12
+ } from "../services/alias-store";
13
+
14
+ const shells: ShellKind[] = ["bash", "zsh", "fish"];
15
+
16
+ describe("alias output when the new options are not enabled", () => {
17
+ const establishedCases: Array<{
18
+ name: string;
19
+ flags: () => Record<string, FlagValue>;
20
+ tokens: string[];
21
+ posixLine: string;
22
+ fishLine: string;
23
+ }> = [
24
+ {
25
+ name: "plain claude alias",
26
+ flags: () => ({}),
27
+ tokens: [],
28
+ posixLine: "alias c='claude'",
29
+ fishLine: "alias c 'claude'",
30
+ },
31
+ {
32
+ name: "ide-only alias",
33
+ flags: () => ({ ide: { kind: "boolean", enabled: true } }),
34
+ tokens: ["--ide"],
35
+ posixLine: "alias c='claude --ide'",
36
+ fishLine: "alias c 'claude '--ide''",
37
+ },
38
+ ];
39
+
40
+ for (const shell of shells) {
41
+ for (const example of establishedCases) {
42
+ it(`TEST-22 REQ-10 preserves established ${example.name} bytes on ${shell} with absent or disabled new options`, () => {
43
+ const absent: AliasConfig = {
44
+ aliasName: "c",
45
+ flags: example.flags(),
46
+ };
47
+ const disabled: AliasConfig = {
48
+ aliasName: "c",
49
+ flags: {
50
+ ...example.flags(),
51
+ autocompact: { kind: "text", enabled: false, value: "200k" },
52
+ "agent-teams": { kind: "boolean", enabled: false },
53
+ "teammate-mode": { kind: "select", enabled: false, value: "tmux" },
54
+ },
55
+ };
56
+
57
+ const absentOutput = renderAlias(absent, shell);
58
+ const disabledOutput = renderAlias(disabled, shell);
59
+
60
+ expect(absentOutput.shell).toBe(shell);
61
+ expect(disabledOutput).toEqual(absentOutput);
62
+ expect(
63
+ absentOutput.block
64
+ .split("\n")
65
+ .filter((line) => line.startsWith("alias ")),
66
+ ).toEqual([shell === "fish" ? example.fishLine : example.posixLine]);
67
+ expect(
68
+ absentOutput.block.startsWith("# >>> magus managed alias >>>\n"),
69
+ ).toBe(true);
70
+ expect(
71
+ absentOutput.block.endsWith("# <<< magus managed alias <<<\n"),
72
+ ).toBe(true);
73
+ expect(renderEnv(absent)).toEqual([]);
74
+ expect(renderEnv(disabled)).toEqual([]);
75
+ expect(renderArgsAsTokens(absent)).toEqual([...example.tokens]);
76
+ expect(renderArgsAsTokens(disabled)).toEqual([...example.tokens]);
77
+ });
78
+ }
79
+
80
+ it(`TEST-23 REQ-10 leaves all new options off in the default ${shell} alias`, () => {
81
+ const config = defaultAliasConfig();
82
+ const emptyConfig: AliasConfig = { aliasName: "c", flags: {} };
83
+
84
+ expect(config.aliasName).toBe("c");
85
+ expect(renderEnv(config)).toEqual([]);
86
+ expect(renderArgsAsTokens(config)).toEqual([]);
87
+ expect(renderAlias(config, shell)).toEqual(
88
+ renderAlias(emptyConfig, shell),
89
+ );
90
+ });
91
+ }
92
+ });
@@ -15,18 +15,36 @@ import path from "node:path";
15
15
  // --- Mock the marketplace registry (the only magus dep of the refresher) ---
16
16
  let configured: Record<string, { source: { source: string; repo?: string } }>;
17
17
  let autoUpdate: Record<string, boolean | undefined>;
18
+ /**
19
+ * USER-scope `enabledPlugins`. `null` means "one USER plugin from every
20
+ * registered marketplace", so the cases written before the needed-only rule
21
+ * keep exercising the pull itself; the needed-only cases set it explicitly.
22
+ */
23
+ let userEnabled: Record<string, boolean> | null;
18
24
 
19
25
  mock.module("../services/claude-settings.js", () => ({
20
26
  getConfiguredMarketplaces: async () => configured,
21
27
  getMarketplaceAutoUpdate: async (name: string) => autoUpdate[name],
28
+ getGlobalEnabledPlugins: async () =>
29
+ userEnabled ??
30
+ Object.fromEntries(Object.keys(configured).map((n) => [`p@${n}`, true])),
22
31
  // The refresher resolves clone paths via this helper; point it at our temp
23
32
  // home so no real ~/.claude marketplace is ever touched. (Bun's os.homedir()
24
33
  // ignores runtime $HOME changes, so mocking the helper is the reliable seam.)
25
34
  getGlobalClaudeDir: () => path.join(tmp, ".claude"),
26
35
  }));
27
36
 
28
- const { refreshRegisteredMarketplaces, fastForwardCloneIfPresent } =
29
- await import("../services/marketplace-refresh.js");
37
+ const {
38
+ refreshRegisteredMarketplaces,
39
+ reportRegisteredMarketplaces,
40
+ fastForwardCloneIfPresent,
41
+ } = await import("../services/marketplace-refresh.js");
42
+ const {
43
+ CATALOG_TTL_MS,
44
+ clearCloneRefreshes,
45
+ readCloneRefreshAge,
46
+ resetCatalogCacheMemo,
47
+ } = await import("../services/catalog-cache-store.js");
30
48
 
31
49
  const gh = (repo = "MadAppGang/magus") => ({
32
50
  source: { source: "github", repo },
@@ -110,6 +128,7 @@ beforeEach(() => {
110
128
 
111
129
  configured = {};
112
130
  autoUpdate = {};
131
+ userEnabled = null;
113
132
  });
114
133
 
115
134
  afterEach(() => {
@@ -457,3 +476,193 @@ describe("fastForwardCloneIfPresent — per-clone deadline", () => {
457
476
  expect(await goneWithin(readPid(pullPid), 1_000)).toBe(true);
458
477
  }, 10_000);
459
478
  });
479
+
480
+ /** The clone's HEAD and origin's main, to tell "pulled" from "left behind". */
481
+ function headOf(dir: string): string {
482
+ return git(dir, "rev-parse", "HEAD").trim();
483
+ }
484
+ function originHead(): string {
485
+ return git(origin, "rev-parse", "main").trim();
486
+ }
487
+
488
+ describe("refreshRegisteredMarketplaces — needed only (MTL-367)", () => {
489
+ it("does not pull a registered marketplace no installed plugin comes from, and reports it skipped", async () => {
490
+ const used = installClone("magus");
491
+ const unused = installClone("unused");
492
+ publishV2();
493
+ configured = { magus: gh(), unused: gh("x/unused") };
494
+ userEnabled = {};
495
+
496
+ const r = await refreshRegisteredMarketplaces(
497
+ [],
498
+ {},
499
+ { pluginIds: ["dev@magus"] },
500
+ );
501
+
502
+ expect(r.refreshed).toEqual(["magus"]);
503
+ expect(r.skipped).toEqual(["unused"]);
504
+ expect(headOf(used)).toBe(originHead());
505
+ expect(headOf(unused)).not.toBe(originHead()); // left behind
506
+ });
507
+
508
+ it("pulls a marketplace used only by a USER-scope plugin", async () => {
509
+ const dir = installClone("userland");
510
+ installClone("unused");
511
+ publishV2();
512
+ configured = { userland: gh("x/userland"), unused: gh("x/unused") };
513
+ // `false` is not installed: only `true` counts.
514
+ userEnabled = { "tool@userland": true, "off@unused": false };
515
+
516
+ const r = await refreshRegisteredMarketplaces([], {}, { pluginIds: [] });
517
+
518
+ expect(r.refreshed).toEqual(["userland"]);
519
+ expect(r.skipped).toEqual(["unused"]);
520
+ expect(headOf(dir)).toBe(originHead());
521
+ });
522
+
523
+ it("report-only applies the same selection: an unused marketplace is skipped, a used one pending", async () => {
524
+ installClone("magus");
525
+ installClone("unused");
526
+ configured = { magus: gh(), unused: gh("x/unused") };
527
+ userEnabled = {};
528
+
529
+ const r = await reportRegisteredMarketplaces([], {
530
+ pluginIds: ["dev@magus"],
531
+ });
532
+
533
+ expect(r.refreshed).toEqual([]);
534
+ expect(r.pending).toEqual(["magus"]);
535
+ expect(r.skipped).toEqual(["unused"]);
536
+ });
537
+ });
538
+
539
+ describe("refreshRegisteredMarketplaces — at most once an hour, machine-wide (MTL-367)", () => {
540
+ let prevConfigDir: string | undefined;
541
+ const T0 = 1_790_000_000_000;
542
+
543
+ beforeEach(() => {
544
+ prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
545
+ // The store lives under the temp home, never the operator's.
546
+ process.env.CLAUDE_CONFIG_DIR = path.join(tmp, ".claude");
547
+ resetCatalogCacheMemo();
548
+ });
549
+ afterEach(() => {
550
+ // biome-ignore lint/performance/noDelete: assigning undefined stores the string "undefined"
551
+ if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
552
+ else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
553
+ resetCatalogCacheMemo();
554
+ });
555
+
556
+ it("does not pull again within the hour, and reports it fresh", async () => {
557
+ const dir = installClone("magus");
558
+ configured = { magus: gh() };
559
+
560
+ const first = await refreshRegisteredMarketplaces([], {}, { now: T0 });
561
+ expect(first.refreshed).toEqual(["magus"]);
562
+
563
+ publishV2(); // origin advances after the first pull
564
+ resetCatalogCacheMemo(); // a second process, same machine
565
+ const before = headOf(dir);
566
+ const second = await refreshRegisteredMarketplaces(
567
+ [],
568
+ {},
569
+ { now: T0 + 10 * 60 * 1000 },
570
+ );
571
+
572
+ expect(second.refreshed).toEqual([]);
573
+ expect(second.fresh).toEqual(["magus"]);
574
+ expect(second.skipped).toEqual([]);
575
+ expect(headOf(dir)).toBe(before);
576
+ expect(headOf(dir)).not.toBe(originHead());
577
+ });
578
+
579
+ it("pulls again once the TTL has passed", async () => {
580
+ const dir = installClone("magus");
581
+ configured = { magus: gh() };
582
+ await refreshRegisteredMarketplaces([], {}, { now: T0 });
583
+
584
+ publishV2();
585
+ const r = await refreshRegisteredMarketplaces(
586
+ [],
587
+ {},
588
+ { now: T0 + CATALOG_TTL_MS + 1 },
589
+ );
590
+
591
+ expect(r.refreshed).toEqual(["magus"]);
592
+ expect(r.fresh).toEqual([]);
593
+ expect(headOf(dir)).toBe(originHead());
594
+ });
595
+
596
+ it("a failed pull records no time, so the next call pulls again", async () => {
597
+ const dir = installClone("magus");
598
+ git(dir, "remote", "set-url", "origin", path.join(tmp, "gone.git"));
599
+ configured = { magus: gh() };
600
+
601
+ const failed = await refreshRegisteredMarketplaces([], {}, { now: T0 });
602
+ expect(failed.failed).toEqual(["magus"]);
603
+ expect(await readCloneRefreshAge("magus", T0 + 1)).toBeNull();
604
+
605
+ git(dir, "remote", "set-url", "origin", origin);
606
+ publishV2();
607
+ const retry = await refreshRegisteredMarketplaces(
608
+ [],
609
+ {},
610
+ { now: T0 + 60_000 },
611
+ );
612
+ expect(retry.refreshed).toEqual(["magus"]);
613
+ expect(headOf(dir)).toBe(originHead());
614
+ });
615
+
616
+ it("an explicit refresh (the TUI's `r`) clears the record, so the next call pulls", async () => {
617
+ const dir = installClone("magus");
618
+ configured = { magus: gh() };
619
+ await refreshRegisteredMarketplaces([], {}, { now: T0 });
620
+
621
+ // `r` -> refreshAllMarketplaces -> clearPersistedCatalogs.
622
+ const { clearPersistedCatalogs } = await import(
623
+ "../services/marketplace-fetcher.js"
624
+ );
625
+ await clearPersistedCatalogs();
626
+
627
+ publishV2();
628
+ resetCatalogCacheMemo();
629
+ const r = await refreshRegisteredMarketplaces([], {}, { now: T0 + 60_000 });
630
+ expect(r.refreshed).toEqual(["magus"]);
631
+ expect(headOf(dir)).toBe(originHead());
632
+ });
633
+
634
+ it("clearCloneRefreshes drops the record", async () => {
635
+ installClone("magus");
636
+ configured = { magus: gh() };
637
+ await refreshRegisteredMarketplaces([], {}, { now: T0 });
638
+ expect(await readCloneRefreshAge("magus", T0 + 1)).toBe(1);
639
+
640
+ await clearCloneRefreshes();
641
+ expect(await readCloneRefreshAge("magus", T0 + 1)).toBeNull();
642
+ });
643
+
644
+ it("report-only writes no time, and the next writing run pulls", async () => {
645
+ const dir = installClone("magus");
646
+ configured = { magus: gh() };
647
+ publishV2();
648
+
649
+ const report = await reportRegisteredMarketplaces([], { now: T0 });
650
+ expect(report.pending).toEqual(["magus"]);
651
+ resetCatalogCacheMemo();
652
+ expect(await readCloneRefreshAge("magus", T0 + 1)).toBeNull();
653
+ expect(headOf(dir)).not.toBe(originHead());
654
+
655
+ const r = await refreshRegisteredMarketplaces([], {}, { now: T0 + 1 });
656
+ expect(r.refreshed).toEqual(["magus"]);
657
+ });
658
+
659
+ it("report-only reports a clone refreshed within the hour as fresh", async () => {
660
+ installClone("magus");
661
+ configured = { magus: gh() };
662
+ await refreshRegisteredMarketplaces([], {}, { now: T0 });
663
+
664
+ const report = await reportRegisteredMarketplaces([], { now: T0 + 1000 });
665
+ expect(report.fresh).toEqual(["magus"]);
666
+ expect(report.pending).toEqual([]);
667
+ });
668
+ });