ework-aio 0.1.17 → 0.2.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/src/log.ts ADDED
@@ -0,0 +1,97 @@
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
+ if (process.env.NO_COLOR) return false;
16
+ if (process.env.FORCE_COLOR) return true;
17
+ return stream.isTTY ?? false;
18
+ }
19
+
20
+ const C = {
21
+ reset: "\x1b[0m",
22
+ bold: "\x1b[1m",
23
+ dim: "\x1b[2m",
24
+ red: "\x1b[31m",
25
+ green: "\x1b[32m",
26
+ yellow: "\x1b[33m",
27
+ blue: "\x1b[34m",
28
+ } as const;
29
+
30
+ export interface LoggerOptions {
31
+ stdout?: WritableStreamLike;
32
+ stderr?: WritableStreamLike;
33
+ }
34
+
35
+ export class Logger {
36
+ private readonly stdout: WritableStreamLike;
37
+ private readonly stderr: WritableStreamLike;
38
+ private readonly stdoutColor: boolean;
39
+ private readonly stderrColor: boolean;
40
+
41
+ constructor(opts: LoggerOptions = {}) {
42
+ this.stdout = opts.stdout ?? process.stdout;
43
+ this.stderr = opts.stderr ?? process.stderr;
44
+ this.stdoutColor = colorEnabled(this.stdout);
45
+ this.stderrColor = colorEnabled(this.stderr);
46
+ }
47
+
48
+ private paint(stream: WritableStreamLike, enabled: boolean, ...parts: Array<string>): string {
49
+ return parts.map((p) => (enabled ? p : p.replace(/\x1b\[[0-9;]*m/g, ""))).join("");
50
+ }
51
+
52
+ log(msg: string): void {
53
+ const line = this.paint(this.stdout, this.stdoutColor, `${C.blue}•${C.reset} `, msg, "\n");
54
+ this.stdout.write(line);
55
+ }
56
+
57
+ ok(msg: string): void {
58
+ const line = this.paint(this.stdout, this.stdoutColor, `${C.green}✓${C.reset} `, msg, "\n");
59
+ this.stdout.write(line);
60
+ }
61
+
62
+ warn(msg: string): void {
63
+ const line = this.paint(this.stderr, this.stderrColor, `${C.yellow}!${C.reset} `, msg, "\n");
64
+ this.stderr.write(line);
65
+ }
66
+
67
+ error(msg: string): void {
68
+ const line = this.paint(this.stderr, this.stderrColor, `${C.red}✗${C.reset} `, msg, "\n");
69
+ this.stderr.write(line);
70
+ }
71
+
72
+ hr(): void {
73
+ const line = this.paint(this.stdout, this.stdoutColor, `${C.dim}──${C.reset}`, "\n");
74
+ this.stdout.write(line);
75
+ }
76
+
77
+ bold(msg: string): string {
78
+ return this.paint(this.stdout, this.stdoutColor, `${C.bold}${msg}${C.reset}`);
79
+ }
80
+
81
+ dim(msg: string): string {
82
+ return this.paint(this.stdout, this.stdoutColor, `${C.dim}${msg}${C.reset}`);
83
+ }
84
+ }
85
+
86
+ export const log = new Logger();
87
+
88
+ // InstallError: typed fatal for install flow. Distinct from generic Error so
89
+ // catch sites can tell "expected install failure" from "unexpected crash".
90
+ export class InstallError extends Error {
91
+ readonly code: number;
92
+ constructor(message: string, code: number = 1) {
93
+ super(message);
94
+ this.name = "InstallError";
95
+ this.code = code;
96
+ }
97
+ }
@@ -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,88 @@
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
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME || opts.configHome || path.join(home, ".config");
54
+
55
+ const dataDir = opts.dataDir || path.join(xdgDataHome, "ework-aio");
56
+ const webDataDir = path.join(dataDir, "ework-web");
57
+ const daemonDataDir = path.join(dataDir, "ework-daemon");
58
+ const runDir = path.join(dataDir, "run");
59
+
60
+ // Unit file location depends on scope and whether systemd is opted-in
61
+ let unitDir: string | null = null;
62
+ if (opts.useSystemd) {
63
+ unitDir = opts.scope === "system"
64
+ ? "/etc/systemd/system"
65
+ : path.join(xdgConfigHome, "systemd", "user");
66
+ }
67
+
68
+ return {
69
+ dataDir,
70
+ webDataDir,
71
+ daemonDataDir,
72
+ webEnvFile: path.join(webDataDir, ".env"),
73
+ daemonEnvFile: path.join(daemonDataDir, ".env"),
74
+ runDir,
75
+ botTokenFile: path.join(dataDir, "bot-token"),
76
+ opencodeWorkdir: path.join(dataDir, "opencode-workdir"),
77
+ opencodeConfigFile: path.join(xdgConfigHome, "opencode", "opencode.json"),
78
+ webDbPath: path.join(webDataDir, "ework.db"),
79
+ daemonDbPath: path.join(daemonDataDir, "ework-daemon.db"),
80
+ webAttachmentRoot: path.join(webDataDir, "attachments"),
81
+ webPidFile: path.join(runDir, "web.pid"),
82
+ daemonPidFile: path.join(runDir, "daemon.pid"),
83
+ webLogFile: path.join(runDir, "web.log"),
84
+ daemonLogFile: path.join(runDir, "daemon.log"),
85
+ webUnitFile: unitDir ? path.join(unitDir, "ework-web.service") : null,
86
+ daemonUnitFile: unitDir ? path.join(unitDir, "ework-daemon.service") : null,
87
+ };
88
+ }
package/src/pidfile.ts ADDED
@@ -0,0 +1,164 @@
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
+
12
+ export interface StartProcessOptions {
13
+ cmd: string;
14
+ args: string[];
15
+ cwd?: string;
16
+ env?: NodeJS.ProcessEnv;
17
+ logFile: string;
18
+ pidFile: string;
19
+ }
20
+
21
+ export interface StartProcessResult {
22
+ pid: number;
23
+ }
24
+
25
+ export class PidFileError extends Error {
26
+ constructor(message: string) {
27
+ super(message);
28
+ this.name = "PidFileError";
29
+ }
30
+ }
31
+
32
+ export async function startProcess(opts: StartProcessOptions): Promise<StartProcessResult> {
33
+ await fs.promises.mkdir(path.dirname(opts.logFile), { recursive: true });
34
+ await fs.promises.mkdir(path.dirname(opts.pidFile), { recursive: true });
35
+
36
+ // sync open — Bun's async FileHandle has a finalizer that closes the fd
37
+ // when GC'd, which can race with the child inheriting it via stdio.
38
+ // openSync returns a raw fd we own; spawn() dups it for the child.
39
+ const logFd = fs.openSync(opts.logFile, "a", 0o644);
40
+
41
+ let child: ChildProcess;
42
+ try {
43
+ child = spawn(opts.cmd, opts.args, {
44
+ cwd: opts.cwd,
45
+ env: opts.env,
46
+ detached: true,
47
+ stdio: ["ignore", logFd, logFd],
48
+ });
49
+ } catch (err) {
50
+ fs.closeSync(logFd);
51
+ throw new PidFileError(`Failed to spawn ${opts.cmd}: ${(err as Error).message}`);
52
+ }
53
+
54
+ // Bun/node:spawn returns a ChildProcess even when the binary doesn't
55
+ // exist (ENOENT) — child.pid stays undefined and an 'error' event is
56
+ // emitted on next tick. Without a handler the event becomes an
57
+ // uncaughtException that takes down the test runner (and on long-running
58
+ // ework-aio, the install). The no-pid check below catches this case
59
+ // synchronously; this handler suppresses the redundant async 'error'.
60
+ child.on("error", () => { /* handled via pid check below */ });
61
+
62
+ if (typeof child.pid !== "number") {
63
+ // S-2: logFd was opened above; we own it until spawn() inherits it
64
+ // via stdio. On the no-pid failure path the child didn't actually
65
+ // take over the fd, so we must close it before throwing — otherwise
66
+ // the fd leaks until process exit (and on long-running installs
67
+ // holding the log file open blocks log rotation).
68
+ fs.closeSync(logFd);
69
+ throw new PidFileError(`Spawned ${opts.cmd} but no pid was assigned`);
70
+ }
71
+
72
+ const pid = child.pid;
73
+
74
+ child.unref();
75
+
76
+ fs.closeSync(logFd);
77
+
78
+ await writePidFileAtomic(opts.pidFile, pid);
79
+
80
+ return { pid };
81
+ }
82
+
83
+ export async function writePidFileAtomic(pidFile: string, pid: number): Promise<void> {
84
+ const dir = path.dirname(pidFile);
85
+ await fs.promises.mkdir(dir, { recursive: true });
86
+ const tmp = path.join(dir, `.pid.tmp.${process.pid}.${Date.now()}`);
87
+ await fs.promises.writeFile(tmp, `${pid}\n`, { mode: 0o644 });
88
+ try {
89
+ await fs.promises.rename(tmp, pidFile);
90
+ } catch (err) {
91
+ await fs.promises.unlink(tmp).catch(() => {});
92
+ throw err;
93
+ }
94
+ }
95
+
96
+ export async function readPidFile(pidFile: string): Promise<number | null> {
97
+ try {
98
+ const content = await fs.promises.readFile(pidFile, "utf8");
99
+ const pid = parseInt(content.trim(), 10);
100
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
101
+ } catch (err) {
102
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
103
+ throw err;
104
+ }
105
+ }
106
+
107
+ export function isProcessRunning(pid: number): boolean {
108
+ try {
109
+ // signal 0 = existence check, no actual signal sent
110
+ process.kill(pid, 0);
111
+ return true;
112
+ } catch (err) {
113
+ if ((err as NodeJS.ErrnoException).code === "ESRCH") return false; // No such process
114
+ if ((err as NodeJS.ErrnoException).code === "EPERM") return true; // Exists but not ours
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ export async function stopProcess(
120
+ pidFile: string,
121
+ opts: { graceMs?: number; sigkillAfter?: boolean } = {},
122
+ ): Promise<{ pid: number; killed: boolean; timedOut: boolean }> {
123
+ const graceMs = opts.graceMs ?? 5000;
124
+ const pid = await readPidFile(pidFile);
125
+ if (pid === null) {
126
+ throw new PidFileError(`PID file ${pidFile} not found or empty`);
127
+ }
128
+ if (!isProcessRunning(pid)) {
129
+ // Stale pidfile; clean up.
130
+ await fs.promises.unlink(pidFile).catch(() => {});
131
+ return { pid, killed: false, timedOut: false };
132
+ }
133
+
134
+ process.kill(pid, "SIGTERM");
135
+
136
+ const deadline = Date.now() + graceMs;
137
+ while (Date.now() < deadline) {
138
+ if (!isProcessRunning(pid)) {
139
+ await fs.promises.unlink(pidFile).catch(() => {});
140
+ return { pid, killed: true, timedOut: false };
141
+ }
142
+ await sleep(100);
143
+ }
144
+
145
+ if (opts.sigkillAfter === false) {
146
+ return { pid, killed: false, timedOut: true };
147
+ }
148
+
149
+ try {
150
+ process.kill(pid, "SIGKILL");
151
+ } catch (err) {
152
+ if ((err as NodeJS.ErrnoException).code === "ESRCH") {
153
+ await fs.promises.unlink(pidFile).catch(() => {});
154
+ return { pid, killed: true, timedOut: true };
155
+ }
156
+ throw err;
157
+ }
158
+ await fs.promises.unlink(pidFile).catch(() => {});
159
+ return { pid, killed: true, timedOut: true };
160
+ }
161
+
162
+ function sleep(ms: number): Promise<void> {
163
+ return new Promise((resolve) => setTimeout(resolve, ms));
164
+ }
@@ -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"];