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
package/src/cli.ts
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
// CLI entry point. Manual arg parser (no commander.js — the constraint is
|
|
2
|
+
// no new external deps). Dispatches to command handlers under src/commands/.
|
|
3
|
+
//
|
|
4
|
+
// Parsing rules:
|
|
5
|
+
// - First positional = subcommand (install/uninstall/status/logs/env/
|
|
6
|
+
// config/start/stop/restart/ps/migrate/backfill-timestamps).
|
|
7
|
+
// - `install systemd` = install with --useSystemd. The literal "systemd"
|
|
8
|
+
// positional after install is the only special case.
|
|
9
|
+
// - Global flags (--data-dir, --port, --user, --system, --yes, etc.) can
|
|
10
|
+
// appear anywhere after the subcommand. `config` consumes its own
|
|
11
|
+
// subcommand position too (list/get/set/restart).
|
|
12
|
+
// - Unknown flags → error. We never silently swallow typos.
|
|
13
|
+
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { Logger, InstallError, log as defaultLogger } from "./log.ts";
|
|
17
|
+
import {
|
|
18
|
+
DEFAULTS,
|
|
19
|
+
type GlobalOptions,
|
|
20
|
+
type ServiceTarget,
|
|
21
|
+
} from "./types.ts";
|
|
22
|
+
import { runInstall } from "./commands/install.ts";
|
|
23
|
+
import { runUninstall } from "./commands/uninstall.ts";
|
|
24
|
+
import {
|
|
25
|
+
runStart,
|
|
26
|
+
runStop,
|
|
27
|
+
runRestart,
|
|
28
|
+
runStatus,
|
|
29
|
+
} from "./commands/lifecycle.ts";
|
|
30
|
+
import { runLogs } from "./commands/logs.ts";
|
|
31
|
+
import { runEnv } from "./commands/env.ts";
|
|
32
|
+
import {
|
|
33
|
+
runConfig,
|
|
34
|
+
printConfigHelp,
|
|
35
|
+
type ConfigArgs,
|
|
36
|
+
} from "./commands/config.ts";
|
|
37
|
+
|
|
38
|
+
const VERSION = "0.2.0-dev";
|
|
39
|
+
|
|
40
|
+
const USAGE = `ework-aio <command> [options]
|
|
41
|
+
|
|
42
|
+
Commands:
|
|
43
|
+
install [systemd] [options] Install or upgrade the ework stack (default).
|
|
44
|
+
Add 'systemd' to also write+enable systemd
|
|
45
|
+
units. Without 'systemd', runs in pure
|
|
46
|
+
PID-file mode (no systemctl calls).
|
|
47
|
+
uninstall Stop services and remove units (data preserved)
|
|
48
|
+
status Show service status (PID-file mode)
|
|
49
|
+
logs [web|daemon] Tail logs (Ctrl+C to stop)
|
|
50
|
+
env Print key paths (no secrets)
|
|
51
|
+
config <subcommand> Read / change runtime config (.env keys)
|
|
52
|
+
config list List all settable keys + current values
|
|
53
|
+
config get <KEY> Print current value of one key
|
|
54
|
+
config set <KEY> <VALUE> Set a key, then restart affected service
|
|
55
|
+
(unless --no-restart is given)
|
|
56
|
+
config restart <web|daemon|both>
|
|
57
|
+
Restart one or both services
|
|
58
|
+
|
|
59
|
+
start [web|daemon|both] Start services in PID-file mode (default both)
|
|
60
|
+
stop [web|daemon|both] Stop services (SIGTERM, 5s grace, then SIGKILL)
|
|
61
|
+
restart [web|daemon|both] Stop + start
|
|
62
|
+
ps Show PID-file mode status (alias for 'status')
|
|
63
|
+
|
|
64
|
+
migrate [options] Migrate issues from a Gitea instance
|
|
65
|
+
backfill-timestamps Fix timestamps on already-migrated data
|
|
66
|
+
|
|
67
|
+
Install options:
|
|
68
|
+
systemd Also install systemd units + enable them
|
|
69
|
+
--user (with systemd) user-level units (default)
|
|
70
|
+
--system (with systemd) system-level units (needs sudo)
|
|
71
|
+
--data-dir <path> Override data directory
|
|
72
|
+
(default ~/.local/share/ework-aio)
|
|
73
|
+
--port <n> ework-web port (default ${DEFAULTS.workPort})
|
|
74
|
+
--daemon-port <n> ework-daemon port (default ${DEFAULTS.daemonPort})
|
|
75
|
+
--bot-name <login> Bot username (default ${DEFAULTS.botName})
|
|
76
|
+
--no-start Install but don't start services
|
|
77
|
+
--yes (-y) Skip all prompts (use generated defaults)
|
|
78
|
+
--as-user <login> (sudo only) drop privileges: re-exec install
|
|
79
|
+
as <login> after enabling linger
|
|
80
|
+
--allow-root (sudo only) override refuse-on-root default
|
|
81
|
+
|
|
82
|
+
Global options:
|
|
83
|
+
--no-restart With 'config set': edit .env but skip restart
|
|
84
|
+
-h, --help Show this help
|
|
85
|
+
-v, --version Print version
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
export interface ParsedArgs {
|
|
89
|
+
subcommand: string;
|
|
90
|
+
opts: GlobalOptions;
|
|
91
|
+
positionals: string[];
|
|
92
|
+
configArgs: ConfigArgs;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function defaultOpts(): GlobalOptions {
|
|
96
|
+
return {
|
|
97
|
+
workPort: DEFAULTS.workPort,
|
|
98
|
+
daemonPort: DEFAULTS.daemonPort,
|
|
99
|
+
botName: DEFAULTS.botName,
|
|
100
|
+
scope: DEFAULTS.scope,
|
|
101
|
+
useSystemd: false,
|
|
102
|
+
assumeYes: false,
|
|
103
|
+
allowRoot: false,
|
|
104
|
+
noRestart: false,
|
|
105
|
+
noStart: false,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// parseArgs: manual argv walk. Returns typed result. Throws InstallError
|
|
110
|
+
// on unknown flags / missing values. The shape is stable so tests can
|
|
111
|
+
// exercise it without touching process.argv.
|
|
112
|
+
export function parseArgs(argv: string[]): ParsedArgs {
|
|
113
|
+
const opts = defaultOpts();
|
|
114
|
+
const positionals: string[] = [];
|
|
115
|
+
const configArgs: ConfigArgs = { subcommand: "list" };
|
|
116
|
+
let inConfig = false;
|
|
117
|
+
let useSystemdFromPositional = false;
|
|
118
|
+
// S-1: track if scope was explicitly chosen. Under EUID=0 we used to
|
|
119
|
+
// silently flip default "user" → "system"; that hid the fact that root
|
|
120
|
+
// can't run user-scope systemd (XDG_RUNTIME_DIR unset). Now: defaulted
|
|
121
|
+
// scope still flips (back-compat), explicit --user under root throws.
|
|
122
|
+
let scopeExplicit = false;
|
|
123
|
+
|
|
124
|
+
let i = 0;
|
|
125
|
+
while (i < argv.length) {
|
|
126
|
+
const a = argv[i];
|
|
127
|
+
if (a === undefined) break;
|
|
128
|
+
|
|
129
|
+
// `config` consumes its own positional subcommand.
|
|
130
|
+
if (!inConfig && a === "config" && positionals.length === 0) {
|
|
131
|
+
inConfig = true;
|
|
132
|
+
positionals.push("config");
|
|
133
|
+
i++;
|
|
134
|
+
const peek = (k: number): string | undefined => {
|
|
135
|
+
const v = argv[k];
|
|
136
|
+
return typeof v === "string" && !v.startsWith("-") ? v : undefined;
|
|
137
|
+
};
|
|
138
|
+
// First positional after `config` is its subcommand. If absent or
|
|
139
|
+
// not a recognized subcommand name, fall through to "list" (default)
|
|
140
|
+
// or "help" (if user typed something unknown).
|
|
141
|
+
const sub = peek(i);
|
|
142
|
+
if (sub === undefined) {
|
|
143
|
+
// `config` alone — default to list.
|
|
144
|
+
} else if (sub === "list" || sub === "get" || sub === "set" || sub === "restart" || sub === "help") {
|
|
145
|
+
configArgs.subcommand = sub;
|
|
146
|
+
i++;
|
|
147
|
+
if (sub === "get") {
|
|
148
|
+
const key = peek(i);
|
|
149
|
+
if (key !== undefined) { configArgs.key = key; i++; }
|
|
150
|
+
} else if (sub === "set") {
|
|
151
|
+
const key = peek(i);
|
|
152
|
+
if (key !== undefined) { configArgs.key = key; i++; }
|
|
153
|
+
const value = peek(i);
|
|
154
|
+
if (value !== undefined) { configArgs.value = value; i++; }
|
|
155
|
+
} else if (sub === "restart") {
|
|
156
|
+
// Distinguish "no target given" (default to both) from "typo
|
|
157
|
+
// target given" (reject). Without this, `config restart bogus`
|
|
158
|
+
// silently restarts both services — a real foot-gun in production.
|
|
159
|
+
const next = argv[i];
|
|
160
|
+
if (next === "web" || next === "daemon" || next === "both") {
|
|
161
|
+
configArgs.target = next;
|
|
162
|
+
i++;
|
|
163
|
+
} else if (next !== undefined && !next.startsWith("-")) {
|
|
164
|
+
throw new InstallError(
|
|
165
|
+
`config restart: invalid target '${next}'. Expected: web | daemon | both`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
// Unknown config subcommand — fall through to help.
|
|
171
|
+
configArgs.subcommand = "help";
|
|
172
|
+
i++;
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (a === "-h" || a === "--help") {
|
|
178
|
+
process.stdout.write(USAGE);
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
181
|
+
if (a === "-v" || a === "--version") {
|
|
182
|
+
process.stdout.write(`${VERSION}\n`);
|
|
183
|
+
process.exit(0);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Boolean flags
|
|
187
|
+
if (a === "systemd") {
|
|
188
|
+
useSystemdFromPositional = true;
|
|
189
|
+
i++;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (a === "--user") { opts.scope = "user"; scopeExplicit = true; i++; continue; }
|
|
193
|
+
if (a === "--system") { opts.scope = "system"; scopeExplicit = true; i++; continue; }
|
|
194
|
+
if (a === "--yes" || a === "-y") { opts.assumeYes = true; i++; continue; }
|
|
195
|
+
if (a === "--allow-root") { opts.allowRoot = true; i++; continue; }
|
|
196
|
+
if (a === "--no-start") { opts.noStart = true; i++; continue; }
|
|
197
|
+
if (a === "--no-restart") { opts.noRestart = true; i++; continue; }
|
|
198
|
+
|
|
199
|
+
// Value flags (--flag <value>)
|
|
200
|
+
if (a === "--data-dir") {
|
|
201
|
+
const v = argv[++i];
|
|
202
|
+
if (v === undefined) throw new InstallError("--data-dir requires a value");
|
|
203
|
+
opts.dataDir = v;
|
|
204
|
+
i++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (a === "--port") {
|
|
208
|
+
const v = argv[++i];
|
|
209
|
+
if (v === undefined) throw new InstallError("--port requires a value");
|
|
210
|
+
const n = Number.parseInt(v, 10);
|
|
211
|
+
if (!Number.isFinite(n) || n <= 0 || n > 65535) {
|
|
212
|
+
throw new InstallError(`--port: invalid value '${v}' (must be 1-65535)`);
|
|
213
|
+
}
|
|
214
|
+
opts.workPort = n;
|
|
215
|
+
i++;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (a === "--daemon-port") {
|
|
219
|
+
const v = argv[++i];
|
|
220
|
+
if (v === undefined) throw new InstallError("--daemon-port requires a value");
|
|
221
|
+
const n = Number.parseInt(v, 10);
|
|
222
|
+
if (!Number.isFinite(n) || n <= 0 || n > 65535) {
|
|
223
|
+
throw new InstallError(`--daemon-port: invalid value '${v}' (must be 1-65535)`);
|
|
224
|
+
}
|
|
225
|
+
opts.daemonPort = n;
|
|
226
|
+
i++;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (a === "--bot-name") {
|
|
230
|
+
const v = argv[++i];
|
|
231
|
+
if (v === undefined) throw new InstallError("--bot-name requires a value");
|
|
232
|
+
opts.botName = v;
|
|
233
|
+
i++;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (a === "--as-user") {
|
|
237
|
+
const v = argv[++i];
|
|
238
|
+
if (v === undefined) throw new InstallError("--as-user requires a value");
|
|
239
|
+
opts.asUser = v;
|
|
240
|
+
i++;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (a.startsWith("--")) {
|
|
245
|
+
throw new InstallError(`Unknown option: ${a} (try --help)`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Positional (e.g. "install", "web", "daemon", "both")
|
|
249
|
+
positionals.push(a);
|
|
250
|
+
i++;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (useSystemdFromPositional) opts.useSystemd = true;
|
|
254
|
+
|
|
255
|
+
// S-1: explicit --user under root would silently fail later (systemd
|
|
256
|
+
// user-scope needs XDG_RUNTIME_DIR which root doesn't have). Default
|
|
257
|
+
// scope (no flag) still flips to "system" under root for back-compat.
|
|
258
|
+
if (process.getuid && process.getuid() === 0 && opts.scope === "user") {
|
|
259
|
+
if (scopeExplicit) {
|
|
260
|
+
throw new InstallError(
|
|
261
|
+
"--user cannot be used when running as root: user-scope systemd needs XDG_RUNTIME_DIR which root does not have. " +
|
|
262
|
+
"Either drop privileges (run as a regular user) or use --system.",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
opts.scope = "system";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// S-10: --port and --daemon-port must differ (otherwise both services
|
|
269
|
+
// fight for the same port and neither binds).
|
|
270
|
+
if (opts.workPort === opts.daemonPort) {
|
|
271
|
+
throw new InstallError(
|
|
272
|
+
`--port and --daemon-port must differ (both are ${opts.workPort})`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const subcommand = positionals[0] ?? "install";
|
|
277
|
+
|
|
278
|
+
return { subcommand, opts, positionals, configArgs };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function parseServiceTarget(arg: string | undefined): ServiceTarget {
|
|
282
|
+
if (arg === undefined || arg === "") return "both";
|
|
283
|
+
if (arg === "web" || arg === "daemon" || arg === "both") return arg;
|
|
284
|
+
throw new InstallError(
|
|
285
|
+
`Invalid service target '${arg}'. Expected: web | daemon | both`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// handleAsUser: when invoked with --as-user (typically via sudo), re-exec
|
|
290
|
+
// the install as the named user after enabling linger. Skipped if not root
|
|
291
|
+
// or no --as-user flag.
|
|
292
|
+
function handleAsUser(opts: GlobalOptions, argv: string[]): void {
|
|
293
|
+
if (!opts.asUser) return;
|
|
294
|
+
if (process.getuid && process.getuid() !== 0) {
|
|
295
|
+
throw new InstallError("--as-user requires running as root (use sudo)");
|
|
296
|
+
}
|
|
297
|
+
const target = opts.asUser;
|
|
298
|
+
|
|
299
|
+
// Resolve target user's home + uid via getpwnam equivalent (os.userInfo
|
|
300
|
+
// doesn't take a name arg, so we shell out to `id`).
|
|
301
|
+
const idRes = spawnSync("id", ["-u", target], { encoding: "utf8" });
|
|
302
|
+
if (idRes.status !== 0 || !idRes.stdout.trim()) {
|
|
303
|
+
throw new InstallError(`--as-user: user '${target}' not found`);
|
|
304
|
+
}
|
|
305
|
+
const targetUid = Number.parseInt(idRes.stdout.trim(), 10);
|
|
306
|
+
if (!Number.isFinite(targetUid)) {
|
|
307
|
+
throw new InstallError(`--as-user: could not parse uid for '${target}'`);
|
|
308
|
+
}
|
|
309
|
+
if (targetUid === 0) {
|
|
310
|
+
throw new InstallError(`--as-user: target '${target}' is root — use --allow-root instead`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Enable linger so user-level systemd services survive logout.
|
|
314
|
+
const linger = spawnSync("loginctl", ["enable-linger", target], { encoding: "utf8" });
|
|
315
|
+
if (linger.status !== 0) {
|
|
316
|
+
throw new InstallError(
|
|
317
|
+
`Failed to enable linger for '${target}': ${linger.stderr?.trim() ?? ""}. ` +
|
|
318
|
+
`Try: sudo loginctl enable-linger ${target}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Re-exec as target user with --as-user stripped from argv.
|
|
323
|
+
const self = process.argv[1]!;
|
|
324
|
+
const newArgs = argv.filter((a) => a !== "--as-user" && a !== opts.asUser!);
|
|
325
|
+
const result = spawnSync("sudo", ["-u", target, "--login", self, ...newArgs], {
|
|
326
|
+
stdio: "inherit",
|
|
327
|
+
env: process.env,
|
|
328
|
+
});
|
|
329
|
+
if (result.status !== null) process.exit(result.status);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// enforceRootGuard: refuse install as root unless --allow-root or --as-user.
|
|
334
|
+
// Matches install.sh's root guard. Install is the only command that needs
|
|
335
|
+
// this — other commands are read-only or scoped to user data.
|
|
336
|
+
function enforceRootGuard(opts: GlobalOptions, logger: Logger): void {
|
|
337
|
+
if (!process.getuid || process.getuid() !== 0) return;
|
|
338
|
+
if (opts.asUser) return; // handled above
|
|
339
|
+
if (opts.allowRoot) {
|
|
340
|
+
logger.warn("running install as root with --allow-root — data will live under /root");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
process.stderr.write(
|
|
344
|
+
`ework-aio install refuses to run as root by default.\n\n` +
|
|
345
|
+
`Why this matters:\n` +
|
|
346
|
+
` - Data goes under /root/.local/share/ework-aio (unreadable by other users)\n` +
|
|
347
|
+
` - opencode is searched in root's PATH (usually not installed there)\n` +
|
|
348
|
+
` - npm packages install to system-wide prefix owned by root\n\n` +
|
|
349
|
+
`Option A (recommended): run as a regular user.\n` +
|
|
350
|
+
` npm config set prefix '~/.local'\n` +
|
|
351
|
+
` npm install -g ework-aio\n` +
|
|
352
|
+
` ework-aio install\n\n` +
|
|
353
|
+
`Option B: install with sudo but target a regular user.\n` +
|
|
354
|
+
` sudo ework-aio install --as-user ${os.userInfo().username}\n\n` +
|
|
355
|
+
`Option C: really install as root.\n` +
|
|
356
|
+
` sudo ework-aio install --allow-root\n`,
|
|
357
|
+
);
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// main: parse + dispatch. Returns exit code.
|
|
362
|
+
export async function main(
|
|
363
|
+
argv: string[],
|
|
364
|
+
logger: Logger = defaultLogger,
|
|
365
|
+
): Promise<number> {
|
|
366
|
+
let parsed: ParsedArgs;
|
|
367
|
+
try {
|
|
368
|
+
parsed = parseArgs(argv);
|
|
369
|
+
} catch (err) {
|
|
370
|
+
if (err instanceof InstallError) {
|
|
371
|
+
logger.error(err.message);
|
|
372
|
+
return err.code;
|
|
373
|
+
}
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const { subcommand, opts, positionals, configArgs } = parsed;
|
|
378
|
+
|
|
379
|
+
// --as-user triggers a re-exec; this never returns.
|
|
380
|
+
if (opts.asUser && (subcommand === "install" || subcommand === undefined)) {
|
|
381
|
+
handleAsUser(opts, argv);
|
|
382
|
+
return 1;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
switch (subcommand) {
|
|
387
|
+
case "install": {
|
|
388
|
+
enforceRootGuard(opts, logger);
|
|
389
|
+
await runInstall(opts, logger);
|
|
390
|
+
return 0;
|
|
391
|
+
}
|
|
392
|
+
case "uninstall": {
|
|
393
|
+
await runUninstall(opts, logger);
|
|
394
|
+
return 0;
|
|
395
|
+
}
|
|
396
|
+
case "status":
|
|
397
|
+
case "ps": {
|
|
398
|
+
await runStatus(opts, logger);
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
401
|
+
case "logs": {
|
|
402
|
+
const svc = positionals[1] === "daemon" ? "daemon" : "web";
|
|
403
|
+
await runLogs(opts, logger, svc);
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
case "env": {
|
|
407
|
+
await runEnv(opts, logger);
|
|
408
|
+
return 0;
|
|
409
|
+
}
|
|
410
|
+
case "config": {
|
|
411
|
+
if (configArgs.subcommand === "help") {
|
|
412
|
+
printConfigHelp(logger);
|
|
413
|
+
return 0;
|
|
414
|
+
}
|
|
415
|
+
await runConfig(opts, logger, configArgs);
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
case "start": {
|
|
419
|
+
const target = parseServiceTarget(positionals[1]);
|
|
420
|
+
await runStart(opts, logger, target);
|
|
421
|
+
return 0;
|
|
422
|
+
}
|
|
423
|
+
case "stop": {
|
|
424
|
+
const target = parseServiceTarget(positionals[1]);
|
|
425
|
+
await runStop(opts, logger, target);
|
|
426
|
+
return 0;
|
|
427
|
+
}
|
|
428
|
+
case "restart": {
|
|
429
|
+
const target = parseServiceTarget(positionals[1]);
|
|
430
|
+
await runRestart(opts, logger, target);
|
|
431
|
+
return 0;
|
|
432
|
+
}
|
|
433
|
+
case "migrate": {
|
|
434
|
+
return runDelegateScript("migrate-from-gitea.ts", argv.slice(1));
|
|
435
|
+
}
|
|
436
|
+
case "backfill-timestamps": {
|
|
437
|
+
return runDelegateScript("backfill-timestamps.ts", argv.slice(1));
|
|
438
|
+
}
|
|
439
|
+
default:
|
|
440
|
+
logger.error(`Unknown command: ${subcommand} (try --help)`);
|
|
441
|
+
return 1;
|
|
442
|
+
}
|
|
443
|
+
} catch (err) {
|
|
444
|
+
if (err instanceof InstallError) {
|
|
445
|
+
logger.error(err.message);
|
|
446
|
+
return err.code;
|
|
447
|
+
}
|
|
448
|
+
logger.error(`unexpected error: ${(err as Error).message}`);
|
|
449
|
+
logger.error((err as Error).stack ?? "");
|
|
450
|
+
return 1;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// runDelegateScript: hand off to a sibling TS script via bun. Used for
|
|
455
|
+
// migrate + backfill-timestamps which live in scripts/ and have their own
|
|
456
|
+
// arg surfaces we don't want to absorb into cli.ts.
|
|
457
|
+
function runDelegateScript(scriptName: string, args: string[]): number {
|
|
458
|
+
const scriptPath = new URL(`../scripts/${scriptName}`, import.meta.url).pathname;
|
|
459
|
+
const result = spawnSync("bun", [scriptPath, ...args], {
|
|
460
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
461
|
+
env: process.env,
|
|
462
|
+
});
|
|
463
|
+
if (result.status !== null) return result.status;
|
|
464
|
+
return 1;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Entry-point is bin/ework-aio only. Tests import main() directly.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// `ework-aio config <subcommand>` — read/change runtime .env keys.
|
|
2
|
+
//
|
|
3
|
+
// Subcommands:
|
|
4
|
+
// list Show all settable keys + current values
|
|
5
|
+
// get <KEY> Print one key's value
|
|
6
|
+
// set <KEY> <VALUE> Write to .env, then restart affected service
|
|
7
|
+
// (use --no-restart to skip)
|
|
8
|
+
// restart <web|daemon|both> Restart one or both services
|
|
9
|
+
//
|
|
10
|
+
// Allow-list: only keys in SETTABLE_KEYS (src/types.ts) are exposed.
|
|
11
|
+
// Secrets, DB paths, and the web<->daemon contract keys are deliberately
|
|
12
|
+
// excluded — those need `rm .env && ework-aio install` to regenerate.
|
|
13
|
+
//
|
|
14
|
+
// Cross-link propagation: when WORK_PORT changes, the daemon .env's
|
|
15
|
+
// GITEA_URL is rewritten; when DAEMON_PORT changes, the web .env's
|
|
16
|
+
// WORK_DAEMON_WEBHOOK_URL is rewritten. Both services then restart.
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { Logger, InstallError } from "../log.ts";
|
|
21
|
+
import { resolvePaths } from "../paths.ts";
|
|
22
|
+
import { parseEnvFile, patchEnvKey } from "../env.ts";
|
|
23
|
+
import { SECRET_ENV_VARS } from "../config.ts";
|
|
24
|
+
import {
|
|
25
|
+
SETTABLE_KEYS,
|
|
26
|
+
findSettableKey,
|
|
27
|
+
serviceForKey,
|
|
28
|
+
type GlobalOptions,
|
|
29
|
+
type ServiceTarget,
|
|
30
|
+
} from "../types.ts";
|
|
31
|
+
import { runRestart } from "./lifecycle.ts";
|
|
32
|
+
|
|
33
|
+
function envFileForService(
|
|
34
|
+
svc: "web" | "daemon",
|
|
35
|
+
paths: ReturnType<typeof resolvePaths>,
|
|
36
|
+
): string {
|
|
37
|
+
return svc === "web" ? paths.webEnvFile : paths.daemonEnvFile;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readEnvKey(envFile: string, key: string): Promise<string | null> {
|
|
41
|
+
try {
|
|
42
|
+
const content = await Bun.file(envFile).text();
|
|
43
|
+
const v = parseEnvFile(content).entries.get(key);
|
|
44
|
+
return v !== undefined ? v : null;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function configList(opts: GlobalOptions, logger: Logger): Promise<void> {
|
|
52
|
+
const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: opts.useSystemd });
|
|
53
|
+
logger.hr();
|
|
54
|
+
logger.log("Settable config keys");
|
|
55
|
+
logger.hr();
|
|
56
|
+
logger.log(` ${"KEY".padEnd(28)} ${"SERVICE".padEnd(8)} VALUE`);
|
|
57
|
+
for (const entry of SETTABLE_KEYS) {
|
|
58
|
+
const envFile = envFileForService(entry.service, paths);
|
|
59
|
+
let val = await readEnvKey(envFile, entry.key);
|
|
60
|
+
if (val === null) val = "(unset)";
|
|
61
|
+
logger.log(` ${entry.key.padEnd(28)} ${entry.service.padEnd(8)} ${val}`);
|
|
62
|
+
}
|
|
63
|
+
logger.hr();
|
|
64
|
+
logger.log(`Use ${logger.bold("config set <KEY> <VALUE>")} to change a key.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function configGet(
|
|
68
|
+
opts: GlobalOptions,
|
|
69
|
+
logger: Logger,
|
|
70
|
+
key: string,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
const entry = findSettableKey(key);
|
|
73
|
+
if (!entry) {
|
|
74
|
+
throw new InstallError(
|
|
75
|
+
`Key '${key}' is not settable. Run 'ework-aio config list' for the allow-list.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (SECRET_ENV_VARS.has(key)) {
|
|
79
|
+
throw new InstallError(
|
|
80
|
+
`Key '${key}' is a secret — use 'rm .env && ework-aio install' to regenerate.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: opts.useSystemd });
|
|
84
|
+
const envFile = envFileForService(entry.service, paths);
|
|
85
|
+
const val = await readEnvKey(envFile, key);
|
|
86
|
+
if (val === null) {
|
|
87
|
+
logger.warn(`${key} is not currently set in ${envFile}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
process.stdout.write(`${val}\n`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function configSet(
|
|
94
|
+
opts: GlobalOptions,
|
|
95
|
+
logger: Logger,
|
|
96
|
+
key: string,
|
|
97
|
+
value: string,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const entry = findSettableKey(key);
|
|
100
|
+
if (!entry) {
|
|
101
|
+
throw new InstallError(
|
|
102
|
+
`Key '${key}' is not settable. Run 'ework-aio config list' for the allow-list.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (SECRET_ENV_VARS.has(key)) {
|
|
106
|
+
throw new InstallError(
|
|
107
|
+
`Key '${key}' is a secret — use 'rm .env && ework-aio install' to regenerate.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
// .env is a line-oriented format; a value containing a newline would be
|
|
111
|
+
// parsed as two entries by parseEnvFile. Reject it outright so a stray
|
|
112
|
+
// shell expansion (e.g. $'alice\nWORK_TOKEN=evil') can't inject keys.
|
|
113
|
+
if (/[\r\n]/.test(value)) {
|
|
114
|
+
throw new InstallError(
|
|
115
|
+
`Value for ${key} contains a newline — multi-line values are not allowed in .env (got ${value.length} chars)`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: opts.useSystemd });
|
|
120
|
+
const envFile = envFileForService(entry.service, paths);
|
|
121
|
+
|
|
122
|
+
logger.log(`setting ${key}=${value} in ${envFile}`);
|
|
123
|
+
await patchEnvKey(envFile, key, value);
|
|
124
|
+
logger.ok(`${key} updated`);
|
|
125
|
+
|
|
126
|
+
if (entry.propagate) {
|
|
127
|
+
const targetFile = envFileForService(entry.propagate.targetService, paths);
|
|
128
|
+
const newVal = entry.propagate.template(value);
|
|
129
|
+
logger.log(`propagating to ${entry.propagate.targetService} (${entry.propagate.targetKey})`);
|
|
130
|
+
await patchEnvKey(targetFile, entry.propagate.targetKey, newVal);
|
|
131
|
+
logger.ok(`${entry.propagate.targetService} .env updated`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (opts.noRestart) {
|
|
135
|
+
const target = serviceForKey(key) ?? "both";
|
|
136
|
+
logger.warn(`--no-restart: changes saved but service not reloaded. Run 'ework-aio config restart ${target}' to apply.`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const target: ServiceTarget = serviceForKey(key) ?? "both";
|
|
141
|
+
logger.log(`restarting ${target}...`);
|
|
142
|
+
await runRestart(opts, logger, target);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function configRestart(
|
|
146
|
+
opts: GlobalOptions,
|
|
147
|
+
logger: Logger,
|
|
148
|
+
target: ServiceTarget,
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
await runRestart(opts, logger, target);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ConfigArgs {
|
|
154
|
+
subcommand: "list" | "get" | "set" | "restart" | "help";
|
|
155
|
+
key?: string;
|
|
156
|
+
value?: string;
|
|
157
|
+
target?: ServiceTarget;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function runConfig(
|
|
161
|
+
opts: GlobalOptions,
|
|
162
|
+
logger: Logger,
|
|
163
|
+
args: ConfigArgs,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
switch (args.subcommand) {
|
|
166
|
+
case "list":
|
|
167
|
+
await configList(opts, logger);
|
|
168
|
+
return;
|
|
169
|
+
case "get":
|
|
170
|
+
if (!args.key) throw new InstallError("Usage: ework-aio config get <KEY>");
|
|
171
|
+
await configGet(opts, logger, args.key);
|
|
172
|
+
return;
|
|
173
|
+
case "set":
|
|
174
|
+
if (!args.key || args.value === undefined) {
|
|
175
|
+
throw new InstallError("Usage: ework-aio config set <KEY> <VALUE>");
|
|
176
|
+
}
|
|
177
|
+
await configSet(opts, logger, args.key, args.value);
|
|
178
|
+
return;
|
|
179
|
+
case "restart":
|
|
180
|
+
await configRestart(opts, logger, args.target ?? "both");
|
|
181
|
+
return;
|
|
182
|
+
case "help":
|
|
183
|
+
printConfigHelp(logger);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function printConfigHelp(logger: Logger): void {
|
|
189
|
+
logger.log(`ework-aio config <subcommand>`);
|
|
190
|
+
logger.log(``);
|
|
191
|
+
logger.log(`Subcommands:`);
|
|
192
|
+
logger.log(` list List all settable keys + current values`);
|
|
193
|
+
logger.log(` get <KEY> Print current value of one key`);
|
|
194
|
+
logger.log(` set <KEY> <VALUE> Set a key in .env, then restart the affected`);
|
|
195
|
+
logger.log(` service (unless --no-restart is given)`);
|
|
196
|
+
logger.log(` restart <web|daemon|both> Restart one or both services`);
|
|
197
|
+
logger.log(``);
|
|
198
|
+
logger.log(`Examples:`);
|
|
199
|
+
logger.log(` ework-aio config list`);
|
|
200
|
+
logger.log(` ework-aio config get WORK_PORT`);
|
|
201
|
+
logger.log(` ework-aio config set WORK_PORT 8080`);
|
|
202
|
+
logger.log(` ework-aio config set WORK_TRANSLATE_URL http://127.0.0.1:8000/v1 --no-restart`);
|
|
203
|
+
logger.log(` ework-aio config restart both`);
|
|
204
|
+
logger.log(``);
|
|
205
|
+
logger.log(`Note: changing WORK_PORT or DAEMON_PORT also rewrites the cross-link the other`);
|
|
206
|
+
logger.log(`service uses, and restarts both. Secrets and DB paths are not settable here —`);
|
|
207
|
+
logger.log(`rerun \`ework-aio install\` (with \`rm .env\` first if you need new tokens).`);
|
|
208
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// `ework-aio env` — print resolved paths (no secrets). Useful for users
|
|
2
|
+
// to find where data, .env files, and the bot token live without grepping.
|
|
3
|
+
|
|
4
|
+
import { Logger } from "../log.ts";
|
|
5
|
+
import { resolvePaths } from "../paths.ts";
|
|
6
|
+
import type { GlobalOptions } from "../types.ts";
|
|
7
|
+
|
|
8
|
+
export async function runEnv(opts: GlobalOptions, logger: Logger): Promise<void> {
|
|
9
|
+
const paths = resolvePaths({
|
|
10
|
+
dataDir: opts.dataDir,
|
|
11
|
+
scope: opts.scope,
|
|
12
|
+
useSystemd: opts.useSystemd,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
logger.hr();
|
|
16
|
+
logger.log("ework-aio paths");
|
|
17
|
+
logger.hr();
|
|
18
|
+
logger.log(` data dir : ${paths.dataDir}`);
|
|
19
|
+
logger.log(` web env : ${paths.webEnvFile}`);
|
|
20
|
+
logger.log(` daemon env : ${paths.daemonEnvFile}`);
|
|
21
|
+
logger.log(` bot token : ${paths.botTokenFile}`);
|
|
22
|
+
logger.log(` opencode cfg : ${paths.opencodeConfigFile}`);
|
|
23
|
+
logger.log(` scope : ${opts.scope}`);
|
|
24
|
+
logger.log(` mode : ${opts.useSystemd ? "systemd" : "PID-file"}`);
|
|
25
|
+
logger.log(` web unit : ${paths.webUnitFile ?? "(not used)"}`);
|
|
26
|
+
logger.log(` daemon unit : ${paths.daemonUnitFile ?? "(not used)"}`);
|
|
27
|
+
}
|