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,637 @@
|
|
|
1
|
+
// Install orchestrator. Wires together every module that Phase 1+2 produced
|
|
2
|
+
// into the end-to-end flow:
|
|
3
|
+
// preflight → resolvePaths → mkdir → ensureEnvFile(web) → startProcess(web)
|
|
4
|
+
// → pollWebUp → bootstrap bot user + PAT → save bot-token file
|
|
5
|
+
// → patch daemon .env with PAT → ensureEnvFile(daemon) → ensurePluginInFile
|
|
6
|
+
// → startProcess(daemon) → summary printout
|
|
7
|
+
//
|
|
8
|
+
// The flow is the TS port of bin/install.sh. Where install.sh used curl,
|
|
9
|
+
// openssl, jq, awk, here we use fetch, node:crypto, JSON.parse, and the
|
|
10
|
+
// typed helpers in src/env.ts.
|
|
11
|
+
//
|
|
12
|
+
// Idempotence contract (sacred — same as install.sh):
|
|
13
|
+
// - Existing .env is preserved; only missing required keys are appended.
|
|
14
|
+
// - bot-token file is reused if present (no second PAT minted).
|
|
15
|
+
// - opencode.json unknown keys are preserved by opencode-config.ts.
|
|
16
|
+
// - Re-running install never destroys user data.
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
22
|
+
import { Logger, InstallError } from "../log.ts";
|
|
23
|
+
import { resolvePaths, type PathConfig } from "../paths.ts";
|
|
24
|
+
import { type InstallContext } from "../config.ts";
|
|
25
|
+
import { ensureEnvFile, parseEnvFile, patchEnvKey } from "../env.ts";
|
|
26
|
+
import { startProcess, isProcessRunning, readPidFile } from "../pidfile.ts";
|
|
27
|
+
import { checkPreflight, resolveCommand, REQUIRED_COMMANDS } from "../preflight.ts";
|
|
28
|
+
import {
|
|
29
|
+
generateUnitFile,
|
|
30
|
+
writeUnitFile,
|
|
31
|
+
installUnit,
|
|
32
|
+
startUnit,
|
|
33
|
+
disableUnit,
|
|
34
|
+
type SystemctlOptions,
|
|
35
|
+
} from "../systemd.ts";
|
|
36
|
+
import { ensurePluginInFile } from "../opencode-config.ts";
|
|
37
|
+
import type { GlobalOptions } from "../types.ts";
|
|
38
|
+
import { DEFAULTS } from "../types.ts";
|
|
39
|
+
|
|
40
|
+
export interface InstallOutcome {
|
|
41
|
+
mode: "pidfile" | "systemd";
|
|
42
|
+
webStarted: boolean;
|
|
43
|
+
daemonStarted: boolean;
|
|
44
|
+
botBootstrapped: boolean;
|
|
45
|
+
paths: PathConfig;
|
|
46
|
+
workPort: number;
|
|
47
|
+
botName: string;
|
|
48
|
+
botToken: string;
|
|
49
|
+
operatorLogin: string;
|
|
50
|
+
workToken: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const PLUGIN_NAME = "opencode-ework@latest";
|
|
54
|
+
const BOT_TOKEN_FILENAME = "bot-token";
|
|
55
|
+
|
|
56
|
+
// Minimal callable fetch signature — narrower than Bun's `typeof fetch`
|
|
57
|
+
// (which adds preconnect etc.) so test mocks can pass plain functions.
|
|
58
|
+
type FetchLike = (input: URL | string | Request, init?: RequestInit) => Promise<Response>;
|
|
59
|
+
|
|
60
|
+
export interface InstallHooks {
|
|
61
|
+
// Overridable fetch (for tests).
|
|
62
|
+
fetchImpl?: FetchLike;
|
|
63
|
+
// Overridable file-existence check (for tests).
|
|
64
|
+
exists?: (p: string) => boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function runInstall(
|
|
68
|
+
opts: GlobalOptions,
|
|
69
|
+
logger: Logger,
|
|
70
|
+
hooks: InstallHooks = {},
|
|
71
|
+
): Promise<InstallOutcome> {
|
|
72
|
+
const exists = hooks.exists ?? ((p) => fs.existsSync(p));
|
|
73
|
+
// S-4: process.env.USER is settable by the caller and sudo variants
|
|
74
|
+
// propagate it inconsistently (sudo -i keeps the original, plain sudo
|
|
75
|
+
// resets it). os.userInfo().username reads from the password database
|
|
76
|
+
// via the getpwuid syscall — the only authoritative source for "who am
|
|
77
|
+
// I running as right now".
|
|
78
|
+
const operatorLogin = os.userInfo().username;
|
|
79
|
+
|
|
80
|
+
logger.hr();
|
|
81
|
+
logger.log("ework-aio install");
|
|
82
|
+
logger.log(` mode : ${opts.useSystemd ? "systemd" : "PID-file (no systemd)"}`);
|
|
83
|
+
logger.log(` data dir : ${opts.dataDir ?? "(default ~/.local/share/ework-aio)"}`);
|
|
84
|
+
logger.hr();
|
|
85
|
+
|
|
86
|
+
// 1. Preflight: bun/npm/opencode must exist on PATH.
|
|
87
|
+
const preflight = checkPreflight([...REQUIRED_COMMANDS], {
|
|
88
|
+
optionalCommands: ["systemctl"],
|
|
89
|
+
});
|
|
90
|
+
if (preflight.missing.length > 0) {
|
|
91
|
+
throw new InstallError(
|
|
92
|
+
`Missing required commands on PATH: ${preflight.missing.join(", ")}. ` +
|
|
93
|
+
`Install them and re-run ework-aio install.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const opencodeBin = preflight.found.get("opencode")!;
|
|
97
|
+
logger.ok(`preflight: bun, npm, opencode all on PATH`);
|
|
98
|
+
|
|
99
|
+
// 2. Resolve ework-web / ework-daemon binaries (npm-installed global bins).
|
|
100
|
+
const webBin = resolveCommand("ework-web");
|
|
101
|
+
const daemonBin = resolveCommand("ework-daemon");
|
|
102
|
+
if (!webBin) {
|
|
103
|
+
throw new InstallError(
|
|
104
|
+
`ework-web binary not found on PATH. Install with: npm install -g ework-web`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (!daemonBin) {
|
|
108
|
+
throw new InstallError(
|
|
109
|
+
`ework-daemon binary not found on PATH. Install with: npm install -g ework-daemon`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
logger.ok(`web bin : ${webBin}`);
|
|
113
|
+
logger.ok(`daemon bin : ${daemonBin}`);
|
|
114
|
+
|
|
115
|
+
// 3. Resolve all filesystem paths.
|
|
116
|
+
const paths = resolvePaths({
|
|
117
|
+
dataDir: opts.dataDir,
|
|
118
|
+
scope: opts.scope,
|
|
119
|
+
useSystemd: opts.useSystemd,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// 4. Ensure data directories exist.
|
|
123
|
+
for (const dir of [
|
|
124
|
+
paths.dataDir,
|
|
125
|
+
paths.webDataDir,
|
|
126
|
+
paths.daemonDataDir,
|
|
127
|
+
paths.runDir,
|
|
128
|
+
paths.opencodeWorkdir,
|
|
129
|
+
paths.webAttachmentRoot,
|
|
130
|
+
]) {
|
|
131
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 5. Write (or forward-fill) web .env.
|
|
135
|
+
const ctx: InstallContext = {
|
|
136
|
+
paths,
|
|
137
|
+
workPort: opts.workPort,
|
|
138
|
+
daemonPort: opts.daemonPort,
|
|
139
|
+
botName: opts.botName,
|
|
140
|
+
operatorLogin,
|
|
141
|
+
opencodeBin,
|
|
142
|
+
};
|
|
143
|
+
const webEnvResult = await ensureEnvFile({ file: "web", filePath: paths.webEnvFile, ctx });
|
|
144
|
+
if (webEnvResult.created) {
|
|
145
|
+
logger.ok(`wrote ${paths.webEnvFile}`);
|
|
146
|
+
} else if (webEnvResult.added.length > 0) {
|
|
147
|
+
logger.ok(`forward-filled ${paths.webEnvFile}: +${webEnvResult.added.length} keys (${webEnvResult.added.join(", ")})`);
|
|
148
|
+
} else {
|
|
149
|
+
logger.ok(`web .env up to date`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// B-4: --port / --daemon-port only apply to FRESH installs (forward-fill
|
|
153
|
+
// never overwrites). If the user passed --port but .env already has a
|
|
154
|
+
// different WORK_PORT, warn — the value didn't take effect. Use
|
|
155
|
+
// `config set WORK_PORT <N>` to change a running install's port.
|
|
156
|
+
if (!webEnvResult.created) {
|
|
157
|
+
const existingWorkPort = await readEnvKey(paths.webEnvFile, "WORK_PORT");
|
|
158
|
+
if (existingWorkPort && parseInt(existingWorkPort, 10) !== opts.workPort) {
|
|
159
|
+
logger.warn(
|
|
160
|
+
`--port ${opts.workPort} ignored: existing .env has WORK_PORT=${existingWorkPort}. ` +
|
|
161
|
+
`Use 'ework-aio config set WORK_PORT ${opts.workPort}' to change it.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const existingDaemonPort = await readEnvKey(paths.daemonEnvFile, "DAEMON_PORT");
|
|
165
|
+
if (existingDaemonPort && parseInt(existingDaemonPort, 10) !== opts.daemonPort) {
|
|
166
|
+
logger.warn(
|
|
167
|
+
`--daemon-port ${opts.daemonPort} ignored: existing .env has DAEMON_PORT=${existingDaemonPort}. ` +
|
|
168
|
+
`Use 'ework-aio config set DAEMON_PORT ${opts.daemonPort}' to change it.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 6. (Systemd only) Write unit, daemon-reload, enable.
|
|
174
|
+
let systemdOk = false;
|
|
175
|
+
if (opts.useSystemd && paths.webUnitFile) {
|
|
176
|
+
const unitOpts: SystemctlOptions = { scope: opts.scope };
|
|
177
|
+
const userInfo = os.userInfo();
|
|
178
|
+
const webUnit = generateUnitFile("ework-web", {
|
|
179
|
+
user: userInfo.username,
|
|
180
|
+
group: userInfo.username,
|
|
181
|
+
binPath: preflight.found.get("bun")!,
|
|
182
|
+
mainScript: webBin,
|
|
183
|
+
envFile: paths.webEnvFile,
|
|
184
|
+
workingDirectory: paths.webDataDir,
|
|
185
|
+
logFile: paths.webLogFile,
|
|
186
|
+
scope: opts.scope,
|
|
187
|
+
});
|
|
188
|
+
await writeUnitFile(paths.webUnitFile, webUnit);
|
|
189
|
+
logger.ok(`wrote ${paths.webUnitFile}`);
|
|
190
|
+
try {
|
|
191
|
+
await installUnit("ework-web", paths.webUnitFile, webUnit, unitOpts);
|
|
192
|
+
systemdOk = true;
|
|
193
|
+
// S-1: enable linger so user-scope units survive logout. Without
|
|
194
|
+
// this, `install systemd --user` produces units that die when the
|
|
195
|
+
// user logs out — defeating the point of systemd mode. Best-effort.
|
|
196
|
+
if (opts.scope === "user") {
|
|
197
|
+
const userInfo = os.userInfo();
|
|
198
|
+
const linger = Bun.spawnSync(["loginctl", "enable-linger", userInfo.username], {
|
|
199
|
+
env: process.env,
|
|
200
|
+
stdout: "pipe", stderr: "pipe",
|
|
201
|
+
});
|
|
202
|
+
if (linger.exitCode === 0) {
|
|
203
|
+
logger.ok(`enabled linger for ${userInfo.username} (user units survive logout)`);
|
|
204
|
+
} else {
|
|
205
|
+
logger.warn(
|
|
206
|
+
`could not enable linger for ${userInfo.username}: ${linger.stderr?.toString().trim() ?? ""}. ` +
|
|
207
|
+
`Run 'sudo loginctl enable-linger ${userInfo.username}' so user units survive logout.`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
logger.warn(`systemd install failed for ework-web: ${(err as Error).message}`);
|
|
213
|
+
logger.warn(`falling back to PID-file mode for ework-web`);
|
|
214
|
+
systemdOk = false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 7. Start ework-web (PID-file mode, unless systemd already brought it up).
|
|
219
|
+
// --no-start skips service startup entirely; .env is written but the user
|
|
220
|
+
// runs `ework-aio start` themselves later. Bot bootstrap is also skipped
|
|
221
|
+
// (web isn't reachable).
|
|
222
|
+
let webStarted = false;
|
|
223
|
+
const webPidExisting = await readPidFile(paths.webPidFile);
|
|
224
|
+
if (webPidExisting !== null && isProcessRunning(webPidExisting)) {
|
|
225
|
+
webStarted = true;
|
|
226
|
+
logger.log(`ework-web already running (pid ${webPidExisting})`);
|
|
227
|
+
}
|
|
228
|
+
if (!webStarted && opts.noStart) {
|
|
229
|
+
logger.warn(`--no-start: skipping ework-web startup (write .env only)`);
|
|
230
|
+
} else if (!webStarted && systemdOk) {
|
|
231
|
+
try {
|
|
232
|
+
await startUnit("ework-web", { scope: opts.scope });
|
|
233
|
+
webStarted = true;
|
|
234
|
+
systemdOk = true;
|
|
235
|
+
logger.ok(`started ework-web via systemd`);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
logger.warn(`systemd start failed: ${(err as Error).message}`);
|
|
238
|
+
logger.warn(`disabling ework-web unit and falling back to PID-file mode`);
|
|
239
|
+
// Disable the already-enabled unit so it doesn't auto-start on next
|
|
240
|
+
// boot and fight the PID-file mode process for the port.
|
|
241
|
+
try {
|
|
242
|
+
await disableUnit("ework-web", { scope: opts.scope });
|
|
243
|
+
} catch (disableErr) {
|
|
244
|
+
logger.warn(`could not disable ework-web unit: ${(disableErr as Error).message}`);
|
|
245
|
+
}
|
|
246
|
+
systemdOk = false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (!webStarted && !opts.noStart) {
|
|
250
|
+
logger.log(`starting ework-web (PID-file mode)...`);
|
|
251
|
+
const env = await loadEnvIntoProcess(paths.webEnvFile);
|
|
252
|
+
const { pid } = await startProcess({
|
|
253
|
+
cmd: webBin,
|
|
254
|
+
args: [],
|
|
255
|
+
cwd: paths.webDataDir,
|
|
256
|
+
env,
|
|
257
|
+
logFile: paths.webLogFile,
|
|
258
|
+
pidFile: paths.webPidFile,
|
|
259
|
+
});
|
|
260
|
+
logger.ok(`ework-web started (pid ${pid}, log ${paths.webLogFile})`);
|
|
261
|
+
webStarted = true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 8. Wait for ework-web to answer on its HTTP port. Skipped under --no-start.
|
|
265
|
+
// S-3: read WORK_PORT from the actual .env rather than opts.workPort.
|
|
266
|
+
// On a re-install where the user previously ran `config set WORK_PORT 8080`
|
|
267
|
+
// and didn't pass --port this time, opts.workPort is the default (3002)
|
|
268
|
+
// but the running service listens on 8080. Polling the wrong port times
|
|
269
|
+
// out at 30s every re-install — a confusing UX with no error hint.
|
|
270
|
+
const actualWorkPortStr = await readEnvKey(paths.webEnvFile, "WORK_PORT");
|
|
271
|
+
const actualWorkPort = actualWorkPortStr && /^\d+$/.test(actualWorkPortStr)
|
|
272
|
+
? parseInt(actualWorkPortStr, 10)
|
|
273
|
+
: opts.workPort;
|
|
274
|
+
const baseUrl = `http://127.0.0.1:${actualWorkPort}`;
|
|
275
|
+
if (webStarted) {
|
|
276
|
+
try {
|
|
277
|
+
await pollHttpUp(baseUrl + "/login", hooks.fetchImpl);
|
|
278
|
+
logger.ok(`ework-web listening on :${actualWorkPort}`);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (!opts.noStart) throw err;
|
|
281
|
+
logger.warn(`ework-web not reachable (start failed under --no-start): ${(err as Error).message}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 9. Bootstrap bot user + PAT (idempotent — reuse saved token if present).
|
|
286
|
+
// Skipped under --no-start (no point — web isn't reachable to drive the API).
|
|
287
|
+
// botTokenFile is per-botName so changing --bot-name invalidates the cache
|
|
288
|
+
// (otherwise we'd reuse a PAT minted for a different bot user).
|
|
289
|
+
const workToken = await readEnvKey(paths.webEnvFile, "WORK_TOKEN");
|
|
290
|
+
const cookieSecret = await readEnvKey(paths.webEnvFile, "WORK_COOKIE_SECRET");
|
|
291
|
+
if (!workToken || !cookieSecret) {
|
|
292
|
+
throw new InstallError(
|
|
293
|
+
`web .env missing WORK_TOKEN or WORK_COOKIE_SECRET — refusing to bootstrap`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const botTokenFile = opts.botName === DEFAULTS.botName
|
|
298
|
+
? paths.botTokenFile
|
|
299
|
+
: `${paths.botTokenFile}.${opts.botName}`;
|
|
300
|
+
let botToken = "";
|
|
301
|
+
let botBootstrapped = false;
|
|
302
|
+
let bootstrapFailed = false;
|
|
303
|
+
if (webStarted && exists(botTokenFile)) {
|
|
304
|
+
botToken = (await fs.promises.readFile(botTokenFile, "utf8")).trim();
|
|
305
|
+
if (botToken) {
|
|
306
|
+
logger.ok(`reusing saved bot token from ${botTokenFile}`);
|
|
307
|
+
botBootstrapped = true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (webStarted && !botBootstrapped) {
|
|
311
|
+
try {
|
|
312
|
+
botToken = await bootstrapBot({
|
|
313
|
+
baseUrl,
|
|
314
|
+
adminCookie: buildAuthCookie(workToken, cookieSecret),
|
|
315
|
+
botLogin: opts.botName,
|
|
316
|
+
fetchImpl: hooks.fetchImpl,
|
|
317
|
+
});
|
|
318
|
+
await fs.promises.writeFile(botTokenFile, botToken, { mode: 0o600 });
|
|
319
|
+
logger.ok(`bot PAT saved to ${botTokenFile}`);
|
|
320
|
+
botBootstrapped = true;
|
|
321
|
+
} catch (err) {
|
|
322
|
+
// B-5: surface this as a real failure, not just a warn. The daemon
|
|
323
|
+
// would silently fail every authenticated call otherwise.
|
|
324
|
+
logger.error(`bot bootstrap failed: ${(err as Error).message}`);
|
|
325
|
+
logger.error(`daemon .env will have empty BOT_TOKEN — re-run install to retry`);
|
|
326
|
+
bootstrapFailed = true;
|
|
327
|
+
}
|
|
328
|
+
} else if (!webStarted) {
|
|
329
|
+
logger.warn(`--no-start: skipping bot bootstrap (web not running)`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 10. Write daemon .env (forward-fill, includes BOT_TOKEN if known).
|
|
333
|
+
// The daemon .env's BOT_TOKEN slot is regenerated empty by config.ts; we
|
|
334
|
+
// patch it post-forward-fill with the bootstrap result.
|
|
335
|
+
const daemonEnvResult = await ensureEnvFile({ file: "daemon", filePath: paths.daemonEnvFile, ctx });
|
|
336
|
+
if (daemonEnvResult.created) {
|
|
337
|
+
logger.ok(`wrote ${paths.daemonEnvFile}`);
|
|
338
|
+
} else if (daemonEnvResult.added.length > 0) {
|
|
339
|
+
logger.ok(`forward-filled ${paths.daemonEnvFile}: +${daemonEnvResult.added.length} keys`);
|
|
340
|
+
}
|
|
341
|
+
if (botToken) {
|
|
342
|
+
await patchEnvKey(paths.daemonEnvFile, "BOT_TOKEN", botToken);
|
|
343
|
+
await patchEnvKey(paths.daemonEnvFile, "GITEA_TOKEN", botToken);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 11. (Systemd only) Write + install daemon unit.
|
|
347
|
+
if (opts.useSystemd && paths.daemonUnitFile && systemdOk && !opts.noStart) {
|
|
348
|
+
const userInfo = os.userInfo();
|
|
349
|
+
const unit = generateUnitFile("ework-daemon", {
|
|
350
|
+
user: userInfo.username,
|
|
351
|
+
group: userInfo.username,
|
|
352
|
+
binPath: preflight.found.get("bun")!,
|
|
353
|
+
mainScript: daemonBin,
|
|
354
|
+
envFile: paths.daemonEnvFile,
|
|
355
|
+
workingDirectory: paths.daemonDataDir,
|
|
356
|
+
logFile: paths.daemonLogFile,
|
|
357
|
+
scope: opts.scope,
|
|
358
|
+
});
|
|
359
|
+
await writeUnitFile(paths.daemonUnitFile, unit);
|
|
360
|
+
logger.ok(`wrote ${paths.daemonUnitFile}`);
|
|
361
|
+
try {
|
|
362
|
+
await installUnit("ework-daemon", paths.daemonUnitFile, unit, { scope: opts.scope });
|
|
363
|
+
await startUnit("ework-daemon", { scope: opts.scope });
|
|
364
|
+
logger.ok(`started ework-daemon via systemd`);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
logger.warn(`daemon systemd start failed: ${(err as Error).message}`);
|
|
367
|
+
logger.warn(`disabling ework-daemon unit and falling back to PID-file mode`);
|
|
368
|
+
try {
|
|
369
|
+
await disableUnit("ework-daemon", { scope: opts.scope });
|
|
370
|
+
} catch (disableErr) {
|
|
371
|
+
logger.warn(`could not disable ework-daemon unit: ${(disableErr as Error).message}`);
|
|
372
|
+
}
|
|
373
|
+
systemdOk = false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 12. Start ework-daemon (PID-file mode if systemd didn't bring it up).
|
|
378
|
+
// Skipped under --no-start.
|
|
379
|
+
let daemonStarted = false;
|
|
380
|
+
if (opts.noStart) {
|
|
381
|
+
logger.warn(`--no-start: skipping ework-daemon startup`);
|
|
382
|
+
} else {
|
|
383
|
+
const daemonPid = await readPidFile(paths.daemonPidFile);
|
|
384
|
+
if (daemonPid !== null && isProcessRunning(daemonPid)) {
|
|
385
|
+
daemonStarted = true;
|
|
386
|
+
logger.log(`ework-daemon already running (pid ${daemonPid})`);
|
|
387
|
+
}
|
|
388
|
+
if (!daemonStarted) {
|
|
389
|
+
logger.log(`starting ework-daemon (PID-file mode)...`);
|
|
390
|
+
const env = await loadEnvIntoProcess(paths.daemonEnvFile);
|
|
391
|
+
const { pid } = await startProcess({
|
|
392
|
+
cmd: daemonBin,
|
|
393
|
+
args: [],
|
|
394
|
+
cwd: paths.daemonDataDir,
|
|
395
|
+
env,
|
|
396
|
+
logFile: paths.daemonLogFile,
|
|
397
|
+
pidFile: paths.daemonPidFile,
|
|
398
|
+
});
|
|
399
|
+
logger.ok(`ework-daemon started (pid ${pid}, log ${paths.daemonLogFile})`);
|
|
400
|
+
daemonStarted = true;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 13. Register opencode-ework plugin in opencode.json (idempotent).
|
|
405
|
+
const { added: pluginAdded } = await ensurePluginInFile(paths.opencodeConfigFile, PLUGIN_NAME);
|
|
406
|
+
if (pluginAdded) {
|
|
407
|
+
logger.ok(`registered ${PLUGIN_NAME} in ${paths.opencodeConfigFile}`);
|
|
408
|
+
} else {
|
|
409
|
+
logger.ok(`opencode.json already has ${PLUGIN_NAME}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 14. Print summary.
|
|
413
|
+
printSummary(logger, {
|
|
414
|
+
mode: opts.useSystemd && systemdOk ? "systemd" : "pidfile",
|
|
415
|
+
webStarted,
|
|
416
|
+
daemonStarted,
|
|
417
|
+
botBootstrapped,
|
|
418
|
+
paths,
|
|
419
|
+
workPort: opts.workPort,
|
|
420
|
+
botName: opts.botName,
|
|
421
|
+
botToken,
|
|
422
|
+
operatorLogin,
|
|
423
|
+
workToken,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// B-5: bootstrap failure must not masquerade as success. Throw a typed
|
|
427
|
+
// error so cli.ts main() exits non-zero and the user knows to re-run.
|
|
428
|
+
if (bootstrapFailed) {
|
|
429
|
+
throw new InstallError(
|
|
430
|
+
`install completed with degraded state: bot bootstrap failed. ` +
|
|
431
|
+
`Services may have started but the daemon will not authenticate. ` +
|
|
432
|
+
`Re-run 'ework-aio install' to retry.`,
|
|
433
|
+
2,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
mode: opts.useSystemd && systemdOk ? "systemd" : "pidfile",
|
|
439
|
+
webStarted,
|
|
440
|
+
daemonStarted,
|
|
441
|
+
botBootstrapped,
|
|
442
|
+
paths,
|
|
443
|
+
workPort: opts.workPort,
|
|
444
|
+
botName: opts.botName,
|
|
445
|
+
botToken,
|
|
446
|
+
operatorLogin,
|
|
447
|
+
workToken,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
async function pollHttpUp(url: string, fetchImpl?: FetchLike): Promise<void> {
|
|
454
|
+
const fetchFn = fetchImpl ?? fetch;
|
|
455
|
+
for (let attempt = 1; attempt <= 60; attempt++) {
|
|
456
|
+
try {
|
|
457
|
+
const r = await fetchFn(url, { method: "GET" });
|
|
458
|
+
if (r.status < 500) return;
|
|
459
|
+
} catch {
|
|
460
|
+
// Network error — retry.
|
|
461
|
+
}
|
|
462
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
463
|
+
}
|
|
464
|
+
throw new InstallError(`service did not come up at ${url} after 60 attempts (30s)`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// buildAuthCookie: construct the ework_auth cookie value the way ework-web
|
|
468
|
+
// expects: `<token>.<base64url(hmac_sha256(token, secret))>` — the same
|
|
469
|
+
// recipe install.sh used with openssl.
|
|
470
|
+
export function buildAuthCookie(token: string, secret: string): string {
|
|
471
|
+
const sig = createHmac("sha256", secret).update(token).digest();
|
|
472
|
+
const b64url = sig.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
473
|
+
return `ework_auth=${token}.${b64url}`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function readEnvKey(envFile: string, key: string): Promise<string | null> {
|
|
477
|
+
try {
|
|
478
|
+
const content = await Bun.file(envFile).text();
|
|
479
|
+
const parsed = parseEnvFile(content);
|
|
480
|
+
const v = parsed.entries.get(key);
|
|
481
|
+
return v !== undefined ? v : null;
|
|
482
|
+
} catch (err) {
|
|
483
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
484
|
+
throw err;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// loadEnvIntoProcess: read .env into a fresh object suitable for spawn env.
|
|
489
|
+
// Inherits process.env (so PATH etc. are available) and overlays .env keys.
|
|
490
|
+
async function loadEnvIntoProcess(envFile: string): Promise<NodeJS.ProcessEnv> {
|
|
491
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
492
|
+
try {
|
|
493
|
+
const content = await Bun.file(envFile).text();
|
|
494
|
+
const parsed = parseEnvFile(content);
|
|
495
|
+
for (const [k, v] of parsed.entries) env[k] = v;
|
|
496
|
+
} catch {
|
|
497
|
+
// Missing .env is fine — fall back to process.env only.
|
|
498
|
+
}
|
|
499
|
+
return env;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
interface BootstrapBotOpts {
|
|
503
|
+
baseUrl: string;
|
|
504
|
+
adminCookie: string;
|
|
505
|
+
botLogin: string;
|
|
506
|
+
fetchImpl?: FetchLike;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// bootstrapBot: faithful port of install.sh's bot user + PAT flow.
|
|
510
|
+
// Uses the operator's token-derived cookie to drive ework-web's admin API,
|
|
511
|
+
// then logs in as the bot to mint a PAT. Step-by-step idempotence:
|
|
512
|
+
// - create user: HTTP 303 = created, 400/409 = already exists (continue)
|
|
513
|
+
// - login as bot: required to get a session cookie for /me/tokens/create
|
|
514
|
+
// - mint PAT: scrape the token out of the HTML response (the only way
|
|
515
|
+
// ework-web exposes clear-text tokens, matching install.sh)
|
|
516
|
+
async function bootstrapBot(opts: BootstrapBotOpts): Promise<string> {
|
|
517
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
518
|
+
const botPassword = randomBytes(24).toString("hex");
|
|
519
|
+
|
|
520
|
+
// 1. Create bot user via admin API.
|
|
521
|
+
const createRes = await fetchImpl(`${opts.baseUrl}/admin/users/create`, {
|
|
522
|
+
method: "POST",
|
|
523
|
+
headers: { Cookie: opts.adminCookie, "Content-Type": "application/x-www-form-urlencoded" },
|
|
524
|
+
body: new URLSearchParams({
|
|
525
|
+
login: opts.botLogin,
|
|
526
|
+
password: botPassword,
|
|
527
|
+
kind: "bot",
|
|
528
|
+
is_admin: "0",
|
|
529
|
+
}).toString(),
|
|
530
|
+
redirect: "manual",
|
|
531
|
+
});
|
|
532
|
+
// 303 = created, 400/409 = already exists. Anything else = abort.
|
|
533
|
+
if (![303, 400, 409].includes(createRes.status)) {
|
|
534
|
+
const body = await createRes.text();
|
|
535
|
+
throw new InstallError(`create bot user failed (HTTP ${createRes.status}): ${body.slice(0, 200)}`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 2. Login as bot to get its session cookie.
|
|
539
|
+
const loginRes = await fetchImpl(`${opts.baseUrl}/login`, {
|
|
540
|
+
method: "POST",
|
|
541
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
542
|
+
body: new URLSearchParams({
|
|
543
|
+
login: opts.botLogin,
|
|
544
|
+
password: botPassword,
|
|
545
|
+
}).toString(),
|
|
546
|
+
redirect: "manual",
|
|
547
|
+
});
|
|
548
|
+
if (loginRes.status !== 302) {
|
|
549
|
+
const body = await loginRes.text();
|
|
550
|
+
throw new InstallError(`bot login failed (HTTP ${loginRes.status}): ${body.slice(0, 200)}`);
|
|
551
|
+
}
|
|
552
|
+
const setCookie = loginRes.headers.get("set-cookie");
|
|
553
|
+
const botCookie = parseFirstCookie(loginRes.headers);
|
|
554
|
+
if (!botCookie) {
|
|
555
|
+
throw new InstallError(`bot login response missing ework_auth cookie (set-cookie: ${setCookie ?? "<absent>"})`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// 3. Mint PAT via /me/tokens/create. Response is HTML containing the
|
|
559
|
+
// clear-text token in `<input id="t" value="<40-hex>">`. We only get one
|
|
560
|
+
// chance to see the clear-text — must scrape it now.
|
|
561
|
+
//
|
|
562
|
+
// S-3: pin redirect:"manual" so a PRG-style 303→GET doesn't land us on
|
|
563
|
+
// a "token list" page where the clear-text value isn't present.
|
|
564
|
+
//
|
|
565
|
+
// S-2: scrape regex is attribute-order-independent — accepts both
|
|
566
|
+
// `<input id="t" value="...">` and `<input value="..." id="t">`.
|
|
567
|
+
const patRes = await fetchImpl(`${opts.baseUrl}/me/tokens/create`, {
|
|
568
|
+
method: "POST",
|
|
569
|
+
headers: { Cookie: botCookie, "Content-Type": "application/x-www-form-urlencoded" },
|
|
570
|
+
body: new URLSearchParams({ name: `aio-${Date.now()}` }).toString(),
|
|
571
|
+
redirect: "manual",
|
|
572
|
+
});
|
|
573
|
+
if (patRes.status !== 200) {
|
|
574
|
+
throw new InstallError(
|
|
575
|
+
`mint PAT returned HTTP ${patRes.status} (expected 200 with HTML body)`,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
const patBody = await patRes.text();
|
|
579
|
+
const match = patBody.match(/<input[^>]*id="t"[^>]*value="([a-f0-9]{40})"/)
|
|
580
|
+
?? patBody.match(/<input[^>]*value="([a-f0-9]{40})"[^>]*id="t"/);
|
|
581
|
+
if (!match || !match[1]) {
|
|
582
|
+
throw new InstallError(`could not extract PAT from token-create response`);
|
|
583
|
+
}
|
|
584
|
+
return match[1];
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function parseFirstCookie(headers: Headers): string | null {
|
|
588
|
+
// S-7: Headers#getSetCookie returns each Set-Cookie as a separate entry.
|
|
589
|
+
// Falling back to parsing the combined "set-cookie" header is fragile —
|
|
590
|
+
// multiple cookies get joined with ", " and individual cookies contain
|
|
591
|
+
// commas in attribute values (`Expires=Wed, 09 Jun 2021 ...`). The
|
|
592
|
+
// regex split looks for ", " followed by a name=value pattern.
|
|
593
|
+
const getSetCookies = (headers as unknown as {
|
|
594
|
+
getSetCookie?: () => string[];
|
|
595
|
+
}).getSetCookie;
|
|
596
|
+
const allCookies: string[] = [];
|
|
597
|
+
if (typeof getSetCookies === "function") {
|
|
598
|
+
allCookies.push(...getSetCookies.call(headers));
|
|
599
|
+
} else {
|
|
600
|
+
const sc = headers.get("set-cookie");
|
|
601
|
+
if (sc) allCookies.push(...sc.split(/,(?=\s*[\w-]+=)/));
|
|
602
|
+
}
|
|
603
|
+
for (const c of allCookies) {
|
|
604
|
+
const kv = c.split(";")[0]?.trim() ?? "";
|
|
605
|
+
if (kv.startsWith("ework_auth=")) return kv;
|
|
606
|
+
}
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function printSummary(logger: Logger, o: InstallOutcome): void {
|
|
611
|
+
logger.hr();
|
|
612
|
+
logger.ok(`install complete (${o.mode} mode)`);
|
|
613
|
+
logger.hr();
|
|
614
|
+
logger.log(` → open http://127.0.0.1:${o.workPort}/login`);
|
|
615
|
+
logger.log(` operator login : ${o.operatorLogin} (auto-promoted admin)`);
|
|
616
|
+
// S-8: only print the token when stdout is a TTY. Piping install output
|
|
617
|
+
// to a file or shell variable (e.g. `logs=$(ework-aio install 2>&1)`)
|
|
618
|
+
// would otherwise leak the admin credential into logs.
|
|
619
|
+
if (process.stdout.isTTY) {
|
|
620
|
+
logger.log(` login token : ${o.workToken}`);
|
|
621
|
+
} else {
|
|
622
|
+
logger.log(` login token : (hidden — read from ${o.paths.webEnvFile})`);
|
|
623
|
+
}
|
|
624
|
+
logger.log(` bot user : ${o.botName} (auto-created)`);
|
|
625
|
+
logger.log(` data dir : ${o.paths.dataDir}`);
|
|
626
|
+
logger.log(` logs : ework-aio logs web | ework-aio logs daemon`);
|
|
627
|
+
logger.log(` status : ework-aio status`);
|
|
628
|
+
logger.log(` stop : ework-aio stop`);
|
|
629
|
+
logger.log(` uninstall : ework-aio uninstall`);
|
|
630
|
+
if (o.mode === "pidfile") {
|
|
631
|
+
logger.hr();
|
|
632
|
+
logger.log(` PID-file mode (services run detached, no systemd supervisor)`);
|
|
633
|
+
logger.log(` • auto-restart on boot: re-run with 'ework-aio install systemd'`);
|
|
634
|
+
logger.log(` • services stop on kill; use 'ework-aio stop' for clean shutdown`);
|
|
635
|
+
}
|
|
636
|
+
logger.hr();
|
|
637
|
+
}
|