@trolleroof/tui 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +349 -0
- package/dist/index.js +8404 -0
- package/overlay-release.json +4 -0
- package/package.json +48 -0
- package/scripts/build-overlay-web.ts +34 -0
- package/scripts/dev-harness.sh +81 -0
- package/scripts/dev-harness.test.sh +33 -0
- package/scripts/export-bc.ts +124 -0
- package/scripts/install-adapters.sh +192 -0
- package/scripts/install-adapters.test.sh +69 -0
- package/scripts/manage-hermes-hooks.py +39 -0
- package/scripts/materialize-agents-plugin.sh +85 -0
- package/scripts/overlay.test.ts +106 -0
- package/scripts/overlay.ts +385 -0
- package/scripts/plugin-update.test.ts +318 -0
- package/scripts/plugin-update.ts +378 -0
- package/scripts/preview-overlay.ts +153 -0
- package/scripts/run-plugin-update.test.ts +31 -0
- package/scripts/run-plugin-update.ts +65 -0
- package/scripts/version-bump.test.ts +76 -0
- package/scripts/version-bump.ts +89 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
fetchRemoteReleaseManifest,
|
|
7
|
+
formatUpdateStatusLine,
|
|
8
|
+
formatUpdateVersions,
|
|
9
|
+
isDirectoryMarketplace,
|
|
10
|
+
nudgeHookOutput,
|
|
11
|
+
parsePluginUpdateAction,
|
|
12
|
+
readInstalledPlugin,
|
|
13
|
+
readUpdateFlag,
|
|
14
|
+
resolvePluginUpdatePaths,
|
|
15
|
+
runPluginUpdateCheck,
|
|
16
|
+
runPluginUpdateClear,
|
|
17
|
+
runPluginUpdateNudge,
|
|
18
|
+
shouldSkipUpdateCheck,
|
|
19
|
+
writeStatusLineShim,
|
|
20
|
+
} from "./plugin-update.js";
|
|
21
|
+
|
|
22
|
+
function tempPaths(homeDir = mkdtempSync(join(tmpdir(), "gamepigeon-update-"))) {
|
|
23
|
+
const claudeConfigDir = join(homeDir, ".claude");
|
|
24
|
+
mkdirSync(join(claudeConfigDir, "plugins"), { recursive: true });
|
|
25
|
+
return resolvePluginUpdatePaths({ homeDir, claudeConfigDir });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeInstalled(
|
|
29
|
+
paths: ReturnType<typeof tempPaths>,
|
|
30
|
+
sha: string,
|
|
31
|
+
version = "0.2.0",
|
|
32
|
+
): void {
|
|
33
|
+
writeFileSync(
|
|
34
|
+
paths.installedPluginsPath,
|
|
35
|
+
JSON.stringify({
|
|
36
|
+
version: 2,
|
|
37
|
+
plugins: {
|
|
38
|
+
"tui-gamepigeon@tui-gamepigeon": [
|
|
39
|
+
{
|
|
40
|
+
scope: "user",
|
|
41
|
+
version,
|
|
42
|
+
gitCommitSha: sha,
|
|
43
|
+
installPath: "/tmp/plugin",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeFlag(
|
|
52
|
+
paths: ReturnType<typeof tempPaths>,
|
|
53
|
+
flag: {
|
|
54
|
+
fromVersion: string;
|
|
55
|
+
toVersion: string;
|
|
56
|
+
fromLabel?: string;
|
|
57
|
+
toLabel?: string;
|
|
58
|
+
detectedAt?: string;
|
|
59
|
+
},
|
|
60
|
+
): void {
|
|
61
|
+
mkdirSync(dirname(paths.flagPath), { recursive: true });
|
|
62
|
+
writeFileSync(
|
|
63
|
+
paths.flagPath,
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
fromLabel: "0.2.0",
|
|
66
|
+
toLabel: "0.2.0",
|
|
67
|
+
detectedAt: "2026-07-19T00:00:00.000Z",
|
|
68
|
+
...flag,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeMarketplace(
|
|
74
|
+
paths: ReturnType<typeof tempPaths>,
|
|
75
|
+
source: { source: string; path?: string; repo?: string },
|
|
76
|
+
): void {
|
|
77
|
+
writeFileSync(
|
|
78
|
+
paths.knownMarketplacesPath,
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
"tui-gamepigeon": { source },
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function mockFetch(remoteVersion: string | null, ok = true, tag?: string): typeof fetch {
|
|
86
|
+
return (async (input: RequestInfo | URL) => {
|
|
87
|
+
const url = String(input);
|
|
88
|
+
if (url.includes("overlay-release.json")) {
|
|
89
|
+
return {
|
|
90
|
+
ok: ok && Boolean(remoteVersion),
|
|
91
|
+
json: async () => (remoteVersion ? { tag: tag ?? `overlay-v${remoteVersion}`, version: remoteVersion } : {}),
|
|
92
|
+
} as Response;
|
|
93
|
+
}
|
|
94
|
+
return { ok: false, json: async () => ({}) } as Response;
|
|
95
|
+
}) as typeof fetch;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("plugin update checker", () => {
|
|
99
|
+
test("parses check, nudge, and clear actions", () => {
|
|
100
|
+
expect(parsePluginUpdateAction(["--check"])).toBe("check");
|
|
101
|
+
expect(parsePluginUpdateAction(["--nudge"])).toBe("nudge");
|
|
102
|
+
expect(parsePluginUpdateAction(["--clear"])).toBe("clear");
|
|
103
|
+
expect(parsePluginUpdateAction([])).toBe("check");
|
|
104
|
+
expect(() => parsePluginUpdateAction(["--unknown"])).toThrow("Unknown plugin-update action");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("skips when GAMEPIGEON_SKIP_UPDATE_CHECK=1", () => {
|
|
108
|
+
expect(shouldSkipUpdateCheck({ GAMEPIGEON_SKIP_UPDATE_CHECK: "1" })).toBe(true);
|
|
109
|
+
expect(shouldSkipUpdateCheck({})).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("detects directory marketplaces and reads installed plugin records", () => {
|
|
113
|
+
const paths = tempPaths();
|
|
114
|
+
writeMarketplace(paths, { source: "directory", path: "/tmp/local" });
|
|
115
|
+
expect(isDirectoryMarketplace(paths.knownMarketplacesPath)).toBe(true);
|
|
116
|
+
writeMarketplace(paths, { source: "github", repo: "Trolleroof/tui-gamepigeon" });
|
|
117
|
+
expect(isDirectoryMarketplace(paths.knownMarketplacesPath)).toBe(false);
|
|
118
|
+
|
|
119
|
+
writeInstalled(paths, "abcdef1234567890");
|
|
120
|
+
expect(readInstalledPlugin(paths.installedPluginsPath)?.gitCommitSha).toBe("abcdef1234567890");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("writes a flag when the published release manifest version differs from the installed plugin", async () => {
|
|
124
|
+
const paths = tempPaths();
|
|
125
|
+
writeMarketplace(paths, { source: "github", repo: "Trolleroof/tui-gamepigeon" });
|
|
126
|
+
writeInstalled(paths, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "0.2.0");
|
|
127
|
+
|
|
128
|
+
const flag = await runPluginUpdateCheck({
|
|
129
|
+
paths,
|
|
130
|
+
request: mockFetch("0.2.2"),
|
|
131
|
+
now: () => "2026-07-19T00:00:00.000Z",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(flag).toEqual({
|
|
135
|
+
fromVersion: "0.2.0",
|
|
136
|
+
toVersion: "0.2.2",
|
|
137
|
+
fromLabel: "0.2.0",
|
|
138
|
+
toLabel: "0.2.2",
|
|
139
|
+
detectedAt: "2026-07-19T00:00:00.000Z",
|
|
140
|
+
});
|
|
141
|
+
expect(readUpdateFlag(paths.flagPath)?.toVersion).toBe("0.2.2");
|
|
142
|
+
expect(existsSync(paths.statusLinePath)).toBe(true);
|
|
143
|
+
expect(readFileSync(paths.statusLinePath, "utf8")).toContain("update-available.json");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("clears the flag when the installed plugin matches the published release", async () => {
|
|
147
|
+
const paths = tempPaths();
|
|
148
|
+
writeMarketplace(paths, { source: "github", repo: "Trolleroof/tui-gamepigeon" });
|
|
149
|
+
writeInstalled(paths, "cccccccccccccccccccccccccccccccccccccccc", "0.2.0");
|
|
150
|
+
writeFlag(paths, { fromVersion: "0.1.9", toVersion: "0.2.0" });
|
|
151
|
+
|
|
152
|
+
const flag = await runPluginUpdateCheck({
|
|
153
|
+
paths,
|
|
154
|
+
request: mockFetch("0.2.0"),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(flag).toBeNull();
|
|
158
|
+
expect(readUpdateFlag(paths.flagPath)).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("does not nudge merely because main's HEAD moved without a published release", async () => {
|
|
162
|
+
// A version-bump commit landing on main (before the release job finishes
|
|
163
|
+
// building/publishing) must not trigger a nudge; only overlay-release.json
|
|
164
|
+
// catching up to the installed version should.
|
|
165
|
+
const paths = tempPaths();
|
|
166
|
+
writeMarketplace(paths, { source: "github", repo: "Trolleroof/tui-gamepigeon" });
|
|
167
|
+
writeInstalled(paths, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "0.2.0");
|
|
168
|
+
|
|
169
|
+
const flag = await runPluginUpdateCheck({
|
|
170
|
+
paths,
|
|
171
|
+
request: mockFetch("0.2.0"),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(flag).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("does not nudge backward when the installed plugin is already ahead of the last published release", async () => {
|
|
178
|
+
// Simulates the marketplace's own auto-update pulling main HEAD (already
|
|
179
|
+
// bumped to 0.2.3 locally) while overlay-release.json still points at
|
|
180
|
+
// the last published 0.2.2 because the 0.2.3 release build is still
|
|
181
|
+
// running. A plain inequality check would wrongly nudge "0.2.3 → 0.2.2".
|
|
182
|
+
const paths = tempPaths();
|
|
183
|
+
writeMarketplace(paths, { source: "github", repo: "Trolleroof/tui-gamepigeon" });
|
|
184
|
+
writeInstalled(paths, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "0.2.3");
|
|
185
|
+
|
|
186
|
+
const flag = await runPluginUpdateCheck({
|
|
187
|
+
paths,
|
|
188
|
+
request: mockFetch("0.2.2"),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(flag).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("skips directory marketplaces and missing install records", async () => {
|
|
195
|
+
const paths = tempPaths();
|
|
196
|
+
writeMarketplace(paths, { source: "directory", path: "/tmp/local" });
|
|
197
|
+
writeInstalled(paths, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
|
198
|
+
expect(
|
|
199
|
+
await runPluginUpdateCheck({
|
|
200
|
+
paths,
|
|
201
|
+
request: mockFetch("0.2.2"),
|
|
202
|
+
}),
|
|
203
|
+
).toBeNull();
|
|
204
|
+
|
|
205
|
+
const empty = tempPaths();
|
|
206
|
+
writeMarketplace(empty, { source: "github", repo: "Trolleroof/tui-gamepigeon" });
|
|
207
|
+
expect(
|
|
208
|
+
await runPluginUpdateCheck({
|
|
209
|
+
paths: empty,
|
|
210
|
+
request: mockFetch("0.2.2"),
|
|
211
|
+
}),
|
|
212
|
+
).toBeNull();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("offline fetch failures are a no-op and leave an existing flag alone", async () => {
|
|
216
|
+
const paths = tempPaths();
|
|
217
|
+
writeMarketplace(paths, { source: "github", repo: "Trolleroof/tui-gamepigeon" });
|
|
218
|
+
writeInstalled(paths, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
|
219
|
+
writeFlag(paths, { fromVersion: "0.2.0", toVersion: "0.2.1" });
|
|
220
|
+
|
|
221
|
+
const flag = await runPluginUpdateCheck({
|
|
222
|
+
paths,
|
|
223
|
+
request: mockFetch(null, false),
|
|
224
|
+
runGhManifest: async () => null,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(flag).toBeNull();
|
|
228
|
+
expect(readUpdateFlag(paths.flagPath)?.toVersion).toBe("0.2.1");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("falls back to gh when the public GitHub API cannot see a private repo", async () => {
|
|
232
|
+
const paths = tempPaths();
|
|
233
|
+
writeMarketplace(paths, { source: "github", repo: "Trolleroof/tui-gamepigeon" });
|
|
234
|
+
writeInstalled(paths, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "0.2.0");
|
|
235
|
+
|
|
236
|
+
const flag = await runPluginUpdateCheck({
|
|
237
|
+
paths,
|
|
238
|
+
request: mockFetch(null, false),
|
|
239
|
+
runGhManifest: async () => ({ tag: "overlay-v0.2.2", version: "0.2.2" }),
|
|
240
|
+
now: () => "2026-07-19T00:00:00.000Z",
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(flag?.fromVersion).toBe("0.2.0");
|
|
244
|
+
expect(flag?.toVersion).toBe("0.2.2");
|
|
245
|
+
expect(readUpdateFlag(paths.flagPath)?.toVersion).toBe("0.2.2");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("skips checks when GAMEPIGEON_SKIP_UPDATE_CHECK is set", async () => {
|
|
249
|
+
const paths = tempPaths();
|
|
250
|
+
writeMarketplace(paths, { source: "github", repo: "Trolleroof/tui-gamepigeon" });
|
|
251
|
+
writeInstalled(paths, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
|
252
|
+
|
|
253
|
+
const flag = await runPluginUpdateCheck({
|
|
254
|
+
paths,
|
|
255
|
+
env: { GAMEPIGEON_SKIP_UPDATE_CHECK: "1" },
|
|
256
|
+
request: mockFetch("0.2.2"),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
expect(flag).toBeNull();
|
|
260
|
+
expect(readUpdateFlag(paths.flagPath)).toBeNull();
|
|
261
|
+
expect(existsSync(paths.statusLinePath)).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("nudge emits hookSpecificOutput JSON when a flag is present", () => {
|
|
265
|
+
const paths = tempPaths();
|
|
266
|
+
writeFlag(paths, { fromVersion: "0.2.0", toVersion: "0.2.1", fromLabel: "0.2.0", toLabel: "0.2.1" });
|
|
267
|
+
|
|
268
|
+
const output = runPluginUpdateNudge(paths);
|
|
269
|
+
expect(output).toBeTruthy();
|
|
270
|
+
const parsed = JSON.parse(output!);
|
|
271
|
+
expect(parsed.hookSpecificOutput.hookEventName).toBe("UserPromptSubmit");
|
|
272
|
+
expect(parsed.hookSpecificOutput.additionalContext).toContain("/tui-gamepigeon:arcade-update");
|
|
273
|
+
expect(parsed.hookSpecificOutput.additionalContext).toContain("(0.2.1)");
|
|
274
|
+
|
|
275
|
+
runPluginUpdateClear(paths);
|
|
276
|
+
expect(runPluginUpdateNudge(paths)).toBeNull();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("status-line shim and nudge helper use subdued version labels", () => {
|
|
280
|
+
const paths = tempPaths();
|
|
281
|
+
writeStatusLineShim(paths.statusLinePath, paths.flagPath);
|
|
282
|
+
const script = readFileSync(paths.statusLinePath, "utf8");
|
|
283
|
+
expect(script).toContain("update available");
|
|
284
|
+
expect(script).toContain("\\u001b[90m"); // muted gray
|
|
285
|
+
expect(script).not.toContain(">>>");
|
|
286
|
+
const line = formatUpdateStatusLine("0.2.0", "0.2.2");
|
|
287
|
+
expect(line).toContain("Arcade");
|
|
288
|
+
expect(line).toContain("0.2.0");
|
|
289
|
+
expect(line).toContain("0.2.2");
|
|
290
|
+
expect(line).toContain("update available");
|
|
291
|
+
expect(line).not.toContain(">>>");
|
|
292
|
+
expect(line).not.toContain("/tui-gamepigeon:arcade-update");
|
|
293
|
+
const nudge = nudgeHookOutput({
|
|
294
|
+
fromVersion: "0.2.0",
|
|
295
|
+
toVersion: "0.2.2",
|
|
296
|
+
fromLabel: "0.2.0",
|
|
297
|
+
toLabel: "0.2.2",
|
|
298
|
+
detectedAt: "2026-07-19T00:00:00.000Z",
|
|
299
|
+
});
|
|
300
|
+
expect(nudge).toContain("An update is available (0.2.2)");
|
|
301
|
+
expect(nudge).toContain("/tui-gamepigeon:arcade-update");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("fetchRemoteReleaseManifest reads overlay-release.json from main", async () => {
|
|
305
|
+
const manifest = await fetchRemoteReleaseManifest("Trolleroof/tui-gamepigeon", mockFetch("0.2.3"));
|
|
306
|
+
expect(manifest).toEqual({ tag: "overlay-v0.2.3", version: "0.2.3" });
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("formatUpdateVersions prefers labels", () => {
|
|
310
|
+
expect(formatUpdateVersions({
|
|
311
|
+
fromVersion: "ignored",
|
|
312
|
+
toVersion: "ignored",
|
|
313
|
+
fromLabel: "0.2.0",
|
|
314
|
+
toLabel: "0.2.2",
|
|
315
|
+
detectedAt: "2026-07-19T00:00:00.000Z",
|
|
316
|
+
})).toEqual({ from: "0.2.0", to: "0.2.2" });
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { repoSlug, type ReleaseManifest } from "./overlay.js";
|
|
6
|
+
|
|
7
|
+
export type PluginUpdateAction = "check" | "nudge" | "clear";
|
|
8
|
+
|
|
9
|
+
export interface UpdateAvailableFlag {
|
|
10
|
+
fromVersion: string;
|
|
11
|
+
toVersion: string;
|
|
12
|
+
fromLabel: string;
|
|
13
|
+
toLabel: string;
|
|
14
|
+
detectedAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface InstalledPluginRecord {
|
|
18
|
+
gitCommitSha?: string;
|
|
19
|
+
version?: string;
|
|
20
|
+
installPath?: string;
|
|
21
|
+
scope?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MarketplaceSource {
|
|
25
|
+
source: string;
|
|
26
|
+
path?: string;
|
|
27
|
+
repo?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PluginUpdatePaths {
|
|
31
|
+
homeDir: string;
|
|
32
|
+
claudeConfigDir: string;
|
|
33
|
+
flagPath: string;
|
|
34
|
+
statusLinePath: string;
|
|
35
|
+
installedPluginsPath: string;
|
|
36
|
+
knownMarketplacesPath: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
40
|
+
const PLUGIN_KEY = "tui-gamepigeon@tui-gamepigeon";
|
|
41
|
+
const MARKETPLACE_NAME = "tui-gamepigeon";
|
|
42
|
+
type FetchLike = typeof fetch;
|
|
43
|
+
|
|
44
|
+
/** Muted terminal palette aligned with overlay tokens (--muted, --ink, --accent). */
|
|
45
|
+
const STATUS = {
|
|
46
|
+
reset: "\x1b[0m",
|
|
47
|
+
muted: "\x1b[90m",
|
|
48
|
+
ink: "\x1b[37m",
|
|
49
|
+
accent: "\x1b[33m",
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
export function parsePluginUpdateAction(args: string[]): PluginUpdateAction {
|
|
53
|
+
const value = args[0] ?? "--check";
|
|
54
|
+
if (value === "--check") return "check";
|
|
55
|
+
if (value === "--nudge") return "nudge";
|
|
56
|
+
if (value === "--clear") return "clear";
|
|
57
|
+
throw new Error(`Unknown plugin-update action: ${value}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function gamepigeonHome(home = homedir()): string {
|
|
61
|
+
return process.env.GAMEPIGEON_HOME ?? join(home, ".gamepigeon");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolvePluginUpdatePaths(options: {
|
|
65
|
+
homeDir?: string;
|
|
66
|
+
claudeConfigDir?: string;
|
|
67
|
+
} = {}): PluginUpdatePaths {
|
|
68
|
+
const homeDir = options.homeDir ?? homedir();
|
|
69
|
+
const claudeConfigDir = options.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(homeDir, ".claude");
|
|
70
|
+
// Explicit homeDir (tests) always nests under that tree; production uses GAMEPIGEON_HOME or ~/.gamepigeon.
|
|
71
|
+
const home = options.homeDir !== undefined ? join(homeDir, ".gamepigeon") : gamepigeonHome(homeDir);
|
|
72
|
+
return {
|
|
73
|
+
homeDir,
|
|
74
|
+
claudeConfigDir,
|
|
75
|
+
flagPath: join(home, "update-available.json"),
|
|
76
|
+
statusLinePath: join(home, "status-line.ts"),
|
|
77
|
+
installedPluginsPath: join(claudeConfigDir, "plugins", "installed_plugins.json"),
|
|
78
|
+
knownMarketplacesPath: join(claudeConfigDir, "plugins", "known_marketplaces.json"),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readUpdateFlag(flagPath: string): UpdateAvailableFlag | null {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(readFileSync(flagPath, "utf8"));
|
|
85
|
+
if (
|
|
86
|
+
typeof parsed.fromVersion === "string" &&
|
|
87
|
+
typeof parsed.toVersion === "string" &&
|
|
88
|
+
typeof parsed.fromLabel === "string" &&
|
|
89
|
+
typeof parsed.toLabel === "string" &&
|
|
90
|
+
typeof parsed.detectedAt === "string"
|
|
91
|
+
) {
|
|
92
|
+
return parsed as UpdateAvailableFlag;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Missing or malformed flag means no update nudge.
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function writeUpdateFlag(flagPath: string, flag: UpdateAvailableFlag): void {
|
|
101
|
+
mkdirSync(dirname(flagPath), { recursive: true });
|
|
102
|
+
writeFileSync(flagPath, `${JSON.stringify(flag, null, 2)}\n`, { mode: 0o600 });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function clearUpdateFlag(flagPath: string): void {
|
|
106
|
+
try {
|
|
107
|
+
rmSync(flagPath, { force: true });
|
|
108
|
+
} catch {
|
|
109
|
+
// Best-effort cleanup.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function readPackageVersion(root = ROOT_DIR): string {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
116
|
+
if (typeof parsed.version === "string") return parsed.version;
|
|
117
|
+
} catch {
|
|
118
|
+
// Fall through.
|
|
119
|
+
}
|
|
120
|
+
return "unknown";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function readInstalledPlugin(
|
|
124
|
+
installedPluginsPath: string,
|
|
125
|
+
pluginKey = PLUGIN_KEY,
|
|
126
|
+
): InstalledPluginRecord | null {
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(readFileSync(installedPluginsPath, "utf8"));
|
|
129
|
+
const entries = parsed?.plugins?.[pluginKey];
|
|
130
|
+
if (!Array.isArray(entries) || entries.length === 0) return null;
|
|
131
|
+
const record = entries[0];
|
|
132
|
+
if (!record || typeof record !== "object") return null;
|
|
133
|
+
return record as InstalledPluginRecord;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isDirectoryMarketplace(
|
|
140
|
+
knownMarketplacesPath: string,
|
|
141
|
+
marketplaceName = MARKETPLACE_NAME,
|
|
142
|
+
): boolean {
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(readFileSync(knownMarketplacesPath, "utf8"));
|
|
145
|
+
const source = parsed?.[marketplaceName]?.source as MarketplaceSource | undefined;
|
|
146
|
+
return source?.source === "directory";
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function shouldSkipUpdateCheck(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
153
|
+
return env.GAMEPIGEON_SKIP_UPDATE_CHECK === "1";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type GhManifestRunner = (slug: string) => Promise<ReleaseManifest | null>;
|
|
157
|
+
|
|
158
|
+
async function fetchRemoteReleaseManifestViaGh(slug: string): Promise<ReleaseManifest | null> {
|
|
159
|
+
try {
|
|
160
|
+
const proc = Bun.spawn(["gh", "api", `repos/${slug}/contents/overlay-release.json`, "--jq", ".content"], {
|
|
161
|
+
stdout: "pipe",
|
|
162
|
+
stderr: "ignore",
|
|
163
|
+
});
|
|
164
|
+
const encoded = (await new Response(proc.stdout).text()).trim();
|
|
165
|
+
if ((await proc.exited) !== 0 || !encoded) return null;
|
|
166
|
+
const parsed = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as Partial<ReleaseManifest>;
|
|
167
|
+
if (typeof parsed.tag === "string" && typeof parsed.version === "string") {
|
|
168
|
+
return { tag: parsed.tag, version: parsed.version };
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resolve the latest *published* release, i.e. `overlay-release.json` on main.
|
|
178
|
+
* That file is only committed once the `overlay.yml` release job has finished
|
|
179
|
+
* building both platform binaries and created the GitHub release — unlike
|
|
180
|
+
* `package.json`'s version, which lands on main as soon as the version-bump
|
|
181
|
+
* commit is pushed, well before CI has built or released anything. Comparing
|
|
182
|
+
* against the version bump would nudge users to update before a release even
|
|
183
|
+
* exists to download.
|
|
184
|
+
*/
|
|
185
|
+
export async function fetchRemoteReleaseManifest(
|
|
186
|
+
slug: string,
|
|
187
|
+
request: FetchLike = fetch,
|
|
188
|
+
runGh: GhManifestRunner = fetchRemoteReleaseManifestViaGh,
|
|
189
|
+
): Promise<ReleaseManifest | null> {
|
|
190
|
+
try {
|
|
191
|
+
const response = await request(`https://raw.githubusercontent.com/${slug}/main/overlay-release.json`, {
|
|
192
|
+
headers: { "User-Agent": "tui-gamepigeon-plugin-update" },
|
|
193
|
+
});
|
|
194
|
+
if (response.ok) {
|
|
195
|
+
const parsed = (await response.json()) as Partial<ReleaseManifest>;
|
|
196
|
+
if (typeof parsed.tag === "string" && typeof parsed.version === "string") {
|
|
197
|
+
return { tag: parsed.tag, version: parsed.version };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// Fall through to gh for private repos.
|
|
202
|
+
}
|
|
203
|
+
return runGh(slug);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Compares dotted numeric versions (e.g. "0.2.3" vs "0.2.10"). Returns
|
|
208
|
+
* positive if `a` > `b`, negative if `a` < `b`, 0 if equal or unparsable.
|
|
209
|
+
*/
|
|
210
|
+
export function compareVersions(a: string, b: string): number {
|
|
211
|
+
const partsA = a.split(".").map((n) => Number.parseInt(n, 10));
|
|
212
|
+
const partsB = b.split(".").map((n) => Number.parseInt(n, 10));
|
|
213
|
+
const length = Math.max(partsA.length, partsB.length);
|
|
214
|
+
for (let i = 0; i < length; i++) {
|
|
215
|
+
const numA = partsA[i] ?? 0;
|
|
216
|
+
const numB = partsB[i] ?? 0;
|
|
217
|
+
if (Number.isNaN(numA) || Number.isNaN(numB)) return 0;
|
|
218
|
+
if (numA !== numB) return numA - numB;
|
|
219
|
+
}
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function formatUpdateVersions(flag: UpdateAvailableFlag): { from: string; to: string } {
|
|
224
|
+
return {
|
|
225
|
+
from: flag.fromLabel || flag.fromVersion,
|
|
226
|
+
to: flag.toLabel || flag.toVersion,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Subdued status-line text for an available plugin update (ASCII-safe for terminals). */
|
|
231
|
+
export function formatUpdateStatusLine(fromVersion: string, toVersion: string): string {
|
|
232
|
+
const { reset, muted, ink, accent } = STATUS;
|
|
233
|
+
return (
|
|
234
|
+
`${muted}Arcade${reset} ` +
|
|
235
|
+
`${ink}${fromVersion}${reset}` +
|
|
236
|
+
`${muted} → ${reset}` +
|
|
237
|
+
`${accent}${toVersion}${reset} ` +
|
|
238
|
+
`${muted}update available${reset}`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function statusLineScriptSource(flagPath: string): string {
|
|
243
|
+
return `#!/usr/bin/env bun
|
|
244
|
+
import { readFileSync } from "node:fs";
|
|
245
|
+
|
|
246
|
+
const FLAG_PATH = ${JSON.stringify(flagPath)};
|
|
247
|
+
const A = ${JSON.stringify(STATUS)};
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const flag = JSON.parse(readFileSync(FLAG_PATH, "utf8"));
|
|
251
|
+
const from = flag?.fromLabel || flag?.fromVersion;
|
|
252
|
+
const to = flag?.toLabel || flag?.toVersion;
|
|
253
|
+
if (typeof from === "string" && typeof to === "string") {
|
|
254
|
+
process.stdout.write(
|
|
255
|
+
A.muted + "Arcade" + A.reset + " " +
|
|
256
|
+
A.ink + from + A.reset +
|
|
257
|
+
A.muted + " → " + A.reset +
|
|
258
|
+
A.accent + to + A.reset + " " +
|
|
259
|
+
A.muted + "update available" + A.reset
|
|
260
|
+
);
|
|
261
|
+
process.exit(0);
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
// No update flag.
|
|
265
|
+
}
|
|
266
|
+
process.stdout.write("");
|
|
267
|
+
`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function writeStatusLineShim(statusLinePath: string, flagPath: string): void {
|
|
271
|
+
mkdirSync(dirname(statusLinePath), { recursive: true });
|
|
272
|
+
writeFileSync(statusLinePath, statusLineScriptSource(flagPath), { mode: 0o755 });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function nudgeHookOutput(flag: UpdateAvailableFlag): string {
|
|
276
|
+
const { to } = formatUpdateVersions(flag);
|
|
277
|
+
const additionalContext =
|
|
278
|
+
`An update is available (${to}). Run this command: /tui-gamepigeon:arcade-update`;
|
|
279
|
+
return JSON.stringify({
|
|
280
|
+
hookSpecificOutput: {
|
|
281
|
+
hookEventName: "UserPromptSubmit",
|
|
282
|
+
additionalContext,
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function runPluginUpdateCheck(options: {
|
|
288
|
+
paths?: PluginUpdatePaths;
|
|
289
|
+
root?: string;
|
|
290
|
+
request?: FetchLike;
|
|
291
|
+
runGhManifest?: GhManifestRunner;
|
|
292
|
+
env?: NodeJS.ProcessEnv;
|
|
293
|
+
now?: () => string;
|
|
294
|
+
}): Promise<UpdateAvailableFlag | null> {
|
|
295
|
+
const env = options.env ?? process.env;
|
|
296
|
+
const paths = options.paths ?? resolvePluginUpdatePaths();
|
|
297
|
+
const root = options.root ?? ROOT_DIR;
|
|
298
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
299
|
+
|
|
300
|
+
writeStatusLineShim(paths.statusLinePath, paths.flagPath);
|
|
301
|
+
|
|
302
|
+
if (shouldSkipUpdateCheck(env)) return null;
|
|
303
|
+
if (isDirectoryMarketplace(paths.knownMarketplacesPath)) return null;
|
|
304
|
+
|
|
305
|
+
const installed = readInstalledPlugin(paths.installedPluginsPath);
|
|
306
|
+
if (!installed?.gitCommitSha) return null;
|
|
307
|
+
|
|
308
|
+
const slug = repoSlug(root);
|
|
309
|
+
// Compare against the published release manifest, not main's HEAD sha or
|
|
310
|
+
// package.json — those move the instant a version-bump commit lands on
|
|
311
|
+
// main, long before CI has finished building and releasing that version.
|
|
312
|
+
const remoteManifest = await fetchRemoteReleaseManifest(
|
|
313
|
+
slug,
|
|
314
|
+
options.request ?? fetch,
|
|
315
|
+
options.runGhManifest ?? fetchRemoteReleaseManifestViaGh,
|
|
316
|
+
);
|
|
317
|
+
if (!remoteManifest) return null;
|
|
318
|
+
|
|
319
|
+
const installedVersion = typeof installed.version === "string" ? installed.version : readPackageVersion(root);
|
|
320
|
+
const remoteVersion = remoteManifest.version;
|
|
321
|
+
|
|
322
|
+
// Only nudge when the published release is strictly newer. The plugin
|
|
323
|
+
// marketplace's own auto-update can pull main HEAD (including an
|
|
324
|
+
// unreleased version bump) ahead of the last published release, so
|
|
325
|
+
// equality isn't enough here — a plain "!==" would fire a nudge pointing
|
|
326
|
+
// users backward to an older release while a build is still in flight.
|
|
327
|
+
if (compareVersions(remoteVersion, installedVersion) <= 0) {
|
|
328
|
+
clearUpdateFlag(paths.flagPath);
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const flag: UpdateAvailableFlag = {
|
|
333
|
+
fromVersion: installedVersion,
|
|
334
|
+
toVersion: remoteVersion,
|
|
335
|
+
fromLabel: installedVersion,
|
|
336
|
+
toLabel: remoteVersion,
|
|
337
|
+
detectedAt: now(),
|
|
338
|
+
};
|
|
339
|
+
writeUpdateFlag(paths.flagPath, flag);
|
|
340
|
+
return flag;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function runPluginUpdateNudge(paths: PluginUpdatePaths = resolvePluginUpdatePaths()): string | null {
|
|
344
|
+
const flag = readUpdateFlag(paths.flagPath);
|
|
345
|
+
if (!flag) return null;
|
|
346
|
+
return nudgeHookOutput(flag);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function runPluginUpdateClear(paths: PluginUpdatePaths = resolvePluginUpdatePaths()): void {
|
|
350
|
+
clearUpdateFlag(paths.flagPath);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export async function main(args = process.argv.slice(2)): Promise<void> {
|
|
354
|
+
const action = parsePluginUpdateAction(args);
|
|
355
|
+
const paths = resolvePluginUpdatePaths();
|
|
356
|
+
|
|
357
|
+
if (action === "clear") {
|
|
358
|
+
runPluginUpdateClear(paths);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (action === "nudge") {
|
|
363
|
+
const output = runPluginUpdateNudge(paths);
|
|
364
|
+
if (output) process.stdout.write(`${output}\n`);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
await runPluginUpdateCheck({ paths });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (import.meta.main) {
|
|
372
|
+
try {
|
|
373
|
+
await main();
|
|
374
|
+
} catch (error) {
|
|
375
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
376
|
+
process.exitCode = 1;
|
|
377
|
+
}
|
|
378
|
+
}
|