ework-aio 0.1.17 → 0.2.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/README.md +21 -20
- package/bin/ework-aio +3 -439
- package/package.json +6 -3
- package/src/cli.ts +504 -0
- package/src/commands/config.ts +208 -0
- package/src/commands/env.ts +27 -0
- package/src/commands/install.ts +665 -0
- package/src/commands/lifecycle.ts +206 -0
- package/src/commands/logs.ts +71 -0
- package/src/commands/uninstall.ts +64 -0
- package/src/config.ts +88 -0
- package/src/env.ts +238 -0
- package/src/log.ts +102 -0
- package/src/opencode-config.ts +104 -0
- package/src/paths.ts +95 -0
- package/src/pidfile.ts +166 -0
- package/src/preflight.ts +48 -0
- package/src/systemd.ts +234 -0
- package/src/types.ts +95 -0
- package/bin/install.sh +0 -1200
package/src/log.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Pretty-printer for install / CLI output. Mirrors the bash aesthetic from
|
|
2
|
+
// bin/install.sh (• for log, ✓ for ok, ! for warn, ✗ for die, ── for hr)
|
|
3
|
+
// but with TS types and injectable stream so tests can capture output.
|
|
4
|
+
|
|
5
|
+
const isTTY = process.stdout.isTTY ?? false;
|
|
6
|
+
|
|
7
|
+
// Minimal stream interface — narrower than the full WriteStream type so
|
|
8
|
+
// stdout (fd 1) and stderr (fd 2) are both assignable without friction.
|
|
9
|
+
interface WritableStreamLike {
|
|
10
|
+
write(chunk: string | Uint8Array): boolean;
|
|
11
|
+
isTTY?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function colorEnabled(stream: WritableStreamLike): boolean {
|
|
15
|
+
// Per no-color.org spec: ANY presence of NO_COLOR in the environment
|
|
16
|
+
// (including empty string) disables color. The truthy check
|
|
17
|
+
// `if (process.env.NO_COLOR)` treats `NO_COLOR=` as "not set", which
|
|
18
|
+
// violates the spec and surprises users who export the empty value
|
|
19
|
+
// explicitly (e.g. shell rc files).
|
|
20
|
+
if ("NO_COLOR" in process.env) return false;
|
|
21
|
+
if (process.env.FORCE_COLOR) return true;
|
|
22
|
+
return stream.isTTY ?? false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const C = {
|
|
26
|
+
reset: "\x1b[0m",
|
|
27
|
+
bold: "\x1b[1m",
|
|
28
|
+
dim: "\x1b[2m",
|
|
29
|
+
red: "\x1b[31m",
|
|
30
|
+
green: "\x1b[32m",
|
|
31
|
+
yellow: "\x1b[33m",
|
|
32
|
+
blue: "\x1b[34m",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
export interface LoggerOptions {
|
|
36
|
+
stdout?: WritableStreamLike;
|
|
37
|
+
stderr?: WritableStreamLike;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class Logger {
|
|
41
|
+
private readonly stdout: WritableStreamLike;
|
|
42
|
+
private readonly stderr: WritableStreamLike;
|
|
43
|
+
private readonly stdoutColor: boolean;
|
|
44
|
+
private readonly stderrColor: boolean;
|
|
45
|
+
|
|
46
|
+
constructor(opts: LoggerOptions = {}) {
|
|
47
|
+
this.stdout = opts.stdout ?? process.stdout;
|
|
48
|
+
this.stderr = opts.stderr ?? process.stderr;
|
|
49
|
+
this.stdoutColor = colorEnabled(this.stdout);
|
|
50
|
+
this.stderrColor = colorEnabled(this.stderr);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private paint(stream: WritableStreamLike, enabled: boolean, ...parts: Array<string>): string {
|
|
54
|
+
return parts.map((p) => (enabled ? p : p.replace(/\x1b\[[0-9;]*m/g, ""))).join("");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
log(msg: string): void {
|
|
58
|
+
const line = this.paint(this.stdout, this.stdoutColor, `${C.blue}•${C.reset} `, msg, "\n");
|
|
59
|
+
this.stdout.write(line);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
ok(msg: string): void {
|
|
63
|
+
const line = this.paint(this.stdout, this.stdoutColor, `${C.green}✓${C.reset} `, msg, "\n");
|
|
64
|
+
this.stdout.write(line);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
warn(msg: string): void {
|
|
68
|
+
const line = this.paint(this.stderr, this.stderrColor, `${C.yellow}!${C.reset} `, msg, "\n");
|
|
69
|
+
this.stderr.write(line);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
error(msg: string): void {
|
|
73
|
+
const line = this.paint(this.stderr, this.stderrColor, `${C.red}✗${C.reset} `, msg, "\n");
|
|
74
|
+
this.stderr.write(line);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
hr(): void {
|
|
78
|
+
const line = this.paint(this.stdout, this.stdoutColor, `${C.dim}──${C.reset}`, "\n");
|
|
79
|
+
this.stdout.write(line);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
bold(msg: string): string {
|
|
83
|
+
return this.paint(this.stdout, this.stdoutColor, `${C.bold}${msg}${C.reset}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
dim(msg: string): string {
|
|
87
|
+
return this.paint(this.stdout, this.stdoutColor, `${C.dim}${msg}${C.reset}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const log = new Logger();
|
|
92
|
+
|
|
93
|
+
// InstallError: typed fatal for install flow. Distinct from generic Error so
|
|
94
|
+
// catch sites can tell "expected install failure" from "unexpected crash".
|
|
95
|
+
export class InstallError extends Error {
|
|
96
|
+
readonly code: number;
|
|
97
|
+
constructor(message: string, code: number = 1) {
|
|
98
|
+
super(message);
|
|
99
|
+
this.name = "InstallError";
|
|
100
|
+
this.code = code;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// opencode.json read/write + plugin registration. Replaces jq with
|
|
2
|
+
// JSON.parse/stringify. Treats the file as untrusted input — malformed
|
|
3
|
+
// JSON throws a typed error, not a raw SyntaxError.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { InstallError } from "./log.ts";
|
|
8
|
+
|
|
9
|
+
export interface OpencodeConfig {
|
|
10
|
+
// Unknown-key passthrough — preserves fields we don't know about.
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
plugins?: Array<string | { name: string; [k: string]: unknown }>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ReadConfigResult {
|
|
16
|
+
config: OpencodeConfig;
|
|
17
|
+
existed: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readConfig(configPath: string): Promise<ReadConfigResult> {
|
|
21
|
+
let content: string;
|
|
22
|
+
try {
|
|
23
|
+
content = await Bun.file(configPath).text();
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
26
|
+
return { config: {}, existed: false };
|
|
27
|
+
}
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let parsed: unknown;
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(content);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new InstallError(
|
|
36
|
+
`${configPath} contains malformed JSON: ${(err as Error).message}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
41
|
+
throw new InstallError(
|
|
42
|
+
`${configPath} must be a JSON object at the top level (got ${Array.isArray(parsed) ? "array" : typeof parsed})`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { config: parsed as OpencodeConfig, existed: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function writeConfig(configPath: string, config: OpencodeConfig): Promise<void> {
|
|
50
|
+
const dir = path.dirname(configPath);
|
|
51
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
52
|
+
const tmp = path.join(dir, `.opencode.json.tmp.${process.pid}.${Date.now()}`);
|
|
53
|
+
const content = JSON.stringify(config, null, 2) + "\n";
|
|
54
|
+
await fs.promises.writeFile(tmp, content, { mode: 0o644 });
|
|
55
|
+
try {
|
|
56
|
+
await fs.promises.rename(tmp, configPath);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
await fs.promises.unlink(tmp).catch(() => {});
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizePluginEntry(entry: unknown): string | null {
|
|
64
|
+
// Plugin entries can be either "name" (string) or { name: "...", ...opts }.
|
|
65
|
+
if (typeof entry === "string") return entry;
|
|
66
|
+
if (entry !== null && typeof entry === "object") {
|
|
67
|
+
const name = (entry as { name?: unknown }).name;
|
|
68
|
+
if (typeof name === "string") return name;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function hasPlugin(config: OpencodeConfig, pluginName: string): boolean {
|
|
74
|
+
const plugins = config.plugins;
|
|
75
|
+
if (!Array.isArray(plugins)) return false;
|
|
76
|
+
return plugins.some((p) => normalizePluginEntry(p) === pluginName);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function ensurePlugin(config: OpencodeConfig, pluginName: string): { config: OpencodeConfig; added: boolean } {
|
|
80
|
+
if (hasPlugin(config, pluginName)) {
|
|
81
|
+
return { config, added: false };
|
|
82
|
+
}
|
|
83
|
+
const next: OpencodeConfig = { ...config };
|
|
84
|
+
next.plugins = [...(Array.isArray(config.plugins) ? config.plugins : []), pluginName];
|
|
85
|
+
return { config: next, added: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function removePlugin(config: OpencodeConfig, pluginName: string): { config: OpencodeConfig; removed: boolean } {
|
|
89
|
+
if (!hasPlugin(config, pluginName)) {
|
|
90
|
+
return { config, removed: false };
|
|
91
|
+
}
|
|
92
|
+
const next: OpencodeConfig = { ...config };
|
|
93
|
+
const plugins = Array.isArray(config.plugins) ? config.plugins : [];
|
|
94
|
+
next.plugins = plugins.filter((p) => normalizePluginEntry(p) !== pluginName);
|
|
95
|
+
return { config: next, removed: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function ensurePluginInFile(configPath: string, pluginName: string): Promise<{ added: boolean }> {
|
|
99
|
+
const { config } = await readConfig(configPath);
|
|
100
|
+
const result = ensurePlugin(config, pluginName);
|
|
101
|
+
if (!result.added) return { added: false };
|
|
102
|
+
await writeConfig(configPath, result.config);
|
|
103
|
+
return { added: true };
|
|
104
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Path resolution for ework-aio. All file system paths used by install
|
|
2
|
+
// flow through here so tests can override via env vars.
|
|
3
|
+
//
|
|
4
|
+
// Hierarchy:
|
|
5
|
+
// DATA_DIR (default: $XDG_DATA_HOME/ework-aio or ~/.local/share/ework-aio)
|
|
6
|
+
// ├── ework-web/ (web service data)
|
|
7
|
+
// │ ├── .env (web env file)
|
|
8
|
+
// │ ├── ework.db (web SQLite DB)
|
|
9
|
+
// │ └── attachments/ (uploaded files)
|
|
10
|
+
// ├── ework-daemon/ (daemon service data)
|
|
11
|
+
// │ ├── .env (daemon env file)
|
|
12
|
+
// │ └── ework-daemon.db (daemon SQLite DB)
|
|
13
|
+
// ├── run/ (PID files + logs for PID-file mode)
|
|
14
|
+
// │ ├── web.{pid,log}
|
|
15
|
+
// │ └── daemon.{pid,log}
|
|
16
|
+
// ├── bot-token (persisted bot PAT)
|
|
17
|
+
// └── opencode-workdir/ (opencode working directory base)
|
|
18
|
+
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
|
|
22
|
+
export interface PathConfig {
|
|
23
|
+
dataDir: string;
|
|
24
|
+
webDataDir: string;
|
|
25
|
+
daemonDataDir: string;
|
|
26
|
+
webEnvFile: string;
|
|
27
|
+
daemonEnvFile: string;
|
|
28
|
+
runDir: string;
|
|
29
|
+
botTokenFile: string;
|
|
30
|
+
opencodeWorkdir: string;
|
|
31
|
+
opencodeConfigFile: string;
|
|
32
|
+
webDbPath: string;
|
|
33
|
+
daemonDbPath: string;
|
|
34
|
+
webAttachmentRoot: string;
|
|
35
|
+
webPidFile: string;
|
|
36
|
+
daemonPidFile: string;
|
|
37
|
+
webLogFile: string;
|
|
38
|
+
daemonLogFile: string;
|
|
39
|
+
webUnitFile: string | null; // null when not using systemd
|
|
40
|
+
daemonUnitFile: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ResolvePathsOptions {
|
|
44
|
+
dataDir?: string; // override via --data-dir
|
|
45
|
+
configHome?: string; // override XDG_CONFIG_HOME for tests
|
|
46
|
+
scope: "user" | "system"; // systemd scope (affects unit file location)
|
|
47
|
+
useSystemd: boolean; // if false, webUnitFile/daemonUnitFile are null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolvePaths(opts: ResolvePathsOptions): PathConfig {
|
|
51
|
+
const home = os.homedir();
|
|
52
|
+
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(home, ".local", "share");
|
|
53
|
+
// opts.configHome takes precedence over XDG_CONFIG_HOME so tests can
|
|
54
|
+
// pin a config dir without polluting or reading the user's real
|
|
55
|
+
// ~/.config. The previous precedence (env || opts || default) meant
|
|
56
|
+
// a developer with XDG_CONFIG_HOME set couldn't override it from tests,
|
|
57
|
+
// and test runs would leak into the real config dir.
|
|
58
|
+
const xdgConfigHome = opts.configHome
|
|
59
|
+
|| process.env.XDG_CONFIG_HOME
|
|
60
|
+
|| path.join(home, ".config");
|
|
61
|
+
|
|
62
|
+
const dataDir = opts.dataDir || path.join(xdgDataHome, "ework-aio");
|
|
63
|
+
const webDataDir = path.join(dataDir, "ework-web");
|
|
64
|
+
const daemonDataDir = path.join(dataDir, "ework-daemon");
|
|
65
|
+
const runDir = path.join(dataDir, "run");
|
|
66
|
+
|
|
67
|
+
// Unit file location depends on scope and whether systemd is opted-in
|
|
68
|
+
let unitDir: string | null = null;
|
|
69
|
+
if (opts.useSystemd) {
|
|
70
|
+
unitDir = opts.scope === "system"
|
|
71
|
+
? "/etc/systemd/system"
|
|
72
|
+
: path.join(xdgConfigHome, "systemd", "user");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
dataDir,
|
|
77
|
+
webDataDir,
|
|
78
|
+
daemonDataDir,
|
|
79
|
+
webEnvFile: path.join(webDataDir, ".env"),
|
|
80
|
+
daemonEnvFile: path.join(daemonDataDir, ".env"),
|
|
81
|
+
runDir,
|
|
82
|
+
botTokenFile: path.join(dataDir, "bot-token"),
|
|
83
|
+
opencodeWorkdir: path.join(dataDir, "opencode-workdir"),
|
|
84
|
+
opencodeConfigFile: path.join(xdgConfigHome, "opencode", "opencode.json"),
|
|
85
|
+
webDbPath: path.join(webDataDir, "ework.db"),
|
|
86
|
+
daemonDbPath: path.join(daemonDataDir, "ework-daemon.db"),
|
|
87
|
+
webAttachmentRoot: path.join(webDataDir, "attachments"),
|
|
88
|
+
webPidFile: path.join(runDir, "web.pid"),
|
|
89
|
+
daemonPidFile: path.join(runDir, "daemon.pid"),
|
|
90
|
+
webLogFile: path.join(runDir, "web.log"),
|
|
91
|
+
daemonLogFile: path.join(runDir, "daemon.log"),
|
|
92
|
+
webUnitFile: unitDir ? path.join(unitDir, "ework-web.service") : null,
|
|
93
|
+
daemonUnitFile: unitDir ? path.join(unitDir, "ework-daemon.service") : null,
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/pidfile.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Detached process management (PID-file mode).
|
|
2
|
+
//
|
|
3
|
+
// Replaces bash `setsid nohup <cmd> & echo $! > pidfile; disown`.
|
|
4
|
+
// Uses node:child_process.spawn with detached:true + child.unref() so the
|
|
5
|
+
// parent can exit without taking the child down. stdio is redirected to a
|
|
6
|
+
// log file so we don't lose stdout/stderr after parent exit.
|
|
7
|
+
|
|
8
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
export interface StartProcessOptions {
|
|
14
|
+
cmd: string;
|
|
15
|
+
args: string[];
|
|
16
|
+
cwd?: string;
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
logFile: string;
|
|
19
|
+
pidFile: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface StartProcessResult {
|
|
23
|
+
pid: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class PidFileError extends Error {
|
|
27
|
+
constructor(message: string) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "PidFileError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function startProcess(opts: StartProcessOptions): Promise<StartProcessResult> {
|
|
34
|
+
await fs.promises.mkdir(path.dirname(opts.logFile), { recursive: true });
|
|
35
|
+
await fs.promises.mkdir(path.dirname(opts.pidFile), { recursive: true });
|
|
36
|
+
|
|
37
|
+
// sync open — Bun's async FileHandle has a finalizer that closes the fd
|
|
38
|
+
// when GC'd, which can race with the child inheriting it via stdio.
|
|
39
|
+
// openSync returns a raw fd we own; spawn() dups it for the child.
|
|
40
|
+
const logFd = fs.openSync(opts.logFile, "a", 0o644);
|
|
41
|
+
|
|
42
|
+
let child: ChildProcess;
|
|
43
|
+
try {
|
|
44
|
+
child = spawn(opts.cmd, opts.args, {
|
|
45
|
+
cwd: opts.cwd,
|
|
46
|
+
env: opts.env,
|
|
47
|
+
detached: true,
|
|
48
|
+
stdio: ["ignore", logFd, logFd],
|
|
49
|
+
});
|
|
50
|
+
} catch (err) {
|
|
51
|
+
fs.closeSync(logFd);
|
|
52
|
+
throw new PidFileError(`Failed to spawn ${opts.cmd}: ${(err as Error).message}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Bun/node:spawn returns a ChildProcess even when the binary doesn't
|
|
56
|
+
// exist (ENOENT) — child.pid stays undefined and an 'error' event is
|
|
57
|
+
// emitted on next tick. Without a handler the event becomes an
|
|
58
|
+
// uncaughtException that takes down the test runner (and on long-running
|
|
59
|
+
// ework-aio, the install). The no-pid check below catches this case
|
|
60
|
+
// synchronously; this handler suppresses the redundant async 'error'.
|
|
61
|
+
child.on("error", () => { /* handled via pid check below */ });
|
|
62
|
+
|
|
63
|
+
if (typeof child.pid !== "number") {
|
|
64
|
+
// S-2: logFd was opened above; we own it until spawn() inherits it
|
|
65
|
+
// via stdio. On the no-pid failure path the child didn't actually
|
|
66
|
+
// take over the fd, so we must close it before throwing — otherwise
|
|
67
|
+
// the fd leaks until process exit (and on long-running installs
|
|
68
|
+
// holding the log file open blocks log rotation).
|
|
69
|
+
fs.closeSync(logFd);
|
|
70
|
+
throw new PidFileError(`Spawned ${opts.cmd} but no pid was assigned`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const pid = child.pid;
|
|
74
|
+
|
|
75
|
+
child.unref();
|
|
76
|
+
|
|
77
|
+
fs.closeSync(logFd);
|
|
78
|
+
|
|
79
|
+
await writePidFileAtomic(opts.pidFile, pid);
|
|
80
|
+
|
|
81
|
+
return { pid };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function writePidFileAtomic(pidFile: string, pid: number): Promise<void> {
|
|
85
|
+
const dir = path.dirname(pidFile);
|
|
86
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
87
|
+
const suffix = `${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}`;
|
|
88
|
+
const tmp = path.join(dir, `.pid.tmp.${suffix}`);
|
|
89
|
+
await fs.promises.writeFile(tmp, `${pid}\n`, { mode: 0o644 });
|
|
90
|
+
try {
|
|
91
|
+
await fs.promises.rename(tmp, pidFile);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
await fs.promises.unlink(tmp).catch(() => {});
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function readPidFile(pidFile: string): Promise<number | null> {
|
|
99
|
+
try {
|
|
100
|
+
const content = await fs.promises.readFile(pidFile, "utf8");
|
|
101
|
+
const pid = parseInt(content.trim(), 10);
|
|
102
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function isProcessRunning(pid: number): boolean {
|
|
110
|
+
try {
|
|
111
|
+
// signal 0 = existence check, no actual signal sent
|
|
112
|
+
process.kill(pid, 0);
|
|
113
|
+
return true;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if ((err as NodeJS.ErrnoException).code === "ESRCH") return false; // No such process
|
|
116
|
+
if ((err as NodeJS.ErrnoException).code === "EPERM") return true; // Exists but not ours
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function stopProcess(
|
|
122
|
+
pidFile: string,
|
|
123
|
+
opts: { graceMs?: number; sigkillAfter?: boolean } = {},
|
|
124
|
+
): Promise<{ pid: number; killed: boolean; timedOut: boolean }> {
|
|
125
|
+
const graceMs = opts.graceMs ?? 5000;
|
|
126
|
+
const pid = await readPidFile(pidFile);
|
|
127
|
+
if (pid === null) {
|
|
128
|
+
throw new PidFileError(`PID file ${pidFile} not found or empty`);
|
|
129
|
+
}
|
|
130
|
+
if (!isProcessRunning(pid)) {
|
|
131
|
+
// Stale pidfile; clean up.
|
|
132
|
+
await fs.promises.unlink(pidFile).catch(() => {});
|
|
133
|
+
return { pid, killed: false, timedOut: false };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
process.kill(pid, "SIGTERM");
|
|
137
|
+
|
|
138
|
+
const deadline = Date.now() + graceMs;
|
|
139
|
+
while (Date.now() < deadline) {
|
|
140
|
+
if (!isProcessRunning(pid)) {
|
|
141
|
+
await fs.promises.unlink(pidFile).catch(() => {});
|
|
142
|
+
return { pid, killed: true, timedOut: false };
|
|
143
|
+
}
|
|
144
|
+
await sleep(100);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (opts.sigkillAfter === false) {
|
|
148
|
+
return { pid, killed: false, timedOut: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
process.kill(pid, "SIGKILL");
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if ((err as NodeJS.ErrnoException).code === "ESRCH") {
|
|
155
|
+
await fs.promises.unlink(pidFile).catch(() => {});
|
|
156
|
+
return { pid, killed: true, timedOut: true };
|
|
157
|
+
}
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
await fs.promises.unlink(pidFile).catch(() => {});
|
|
161
|
+
return { pid, killed: true, timedOut: true };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sleep(ms: number): Promise<void> {
|
|
165
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
166
|
+
}
|
package/src/preflight.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface PreflightResult {
|
|
4
|
+
missing: string[];
|
|
5
|
+
found: Map<string, string>;
|
|
6
|
+
optional: Map<string, string | null>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PreflightOptions {
|
|
10
|
+
optionalCommands?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveCommand(cmd: string): string | null {
|
|
14
|
+
const result = spawnSync("sh", ["-c", `command -v ${JSON.stringify(cmd)}`], {
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
17
|
+
// Pass env explicitly — Bun's spawnSync doesn't pick up process.env
|
|
18
|
+
// mutations if env is omitted (unlike Node which inherits at spawn).
|
|
19
|
+
env: process.env,
|
|
20
|
+
});
|
|
21
|
+
if (result.status !== 0) return null;
|
|
22
|
+
const path = result.stdout.trim();
|
|
23
|
+
return path === "" ? null : path;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function checkPreflight(
|
|
27
|
+
required: string[],
|
|
28
|
+
opts: PreflightOptions = {},
|
|
29
|
+
): PreflightResult {
|
|
30
|
+
const missing: string[] = [];
|
|
31
|
+
const found = new Map<string, string>();
|
|
32
|
+
const optional = new Map<string, string | null>();
|
|
33
|
+
|
|
34
|
+
for (const cmd of required) {
|
|
35
|
+
const path = resolveCommand(cmd);
|
|
36
|
+
if (path === null) missing.push(cmd);
|
|
37
|
+
else found.set(cmd, path);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const cmd of opts.optionalCommands ?? []) {
|
|
41
|
+
optional.set(cmd, resolveCommand(cmd));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { missing, found, optional };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const REQUIRED_COMMANDS: readonly string[] = ["bun", "npm", "opencode"];
|
|
48
|
+
export const OPTIONAL_COMMANDS: readonly string[] = ["systemctl", "sudo"];
|