codehost 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/cli/commands/supervise.ts +6 -10
- package/src/cli/fallback-daemon.ts +105 -38
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.9.0](https://github.com/snomiao/codehost/compare/v0.8.0...v0.9.0) (2026-06-08)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* Windows fallback daemon via Scheduled Task (persists + auto-starts at logon) ([cd72bb7](https://github.com/snomiao/codehost/commit/cd72bb7954d05ddf093337b627bcf6b9e9003c53))
|
|
7
|
+
|
|
1
8
|
# [0.8.0](https://github.com/snomiao/codehost/compare/v0.7.1...v0.8.0) (2026-06-08)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
|
@@ -3,22 +3,18 @@ import { runSupervisor } from "../fallback-daemon";
|
|
|
3
3
|
|
|
4
4
|
interface SuperviseArgs {
|
|
5
5
|
name: string;
|
|
6
|
-
argv: string;
|
|
7
6
|
}
|
|
8
7
|
|
|
9
|
-
// Hidden internal command: the supervisor process behind a
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// Hidden internal command: the supervisor process behind a fallback daemon (see
|
|
9
|
+
// fallback-daemon.ts). Not meant to be run by hand — `serve -d` / `setup` launch
|
|
10
|
+
// it (detached child on POSIX, scheduled task on Windows) when oxmgr isn't
|
|
11
|
+
// available. It reads its serve argv from the registry by name.
|
|
12
12
|
export const superviseCommand: CommandModule<{}, SuperviseArgs> = {
|
|
13
13
|
command: "__supervise",
|
|
14
14
|
describe: false, // hidden from help
|
|
15
|
-
builder: (y) =>
|
|
16
|
-
y
|
|
17
|
-
.option("name", { type: "string", demandOption: true })
|
|
18
|
-
.option("argv", { type: "string", demandOption: true, describe: "JSON-encoded serve argv" }) as any,
|
|
15
|
+
builder: (y) => y.option("name", { type: "string", demandOption: true }) as any,
|
|
19
16
|
handler: async (a) => {
|
|
20
|
-
const
|
|
21
|
-
const code = await runSupervisor(a.name, argv);
|
|
17
|
+
const code = await runSupervisor(a.name);
|
|
22
18
|
process.exit(code);
|
|
23
19
|
},
|
|
24
20
|
};
|
|
@@ -1,30 +1,38 @@
|
|
|
1
1
|
import { spawn, type Subprocess } from "bun";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { dirname, join } from "node:path";
|
|
6
|
+
import { killProcessTree } from "./proc";
|
|
5
7
|
|
|
6
|
-
// A
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
8
|
+
// A non-oxmgr daemon manager: keeps a server running across the shell without
|
|
9
|
+
// depending on oxmgr's flaky native binary. Two backends:
|
|
10
|
+
// - Windows: a Scheduled Task (`schtasks`) — built in, always runnable, and it
|
|
11
|
+
// also auto-starts the server at logon. Unref'd child processes don't
|
|
12
|
+
// survive their launcher exiting on Windows, so a task is required.
|
|
13
|
+
// - POSIX: a detached, unref'd supervisor child (reparents to init).
|
|
14
|
+
// Both run the same `__supervise` loop (restart-on-failure) and read their serve
|
|
15
|
+
// argv from this registry by name, so the task/launch command stays short.
|
|
14
16
|
|
|
15
17
|
const ROOT = join(homedir(), ".codehost");
|
|
16
18
|
const REGISTRY = join(ROOT, "daemons.json");
|
|
17
19
|
const LOG_DIR = join(ROOT, "logs");
|
|
20
|
+
const TASK_DIR = join(ROOT, "tasks");
|
|
21
|
+
const isWindows = process.platform === "win32";
|
|
18
22
|
|
|
19
23
|
export interface FallbackDaemon {
|
|
20
24
|
name: string;
|
|
21
|
-
/** Supervisor process pid. */
|
|
22
|
-
pid: number;
|
|
23
25
|
cwd: string;
|
|
24
26
|
/** The foreground serve argv the supervisor (re)spawns. */
|
|
25
27
|
argv: string[];
|
|
26
28
|
log: string;
|
|
27
29
|
startedAt: number;
|
|
30
|
+
/** POSIX: supervisor process pid. */
|
|
31
|
+
pid?: number;
|
|
32
|
+
/** Windows: scheduled-task name (equals `name`). */
|
|
33
|
+
task?: string;
|
|
34
|
+
/** Pid of the serve child the supervisor last spawned (for tree-kill on stop). */
|
|
35
|
+
servePid?: number;
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
function readRegistry(): FallbackDaemon[] {
|
|
@@ -40,6 +48,18 @@ function writeRegistry(list: FallbackDaemon[]): void {
|
|
|
40
48
|
writeFileSync(REGISTRY, JSON.stringify(list, null, 2));
|
|
41
49
|
}
|
|
42
50
|
|
|
51
|
+
function upsert(entry: FallbackDaemon): void {
|
|
52
|
+
writeRegistry([...readRegistry().filter((d) => d.name !== entry.name), entry]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function patch(name: string, fields: Partial<FallbackDaemon>): void {
|
|
56
|
+
const list = readRegistry();
|
|
57
|
+
const hit = list.find((d) => d.name === name);
|
|
58
|
+
if (!hit) return;
|
|
59
|
+
Object.assign(hit, fields);
|
|
60
|
+
writeRegistry(list);
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
/** True if a pid is currently alive (signal 0 probe). */
|
|
44
64
|
export function isAlive(pid: number): boolean {
|
|
45
65
|
try {
|
|
@@ -50,67 +70,113 @@ export function isAlive(pid: number): boolean {
|
|
|
50
70
|
}
|
|
51
71
|
}
|
|
52
72
|
|
|
73
|
+
function schtasks(args: string[]): number {
|
|
74
|
+
return spawnSync("schtasks", args, { stdio: "ignore" }).status ?? 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True if a Windows scheduled task with this name is registered. */
|
|
78
|
+
function taskExists(name: string): boolean {
|
|
79
|
+
return spawnSync("schtasks", ["/query", "/tn", name], { stdio: "ignore" }).status === 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
53
82
|
/**
|
|
54
|
-
* Start (replacing any same-named instance) a
|
|
55
|
-
*
|
|
56
|
-
*
|
|
83
|
+
* Start (replacing any same-named instance) a daemon that runs `argv` from
|
|
84
|
+
* `cwd`. Returns false if it couldn't be started. The registry entry is written
|
|
85
|
+
* first so the supervisor can read its argv by name.
|
|
57
86
|
*/
|
|
58
87
|
export function startFallbackDaemon(opts: { name: string; argv: string[]; cwd: string }): boolean {
|
|
59
|
-
stopFallbackDaemon(opts.name); // replace any previous instance
|
|
88
|
+
stopFallbackDaemon(opts.name); // replace any previous instance
|
|
60
89
|
|
|
61
90
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
62
91
|
const log = join(LOG_DIR, `${opts.name}.log`);
|
|
92
|
+
upsert({ name: opts.name, cwd: opts.cwd, argv: opts.argv, log, startedAt: Date.now() });
|
|
93
|
+
|
|
94
|
+
return isWindows ? startWindowsTask(opts.name, log) : startUnixSupervisor(opts.name, log);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** POSIX: detached, unref'd supervisor child that survives as an orphan. */
|
|
98
|
+
function startUnixSupervisor(name: string, log: string): boolean {
|
|
63
99
|
const fd = openSync(log, "a");
|
|
64
|
-
const proc = spawn(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// running as an orphan.
|
|
100
|
+
const proc = spawn([process.execPath, process.argv[1], "__supervise", "--name", name], {
|
|
101
|
+
stdin: "ignore",
|
|
102
|
+
stdout: fd,
|
|
103
|
+
stderr: fd,
|
|
104
|
+
});
|
|
70
105
|
proc.unref();
|
|
71
106
|
if (!proc.pid) return false;
|
|
72
|
-
|
|
73
|
-
const list = readRegistry().filter((d) => d.name !== opts.name);
|
|
74
|
-
list.push({ name: opts.name, pid: proc.pid, cwd: opts.cwd, argv: opts.argv, log, startedAt: Date.now() });
|
|
75
|
-
writeRegistry(list);
|
|
107
|
+
patch(name, { pid: proc.pid });
|
|
76
108
|
return true;
|
|
77
109
|
}
|
|
78
110
|
|
|
79
|
-
/**
|
|
111
|
+
/** Windows: a Scheduled Task running the supervisor, with output redirected to
|
|
112
|
+
* the log via a small launcher .cmd (avoids schtasks /tr quoting limits). It
|
|
113
|
+
* auto-starts at logon and is started immediately. */
|
|
114
|
+
function startWindowsTask(name: string, log: string): boolean {
|
|
115
|
+
mkdirSync(TASK_DIR, { recursive: true });
|
|
116
|
+
const cmdPath = join(TASK_DIR, `${name}.cmd`);
|
|
117
|
+
const cmd = `@echo off\r\n"${process.execPath}" "${process.argv[1]}" __supervise --name "${name}" >> "${log}" 2>&1\r\n`;
|
|
118
|
+
writeFileSync(cmdPath, cmd);
|
|
119
|
+
|
|
120
|
+
// Create the task (onlogon for a normal user; onstart when running elevated as
|
|
121
|
+
// SYSTEM, where there's no interactive logon), then run it now.
|
|
122
|
+
let created = schtasks(["/create", "/tn", name, "/tr", cmdPath, "/sc", "onlogon", "/f"]);
|
|
123
|
+
if (created !== 0) created = schtasks(["/create", "/tn", name, "/tr", cmdPath, "/sc", "onstart", "/f"]);
|
|
124
|
+
if (created !== 0) return false;
|
|
125
|
+
patch(name, { task: name });
|
|
126
|
+
return schtasks(["/run", "/tn", name]) === 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Live daemons. Dead POSIX supervisors are pruned; Windows entries persist as
|
|
130
|
+
* long as their task is still registered (a Ready task is a valid auto-start). */
|
|
80
131
|
export function listFallbackDaemons(): FallbackDaemon[] {
|
|
81
132
|
const list = readRegistry();
|
|
82
|
-
const alive = list.filter((d) => isAlive(d.pid));
|
|
133
|
+
const alive = list.filter((d) => (d.task ? taskExists(d.task) : d.pid != null && isAlive(d.pid)));
|
|
83
134
|
if (alive.length !== list.length) writeRegistry(alive);
|
|
84
135
|
return alive;
|
|
85
136
|
}
|
|
86
137
|
|
|
87
|
-
/** Stop and deregister a
|
|
138
|
+
/** Stop and deregister a daemon by name. Returns false if not found. */
|
|
88
139
|
export function stopFallbackDaemon(name: string): boolean {
|
|
89
140
|
const list = readRegistry();
|
|
90
141
|
const hit = list.find((d) => d.name === name);
|
|
91
142
|
if (!hit) return false;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
143
|
+
if (hit.task) {
|
|
144
|
+
schtasks(["/end", "/tn", hit.task]);
|
|
145
|
+
schtasks(["/delete", "/tn", hit.task, "/f"]);
|
|
146
|
+
// /end hard-terminates the task's top process; kill the serve subtree (VS
|
|
147
|
+
// Code) too so it doesn't orphan.
|
|
148
|
+
if (hit.servePid) killProcessTree(hit.servePid);
|
|
149
|
+
} else if (hit.pid != null) {
|
|
150
|
+
try {
|
|
151
|
+
process.kill(hit.pid); // SIGTERM -> supervisor kills its child, then exits
|
|
152
|
+
} catch {
|
|
153
|
+
// already gone
|
|
154
|
+
}
|
|
96
155
|
}
|
|
97
156
|
writeRegistry(list.filter((d) => d.name !== name));
|
|
98
157
|
return true;
|
|
99
158
|
}
|
|
100
159
|
|
|
101
160
|
/**
|
|
102
|
-
* Supervisor body (run via the hidden `__supervise` command).
|
|
103
|
-
* argv,
|
|
104
|
-
* when the child exits cleanly or
|
|
105
|
-
* (killing the child first).
|
|
161
|
+
* Supervisor body (run via the hidden `__supervise` command). Loads its serve
|
|
162
|
+
* argv + cwd from the registry by name, runs it, and restarts it on a non-zero
|
|
163
|
+
* exit with capped exponential backoff; stops when the child exits cleanly or on
|
|
164
|
+
* SIGTERM/SIGINT (killing the child first).
|
|
106
165
|
*/
|
|
107
|
-
export async function runSupervisor(name: string
|
|
166
|
+
export async function runSupervisor(name: string): Promise<number> {
|
|
167
|
+
const entry = readRegistry().find((d) => d.name === name);
|
|
168
|
+
if (!entry) {
|
|
169
|
+
console.error(`[codehost:${name}] no registry entry; nothing to supervise.`);
|
|
170
|
+
return 1;
|
|
171
|
+
}
|
|
172
|
+
const { argv, cwd } = entry;
|
|
173
|
+
|
|
108
174
|
let child: Subprocess | null = null;
|
|
109
175
|
let stopping = false;
|
|
110
176
|
const onSignal = () => {
|
|
111
177
|
stopping = true;
|
|
112
178
|
try {
|
|
113
|
-
child?.
|
|
179
|
+
if (child?.pid) killProcessTree(child.pid);
|
|
114
180
|
} catch {
|
|
115
181
|
// ignore
|
|
116
182
|
}
|
|
@@ -121,7 +187,8 @@ export async function runSupervisor(name: string, argv: string[]): Promise<numbe
|
|
|
121
187
|
let attempt = 0;
|
|
122
188
|
while (!stopping) {
|
|
123
189
|
console.log(`[codehost:${name}] starting: ${argv.join(" ")}`);
|
|
124
|
-
child = spawn(argv, { cwd
|
|
190
|
+
child = spawn(argv, { cwd, stdin: "ignore", stdout: "inherit", stderr: "inherit" });
|
|
191
|
+
patch(name, { servePid: child.pid });
|
|
125
192
|
const code = await child.exited;
|
|
126
193
|
if (stopping) break;
|
|
127
194
|
if (code === 0) {
|