token-larper 1.10.0 → 1.10.1
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/bin/cli.js +48 -15
- package/package.json +1 -1
- package/src/ccusage.ts +18 -0
- package/src/logFile.ts +37 -0
- package/src/server.ts +37 -12
- package/src/startup.ts +1 -1
- package/src/tray.ts +2 -0
- package/src/updates.ts +33 -9
package/bin/cli.js
CHANGED
|
@@ -32,7 +32,7 @@ function dataDir() {
|
|
|
32
32
|
|
|
33
33
|
function bunPath() {
|
|
34
34
|
if (typeof Bun !== "undefined") return process.execPath;
|
|
35
|
-
if (spawnSync("bun", ["--version"], { stdio: "ignore" }).status === 0) return "bun";
|
|
35
|
+
if (spawnSync("bun", ["--version"], { stdio: "ignore", windowsHide: true }).status === 0) return "bun";
|
|
36
36
|
console.error("🔥 Token Larper requires Bun (v1.1+) to run.");
|
|
37
37
|
console.error("\nInstall Bun with one command:");
|
|
38
38
|
if (process.platform === "win32") {
|
|
@@ -85,26 +85,47 @@ async function foreground() {
|
|
|
85
85
|
process.exit(child.status ?? 0);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Start the server so it outlives this terminal. On Windows it goes through Start-Process:
|
|
90
|
+
* the server then gets a hidden console that everything it starts shares (a process with
|
|
91
|
+
* no console at all makes Windows open a window for each console program it runs), and it
|
|
92
|
+
* inherits no handles, so it can't end up holding an older copy's port when this CLI was
|
|
93
|
+
* itself started by one during an update. The server writes its own log (src/logFile.ts).
|
|
94
|
+
*/
|
|
95
|
+
function startServerProcess(bun, env) {
|
|
96
|
+
if (process.platform === "win32") {
|
|
97
|
+
const quote = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
98
|
+
const command = [
|
|
99
|
+
"Start-Process -WindowStyle Hidden",
|
|
100
|
+
`-FilePath ${quote(bun)}`,
|
|
101
|
+
`-ArgumentList ${quote(`"${serverScript}"`)}`,
|
|
102
|
+
`-WorkingDirectory ${quote(rootDir)}`,
|
|
103
|
+
].join(" ");
|
|
104
|
+
const hop = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {
|
|
105
|
+
stdio: "ignore",
|
|
106
|
+
windowsHide: true,
|
|
107
|
+
env,
|
|
108
|
+
});
|
|
109
|
+
return hop.status === 0;
|
|
110
|
+
}
|
|
111
|
+
spawn(bun, [serverScript], { cwd: rootDir, detached: true, stdio: "ignore", env }).unref();
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
88
115
|
async function background(open) {
|
|
89
116
|
const bun = bunPath();
|
|
90
117
|
const dir = dataDir();
|
|
91
118
|
mkdirSync(dir, { recursive: true });
|
|
92
119
|
const logFile = join(dir, "server.log");
|
|
93
|
-
|
|
120
|
+
closeSync(openSync(logFile, "w"));
|
|
94
121
|
|
|
95
122
|
// The server moves to the next free port if another program has this one, and takes
|
|
96
123
|
// over from an older Token Larper on the same port (see src/server.ts).
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
stdio: ["ignore", log, log],
|
|
102
|
-
env: { ...process.env, TOKEN_LARPER_NO_BROWSER: "1" },
|
|
124
|
+
const started = startServerProcess(bun, {
|
|
125
|
+
...process.env,
|
|
126
|
+
TOKEN_LARPER_NO_BROWSER: "1",
|
|
127
|
+
TOKEN_LARPER_LOG_FILE: logFile,
|
|
103
128
|
});
|
|
104
|
-
let exited = false;
|
|
105
|
-
child.on("exit", () => (exited = true));
|
|
106
|
-
child.unref();
|
|
107
|
-
closeSync(log);
|
|
108
129
|
|
|
109
130
|
const readLog = () => {
|
|
110
131
|
try {
|
|
@@ -117,10 +138,22 @@ async function background(open) {
|
|
|
117
138
|
// Ready once the server says where it is listening. A copy of the same version that
|
|
118
139
|
// is already running says so too ("already running at ..."), then the new one exits.
|
|
119
140
|
let url = null;
|
|
120
|
-
|
|
141
|
+
let waitingForPort = false;
|
|
142
|
+
for (let i = 0; started && i < 120 && !url; i++) {
|
|
121
143
|
await sleep(250);
|
|
122
|
-
|
|
123
|
-
|
|
144
|
+
const text = readLog();
|
|
145
|
+
url = text.match(/running at (http:\/\/localhost:\d+)/)?.[1] ?? null;
|
|
146
|
+
// Taking over from an older copy can take a minute while its port is released. This
|
|
147
|
+
// CLI may be holding that port itself (when the old copy started it for an update),
|
|
148
|
+
// so leave now instead of waiting.
|
|
149
|
+
waitingForPort = !url && text.includes("Waiting for port");
|
|
150
|
+
if (waitingForPort) break;
|
|
151
|
+
if (/Token Larper stopped:/.test(text)) break;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (waitingForPort) {
|
|
155
|
+
console.log(`🔥 Token Larper is replacing an older copy. It will be at http://localhost:${port} in a minute.`);
|
|
156
|
+
process.exit(0);
|
|
124
157
|
}
|
|
125
158
|
|
|
126
159
|
if (!url) {
|
package/package.json
CHANGED
package/src/ccusage.ts
CHANGED
|
@@ -337,6 +337,21 @@ export function estimateFrontierCost(params: {
|
|
|
337
337
|
);
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
+
// Runs still in progress. On Windows a child keeps the server's listening socket open, so
|
|
341
|
+
// shutdown stops them; otherwise a newer copy can't take over the port.
|
|
342
|
+
const activeRuns = new Set<ReturnType<typeof Bun.spawn>>();
|
|
343
|
+
|
|
344
|
+
export function stopCcusageRuns(): void {
|
|
345
|
+
for (const proc of activeRuns) {
|
|
346
|
+
try {
|
|
347
|
+
proc.kill();
|
|
348
|
+
} catch {
|
|
349
|
+
// Already exited.
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
activeRuns.clear();
|
|
353
|
+
}
|
|
354
|
+
|
|
340
355
|
async function runCcusageJson(subcommandArgs: string[], timeoutMs = 25000): Promise<any | null> {
|
|
341
356
|
const { cmd } = resolveCcusageBinary();
|
|
342
357
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -345,7 +360,10 @@ async function runCcusageJson(subcommandArgs: string[], timeoutMs = 25000): Prom
|
|
|
345
360
|
const proc = Bun.spawn([...cmd, ...subcommandArgs, "--json", "-O"], {
|
|
346
361
|
stdout: "pipe",
|
|
347
362
|
stderr: "ignore",
|
|
363
|
+
windowsHide: true,
|
|
348
364
|
});
|
|
365
|
+
activeRuns.add(proc);
|
|
366
|
+
void proc.exited.finally(() => activeRuns.delete(proc));
|
|
349
367
|
|
|
350
368
|
timer = setTimeout(() => {
|
|
351
369
|
try {
|
package/src/logFile.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { appendFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
// When the CLI starts the server in the background it has no terminal, so the server
|
|
4
|
+
// writes its own output to the file named in TOKEN_LARPER_LOG_FILE (the CLI reads it
|
|
5
|
+
// to learn the URL). Imported first by server.ts so nothing is missed.
|
|
6
|
+
|
|
7
|
+
const file = process.env.TOKEN_LARPER_LOG_FILE;
|
|
8
|
+
|
|
9
|
+
if (file) {
|
|
10
|
+
try {
|
|
11
|
+
writeFileSync(file, "");
|
|
12
|
+
} catch {
|
|
13
|
+
// Logging is best effort.
|
|
14
|
+
}
|
|
15
|
+
const write = (args: unknown[]) => {
|
|
16
|
+
try {
|
|
17
|
+
appendFileSync(file, args.map((a) => (a instanceof Error ? a.stack ?? a.message : String(a))).join(" ") + "\n");
|
|
18
|
+
} catch {
|
|
19
|
+
// Logging is best effort.
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
for (const level of ["log", "info", "warn", "error"] as const) {
|
|
23
|
+
const original = console[level].bind(console);
|
|
24
|
+
console[level] = (...args: unknown[]) => {
|
|
25
|
+
write(args);
|
|
26
|
+
original(...args);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
process.on("uncaughtException", (error) => {
|
|
30
|
+
write(["Token Larper stopped:", error]);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
});
|
|
33
|
+
process.on("unhandledRejection", (reason) => {
|
|
34
|
+
write(["Token Larper stopped:", reason]);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
});
|
|
37
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import "./logFile.ts";
|
|
1
2
|
import { join, resolve } from "node:path";
|
|
2
|
-
import { getDashboardData } from "./ccusage.ts";
|
|
3
|
+
import { getDashboardData, stopCcusageRuns } from "./ccusage.ts";
|
|
3
4
|
import { getStartupStatus, repointStartupIfStale, setStartupStatus } from "./startup.ts";
|
|
4
5
|
import { startSystemTray, stopSystemTray } from "./tray.ts";
|
|
5
6
|
import { buildTrayStatus, parseTheme, saveTheme } from "./trayStatus.ts";
|
|
@@ -60,7 +61,7 @@ function openDashboard(port: number) {
|
|
|
60
61
|
: process.platform === "darwin"
|
|
61
62
|
? `open http://localhost:${port}`
|
|
62
63
|
: `xdg-open http://localhost:${port}`;
|
|
63
|
-
import("node:child_process").then(({ exec }) => exec(cmd));
|
|
64
|
+
import("node:child_process").then(({ exec }) => exec(cmd, { windowsHide: true }));
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/** The version of a Token Larper already on the port, "0.0.0" for one too old to say, or null if none. */
|
|
@@ -82,6 +83,22 @@ async function runningVersion(port: number): Promise<string | null> {
|
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
async function waitForPort(port: number, timeoutMs: number): Promise<boolean> {
|
|
87
|
+
const deadline = Date.now() + timeoutMs;
|
|
88
|
+
let announced = false;
|
|
89
|
+
while (true) {
|
|
90
|
+
try {
|
|
91
|
+
Bun.serve({ port, hostname: "127.0.0.1", fetch: () => new Response() }).stop(true);
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
if (Date.now() > deadline) return false;
|
|
95
|
+
if (!announced) console.log(`Waiting for port ${port} to be released...`);
|
|
96
|
+
announced = true;
|
|
97
|
+
await Bun.sleep(500);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
85
102
|
// A newer copy (after an update, or a fresh npx run) replaces an older one on the
|
|
86
103
|
// same port. The same or a newer version already running just gets opened.
|
|
87
104
|
const running = await runningVersion(PORT);
|
|
@@ -98,6 +115,10 @@ if (running !== null && compareVersions(running, APP_VERSION) < 0) {
|
|
|
98
115
|
// It may exit before answering.
|
|
99
116
|
}
|
|
100
117
|
for (let i = 0; i < 40 && (await runningVersion(PORT)) !== null; i++) await Bun.sleep(250);
|
|
118
|
+
// The old copy can stop answering before its port is free: on Windows, processes it
|
|
119
|
+
// started hold the socket until they exit. Wait for the port rather than moving to the
|
|
120
|
+
// next one, which the open dashboard isn't watching.
|
|
121
|
+
if (!(await waitForPort(PORT, 90_000))) console.warn(`Port ${PORT} is still taken after 90 s.`);
|
|
101
122
|
} else if (running !== null) {
|
|
102
123
|
console.log(`🔥 Token Larper is already running at http://localhost:${PORT}`);
|
|
103
124
|
console.log(`🚀 Opening dashboard in your default browser...`);
|
|
@@ -231,8 +252,12 @@ function startServer(preferredPort: number) {
|
|
|
231
252
|
return Response.json({ error: "This copy runs from a git checkout. Update it with git pull." }, { status: 409 });
|
|
232
253
|
}
|
|
233
254
|
try {
|
|
234
|
-
|
|
235
|
-
|
|
255
|
+
const status = await checkForUpdates();
|
|
256
|
+
if (!status.updateAvailable || !status.latest) {
|
|
257
|
+
return Response.json({ error: status.error ?? "Already on the latest version." }, { status: 409 });
|
|
258
|
+
}
|
|
259
|
+
startUpdate(activePort, status.latest);
|
|
260
|
+
return Response.json({ ok: true, version: status.latest });
|
|
236
261
|
} catch (error) {
|
|
237
262
|
return Response.json({ error: error instanceof Error ? error.message : "Could not start the update" }, { status: 500 });
|
|
238
263
|
}
|
|
@@ -240,6 +265,7 @@ function startServer(preferredPort: number) {
|
|
|
240
265
|
|
|
241
266
|
if (url.pathname === "/api/shutdown" && req.method === "POST") {
|
|
242
267
|
setTimeout(() => {
|
|
268
|
+
stopCcusageRuns();
|
|
243
269
|
stopSystemTray();
|
|
244
270
|
server.stop(true);
|
|
245
271
|
process.exit(0);
|
|
@@ -266,13 +292,12 @@ const server = startServer(PORT);
|
|
|
266
292
|
startSystemTray(server.port ?? PORT);
|
|
267
293
|
void repointStartupIfStale();
|
|
268
294
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
process.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
});
|
|
295
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
296
|
+
process.on(signal, () => {
|
|
297
|
+
stopCcusageRuns();
|
|
298
|
+
stopSystemTray();
|
|
299
|
+
process.exit(0);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
277
302
|
|
|
278
303
|
console.log(`🔥 Token Larper running at http://localhost:${server.port} (System Tray Icon Active)`);
|
package/src/startup.ts
CHANGED
|
@@ -42,7 +42,7 @@ function saveSettings(settings: SavedSettings): void {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
async function runReg(args: string[]): Promise<{ code: number; output: string; error: string }> {
|
|
45
|
-
const proc = Bun.spawn(["reg.exe", ...args], { stdout: "pipe", stderr: "pipe" });
|
|
45
|
+
const proc = Bun.spawn(["reg.exe", ...args], { stdout: "pipe", stderr: "pipe", windowsHide: true });
|
|
46
46
|
const [output, error, code] = await Promise.all([
|
|
47
47
|
new Response(proc.stdout).text(),
|
|
48
48
|
new Response(proc.stderr).text(),
|
package/src/tray.ts
CHANGED
|
@@ -444,6 +444,7 @@ export async function startSystemTray(port: number): Promise<void> {
|
|
|
444
444
|
{
|
|
445
445
|
stdout: "pipe",
|
|
446
446
|
stderr: "ignore",
|
|
447
|
+
windowsHide: true,
|
|
447
448
|
}
|
|
448
449
|
);
|
|
449
450
|
const out = (await new Response(proc.stdout).text()).trim();
|
|
@@ -462,6 +463,7 @@ export function stopSystemTray(): void {
|
|
|
462
463
|
Bun.spawnSync(["taskkill.exe", "/PID", String(trayPid), "/F"], {
|
|
463
464
|
stdout: "ignore",
|
|
464
465
|
stderr: "ignore",
|
|
466
|
+
windowsHide: true,
|
|
465
467
|
});
|
|
466
468
|
} catch {
|
|
467
469
|
// ignore
|
package/src/updates.ts
CHANGED
|
@@ -48,15 +48,39 @@ export async function checkForUpdates(force = false): Promise<UpdateStatus> {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
51
|
+
* Run a program in the background, independent of this server.
|
|
52
|
+
*
|
|
53
|
+
* On Windows every child of this server inherits its listening socket, which keeps the
|
|
54
|
+
* port taken until the child exits. Start-Process launches the program through the shell
|
|
55
|
+
* instead, so it inherits nothing, and gives it a hidden console that anything it starts
|
|
56
|
+
* shares instead of opening windows of their own. Arguments must not contain double quotes.
|
|
53
57
|
*/
|
|
54
|
-
export function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
export function launchIndependent(program: string, args: string[], env: Record<string, string | undefined>): void {
|
|
59
|
+
if (process.platform === "win32") {
|
|
60
|
+
const quote = (value: string) => `'${value.replace(/'/g, "''")}'`;
|
|
61
|
+
const command = `Start-Process -WindowStyle Hidden -FilePath ${quote(program)} -ArgumentList ${args.map((a) => quote(`"${a}"`)).join(",")}`;
|
|
62
|
+
// Not detached: a PowerShell without any console fails to start the program.
|
|
63
|
+
spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {
|
|
64
|
+
stdio: "ignore",
|
|
65
|
+
windowsHide: true,
|
|
66
|
+
env,
|
|
67
|
+
}).unref();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
spawn(program, args, { detached: true, stdio: "ignore", env }).unref();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Start the given version in the background. The new copy sees this server running an
|
|
75
|
+
* older version, asks it to shut down, and takes over the same port (src/server.ts).
|
|
76
|
+
* The exact version is installed because `@latest` can resolve from bun's cached
|
|
77
|
+
* package list and bring back the version already running.
|
|
78
|
+
*/
|
|
79
|
+
export function startUpdate(port: number, version: string): void {
|
|
80
|
+
if (!/^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(version)) throw new Error(`Unexpected version "${version}"`);
|
|
81
|
+
launchIndependent(process.execPath, ["x", `${PACKAGE}@${version}`], {
|
|
82
|
+
...process.env,
|
|
83
|
+
PORT: String(port),
|
|
84
|
+
TOKEN_LARPER_NO_BROWSER: "1",
|
|
60
85
|
});
|
|
61
|
-
child.unref();
|
|
62
86
|
}
|