ework-aio 0.1.16 → 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/README.md +21 -20
- package/bin/ework-aio +3 -439
- package/package.json +6 -3
- package/src/cli.ts +467 -0
- package/src/commands/config.ts +208 -0
- package/src/commands/env.ts +27 -0
- package/src/commands/install.ts +637 -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 +219 -0
- package/src/log.ts +97 -0
- package/src/opencode-config.ts +104 -0
- package/src/paths.ts +88 -0
- package/src/pidfile.ts +164 -0
- package/src/preflight.ts +48 -0
- package/src/systemd.ts +234 -0
- package/src/types.ts +95 -0
- package/bin/install.sh +0 -1146
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// PID-file mode lifecycle: start / stop / restart / status.
|
|
2
|
+
//
|
|
3
|
+
// Each command targets a service ("web" | "daemon") or "both". Services
|
|
4
|
+
// are detached (startProcess uses spawn+unref) so they survive the CLI's
|
|
5
|
+
// exit. PIDs land in <dataDir>/run/<svc>.pid, logs in <dataDir>/run/<svc>.log.
|
|
6
|
+
//
|
|
7
|
+
// `status` reports per-service: PID, alive?, port (if listed in .env),
|
|
8
|
+
// and a one-line fetch probe (so users see "✓ listening" vs "✗ not
|
|
9
|
+
// responding" without having to curl themselves).
|
|
10
|
+
|
|
11
|
+
import { Logger, InstallError } from "../log.ts";
|
|
12
|
+
import { resolvePaths, type PathConfig } from "../paths.ts";
|
|
13
|
+
import {
|
|
14
|
+
startProcess,
|
|
15
|
+
stopProcess,
|
|
16
|
+
readPidFile,
|
|
17
|
+
isProcessRunning,
|
|
18
|
+
} from "../pidfile.ts";
|
|
19
|
+
import { parseEnvFile } from "../env.ts";
|
|
20
|
+
import { resolveCommand } from "../preflight.ts";
|
|
21
|
+
import type { GlobalOptions, ServiceTarget } from "../types.ts";
|
|
22
|
+
|
|
23
|
+
interface ServicePaths {
|
|
24
|
+
bin: string;
|
|
25
|
+
dataDir: string;
|
|
26
|
+
envFile: string;
|
|
27
|
+
pidFile: string;
|
|
28
|
+
logFile: string;
|
|
29
|
+
portKey: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function servicePaths(paths: PathConfig, svc: "web" | "daemon"): ServicePaths {
|
|
33
|
+
if (svc === "web") {
|
|
34
|
+
return {
|
|
35
|
+
bin: "ework-web",
|
|
36
|
+
dataDir: paths.webDataDir,
|
|
37
|
+
envFile: paths.webEnvFile,
|
|
38
|
+
pidFile: paths.webPidFile,
|
|
39
|
+
logFile: paths.webLogFile,
|
|
40
|
+
portKey: "WORK_PORT",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
bin: "ework-daemon",
|
|
45
|
+
dataDir: paths.daemonDataDir,
|
|
46
|
+
envFile: paths.daemonEnvFile,
|
|
47
|
+
pidFile: paths.daemonPidFile,
|
|
48
|
+
logFile: paths.daemonLogFile,
|
|
49
|
+
portKey: "DAEMON_PORT",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function loadEnv(envFile: string): Promise<NodeJS.ProcessEnv> {
|
|
54
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
55
|
+
try {
|
|
56
|
+
const content = await Bun.file(envFile).text();
|
|
57
|
+
const parsed = parseEnvFile(content);
|
|
58
|
+
for (const [k, v] of parsed.entries) env[k] = v;
|
|
59
|
+
} catch {
|
|
60
|
+
// missing .env → fall back to process.env only
|
|
61
|
+
}
|
|
62
|
+
return env;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function startOne(
|
|
66
|
+
svc: "web" | "daemon",
|
|
67
|
+
paths: PathConfig,
|
|
68
|
+
logger: Logger,
|
|
69
|
+
): Promise<boolean> {
|
|
70
|
+
const sp = servicePaths(paths, svc);
|
|
71
|
+
const binPath = resolveCommand(sp.bin);
|
|
72
|
+
if (!binPath) {
|
|
73
|
+
throw new InstallError(`${sp.bin} not found on PATH — install with: npm install -g ${sp.bin}`);
|
|
74
|
+
}
|
|
75
|
+
const existingPid = await readPidFile(sp.pidFile);
|
|
76
|
+
if (existingPid !== null && isProcessRunning(existingPid)) {
|
|
77
|
+
logger.log(`ework-${svc} already running (pid ${existingPid})`);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const env = await loadEnv(sp.envFile);
|
|
81
|
+
const { pid } = await startProcess({
|
|
82
|
+
cmd: binPath,
|
|
83
|
+
args: [],
|
|
84
|
+
cwd: sp.dataDir,
|
|
85
|
+
env,
|
|
86
|
+
logFile: sp.logFile,
|
|
87
|
+
pidFile: sp.pidFile,
|
|
88
|
+
});
|
|
89
|
+
logger.ok(`ework-${svc} started (pid ${pid}, log ${sp.logFile})`);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function stopOne(svc: "web" | "daemon", paths: PathConfig, logger: Logger): Promise<boolean> {
|
|
94
|
+
const sp = servicePaths(paths, svc);
|
|
95
|
+
try {
|
|
96
|
+
const result = await stopProcess(sp.pidFile, { graceMs: 5000, sigkillAfter: true });
|
|
97
|
+
if (result.killed) {
|
|
98
|
+
logger.ok(`ework-${svc} stopped (pid ${result.pid}${result.timedOut ? ", SIGKILL after timeout" : ""})`);
|
|
99
|
+
} else {
|
|
100
|
+
logger.log(`ework-${svc} was not running (stale pidfile cleaned)`);
|
|
101
|
+
}
|
|
102
|
+
return result.killed;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err instanceof Error && /not found or empty/.test(err.message)) {
|
|
105
|
+
logger.log(`ework-${svc} not running (no pidfile at ${sp.pidFile})`);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function targets(target: ServiceTarget): Array<"web" | "daemon"> {
|
|
113
|
+
return target === "both" ? ["web", "daemon"] : [target];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function runStart(
|
|
117
|
+
opts: GlobalOptions,
|
|
118
|
+
logger: Logger,
|
|
119
|
+
target: ServiceTarget,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: false });
|
|
122
|
+
for (const svc of targets(target)) {
|
|
123
|
+
await startOne(svc, paths, logger);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function runStop(
|
|
128
|
+
opts: GlobalOptions,
|
|
129
|
+
logger: Logger,
|
|
130
|
+
target: ServiceTarget,
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: false });
|
|
133
|
+
for (const svc of targets(target)) {
|
|
134
|
+
await stopOne(svc, paths, logger);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function runRestart(
|
|
139
|
+
opts: GlobalOptions,
|
|
140
|
+
logger: Logger,
|
|
141
|
+
target: ServiceTarget,
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: false });
|
|
144
|
+
for (const svc of targets(target)) {
|
|
145
|
+
await stopOne(svc, paths, logger);
|
|
146
|
+
await startOne(svc, paths, logger);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface StatusEntry {
|
|
151
|
+
svc: "web" | "daemon";
|
|
152
|
+
pid: number | null;
|
|
153
|
+
alive: boolean;
|
|
154
|
+
port: number | null;
|
|
155
|
+
listening: boolean | null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function runStatus(opts: GlobalOptions, logger: Logger): Promise<StatusEntry[]> {
|
|
159
|
+
const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: false });
|
|
160
|
+
|
|
161
|
+
logger.hr();
|
|
162
|
+
logger.log("ework-aio status (PID-file mode)");
|
|
163
|
+
logger.hr();
|
|
164
|
+
|
|
165
|
+
const entries: StatusEntry[] = [];
|
|
166
|
+
for (const svc of ["web", "daemon"] as const) {
|
|
167
|
+
const sp = servicePaths(paths, svc);
|
|
168
|
+
const pid = await readPidFile(sp.pidFile);
|
|
169
|
+
const alive = pid !== null && isProcessRunning(pid);
|
|
170
|
+
|
|
171
|
+
let port: number | null = null;
|
|
172
|
+
try {
|
|
173
|
+
const content = await Bun.file(sp.envFile).text();
|
|
174
|
+
const portStr = parseEnvFile(content).entries.get(sp.portKey ?? "");
|
|
175
|
+
if (portStr) port = Number.parseInt(portStr, 10) || null;
|
|
176
|
+
} catch {
|
|
177
|
+
// missing .env → no port
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let listening: boolean | null = null;
|
|
181
|
+
if (port !== null) {
|
|
182
|
+
// web exposes /login; daemon doesn't. Probe a per-service URL so the
|
|
183
|
+
// status line doesn't lie about the daemon being "not responding"
|
|
184
|
+
// when it's actually healthy.
|
|
185
|
+
const probeUrl = svc === "web"
|
|
186
|
+
? `http://127.0.0.1:${port}/login`
|
|
187
|
+
: `http://127.0.0.1:${port}/`;
|
|
188
|
+
try {
|
|
189
|
+
const r = await fetch(probeUrl, { method: "GET" });
|
|
190
|
+
listening = r.status < 500;
|
|
191
|
+
} catch {
|
|
192
|
+
listening = false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
entries.push({ svc, pid, alive, port, listening });
|
|
197
|
+
|
|
198
|
+
const pidStr = pid === null ? "—" : `pid ${pid}`;
|
|
199
|
+
const aliveStr = alive ? "✓ running" : "✗ not running";
|
|
200
|
+
const portStr = port === null ? "(no port in .env)" : `:${port}`;
|
|
201
|
+
const listenStr = listening === null ? "" : listening ? " ✓ listening" : " ✗ not responding";
|
|
202
|
+
logger.log(` ework-${svc.padEnd(8)} ${pidStr.padEnd(10)} ${aliveStr} ${portStr}${listenStr}`);
|
|
203
|
+
}
|
|
204
|
+
logger.hr();
|
|
205
|
+
return entries;
|
|
206
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `ework-aio logs [web|daemon]` — tail -f the PID-file log file.
|
|
2
|
+
//
|
|
3
|
+
// Pure-TS implementation (no `tail -f` shell-out — the constraint is JS
|
|
4
|
+
// handles 99% of cases, only bun/npm/opencode/sudo/systemctl are
|
|
5
|
+
// whitelisted). Uses fs.watchFile to poll for size changes; appends new
|
|
6
|
+
// bytes to stdout. Polling is portable across Linux/macOS/WSL without
|
|
7
|
+
// inotify quirks.
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import { Logger, InstallError } from "../log.ts";
|
|
11
|
+
import { resolvePaths } from "../paths.ts";
|
|
12
|
+
import type { GlobalOptions } from "../types.ts";
|
|
13
|
+
|
|
14
|
+
export async function runLogs(
|
|
15
|
+
opts: GlobalOptions,
|
|
16
|
+
logger: Logger,
|
|
17
|
+
svc: "web" | "daemon",
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: false });
|
|
20
|
+
const logFile = svc === "web" ? paths.webLogFile : paths.daemonLogFile;
|
|
21
|
+
|
|
22
|
+
if (!fs.existsSync(logFile)) {
|
|
23
|
+
throw new InstallError(
|
|
24
|
+
`log file not found at ${logFile}. Start the service first: ework-aio start ${svc}`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
logger.log(`tailing ${logFile} (Ctrl+C to stop)`);
|
|
29
|
+
|
|
30
|
+
const fd = fs.openSync(logFile, "r");
|
|
31
|
+
let size = fs.fstatSync(fd).size;
|
|
32
|
+
|
|
33
|
+
// Print the last 200 lines on attach.
|
|
34
|
+
const headBytes = Math.min(size, 8192);
|
|
35
|
+
const headBuf = Buffer.alloc(headBytes);
|
|
36
|
+
fs.readSync(fd, headBuf, 0, headBytes, size - headBytes);
|
|
37
|
+
const headText = headBuf.toString("utf8");
|
|
38
|
+
const headLines = headText.split("\n");
|
|
39
|
+
const recent = headLines.slice(Math.max(0, headLines.length - 201)).join("\n");
|
|
40
|
+
process.stdout.write(recent);
|
|
41
|
+
|
|
42
|
+
let stopped = false;
|
|
43
|
+
const stop = () => {
|
|
44
|
+
if (stopped) return;
|
|
45
|
+
stopped = true;
|
|
46
|
+
fs.unwatchFile(logFile);
|
|
47
|
+
fs.closeSync(fd);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
};
|
|
50
|
+
process.on("SIGINT", stop);
|
|
51
|
+
process.on("SIGTERM", stop);
|
|
52
|
+
|
|
53
|
+
// Poll every 500ms — fs.watchFile default is 5007ms which feels laggy.
|
|
54
|
+
fs.watchFile(logFile, { interval: 500 }, (curr) => {
|
|
55
|
+
if (curr.size > size) {
|
|
56
|
+
const len = curr.size - size;
|
|
57
|
+
const buf = Buffer.alloc(len);
|
|
58
|
+
fs.readSync(fd, buf, 0, len, size);
|
|
59
|
+
process.stdout.write(buf.toString("utf8"));
|
|
60
|
+
size = curr.size;
|
|
61
|
+
} else if (curr.size < size) {
|
|
62
|
+
// File was truncated (log rotation). Reset to end.
|
|
63
|
+
size = curr.size;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Hold the process open until signal.
|
|
68
|
+
await new Promise<void>(() => {
|
|
69
|
+
// never resolves; signal handler exits
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Uninstall: stop services, remove systemd units (if installed), preserve
|
|
2
|
+
// all user data. Idempotent — missing units or stale PID files are not
|
|
3
|
+
// errors. Data dir is never touched; user removes it manually if desired.
|
|
4
|
+
|
|
5
|
+
import { Logger } from "../log.ts";
|
|
6
|
+
import { resolvePaths } from "../paths.ts";
|
|
7
|
+
import { stopProcess } from "../pidfile.ts";
|
|
8
|
+
import { removeUnit, getUnitState, type SystemctlOptions } from "../systemd.ts";
|
|
9
|
+
import type { GlobalOptions } from "../types.ts";
|
|
10
|
+
|
|
11
|
+
export async function runUninstall(opts: GlobalOptions, logger: Logger): Promise<void> {
|
|
12
|
+
const paths = resolvePaths({
|
|
13
|
+
dataDir: opts.dataDir,
|
|
14
|
+
scope: opts.scope,
|
|
15
|
+
useSystemd: opts.useSystemd,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
logger.hr();
|
|
19
|
+
logger.log("uninstalling ework-aio services (keeping data)");
|
|
20
|
+
logger.hr();
|
|
21
|
+
|
|
22
|
+
// 1. Stop PID-file mode services (best-effort).
|
|
23
|
+
for (const svc of ["web", "daemon"] as const) {
|
|
24
|
+
const pidFile = svc === "web" ? paths.webPidFile : paths.daemonPidFile;
|
|
25
|
+
try {
|
|
26
|
+
const result = await stopProcess(pidFile, { graceMs: 5000, sigkillAfter: true });
|
|
27
|
+
if (result.killed) {
|
|
28
|
+
logger.ok(`stopped ework-${svc} (pid ${result.pid})`);
|
|
29
|
+
}
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (err instanceof Error && /not found or empty/.test(err.message)) {
|
|
32
|
+
// No PID file — service wasn't running in PID-file mode. Fine.
|
|
33
|
+
} else {
|
|
34
|
+
logger.warn(`stop ework-${svc} failed: ${(err as Error).message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 2. Remove systemd units (only if installed with --useSystemd).
|
|
40
|
+
if (opts.useSystemd && paths.webUnitFile && paths.daemonUnitFile) {
|
|
41
|
+
const unitOpts: SystemctlOptions = { scope: opts.scope };
|
|
42
|
+
for (const svc of ["ework-web", "ework-daemon"] as const) {
|
|
43
|
+
const state = getUnitState(svc, unitOpts);
|
|
44
|
+
if (state === "unknown") {
|
|
45
|
+
// Unit not loaded — nothing to remove.
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const unitFile = svc === "ework-web" ? paths.webUnitFile : paths.daemonUnitFile;
|
|
50
|
+
await removeUnit(svc, unitFile, unitOpts);
|
|
51
|
+
logger.ok(`removed ${svc}.service`);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
logger.warn(`remove ${svc}.service failed: ${(err as Error).message}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
logger.log("(PID-file mode — no systemd units to remove)");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 3. Print recovery hint.
|
|
61
|
+
logger.hr();
|
|
62
|
+
logger.ok(`services removed. data preserved at ${paths.dataDir}`);
|
|
63
|
+
logger.warn(`to fully remove: rm -rf ${paths.dataDir} && npm uninstall -g ework-aio ework-web ework-daemon opencode-ework`);
|
|
64
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Single source of truth for ework-web + ework-daemon .env keys.
|
|
2
|
+
//
|
|
3
|
+
// Each key has:
|
|
4
|
+
// - envVar: the literal env var name written to .env
|
|
5
|
+
// - generate(): returns the value to use when forward-filling a missing key
|
|
6
|
+
// - secret?: true if the value is sensitive (token, secret, password)
|
|
7
|
+
// — used to redact in `config list` output
|
|
8
|
+
//
|
|
9
|
+
// The forward-fill principle: when a user re-runs install and we preserve
|
|
10
|
+
// their existing .env, we walk this list and append any missing keys with
|
|
11
|
+
// freshly generated values. User-set values are NEVER overwritten.
|
|
12
|
+
//
|
|
13
|
+
// This schema MUST stay in sync with ework-web's src/config.ts (the runtime
|
|
14
|
+
// Zod parser). Any new required field added there must be added here too,
|
|
15
|
+
// otherwise stale .env files will crash the next start (the v0.1.17 bug).
|
|
16
|
+
|
|
17
|
+
import { randomBytes } from "node:crypto";
|
|
18
|
+
import type { PathConfig } from "./paths.ts";
|
|
19
|
+
|
|
20
|
+
export type EnvFile = "web" | "daemon";
|
|
21
|
+
|
|
22
|
+
export interface InstallContext {
|
|
23
|
+
paths: PathConfig;
|
|
24
|
+
workPort: number;
|
|
25
|
+
daemonPort: number;
|
|
26
|
+
botName: string;
|
|
27
|
+
operatorLogin: string;
|
|
28
|
+
// opencode binary path (looked up via preflight)
|
|
29
|
+
opencodeBin: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EnvKeySpec {
|
|
33
|
+
envVar: string;
|
|
34
|
+
file: EnvFile;
|
|
35
|
+
secret?: boolean;
|
|
36
|
+
generate: (ctx: InstallContext) => string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const hex = (bytes: number): string => randomBytes(bytes).toString("hex");
|
|
40
|
+
|
|
41
|
+
export const WEB_ENV_KEYS: readonly EnvKeySpec[] = [
|
|
42
|
+
{ envVar: "WORK_PORT", file: "web", generate: (c) => String(c.workPort) },
|
|
43
|
+
{ envVar: "WORK_HOST", file: "web", generate: () => "127.0.0.1" },
|
|
44
|
+
{ envVar: "WORK_TOKEN", file: "web", secret: true, generate: () => hex(20) },
|
|
45
|
+
{ envVar: "WORK_COOKIE_SECRET", file: "web", secret: true, generate: () => hex(24) },
|
|
46
|
+
{ envVar: "WORK_OPERATOR_LOGIN", file: "web", generate: (c) => c.operatorLogin },
|
|
47
|
+
{ envVar: "WORK_WRITES_ENABLED", file: "web", generate: () => "true" },
|
|
48
|
+
{ envVar: "WORK_DB_PATH", file: "web", generate: (c) => c.paths.webDbPath },
|
|
49
|
+
{ envVar: "WORK_ATTACHMENT_ROOT", file: "web", generate: (c) => c.paths.webAttachmentRoot },
|
|
50
|
+
{ envVar: "WORK_FILE_ROOTS", file: "web", generate: (c) => `/tmp,${c.paths.dataDir}` },
|
|
51
|
+
{ envVar: "WORK_DAEMON_BOT_LOGIN", file: "web", generate: (c) => c.botName },
|
|
52
|
+
{ envVar: "WORK_DAEMON_WEBHOOK_URL", file: "web", generate: (c) => `http://127.0.0.1:${c.daemonPort}` },
|
|
53
|
+
{ envVar: "WORK_DAEMON_WEBHOOK_SECRET", file: "web", secret: true, generate: () => hex(20) },
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export const DAEMON_ENV_KEYS: readonly EnvKeySpec[] = [
|
|
57
|
+
{ envVar: "DAEMON_ENV", file: "daemon", generate: () => "production" },
|
|
58
|
+
{ envVar: "DAEMON_PORT", file: "daemon", generate: (c) => String(c.daemonPort) },
|
|
59
|
+
{ envVar: "DAEMON_HOST", file: "daemon", generate: () => "127.0.0.1" },
|
|
60
|
+
{ envVar: "DAEMON_DB_PATH", file: "daemon", generate: (c) => c.paths.daemonDbPath },
|
|
61
|
+
{ envVar: "GITEA_URL", file: "daemon", generate: (c) => `http://127.0.0.1:${c.workPort}` },
|
|
62
|
+
// These tokens come from the bot bootstrap flow — empty placeholder here,
|
|
63
|
+
// filled in by write_daemon_env after PAT is minted.
|
|
64
|
+
{ envVar: "GITEA_TOKEN", file: "daemon", secret: true, generate: () => "" },
|
|
65
|
+
{ envVar: "GITEA_WEBHOOK_SECRET", file: "daemon", secret: true, generate: (c) => hex(20) },
|
|
66
|
+
{ envVar: "BOT_USERNAME", file: "daemon", generate: (c) => c.botName },
|
|
67
|
+
{ envVar: "BOT_TOKEN", file: "daemon", secret: true, generate: () => "" },
|
|
68
|
+
{ envVar: "OPENCODE_BINARY", file: "daemon", generate: (c) => c.opencodeBin },
|
|
69
|
+
{ envVar: "OPENCODE_BASE_WORKDIR", file: "daemon", generate: (c) => c.paths.opencodeWorkdir },
|
|
70
|
+
] as const;
|
|
71
|
+
|
|
72
|
+
export function keysForFile(file: EnvFile): readonly EnvKeySpec[] {
|
|
73
|
+
return file === "web" ? WEB_ENV_KEYS : DAEMON_ENV_KEYS;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Set of env vars that, if missing, would crash ework-web or ework-daemon
|
|
77
|
+
// at startup (Zod schema rejects). Used by env.ts forward-fill to log
|
|
78
|
+
// which keys were injected.
|
|
79
|
+
export const REQUIRED_KEYS: ReadonlySet<string> = new Set([
|
|
80
|
+
...WEB_ENV_KEYS.map((k) => k.envVar),
|
|
81
|
+
...DAEMON_ENV_KEYS.map((k) => k.envVar),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
// Keys whose value should be redacted in `config list` output.
|
|
85
|
+
export const SECRET_ENV_VARS: ReadonlySet<string> = new Set([
|
|
86
|
+
...WEB_ENV_KEYS.filter((k) => k.secret).map((k) => k.envVar),
|
|
87
|
+
...DAEMON_ENV_KEYS.filter((k) => k.secret).map((k) => k.envVar),
|
|
88
|
+
]);
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// .env file read / write / preserve / forward-fill.
|
|
2
|
+
//
|
|
3
|
+
// The v0.1.17 bug we're regressing against: install.sh preserved existing
|
|
4
|
+
// .env files verbatim, never injecting schema-required keys that were
|
|
5
|
+
// missing. When ework-web's Zod schema grew new required fields, old .env
|
|
6
|
+
// files crashed on next startup. fix(install): forward-fill missing
|
|
7
|
+
// schema-required keys (bc6b065) was the bash patch; this is the TS port.
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { keysForFile, type EnvFile, type InstallContext } from "./config.ts";
|
|
12
|
+
|
|
13
|
+
export interface EnvMap {
|
|
14
|
+
// Preserves insertion order, matching how .env is read + written.
|
|
15
|
+
readonly entries: ReadonlyMap<string, string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ForwardFillResult {
|
|
19
|
+
// Keys that were added (envVar names).
|
|
20
|
+
added: string[];
|
|
21
|
+
// Final map after merge.
|
|
22
|
+
merged: Map<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// parseEnvFile: read a .env file into an ordered map. Format:
|
|
26
|
+
// KEY=value → entry
|
|
27
|
+
// KEY="value" → entry, quotes stripped
|
|
28
|
+
// # comment → skipped (preserved on write via rawLines)
|
|
29
|
+
// <blank> → skipped (preserved on write via rawLines)
|
|
30
|
+
// Malformed lines are skipped with a warning (caller decides whether to fail).
|
|
31
|
+
export interface ParsedEnvFile {
|
|
32
|
+
entries: Map<string, string>;
|
|
33
|
+
// Raw lines preserved in original order, for writeback that doesn't
|
|
34
|
+
// disturb user comments / formatting. New keys are appended after the
|
|
35
|
+
// last non-blank non-comment line.
|
|
36
|
+
rawLines: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseEnvFile(content: string): ParsedEnvFile {
|
|
40
|
+
const entries = new Map<string, string>();
|
|
41
|
+
const rawLines = content.split(/\r?\n/);
|
|
42
|
+
for (const line of rawLines) {
|
|
43
|
+
const trimmed = line.trim();
|
|
44
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
45
|
+
const eqIdx = line.indexOf("=");
|
|
46
|
+
if (eqIdx === -1) continue;
|
|
47
|
+
const key = line.slice(0, eqIdx).trim();
|
|
48
|
+
let value = line.slice(eqIdx + 1);
|
|
49
|
+
// Strip matching surrounding quotes if present.
|
|
50
|
+
if (
|
|
51
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
52
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
53
|
+
) {
|
|
54
|
+
value = value.slice(1, -1);
|
|
55
|
+
}
|
|
56
|
+
if (key) entries.set(key, value);
|
|
57
|
+
}
|
|
58
|
+
return { entries, rawLines };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// forwardFill: given a parsed .env and an InstallContext, ensure every
|
|
62
|
+
// required key for the given file (web or daemon) is present. Missing
|
|
63
|
+
// keys are appended to rawLines + merged. Existing keys are NEVER touched.
|
|
64
|
+
export function forwardFill(
|
|
65
|
+
parsed: ParsedEnvFile,
|
|
66
|
+
file: EnvFile,
|
|
67
|
+
ctx: InstallContext,
|
|
68
|
+
): ForwardFillResult {
|
|
69
|
+
const added: string[] = [];
|
|
70
|
+
const merged = new Map(parsed.entries);
|
|
71
|
+
|
|
72
|
+
for (const spec of keysForFile(file)) {
|
|
73
|
+
if (merged.has(spec.envVar)) continue;
|
|
74
|
+
const value = spec.generate(ctx);
|
|
75
|
+
merged.set(spec.envVar, value);
|
|
76
|
+
added.push(spec.envVar);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { added, merged };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// serializeEnvFile: write a map back to .env format, preserving raw lines
|
|
83
|
+
// for keys that already existed and appending new keys at the end.
|
|
84
|
+
export function serializeEnvFile(parsed: ParsedEnvFile, added: Array<{ key: string; value: string }>): string {
|
|
85
|
+
const out: string[] = [...parsed.rawLines];
|
|
86
|
+
while (out.length > 0) {
|
|
87
|
+
const last = out[out.length - 1];
|
|
88
|
+
if (last === undefined || last.trim() !== "") break;
|
|
89
|
+
out.pop();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (added.length > 0) {
|
|
93
|
+
if (out.length > 0) {
|
|
94
|
+
const last = out[out.length - 1];
|
|
95
|
+
if (last !== undefined && last !== "") out.push("");
|
|
96
|
+
}
|
|
97
|
+
const now = new Date().toISOString();
|
|
98
|
+
out.push(`# ework-aio forward-fill at ${now}`);
|
|
99
|
+
for (const { key, value } of added) {
|
|
100
|
+
out.push(`${key}=${value}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out.join("\n") + "\n";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// readEnvFile: read + parse, return null if file doesn't exist.
|
|
107
|
+
export async function readEnvFile(filePath: string): Promise<ParsedEnvFile | null> {
|
|
108
|
+
try {
|
|
109
|
+
const content = await Bun.file(filePath).text();
|
|
110
|
+
return parseEnvFile(content);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// writeEnvFileAtomic: write .env via temp file + rename, mode 0600.
|
|
118
|
+
// Atomic so a crash mid-write doesn't leave a corrupt .env.
|
|
119
|
+
export async function writeEnvFileAtomic(filePath: string, content: string): Promise<void> {
|
|
120
|
+
const dir = path.dirname(filePath);
|
|
121
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
122
|
+
const tmp = path.join(dir, `.env.tmp.${process.pid}.${Date.now()}`);
|
|
123
|
+
await fs.promises.writeFile(tmp, content, { mode: 0o600 });
|
|
124
|
+
try {
|
|
125
|
+
await fs.promises.rename(tmp, filePath);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
await fs.promises.unlink(tmp).catch(() => {});
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ensureEnvFile: top-level entry for write_web_env / write_daemon_env.
|
|
133
|
+
// Reads existing .env (if any), forward-fills missing keys, writes back
|
|
134
|
+
// atomically. Returns the list of keys that were added.
|
|
135
|
+
export interface EnsureEnvOptions {
|
|
136
|
+
file: EnvFile;
|
|
137
|
+
filePath: string;
|
|
138
|
+
ctx: InstallContext;
|
|
139
|
+
// If true and file doesn't exist, generate fresh values for ALL keys
|
|
140
|
+
// (not just required ones). Default true.
|
|
141
|
+
freshIfMissing?: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function ensureEnvFile(opts: EnsureEnvOptions): Promise<{ added: string[]; created: boolean }> {
|
|
145
|
+
const existing = await readEnvFile(opts.filePath);
|
|
146
|
+
if (existing === null) {
|
|
147
|
+
// Fresh file: synthesize an empty ParsedEnvFile so forwardFill adds all keys.
|
|
148
|
+
const fresh: ParsedEnvFile = { entries: new Map(), rawLines: [] };
|
|
149
|
+
const result = forwardFill(fresh, opts.file, opts.ctx);
|
|
150
|
+
const now = new Date().toISOString();
|
|
151
|
+
const header = `# Generated by ework-aio at ${now}\n`;
|
|
152
|
+
const body = serializeEnvFile(fresh, result.added.map((key) => ({
|
|
153
|
+
key,
|
|
154
|
+
value: result.merged.get(key) ?? "",
|
|
155
|
+
})));
|
|
156
|
+
await writeEnvFileAtomic(opts.filePath, header + body);
|
|
157
|
+
return { added: result.added, created: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const result = forwardFill(existing, opts.file, opts.ctx);
|
|
161
|
+
if (result.added.length === 0) {
|
|
162
|
+
// Nothing to do — preserve the file untouched.
|
|
163
|
+
return { added: [], created: false };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const body = serializeEnvFile(existing, result.added.map((key) => ({
|
|
167
|
+
key,
|
|
168
|
+
value: result.merged.get(key) ?? "",
|
|
169
|
+
})));
|
|
170
|
+
await writeEnvFileAtomic(opts.filePath, body);
|
|
171
|
+
return { added: result.added, created: false };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// patchEnvKey: rewrite a single key in .env, preserving all other lines,
|
|
175
|
+
// then write atomically (temp + rename, mode 0600). Used by config set
|
|
176
|
+
// and install.ts (BOT_TOKEN injection after forward-fill).
|
|
177
|
+
//
|
|
178
|
+
// If the file doesn't exist, it's created with just this key. If the key
|
|
179
|
+
// already exists, its value is replaced in-place. Otherwise the key is
|
|
180
|
+
// appended after the last non-blank line.
|
|
181
|
+
//
|
|
182
|
+
// Key matching uses the same tolerance as parseEnvFile: leading/trailing
|
|
183
|
+
// whitespace around `=` is accepted (`KEY = value` matches key `KEY`).
|
|
184
|
+
// Without this alignment, patch would append a duplicate line instead of
|
|
185
|
+
// replacing, and downstream parseEnvFile would silently pick one of the
|
|
186
|
+
// two values depending on iteration order.
|
|
187
|
+
export async function patchEnvKey(envFile: string, key: string, value: string): Promise<void> {
|
|
188
|
+
let content = "";
|
|
189
|
+
try {
|
|
190
|
+
content = await Bun.file(envFile).text();
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
193
|
+
}
|
|
194
|
+
const lines = content.split(/\r?\n/);
|
|
195
|
+
let found = false;
|
|
196
|
+
for (const [i, line] of lines.entries()) {
|
|
197
|
+
const eqIdx = line.indexOf("=");
|
|
198
|
+
if (eqIdx === -1) continue;
|
|
199
|
+
// Skip comment lines (parseEnvFile ignores leading '#' too).
|
|
200
|
+
if (line.slice(0, eqIdx).trim().startsWith("#")) continue;
|
|
201
|
+
if (line.slice(0, eqIdx).trim() === key) {
|
|
202
|
+
lines[i] = `${key}=${value}`;
|
|
203
|
+
found = true;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!found) {
|
|
208
|
+
// Trim trailing blank lines, then append.
|
|
209
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
210
|
+
if (lines.length > 0) lines.push("");
|
|
211
|
+
lines.push(`${key}=${value}`);
|
|
212
|
+
} else {
|
|
213
|
+
// On in-place replace, `content.split(/\r?\n/)` keeps a trailing "" if
|
|
214
|
+
// the file ended with `\n` (the common case). Joining back with "\n"
|
|
215
|
+
// reproduces the trailing "\n", so we'd otherwise double it below.
|
|
216
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
217
|
+
}
|
|
218
|
+
await writeEnvFileAtomic(envFile, lines.join("\n") + "\n");
|
|
219
|
+
}
|