codehost 0.23.1 → 0.23.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/CHANGELOG.md +15 -0
- package/package.json +1 -1
- package/src/cli/commands/list.ts +7 -5
- package/src/cli/commands/stop.ts +5 -0
- package/src/cli/daemonize.ts +11 -6
- package/src/cli/fallback-daemon.ts +30 -11
- package/src/cli/index.ts +2 -0
- package/src/cli/pm2.ts +134 -0
- package/src/cli/run-server.ts +34 -6
- package/src/cli/tempenv.test.ts +32 -0
- package/src/cli/tempenv.ts +53 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## [0.23.3](https://github.com/snomiao/codehost/compare/v0.23.2...v0.23.3) (2026-06-20)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **cli:** expand an unexpanded %USERPROFILE% in TEMP/TMP on Windows ([d258ee8](https://github.com/snomiao/codehost/commit/d258ee80131880aa5bc0b2bd283474e65c289184))
|
|
7
|
+
* **cli:** manage the Windows daemon with pm2 instead of oxmgr ([ebade2e](https://github.com/snomiao/codehost/commit/ebade2eb67868a6ace899bba52c47d9c64934dbb))
|
|
8
|
+
|
|
9
|
+
## [0.23.2](https://github.com/snomiao/codehost/compare/v0.23.1...v0.23.2) (2026-06-16)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Performance Improvements
|
|
13
|
+
|
|
14
|
+
* **cli:** throttle meta pushes to the signaling room ([654ee10](https://github.com/snomiao/codehost/commit/654ee1088bc88f95ca6b57545fa477ccb77b994f))
|
|
15
|
+
|
|
1
16
|
## [0.23.1](https://github.com/snomiao/codehost/compare/v0.23.0...v0.23.1) (2026-06-16)
|
|
2
17
|
|
|
3
18
|
|
package/package.json
CHANGED
package/src/cli/commands/list.ts
CHANGED
|
@@ -9,15 +9,17 @@ export const listCommand: CommandModule = {
|
|
|
9
9
|
handler: async () => {
|
|
10
10
|
const detached = listFallbackDaemons();
|
|
11
11
|
if (detached.length) {
|
|
12
|
-
console.log("
|
|
12
|
+
console.log("Managed daemons (no oxmgr):");
|
|
13
13
|
for (const d of detached) {
|
|
14
|
-
|
|
14
|
+
const backend = d.pm2 ? "pm2" : d.task ? "task" : `pid ${d.pid}`;
|
|
15
|
+
console.log(` ${d.name} [${backend}] ${d.cwd} (log: ${d.log})`);
|
|
15
16
|
}
|
|
16
17
|
console.log("");
|
|
17
18
|
}
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
19
|
+
// Windows never uses oxmgr (it manages via pm2/schtasks), so don't poke it
|
|
20
|
+
// there. Elsewhere, only hit oxmgr if it's actually runnable — `hasOxmgr`
|
|
21
|
+
// doesn't self-heal, so a broken install won't re-download on every `list`.
|
|
22
|
+
if (process.platform !== "win32" && (await hasOxmgr())) {
|
|
21
23
|
const shown = await listDaemons();
|
|
22
24
|
// listDaemons returns the count of codehost daemons (>=0) or -1 if oxmgr
|
|
23
25
|
// is unusable; only the latter is an error exit. It prints its own
|
package/src/cli/commands/stop.ts
CHANGED
|
@@ -23,6 +23,11 @@ export const stopCommand: CommandModule<{}, StopArgs> = {
|
|
|
23
23
|
console.log(`[codehost] stopped ${full}`);
|
|
24
24
|
process.exit(0);
|
|
25
25
|
}
|
|
26
|
+
// Windows manages via pm2/schtasks only — don't fall through to oxmgr there.
|
|
27
|
+
if (process.platform === "win32") {
|
|
28
|
+
console.error(`[codehost] no daemon named ${full}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
26
31
|
process.exit(await stopDaemon(argv.name));
|
|
27
32
|
},
|
|
28
33
|
};
|
package/src/cli/daemonize.ts
CHANGED
|
@@ -35,9 +35,11 @@ export interface ServeDaemonResult {
|
|
|
35
35
|
/**
|
|
36
36
|
* Launch a foreground `codehost serve` (without -d) so it survives the shell and
|
|
37
37
|
* restarts on failure. Prefers oxmgr (which also adds login auto-start); when
|
|
38
|
-
* oxmgr can't run here
|
|
39
|
-
*
|
|
40
|
-
*
|
|
38
|
+
* oxmgr can't run here it falls back to a managed daemon instead of failing —
|
|
39
|
+
* pm2 on Windows (hidden, restart + logon resurrect), a detached supervisor on
|
|
40
|
+
* POSIX. Windows skips oxmgr entirely (its native binary tends to hang/fail
|
|
41
|
+
* there) and goes straight to pm2. `CODEHOST_NO_OXMGR=1` forces the fallback on
|
|
42
|
+
* any platform. Shared by `serve -d` and `setup`.
|
|
41
43
|
*/
|
|
42
44
|
export async function launchServeDaemon(opts: ServeDaemonOptions): Promise<ServeDaemonResult> {
|
|
43
45
|
// Upgrade the global install (if that's how we're running) before spawning, so
|
|
@@ -49,7 +51,9 @@ export async function launchServeDaemon(opts: ServeDaemonOptions): Promise<Serve
|
|
|
49
51
|
const name = daemonName(label);
|
|
50
52
|
const argv = buildForegroundArgv(opts);
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
// Windows skips oxmgr (its native binary hangs/fails there) and uses pm2.
|
|
55
|
+
const useOxmgr = process.env.CODEHOST_NO_OXMGR !== "1" && process.platform !== "win32";
|
|
56
|
+
if (useOxmgr) {
|
|
53
57
|
console.log(`[codehost] starting daemon "${name}" via oxmgr`);
|
|
54
58
|
// startDaemon attempts to self-heal oxmgr once; false means it's unusable here.
|
|
55
59
|
const ok = await startDaemon({ name, command: argv.map(quote).join(" "), cwd: opts.dir });
|
|
@@ -57,12 +61,13 @@ export async function launchServeDaemon(opts: ServeDaemonOptions): Promise<Serve
|
|
|
57
61
|
console.log(`[codehost] daemon started. View: codehost list · Stop: codehost stop ${name}`);
|
|
58
62
|
return { ok: true, name };
|
|
59
63
|
}
|
|
60
|
-
console.warn("[codehost] oxmgr unavailable — falling back to a
|
|
64
|
+
console.warn("[codehost] oxmgr unavailable — falling back to a managed daemon.");
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
console.log(`[codehost] starting daemon "${name}"…`);
|
|
63
68
|
const ok = startFallbackDaemon({ name, argv, cwd: opts.dir });
|
|
64
69
|
if (ok) {
|
|
65
|
-
console.log(`[codehost]
|
|
70
|
+
console.log(`[codehost] daemon "${name}" started. View: codehost list · Stop: codehost stop ${name}`);
|
|
66
71
|
} else {
|
|
67
72
|
console.error("[codehost] failed to start a detached daemon.");
|
|
68
73
|
}
|
|
@@ -4,15 +4,17 @@ import { mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { killProcessTree } from "./proc";
|
|
7
|
+
import { hasPm2, pm2Delete, pm2Online, pm2Start } from "./pm2";
|
|
7
8
|
|
|
8
9
|
// A non-oxmgr daemon manager: keeps a server running across the shell without
|
|
9
|
-
// depending on oxmgr's flaky native binary.
|
|
10
|
-
// - Windows:
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
10
|
+
// depending on oxmgr's flaky native binary. Backends, by platform:
|
|
11
|
+
// - Windows: pm2 (preferred) — restart-on-failure + logon resurrect, launched
|
|
12
|
+
// hidden so no console window appears (see pm2.ts). If pm2 isn't installed we
|
|
13
|
+
// fall back to a Scheduled Task (`schtasks`), which is always available and
|
|
14
|
+
// also auto-starts at logon. Unref'd children don't survive their launcher on
|
|
15
|
+
// Windows, so one of these managers is required.
|
|
16
|
+
// - POSIX: a detached, unref'd supervisor child (reparents to init), running the
|
|
17
|
+
// `__supervise` restart loop and reading its serve argv from this registry.
|
|
16
18
|
|
|
17
19
|
const ROOT = join(homedir(), ".codehost");
|
|
18
20
|
const REGISTRY = join(ROOT, "daemons.json");
|
|
@@ -29,7 +31,9 @@ export interface FallbackDaemon {
|
|
|
29
31
|
startedAt: number;
|
|
30
32
|
/** POSIX: supervisor process pid. */
|
|
31
33
|
pid?: number;
|
|
32
|
-
/** Windows:
|
|
34
|
+
/** Windows (pm2 backend): pm2 process name (equals `name`). */
|
|
35
|
+
pm2?: string;
|
|
36
|
+
/** Windows (schtasks backend): scheduled-task name (equals `name`). */
|
|
33
37
|
task?: string;
|
|
34
38
|
/** Pid of the serve child the supervisor last spawned (for tree-kill on stop). */
|
|
35
39
|
servePid?: number;
|
|
@@ -91,7 +95,18 @@ export function startFallbackDaemon(opts: { name: string; argv: string[]; cwd: s
|
|
|
91
95
|
const log = join(LOG_DIR, `${opts.name}.log`);
|
|
92
96
|
upsert({ name: opts.name, cwd: opts.cwd, argv: opts.argv, log, startedAt: Date.now() });
|
|
93
97
|
|
|
94
|
-
|
|
98
|
+
if (!isWindows) return startUnixSupervisor(opts.name, log);
|
|
99
|
+
// Windows: pm2 if available (hidden, restart + logon resurrect), else schtasks.
|
|
100
|
+
if (hasPm2() && startWindowsPm2(opts.name, opts.cwd, opts.argv, log)) return true;
|
|
101
|
+
return startWindowsTask(opts.name, log);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Windows pm2 backend: pm2 runs the serve argv directly (no supervisor loop —
|
|
105
|
+
* pm2 owns restart-on-failure), launched hidden. argv[0] is the bun runtime. */
|
|
106
|
+
function startWindowsPm2(name: string, cwd: string, argv: string[], log: string): boolean {
|
|
107
|
+
const ok = pm2Start({ name, cwd, script: argv[0], args: argv.slice(1), log });
|
|
108
|
+
if (ok) patch(name, { pm2: name });
|
|
109
|
+
return ok;
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
/** POSIX: detached, unref'd supervisor child that survives as an orphan. */
|
|
@@ -130,7 +145,9 @@ function startWindowsTask(name: string, log: string): boolean {
|
|
|
130
145
|
* long as their task is still registered (a Ready task is a valid auto-start). */
|
|
131
146
|
export function listFallbackDaemons(): FallbackDaemon[] {
|
|
132
147
|
const list = readRegistry();
|
|
133
|
-
const alive = list.filter((d) =>
|
|
148
|
+
const alive = list.filter((d) =>
|
|
149
|
+
d.pm2 ? pm2Online(d.pm2) : d.task ? taskExists(d.task) : d.pid != null && isAlive(d.pid),
|
|
150
|
+
);
|
|
134
151
|
if (alive.length !== list.length) writeRegistry(alive);
|
|
135
152
|
return alive;
|
|
136
153
|
}
|
|
@@ -140,7 +157,9 @@ export function stopFallbackDaemon(name: string): boolean {
|
|
|
140
157
|
const list = readRegistry();
|
|
141
158
|
const hit = list.find((d) => d.name === name);
|
|
142
159
|
if (!hit) return false;
|
|
143
|
-
if (hit.
|
|
160
|
+
if (hit.pm2) {
|
|
161
|
+
pm2Delete(hit.pm2); // pm2 stops + removes the managed process (incl. its child tree)
|
|
162
|
+
} else if (hit.task) {
|
|
144
163
|
schtasks(["/end", "/tn", hit.task]);
|
|
145
164
|
schtasks(["/delete", "/tn", hit.task, "/f"]);
|
|
146
165
|
// /end hard-terminates the task's top process; kill the serve subtree (VS
|
package/src/cli/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import "./tempenv"; // must be first: repair an unexpanded TEMP/TMP before any
|
|
3
|
+
// native dep (node-datachannel/bun-pty) or child process reads it.
|
|
2
4
|
import yargs from "yargs";
|
|
3
5
|
import { hideBin } from "yargs/helpers";
|
|
4
6
|
import { setupCommand } from "./commands/setup";
|
package/src/cli/pm2.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { type SpawnSyncOptions, spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
// Windows daemon backend built on pm2 (https://pm2.keymetrics.io/). oxmgr's
|
|
8
|
+
// native binary tends to hang/fail on Windows, so `serve -d` / `setup` manage the
|
|
9
|
+
// foreground server with pm2 instead — it's a battle-tested, restart-on-failure
|
|
10
|
+
// process manager the user likely already runs.
|
|
11
|
+
//
|
|
12
|
+
// Two Windows-specific wrinkles this module handles:
|
|
13
|
+
// 1. pm2 is invoked via its resolved JS entry under the current runtime (bun) —
|
|
14
|
+
// never the bare `pm2` command, whose PATH shim has no .exe/.cmd extension
|
|
15
|
+
// so spawnSync can't resolve it (same trap as oxmgr).
|
|
16
|
+
// 2. Starting pm2 (which forks its God daemon + the managed child) is routed
|
|
17
|
+
// through a hidden VBScript launcher so no console window ever flashes. A
|
|
18
|
+
// plain spawn with windowsHide isn't enough — the detached daemon and its
|
|
19
|
+
// fork would still pop their own windows.
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const ROOT = join(homedir(), ".codehost");
|
|
23
|
+
const TASK_DIR = join(ROOT, "tasks");
|
|
24
|
+
|
|
25
|
+
/** Resolve pm2's CLI entry from a local dep or the bun/npm global install. */
|
|
26
|
+
export function pm2Entry(): string | null {
|
|
27
|
+
const attempts: Array<() => string> = [
|
|
28
|
+
() => require.resolve("pm2/bin/pm2"),
|
|
29
|
+
() => createRequire(join(bunGlobalRoot(), "_")).resolve("pm2/bin/pm2"),
|
|
30
|
+
];
|
|
31
|
+
for (const attempt of attempts) {
|
|
32
|
+
try {
|
|
33
|
+
return attempt();
|
|
34
|
+
} catch {
|
|
35
|
+
// try next
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where `bun add -g` drops global packages. */
|
|
42
|
+
function bunGlobalRoot(): string {
|
|
43
|
+
const base = process.env.BUN_INSTALL ?? join(homedir(), ".bun");
|
|
44
|
+
return join(base, "install", "global", "node_modules");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True if a runnable pm2 is resolvable (so the Windows fallback can use it). */
|
|
48
|
+
export function hasPm2(): boolean {
|
|
49
|
+
return pm2Entry() != null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Run the pm2 CLI under the current runtime (bun). Window stays hidden. */
|
|
53
|
+
function pm2(args: string[], opts: SpawnSyncOptions = {}) {
|
|
54
|
+
const entry = pm2Entry();
|
|
55
|
+
if (!entry) return { status: 1, stdout: "", stderr: "" } as const;
|
|
56
|
+
return spawnSync(process.execPath, [entry, ...args], { encoding: "utf8", windowsHide: true, ...opts });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Start (replacing any same-named instance) `script args` under pm2 with logs
|
|
61
|
+
* redirected to `log`, launched hidden so no window appears, and persist the
|
|
62
|
+
* process list (`pm2 save`). Returns true once pm2 reports the process online.
|
|
63
|
+
*
|
|
64
|
+
* Reboot auto-start is intentionally left to pm2's own startup integration
|
|
65
|
+
* (`pm2 save` records the list for `pm2 resurrect`): the Startup folder is often
|
|
66
|
+
* blocked by Controlled Folder Access and `schtasks /create` needs elevation, so
|
|
67
|
+
* auto-installing a logon hook here can't be done reliably.
|
|
68
|
+
*/
|
|
69
|
+
export function pm2Start(opts: { name: string; cwd: string; script: string; args: string[]; log: string }): boolean {
|
|
70
|
+
const entry = pm2Entry();
|
|
71
|
+
if (!entry) return false;
|
|
72
|
+
|
|
73
|
+
// `--interpreter none` execs the script (bun.exe) directly with the args after
|
|
74
|
+
// `--`, instead of trying to run it through node.
|
|
75
|
+
const start = [
|
|
76
|
+
process.execPath, entry, "start", opts.script,
|
|
77
|
+
"--name", opts.name,
|
|
78
|
+
"--cwd", opts.cwd,
|
|
79
|
+
"--interpreter", "none",
|
|
80
|
+
"--output", opts.log,
|
|
81
|
+
"--error", opts.log,
|
|
82
|
+
"--restart-delay", "2000",
|
|
83
|
+
"--", ...opts.args,
|
|
84
|
+
];
|
|
85
|
+
const save = [process.execPath, entry, "save", "--force"];
|
|
86
|
+
runHidden([start, save], opts.name);
|
|
87
|
+
|
|
88
|
+
// pm2 start is synchronous (the hidden launcher waits), so liveness is the
|
|
89
|
+
// source of truth — more reliable than the launcher's exit code, which is the
|
|
90
|
+
// .cmd's last line.
|
|
91
|
+
return pm2Online(opts.name);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True if pm2 currently has `name` online. */
|
|
95
|
+
export function pm2Online(name: string): boolean {
|
|
96
|
+
const r = pm2(["jlist"]);
|
|
97
|
+
if (r.status !== 0 || !r.stdout) return false;
|
|
98
|
+
try {
|
|
99
|
+
const list = JSON.parse(String(r.stdout)) as Array<{ name: string; pm2_env?: { status?: string } }>;
|
|
100
|
+
return list.some((p) => p.name === name && p.pm2_env?.status === "online");
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Stop + deregister a pm2-managed daemon and re-save the list. */
|
|
107
|
+
export function pm2Delete(name: string): void {
|
|
108
|
+
pm2(["delete", name], { stdio: "ignore" });
|
|
109
|
+
pm2(["save", "--force"], { stdio: "ignore" });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Run one or more command argv's hidden, in order, waiting for completion. Writes
|
|
114
|
+
* a launcher .cmd (so we get normal Windows quoting, not VBS string escaping) and
|
|
115
|
+
* a .vbs that runs it with window style 0 via wscript (no console host at all).
|
|
116
|
+
*/
|
|
117
|
+
function runHidden(commands: string[][], name: string): void {
|
|
118
|
+
mkdirSync(TASK_DIR, { recursive: true });
|
|
119
|
+
const cmdPath = join(TASK_DIR, `${name}.pm2.cmd`);
|
|
120
|
+
const vbsPath = join(TASK_DIR, `${name}.pm2.vbs`);
|
|
121
|
+
const body = ["@echo off", ...commands.map(quoteCmd)].join("\r\n") + "\r\n";
|
|
122
|
+
writeFileSync(cmdPath, body);
|
|
123
|
+
// Hidden launcher: wscript (no console host) runs the .cmd with window style 0
|
|
124
|
+
// and waits. The path is wrapped in literal quotes — in VBS source a `"` inside
|
|
125
|
+
// a string is written `""`, so a quoted path becomes `"""<path>"""`.
|
|
126
|
+
writeFileSync(vbsPath, `Set sh = CreateObject("WScript.Shell")\r\nsh.Run """${cmdPath}""", 0, True\r\n`);
|
|
127
|
+
spawnSync("wscript", ["//B", "//Nologo", vbsPath], { windowsHide: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Quote an argv into a single cmd.exe command line. Paths/tokens here never
|
|
131
|
+
* contain double quotes, so simple space-aware quoting is sufficient. */
|
|
132
|
+
function quoteCmd(argv: string[]): string {
|
|
133
|
+
return argv.map((a) => (/[\s&|<>^()]/.test(a) ? `"${a}"` : a)).join(" ");
|
|
134
|
+
}
|
package/src/cli/run-server.ts
CHANGED
|
@@ -56,6 +56,13 @@ export interface RunServerOptions {
|
|
|
56
56
|
/** How often a daemon re-enumerates its workspaces (manual clones show up). */
|
|
57
57
|
const META_REFRESH_MS = 60_000;
|
|
58
58
|
|
|
59
|
+
/** Floor between meta pushes to the room. `serve` re-evaluates meta every ~3s so
|
|
60
|
+
* live agent titles stay fresh, but each push is a billable DO request that
|
|
61
|
+
* fans out to every peer — so coalesce bursts (a churning agent title) into at
|
|
62
|
+
* most one push per this interval. The first change in a quiet period goes out
|
|
63
|
+
* immediately; rapid follow-ups ride a single trailing push. */
|
|
64
|
+
const MIN_META_PUSH_MS = 15_000;
|
|
65
|
+
|
|
59
66
|
/**
|
|
60
67
|
* Foreground server loop shared by `serve`, `dev`, and `expose`: register in the
|
|
61
68
|
* signaling room with the given meta and bridge each client's data channel to a
|
|
@@ -113,15 +120,36 @@ export async function runServer(opts: RunServerOptions): Promise<never> {
|
|
|
113
120
|
onSignal: (from, data) => rtc.handleSignal(from, data),
|
|
114
121
|
});
|
|
115
122
|
|
|
116
|
-
// Re-advertise when the workspace set changes (provision, manual clone)
|
|
117
|
-
|
|
123
|
+
// Re-advertise when the workspace set changes (provision, manual clone),
|
|
124
|
+
// throttled so a burst of changes is at most one room push per MIN_META_PUSH_MS.
|
|
125
|
+
let sentMeta = JSON.stringify(opts.meta); // last meta the room actually has
|
|
126
|
+
let lastPushAt = 0; // ms of the last updateMeta send (0 = none since connect)
|
|
127
|
+
let pushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
128
|
+
const sendMeta = (meta: PeerMeta) => {
|
|
129
|
+
const s = JSON.stringify(meta);
|
|
130
|
+
if (s === sentMeta) return; // nothing new since the last push
|
|
131
|
+
sentMeta = s;
|
|
132
|
+
lastPushAt = Date.now();
|
|
133
|
+
client.updateMeta(meta);
|
|
134
|
+
};
|
|
118
135
|
const refreshMeta = () => {
|
|
119
136
|
if (!opts.refreshMeta) return;
|
|
120
137
|
const meta = opts.refreshMeta();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
138
|
+
if (JSON.stringify(meta) === sentMeta) return; // unchanged — nothing to do
|
|
139
|
+
const wait = MIN_META_PUSH_MS - (Date.now() - lastPushAt);
|
|
140
|
+
if (wait <= 0) {
|
|
141
|
+
// Cooldown elapsed: push the leading change now, dropping any pending one.
|
|
142
|
+
if (pushTimer) clearTimeout(pushTimer);
|
|
143
|
+
pushTimer = null;
|
|
144
|
+
sendMeta(meta);
|
|
145
|
+
} else if (!pushTimer) {
|
|
146
|
+
// Within the cooldown: schedule one trailing push that re-reads the
|
|
147
|
+
// freshest meta when it fires, so coalesced changes all ship at once.
|
|
148
|
+
pushTimer = setTimeout(() => {
|
|
149
|
+
pushTimer = null;
|
|
150
|
+
sendMeta(opts.refreshMeta!());
|
|
151
|
+
}, wait);
|
|
152
|
+
}
|
|
125
153
|
};
|
|
126
154
|
const provision: ProvisionDeps | undefined = opts.provision
|
|
127
155
|
? { ...opts.provision, onProvisioned: refreshMeta }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { expandWinVars, normalizeTempEnv } from "./tempenv";
|
|
3
|
+
|
|
4
|
+
describe("expandWinVars", () => {
|
|
5
|
+
test("expands a known %VAR% from the given env", () => {
|
|
6
|
+
expect(expandWinVars("%USERPROFILE%\\AppData\\Local\\Temp", { USERPROFILE: "C:\\Users\\x" })).toBe(
|
|
7
|
+
"C:\\Users\\x\\AppData\\Local\\Temp",
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("leaves unknown vars untouched", () => {
|
|
12
|
+
expect(expandWinVars("%NOPE%\\a", {})).toBe("%NOPE%\\a");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("passes through a path with no vars", () => {
|
|
16
|
+
expect(expandWinVars("C:\\Users\\x\\AppData\\Local\\Temp", {})).toBe("C:\\Users\\x\\AppData\\Local\\Temp");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("normalizeTempEnv", () => {
|
|
21
|
+
test("rewrites a literal %USERPROFILE% TEMP to a real path (win32)", () => {
|
|
22
|
+
if (process.platform !== "win32") return; // no-op off Windows
|
|
23
|
+
const saved = process.env.TEMP;
|
|
24
|
+
try {
|
|
25
|
+
process.env.TEMP = "%USERPROFILE%\\AppData\\Local\\Temp";
|
|
26
|
+
normalizeTempEnv();
|
|
27
|
+
expect(process.env.TEMP).not.toContain("%");
|
|
28
|
+
} finally {
|
|
29
|
+
process.env.TEMP = saved;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Importing this module repairs a broken TEMP/TMP as a side effect, as early as
|
|
6
|
+
// possible — before any native dep (node-datachannel/bun-pty) or child process
|
|
7
|
+
// reads it. See normalizeTempEnv for the why.
|
|
8
|
+
|
|
9
|
+
/** Expand `%VAR%` references in `value` against `env`; unknown vars are left
|
|
10
|
+
* as-is. (`%VAR%` is cmd.exe syntax — bun/node/bash never expand it.) */
|
|
11
|
+
export function expandWinVars(value: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
12
|
+
return value.replace(/%([^%]+)%/g, (whole, name) => env[name] ?? whole);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Repair an unexpanded TEMP/TMP on Windows. Windows stores them in the registry
|
|
17
|
+
* as REG_EXPAND_SZ — literally `%USERPROFILE%\AppData\Local\Temp` — and some
|
|
18
|
+
* launch contexts (services, scheduled tasks, a parent started by one) pass that
|
|
19
|
+
* string through without expanding it. bun/node/bash don't expand `%VAR%`, so
|
|
20
|
+
* every temp write then resolves *relative to cwd*, littering the working dir
|
|
21
|
+
* with a `%USERPROFILE%/AppData/Local/Temp` tree (we hit ~1GB of this inside
|
|
22
|
+
* provisioned worktrees). Expand it in-process so the server, its native deps,
|
|
23
|
+
* and every spawned child get a real temp dir. Idempotent; no-op off Windows.
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeTempEnv(): void {
|
|
26
|
+
if (process.platform !== "win32") return;
|
|
27
|
+
for (const key of ["TEMP", "TMP"]) {
|
|
28
|
+
const raw = process.env[key];
|
|
29
|
+
if (!raw || !raw.includes("%")) continue; // already a real path
|
|
30
|
+
const expanded = expandWinVars(raw);
|
|
31
|
+
// If any var was still unresolved, or the dir can't be created, fall back to
|
|
32
|
+
// the canonical per-user temp under the (always-expanded) home dir.
|
|
33
|
+
const fixed = expanded.includes("%") || !ensureDir(expanded) ? fallbackTemp() : expanded;
|
|
34
|
+
process.env[key] = fixed;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fallbackTemp(): string {
|
|
39
|
+
const dir = join(homedir(), "AppData", "Local", "Temp");
|
|
40
|
+
ensureDir(dir);
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ensureDir(path: string): boolean {
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(path, { recursive: true });
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
normalizeTempEnv();
|