@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,85 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
+
PLUGIN_DIR="$ROOT_DIR/.agents/plugins/tui-gamepigeon"
|
|
6
|
+
MARKER="Generated by tui-gamepigeon scripts/materialize-agents-plugin.sh"
|
|
7
|
+
|
|
8
|
+
printf -v overlay_cmd 'bun %q' "$ROOT_DIR/scripts/overlay.ts"
|
|
9
|
+
printf -v launcher_command 'bun %q --enable' "$ROOT_DIR/scripts/overlay.ts"
|
|
10
|
+
|
|
11
|
+
mkdir -p "$PLUGIN_DIR"
|
|
12
|
+
rm -f "$PLUGIN_DIR/plugin.json" "$PLUGIN_DIR/hooks.json" "$PLUGIN_DIR/skills"
|
|
13
|
+
mkdir -p "$PLUGIN_DIR/skills/arcade"
|
|
14
|
+
install -m 644 "$ROOT_DIR/.codex-plugin/plugin.json" "$PLUGIN_DIR/plugin.json"
|
|
15
|
+
|
|
16
|
+
cat > "$ROOT_DIR/.agents/plugins/marketplace.json" <<EOF
|
|
17
|
+
{
|
|
18
|
+
"name": "tui-gamepigeon-dev",
|
|
19
|
+
"interface": {
|
|
20
|
+
"displayName": "GamePigeon (local checkout)"
|
|
21
|
+
},
|
|
22
|
+
"plugins": [
|
|
23
|
+
{
|
|
24
|
+
"name": "tui-gamepigeon",
|
|
25
|
+
"source": {
|
|
26
|
+
"source": "local",
|
|
27
|
+
"path": "./.agents/plugins/tui-gamepigeon"
|
|
28
|
+
},
|
|
29
|
+
"policy": {
|
|
30
|
+
"installation": "AVAILABLE"
|
|
31
|
+
},
|
|
32
|
+
"category": "Developer Tools"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
EOF
|
|
37
|
+
|
|
38
|
+
cat > "$PLUGIN_DIR/skills/arcade/SKILL.md" <<EOF
|
|
39
|
+
---
|
|
40
|
+
name: arcade
|
|
41
|
+
description: Open or restore the native GamePigeon HTML overlay while the coding harness works.
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
# Arcade
|
|
45
|
+
|
|
46
|
+
<!-- $MARKER -->
|
|
47
|
+
|
|
48
|
+
Execute this command exactly once:
|
|
49
|
+
|
|
50
|
+
\`$launcher_command\`
|
|
51
|
+
|
|
52
|
+
Do not inspect the user's repository. Do not spawn another agent or task. Do not run any other command.
|
|
53
|
+
|
|
54
|
+
GamePigeon opens as a borderless HTML overlay without a Dock or taskbar entry. Running Arcade again focuses the existing overlay.
|
|
55
|
+
|
|
56
|
+
On success, respond only with \`Arcade opened.\`. On failure, relay the launcher's error without attempting another launch method.
|
|
57
|
+
EOF
|
|
58
|
+
|
|
59
|
+
cat > "$PLUGIN_DIR/hooks.json" <<EOF
|
|
60
|
+
{
|
|
61
|
+
"tui-gamepigeon": {
|
|
62
|
+
"enabled": true,
|
|
63
|
+
"SessionStart": [
|
|
64
|
+
{
|
|
65
|
+
"type": "command",
|
|
66
|
+
"command": "$overlay_cmd --reset"
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
"UserPromptSubmit": [
|
|
70
|
+
{
|
|
71
|
+
"type": "command",
|
|
72
|
+
"command": "$overlay_cmd --show"
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
"SessionEnd": [
|
|
76
|
+
{
|
|
77
|
+
"type": "command",
|
|
78
|
+
"command": "$overlay_cmd --quit"
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
EOF
|
|
84
|
+
|
|
85
|
+
echo "Materialized Codex/Antigravity workspace plugin at $PLUGIN_DIR"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
applyOverlayLease,
|
|
7
|
+
managedBinaryPath,
|
|
8
|
+
overlayBinaryCandidates,
|
|
9
|
+
overlayReleaseAsset,
|
|
10
|
+
parseAction,
|
|
11
|
+
readReleaseManifest,
|
|
12
|
+
resolveOverlayBinary,
|
|
13
|
+
setOverlayLease,
|
|
14
|
+
} from "./overlay.js";
|
|
15
|
+
|
|
16
|
+
describe("overlay launcher", () => {
|
|
17
|
+
test("opens only when the user submits a prompt; new sessions reset without popping the window open", () => {
|
|
18
|
+
const hooks = JSON.parse(readFileSync(new URL("../hooks/hooks.json", import.meta.url), "utf8")).hooks;
|
|
19
|
+
expect(hooks.SessionStart[0].hooks.map((hook: { args: string[] }) => hook.args)).toEqual([
|
|
20
|
+
["${CLAUDE_PLUGIN_ROOT}/scripts/plugin-update.ts", "--check"],
|
|
21
|
+
["${CLAUDE_PLUGIN_ROOT}/scripts/overlay.ts", "--reset"],
|
|
22
|
+
]);
|
|
23
|
+
expect(hooks.UserPromptSubmit[0].hooks.map((hook: { args: string[] }) => hook.args)).toEqual([
|
|
24
|
+
["${CLAUDE_PLUGIN_ROOT}/scripts/plugin-update.ts", "--nudge"],
|
|
25
|
+
["${CLAUDE_PLUGIN_ROOT}/scripts/overlay.ts", "--show"],
|
|
26
|
+
]);
|
|
27
|
+
// The overlay stays visible across turns; only SessionEnd tears it down.
|
|
28
|
+
expect(hooks.Stop).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("accepts lifecycle actions and legacy hook aliases", () => {
|
|
32
|
+
expect(parseAction([])).toBe("show");
|
|
33
|
+
expect(parseAction(["--enable"])).toBe("enable");
|
|
34
|
+
expect(parseAction(["--pause"])).toBe("pause");
|
|
35
|
+
expect(parseAction(["--reset"])).toBe("reset");
|
|
36
|
+
expect(parseAction(["--stop"])).toBe("quit");
|
|
37
|
+
expect(() => parseAction(["--unknown"])).toThrow("Unknown overlay action");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("session cleanup removes the lease watched by every overlay process", () => {
|
|
41
|
+
const path = join(mkdtempSync(join(tmpdir(), "gamepigeon-lease-")), "overlay.alive");
|
|
42
|
+
setOverlayLease(true, path);
|
|
43
|
+
expect(existsSync(path)).toBe(true);
|
|
44
|
+
setOverlayLease(false, path);
|
|
45
|
+
expect(existsSync(path)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("reset keeps the overlay lease alive while quit clears it", () => {
|
|
49
|
+
const path = join(mkdtempSync(join(tmpdir(), "gamepigeon-lease-")), "overlay.alive");
|
|
50
|
+
setOverlayLease(true, path);
|
|
51
|
+
applyOverlayLease("reset", path);
|
|
52
|
+
expect(existsSync(path)).toBe(true);
|
|
53
|
+
applyOverlayLease("quit", path);
|
|
54
|
+
expect(existsSync(path)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("prefers a packaged platform binary", () => {
|
|
58
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-overlay-"));
|
|
59
|
+
const candidate = overlayBinaryCandidates(root).find((path) => path.startsWith(join(root, "bin")))!;
|
|
60
|
+
mkdirSync(dirname(candidate), { recursive: true });
|
|
61
|
+
writeFileSync(candidate, "");
|
|
62
|
+
if (process.platform !== "win32") chmodSync(candidate, 0o755);
|
|
63
|
+
expect(resolveOverlayBinary(root)).toBe(candidate);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("reads the release manifest committed by CI and ignores missing or malformed ones", () => {
|
|
67
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-overlay-"));
|
|
68
|
+
expect(readReleaseManifest(root)).toBeNull();
|
|
69
|
+
writeFileSync(join(root, "overlay-release.json"), "not json");
|
|
70
|
+
expect(readReleaseManifest(root)).toBeNull();
|
|
71
|
+
writeFileSync(join(root, "overlay-release.json"), JSON.stringify({ tag: "overlay-abc1234", version: "abc1234" }));
|
|
72
|
+
expect(readReleaseManifest(root)).toEqual({ tag: "overlay-abc1234", version: "abc1234" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("prefers the managed auto-updated binary over local development builds", () => {
|
|
76
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-overlay-"));
|
|
77
|
+
const manifest = { tag: "overlay-abc1234", version: "abc1234" };
|
|
78
|
+
const candidates = overlayBinaryCandidates(root, manifest);
|
|
79
|
+
expect(candidates[0]).toBe(managedBinaryPath(manifest.version));
|
|
80
|
+
expect(candidates.some((path) => path.startsWith(join(root, "bin")))).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("falls back to the managed version in use when the manifest's version was pruned", () => {
|
|
84
|
+
const home = mkdtempSync(join(tmpdir(), "gamepigeon-bin-"));
|
|
85
|
+
const previous = process.env.GAMEPIGEON_OVERLAY_HOME;
|
|
86
|
+
process.env.GAMEPIGEON_OVERLAY_HOME = home;
|
|
87
|
+
try {
|
|
88
|
+
writeFileSync(join(home, "current"), "9.9.9\n");
|
|
89
|
+
const root = mkdtempSync(join(tmpdir(), "gamepigeon-overlay-"));
|
|
90
|
+
const candidates = overlayBinaryCandidates(root, { tag: "overlay-abc1234", version: "abc1234" });
|
|
91
|
+
expect(candidates.at(-1)).toBe(managedBinaryPath("9.9.9"));
|
|
92
|
+
// A dev checkout with no manifest keeps resolving its own builds only.
|
|
93
|
+
expect(overlayBinaryCandidates(root, null)).not.toContain(managedBinaryPath("9.9.9"));
|
|
94
|
+
} finally {
|
|
95
|
+
if (previous === undefined) delete process.env.GAMEPIGEON_OVERLAY_HOME;
|
|
96
|
+
else process.env.GAMEPIGEON_OVERLAY_HOME = previous;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("names release assets by platform so the launcher and CI agree", () => {
|
|
101
|
+
const asset = overlayReleaseAsset();
|
|
102
|
+
expect(asset.startsWith("gamepigeon-overlay-")).toBe(true);
|
|
103
|
+
if (process.platform === "darwin") expect(asset).toBe(`gamepigeon-overlay-macos-${process.arch}`);
|
|
104
|
+
if (process.platform === "win32") expect(asset).toBe(`gamepigeon-overlay-windows-${process.arch}.exe`);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import {
|
|
2
|
+
accessSync,
|
|
3
|
+
chmodSync,
|
|
4
|
+
constants,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { homedir, tmpdir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { analytics } from "../src/analytics/posthog.js";
|
|
17
|
+
import type { AnalyticsCloudEvent } from "../src/analytics/events.js";
|
|
18
|
+
import { refreshSessionIfPresent } from "../src/auth.js";
|
|
19
|
+
|
|
20
|
+
export type OverlayAction = "show" | "enable" | "pause" | "hide" | "reset" | "quit";
|
|
21
|
+
|
|
22
|
+
export interface LaunchOptions {
|
|
23
|
+
gameId?: string;
|
|
24
|
+
variationId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ReleaseManifest {
|
|
28
|
+
tag: string;
|
|
29
|
+
version: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
33
|
+
const DEFAULT_REPO = "Trolleroof/tui-gamepigeon";
|
|
34
|
+
|
|
35
|
+
// When the user hits "Shutdown" in the overlay, the window writes this flag and
|
|
36
|
+
// quits. While the flag exists we suppress `show`/`pause` so the overlay stops
|
|
37
|
+
// popping open for the rest of the session; the SessionStart `--reset` clears it.
|
|
38
|
+
const SHUTDOWN_FLAG = join(tmpdir(), ".gamepigeon-overlay.off");
|
|
39
|
+
const OVERLAY_LEASE_FILE = join(tmpdir(), ".gamepigeon-overlay.alive");
|
|
40
|
+
const PAUSED_FLAG = join(tmpdir(), ".gamepigeon-overlay.paused");
|
|
41
|
+
const ANALYTICS_QUEUE = join(homedir(), ".tui-gamepigeon", "analytics-events.jsonl");
|
|
42
|
+
|
|
43
|
+
async function flushAnalyticsQueue(): Promise<void> {
|
|
44
|
+
if (!process.env.POSTHOG_API_KEY || !existsSync(ANALYTICS_QUEUE)) return;
|
|
45
|
+
try {
|
|
46
|
+
const events = readFileSync(ANALYTICS_QUEUE, "utf8").trim().split("\n")
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map((line) => JSON.parse(line) as AnalyticsCloudEvent);
|
|
49
|
+
for (const event of events) analytics.capture(event);
|
|
50
|
+
// Bounded: this runs on the shutdown path, where a hung upload would stall
|
|
51
|
+
// the session's exit. A dropped flush just stays queued for the next run.
|
|
52
|
+
const flushed = await Promise.race([
|
|
53
|
+
analytics.shutdown().then(() => true),
|
|
54
|
+
Bun.sleep(2000).then(() => false),
|
|
55
|
+
]);
|
|
56
|
+
if (flushed) rmSync(ANALYTICS_QUEUE, { force: true });
|
|
57
|
+
} catch {
|
|
58
|
+
// Keep the queue for the next launcher run.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isShutDown(): boolean {
|
|
63
|
+
return existsSync(SHUTDOWN_FLAG);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function clearShutdown(): void {
|
|
67
|
+
try {
|
|
68
|
+
rmSync(SHUTDOWN_FLAG, { force: true });
|
|
69
|
+
} catch {
|
|
70
|
+
// Best-effort: a leftover flag only means the overlay stays hidden.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function setOverlayLease(active: boolean, path = OVERLAY_LEASE_FILE): void {
|
|
75
|
+
if (active) writeFileSync(path, "", { mode: 0o600 });
|
|
76
|
+
else rmSync(path, { force: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseAction(args: string[]): OverlayAction {
|
|
80
|
+
const value = args[0] ?? "--show";
|
|
81
|
+
if (value === "--show") return "show";
|
|
82
|
+
if (value === "--enable") return "enable";
|
|
83
|
+
if (value === "--pause") return "pause";
|
|
84
|
+
if (value === "--hide") return "hide";
|
|
85
|
+
if (value === "--reset") return "reset";
|
|
86
|
+
if (value === "--quit" || value === "--stop") return "quit";
|
|
87
|
+
throw new Error(`Unknown overlay action: ${value}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function overlayExecutableName(): string {
|
|
91
|
+
return process.platform === "win32" ? "gamepigeon-overlay.exe" : "gamepigeon-overlay";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function overlayReleaseAsset(): string {
|
|
95
|
+
const platform =
|
|
96
|
+
process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : process.platform;
|
|
97
|
+
const extension = process.platform === "win32" ? ".exe" : "";
|
|
98
|
+
return `gamepigeon-overlay-${platform}-${process.arch}${extension}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function managedBinDir(): string {
|
|
102
|
+
return process.env.GAMEPIGEON_OVERLAY_HOME ?? join(homedir(), ".gamepigeon", "bin");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function managedBinaryPath(version: string): string {
|
|
106
|
+
return join(managedBinDir(), version, overlayExecutableName());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function readReleaseManifest(root = ROOT_DIR): ReleaseManifest | null {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(readFileSync(join(root, "overlay-release.json"), "utf8"));
|
|
112
|
+
if (typeof parsed.tag === "string" && typeof parsed.version === "string") {
|
|
113
|
+
return { tag: parsed.tag, version: parsed.version };
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// No packaged release manifest; fall back to local builds.
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function repoSlug(root = ROOT_DIR): string {
|
|
122
|
+
try {
|
|
123
|
+
const parsed = JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8"));
|
|
124
|
+
const match = /github\.com\/([^/]+\/[^/.]+)/.exec(parsed.repository ?? "");
|
|
125
|
+
if (match?.[1]) return match[1];
|
|
126
|
+
} catch {
|
|
127
|
+
// Use the default repository below.
|
|
128
|
+
}
|
|
129
|
+
return DEFAULT_REPO;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function overlayBinaryCandidates(root = ROOT_DIR, manifest = readReleaseManifest(root)): string[] {
|
|
133
|
+
const executable = overlayExecutableName();
|
|
134
|
+
const platform = process.platform === "darwin" ? "macos" : process.platform;
|
|
135
|
+
// Last resort, for packaged copies only: the version named by `current`. A
|
|
136
|
+
// plugin whose manifest trails the managed dir (another checkout downloaded a
|
|
137
|
+
// newer overlay and pruned this one's version) would otherwise resolve nothing.
|
|
138
|
+
// Local builds still win, so a dev checkout is unaffected.
|
|
139
|
+
const current = manifest ? readCurrentVersion() : null;
|
|
140
|
+
return [
|
|
141
|
+
process.env.GAMEPIGEON_OVERLAY_BIN,
|
|
142
|
+
manifest ? managedBinaryPath(manifest.version) : undefined,
|
|
143
|
+
join(root, "bin", `${platform}-${process.arch}`, executable),
|
|
144
|
+
join(root, "tauri", "target", "release", executable),
|
|
145
|
+
join(root, "tauri", "target", "debug", executable),
|
|
146
|
+
current && current !== manifest?.version ? managedBinaryPath(current) : undefined,
|
|
147
|
+
].filter((candidate): candidate is string => Boolean(candidate));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isLaunchable(candidate: string): boolean {
|
|
151
|
+
try {
|
|
152
|
+
accessSync(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
153
|
+
return true;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function resolveOverlayBinary(root = ROOT_DIR, manifest = readReleaseManifest(root)): string {
|
|
160
|
+
for (const candidate of overlayBinaryCandidates(root, manifest)) {
|
|
161
|
+
if (isLaunchable(candidate)) return candidate;
|
|
162
|
+
}
|
|
163
|
+
throw new Error(
|
|
164
|
+
"GamePigeon overlay binary was not found. Run `bun run build:overlay` or install a packaged release.",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function downloadReleaseBinary(manifest: ReleaseManifest, root: string): Promise<void> {
|
|
169
|
+
const destination = managedBinaryPath(manifest.version);
|
|
170
|
+
const staging = `${destination}.download`;
|
|
171
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
172
|
+
|
|
173
|
+
const asset = overlayReleaseAsset();
|
|
174
|
+
const slug = repoSlug(root);
|
|
175
|
+
let downloaded = false;
|
|
176
|
+
|
|
177
|
+
// `gh` authenticates against private repositories; plain fetch covers public ones.
|
|
178
|
+
try {
|
|
179
|
+
const gh = Bun.spawn(
|
|
180
|
+
["gh", "release", "download", manifest.tag, "--repo", slug, "--pattern", asset, "--output", staging, "--clobber"],
|
|
181
|
+
{ stdin: "ignore", stdout: "ignore", stderr: "ignore" },
|
|
182
|
+
);
|
|
183
|
+
downloaded = (await gh.exited) === 0;
|
|
184
|
+
} catch {
|
|
185
|
+
downloaded = false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!downloaded) {
|
|
189
|
+
const response = await fetch(`https://github.com/${slug}/releases/download/${manifest.tag}/${asset}`);
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
throw new Error(`Overlay download failed (${response.status}) for ${manifest.tag}/${asset}`);
|
|
192
|
+
}
|
|
193
|
+
writeFileSync(staging, new Uint8Array(await response.arrayBuffer()));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (process.platform !== "win32") chmodSync(staging, 0o755);
|
|
197
|
+
renameSync(staging, destination);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function pruneManagedBinaries(keepVersion: string): void {
|
|
201
|
+
try {
|
|
202
|
+
for (const entry of readdirSync(managedBinDir())) {
|
|
203
|
+
if (entry !== keepVersion && entry !== "current") {
|
|
204
|
+
rmSync(join(managedBinDir(), entry), { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
// Nothing to prune.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function readCurrentVersion(): string | null {
|
|
213
|
+
try {
|
|
214
|
+
return readFileSync(join(managedBinDir(), "current"), "utf8").trim() || null;
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Detached so a Ctrl+C that ends the session cannot take the quit down with it:
|
|
221
|
+
// the signal reaches the whole foreground process group, and this child must
|
|
222
|
+
// outlive that.
|
|
223
|
+
//
|
|
224
|
+
// `wait` is for the upgrade path, where the old instance has to be gone before
|
|
225
|
+
// the new binary launches. The SessionEnd hook must not wait: every millisecond
|
|
226
|
+
// it stays alive is a millisecond in which the exiting session can abort it and
|
|
227
|
+
// print a "Hook cancelled" failure at the user. The detached child finishes the
|
|
228
|
+
// quit on its own either way.
|
|
229
|
+
function quitOverlay(binary: string, wait = true): Promise<unknown> {
|
|
230
|
+
const child = Bun.spawn([binary, "--quit"], {
|
|
231
|
+
detached: true,
|
|
232
|
+
stdin: "ignore",
|
|
233
|
+
stdout: "ignore",
|
|
234
|
+
stderr: "ignore",
|
|
235
|
+
});
|
|
236
|
+
child.unref();
|
|
237
|
+
if (!wait) return Promise.resolve();
|
|
238
|
+
return Promise.race([child.exited, Bun.sleep(2000)]);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Downloads the packaged overlay binary named by overlay-release.json when the
|
|
243
|
+
* managed copy is missing or stale, quitting a running older instance so the
|
|
244
|
+
* next launch runs the fresh build. Failures fall back to whatever binary is
|
|
245
|
+
* already resolvable so an offline session keeps working.
|
|
246
|
+
*/
|
|
247
|
+
export async function ensureOverlayBinary(root = ROOT_DIR): Promise<void> {
|
|
248
|
+
const manifest = readReleaseManifest(root);
|
|
249
|
+
if (!manifest) return;
|
|
250
|
+
|
|
251
|
+
const destination = managedBinaryPath(manifest.version);
|
|
252
|
+
if (!isLaunchable(destination)) {
|
|
253
|
+
try {
|
|
254
|
+
await downloadReleaseBinary(manifest, root);
|
|
255
|
+
} catch {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const previous = readCurrentVersion();
|
|
261
|
+
if (previous && previous !== manifest.version) {
|
|
262
|
+
const stale = managedBinaryPath(previous);
|
|
263
|
+
if (isLaunchable(stale)) await quitOverlay(stale);
|
|
264
|
+
}
|
|
265
|
+
writeFileSync(join(managedBinDir(), "current"), manifest.version);
|
|
266
|
+
pruneManagedBinaries(manifest.version);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function applyOverlayLease(action: OverlayAction, leasePath = OVERLAY_LEASE_FILE): void {
|
|
270
|
+
if (action === "quit") setOverlayLease(false, leasePath);
|
|
271
|
+
else if (action === "show" || action === "enable") setOverlayLease(true, leasePath);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function overlaySpawnArgs(action: OverlayAction, options: LaunchOptions = {}): string[] {
|
|
275
|
+
const args = [`--${action}`];
|
|
276
|
+
if (options.gameId) args.push("--game", options.gameId);
|
|
277
|
+
if (options.variationId) args.push("--variation", options.variationId);
|
|
278
|
+
return args;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function launchOverlay(
|
|
282
|
+
action: OverlayAction,
|
|
283
|
+
root = ROOT_DIR,
|
|
284
|
+
options: LaunchOptions = {},
|
|
285
|
+
): Promise<void> {
|
|
286
|
+
// Not on the quit path: SessionEnd has to return immediately, and the queue is
|
|
287
|
+
// durable, so the next session's `--reset` flushes it from an async hook.
|
|
288
|
+
if (action !== "quit") await flushAnalyticsQueue();
|
|
289
|
+
if (
|
|
290
|
+
(action === "show" || action === "enable") &&
|
|
291
|
+
process.env.GAMEPIGEON_OVERLAY_DRY_RUN !== "1"
|
|
292
|
+
) {
|
|
293
|
+
// Refresh an existing session only — never open a browser here. This runs
|
|
294
|
+
// on every UserPromptSubmit hook, so triggering interactive sign-in from
|
|
295
|
+
// it would pop a new login tab on every prompt while signed out. The
|
|
296
|
+
// overlay's own gate screen owns interactive sign-in (explicit click/Enter).
|
|
297
|
+
await refreshSessionIfPresent();
|
|
298
|
+
}
|
|
299
|
+
// A new session re-enables the overlay; every other action honors the shutdown flag.
|
|
300
|
+
applyOverlayLease(action);
|
|
301
|
+
if (action === "quit") {
|
|
302
|
+
try {
|
|
303
|
+
rmSync(PAUSED_FLAG, { force: true });
|
|
304
|
+
} catch {}
|
|
305
|
+
}
|
|
306
|
+
if (action === "reset" || action === "enable") clearShutdown();
|
|
307
|
+
else if ((action === "show" || action === "pause") && isShutDown()) return;
|
|
308
|
+
|
|
309
|
+
if (action === "pause") {
|
|
310
|
+
if (!existsSync(OVERLAY_LEASE_FILE) || existsSync(PAUSED_FLAG)) return;
|
|
311
|
+
try {
|
|
312
|
+
writeFileSync(PAUSED_FLAG, "", { mode: 0o600 });
|
|
313
|
+
} catch {}
|
|
314
|
+
} else if (action === "show" || action === "enable") {
|
|
315
|
+
try {
|
|
316
|
+
rmSync(PAUSED_FLAG, { force: true });
|
|
317
|
+
} catch {}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// An explicit binary is a development override: never replace it with a download.
|
|
321
|
+
// Shutdown never downloads either — it only widens the window for the session's
|
|
322
|
+
// Ctrl+C to kill this hook mid-flight.
|
|
323
|
+
if (
|
|
324
|
+
action !== "quit" &&
|
|
325
|
+
process.env.GAMEPIGEON_OVERLAY_DRY_RUN !== "1" &&
|
|
326
|
+
!process.env.GAMEPIGEON_OVERLAY_BIN
|
|
327
|
+
) {
|
|
328
|
+
await ensureOverlayBinary(root);
|
|
329
|
+
}
|
|
330
|
+
// No resolvable binary means there is nothing running to shut down, so quit
|
|
331
|
+
// succeeds by doing nothing rather than failing the SessionEnd hook at the user.
|
|
332
|
+
let binary: string;
|
|
333
|
+
try {
|
|
334
|
+
binary = resolveOverlayBinary(root);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (action === "quit") return;
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
const spawnArgs = overlaySpawnArgs(action, options);
|
|
340
|
+
if (process.env.GAMEPIGEON_OVERLAY_DRY_RUN === "1") {
|
|
341
|
+
console.log(JSON.stringify([binary, ...spawnArgs]));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (action === "quit") {
|
|
345
|
+
await quitOverlay(binary, false);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const child = Bun.spawn([binary, ...spawnArgs], {
|
|
349
|
+
cwd: root,
|
|
350
|
+
detached: true,
|
|
351
|
+
env: {
|
|
352
|
+
...process.env,
|
|
353
|
+
GAMEPIGEON_OVERLAY_LEASE: OVERLAY_LEASE_FILE,
|
|
354
|
+
...(options.gameId ? { GAMEPIGEON_GAME: options.gameId } : {}),
|
|
355
|
+
...(options.variationId ? { GAMEPIGEON_VARIATION: options.variationId } : {}),
|
|
356
|
+
},
|
|
357
|
+
stdin: "ignore",
|
|
358
|
+
stdout: "ignore",
|
|
359
|
+
stderr: "ignore",
|
|
360
|
+
});
|
|
361
|
+
child.unref();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (import.meta.main) {
|
|
365
|
+
try {
|
|
366
|
+
const args = process.argv.slice(2);
|
|
367
|
+
const action = parseAction(args);
|
|
368
|
+
// Ctrl+C signals the whole foreground group, so the SessionEnd hook takes the
|
|
369
|
+
// same SIGINT that ended the session and dies before it can tear the overlay
|
|
370
|
+
// down. Hold the signals off; the work here is short and always terminates.
|
|
371
|
+
if (action === "quit") {
|
|
372
|
+
process.on("SIGINT", () => {});
|
|
373
|
+
process.on("SIGTERM", () => {});
|
|
374
|
+
process.on("SIGHUP", () => {});
|
|
375
|
+
}
|
|
376
|
+
const gameFlag = args.indexOf("--game");
|
|
377
|
+
const variationFlag = args.indexOf("--variation");
|
|
378
|
+
const gameId = gameFlag >= 0 ? args[gameFlag + 1] : undefined;
|
|
379
|
+
const variationId = variationFlag >= 0 ? args[variationFlag + 1] : undefined;
|
|
380
|
+
await launchOverlay(action, ROOT_DIR, { gameId, variationId });
|
|
381
|
+
} catch (error) {
|
|
382
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
383
|
+
process.exitCode = 1;
|
|
384
|
+
}
|
|
385
|
+
}
|