ework-aio 0.1.17 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts ADDED
@@ -0,0 +1,504 @@
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
+ // G1: normalize `--flag=value` (Unix-conventional equals form) into
114
+ // `--flag value` so the rest of the parser only handles the space
115
+ // form. Short flags (-h, -v, -y) don't get this treatment (they never
116
+ // take `=` syntax). Plain `--` (end-of-options marker) and tokens
117
+ // without `--` prefix are left alone.
118
+ const expanded: string[] = [];
119
+ for (const tok of argv) {
120
+ if (tok.length > 3 && tok.startsWith("--") && tok.indexOf("=") > 2) {
121
+ const eqIdx = tok.indexOf("=");
122
+ expanded.push(tok.slice(0, eqIdx), tok.slice(eqIdx + 1));
123
+ } else {
124
+ expanded.push(tok);
125
+ }
126
+ }
127
+ argv = expanded;
128
+
129
+ const opts = defaultOpts();
130
+ const positionals: string[] = [];
131
+ const configArgs: ConfigArgs = { subcommand: "list" };
132
+ let inConfig = false;
133
+ let useSystemdFromPositional = false;
134
+ // S-1: track if scope was explicitly chosen. Under EUID=0 we used to
135
+ // silently flip default "user" → "system"; that hid the fact that root
136
+ // can't run user-scope systemd (XDG_RUNTIME_DIR unset). Now: defaulted
137
+ // scope still flips (back-compat), explicit --user under root throws.
138
+ let scopeExplicit = false;
139
+
140
+ let i = 0;
141
+ while (i < argv.length) {
142
+ const a = argv[i];
143
+ if (a === undefined) break;
144
+
145
+ // `config` consumes its own positional subcommand.
146
+ if (!inConfig && a === "config" && positionals.length === 0) {
147
+ inConfig = true;
148
+ positionals.push("config");
149
+ i++;
150
+ const peek = (k: number): string | undefined => {
151
+ const v = argv[k];
152
+ return typeof v === "string" && !v.startsWith("-") ? v : undefined;
153
+ };
154
+ // First positional after `config` is its subcommand. If absent or
155
+ // not a recognized subcommand name, fall through to "list" (default)
156
+ // or "help" (if user typed something unknown).
157
+ const sub = peek(i);
158
+ if (sub === undefined) {
159
+ // `config` alone — default to list.
160
+ } else if (sub === "list" || sub === "get" || sub === "set" || sub === "restart" || sub === "help") {
161
+ configArgs.subcommand = sub;
162
+ i++;
163
+ if (sub === "get") {
164
+ const key = peek(i);
165
+ if (key !== undefined) { configArgs.key = key; i++; }
166
+ } else if (sub === "set") {
167
+ const key = peek(i);
168
+ if (key !== undefined) { configArgs.key = key; i++; }
169
+ const value = peek(i);
170
+ if (value !== undefined) { configArgs.value = value; i++; }
171
+ } else if (sub === "restart") {
172
+ // Distinguish "no target given" (default to both) from "typo
173
+ // target given" (reject). Without this, `config restart bogus`
174
+ // silently restarts both services — a real foot-gun in production.
175
+ const next = argv[i];
176
+ if (next === "web" || next === "daemon" || next === "both") {
177
+ configArgs.target = next;
178
+ i++;
179
+ } else if (next !== undefined && !next.startsWith("-")) {
180
+ throw new InstallError(
181
+ `config restart: invalid target '${next}'. Expected: web | daemon | both`,
182
+ );
183
+ }
184
+ }
185
+ } else {
186
+ // Unknown config subcommand — fall through to help.
187
+ configArgs.subcommand = "help";
188
+ i++;
189
+ }
190
+ continue;
191
+ }
192
+
193
+ if (a === "-h" || a === "--help") {
194
+ process.stdout.write(USAGE);
195
+ process.exit(0);
196
+ }
197
+ if (a === "-v" || a === "--version") {
198
+ process.stdout.write(`${VERSION}\n`);
199
+ process.exit(0);
200
+ }
201
+
202
+ // Boolean flags
203
+ if (a === "systemd") {
204
+ useSystemdFromPositional = true;
205
+ i++;
206
+ continue;
207
+ }
208
+ if (a === "--user") { opts.scope = "user"; scopeExplicit = true; i++; continue; }
209
+ if (a === "--system") { opts.scope = "system"; scopeExplicit = true; i++; continue; }
210
+ if (a === "--yes" || a === "-y") { opts.assumeYes = true; i++; continue; }
211
+ if (a === "--allow-root") { opts.allowRoot = true; i++; continue; }
212
+ if (a === "--no-start") { opts.noStart = true; i++; continue; }
213
+ if (a === "--no-restart") { opts.noRestart = true; i++; continue; }
214
+
215
+ // Value flags (--flag <value>)
216
+ if (a === "--data-dir") {
217
+ const v = argv[++i];
218
+ if (v === undefined) throw new InstallError("--data-dir requires a value");
219
+ opts.dataDir = v;
220
+ i++;
221
+ continue;
222
+ }
223
+ if (a === "--port") {
224
+ const v = argv[++i];
225
+ if (v === undefined) throw new InstallError("--port requires a value");
226
+ const n = Number.parseInt(v, 10);
227
+ if (!Number.isFinite(n) || n <= 0 || n > 65535) {
228
+ throw new InstallError(`--port: invalid value '${v}' (must be 1-65535)`);
229
+ }
230
+ opts.workPort = n;
231
+ i++;
232
+ continue;
233
+ }
234
+ if (a === "--daemon-port") {
235
+ const v = argv[++i];
236
+ if (v === undefined) throw new InstallError("--daemon-port requires a value");
237
+ const n = Number.parseInt(v, 10);
238
+ if (!Number.isFinite(n) || n <= 0 || n > 65535) {
239
+ throw new InstallError(`--daemon-port: invalid value '${v}' (must be 1-65535)`);
240
+ }
241
+ opts.daemonPort = n;
242
+ i++;
243
+ continue;
244
+ }
245
+ if (a === "--bot-name") {
246
+ const v = argv[++i];
247
+ if (v === undefined) throw new InstallError("--bot-name requires a value");
248
+ opts.botName = v;
249
+ i++;
250
+ continue;
251
+ }
252
+ if (a === "--as-user") {
253
+ const v = argv[++i];
254
+ if (v === undefined) throw new InstallError("--as-user requires a value");
255
+ opts.asUser = v;
256
+ i++;
257
+ continue;
258
+ }
259
+
260
+ if (a.startsWith("--")) {
261
+ throw new InstallError(`Unknown option: ${a} (try --help)`);
262
+ }
263
+
264
+ // Positional (e.g. "install", "web", "daemon", "both")
265
+ positionals.push(a);
266
+ i++;
267
+ }
268
+
269
+ if (useSystemdFromPositional) opts.useSystemd = true;
270
+
271
+ // S-1: explicit --user under root would silently fail later (systemd
272
+ // user-scope needs XDG_RUNTIME_DIR which root doesn't have). Default
273
+ // scope (no flag) still flips to "system" under root for back-compat.
274
+ if (process.getuid && process.getuid() === 0 && opts.scope === "user") {
275
+ if (scopeExplicit) {
276
+ throw new InstallError(
277
+ "--user cannot be used when running as root: user-scope systemd needs XDG_RUNTIME_DIR which root does not have. " +
278
+ "Either drop privileges (run as a regular user) or use --system.",
279
+ );
280
+ }
281
+ opts.scope = "system";
282
+ }
283
+
284
+ // S-10: --port and --daemon-port must differ (otherwise both services
285
+ // fight for the same port and neither binds).
286
+ if (opts.workPort === opts.daemonPort) {
287
+ throw new InstallError(
288
+ `--port and --daemon-port must differ (both are ${opts.workPort})`,
289
+ );
290
+ }
291
+
292
+ const subcommand = positionals[0] ?? "install";
293
+
294
+ return { subcommand, opts, positionals, configArgs };
295
+ }
296
+
297
+ function parseServiceTarget(arg: string | undefined): ServiceTarget {
298
+ if (arg === undefined || arg === "") return "both";
299
+ if (arg === "web" || arg === "daemon" || arg === "both") return arg;
300
+ throw new InstallError(
301
+ `Invalid service target '${arg}'. Expected: web | daemon | both`,
302
+ );
303
+ }
304
+
305
+ // stripAsUserFlag: return argv without `--as-user <login>` (the flag and
306
+ // its value), so the sudo'd child doesn't re-enter handleAsUser and loop.
307
+ //
308
+ // Iterates by index, NOT Array.filter on value. If the username happens to
309
+ // appear in another argument (path component, common word like "install",
310
+ // or a --bot-name matching the operator login), filter-on-value would
311
+ // silently drop that token and corrupt the child's argv.
312
+ export function stripAsUserFlag(argv: readonly string[]): string[] {
313
+ const out: string[] = [];
314
+ for (let i = 0; i < argv.length; i++) {
315
+ const tok = argv[i];
316
+ if (tok === undefined) break;
317
+ if (tok === "--as-user") {
318
+ i++; // also skip the value
319
+ continue;
320
+ }
321
+ out.push(tok);
322
+ }
323
+ return out;
324
+ }
325
+
326
+ // handleAsUser: when invoked with --as-user (typically via sudo), re-exec
327
+ // the install as the named user after enabling linger. Skipped if not root
328
+ // or no --as-user flag.
329
+ function handleAsUser(opts: GlobalOptions, argv: string[]): void {
330
+ if (!opts.asUser) return;
331
+ if (process.getuid && process.getuid() !== 0) {
332
+ throw new InstallError("--as-user requires running as root (use sudo)");
333
+ }
334
+ const target = opts.asUser;
335
+
336
+ // Resolve target user's home + uid via getpwnam equivalent (os.userInfo
337
+ // doesn't take a name arg, so we shell out to `id`).
338
+ const idRes = spawnSync("id", ["-u", target], { encoding: "utf8" });
339
+ if (idRes.status !== 0 || !idRes.stdout.trim()) {
340
+ throw new InstallError(`--as-user: user '${target}' not found`);
341
+ }
342
+ const targetUid = Number.parseInt(idRes.stdout.trim(), 10);
343
+ if (!Number.isFinite(targetUid)) {
344
+ throw new InstallError(`--as-user: could not parse uid for '${target}'`);
345
+ }
346
+ if (targetUid === 0) {
347
+ throw new InstallError(`--as-user: target '${target}' is root — use --allow-root instead`);
348
+ }
349
+
350
+ // Enable linger so user-level systemd services survive logout.
351
+ const linger = spawnSync("loginctl", ["enable-linger", target], { encoding: "utf8" });
352
+ if (linger.status !== 0) {
353
+ throw new InstallError(
354
+ `Failed to enable linger for '${target}': ${linger.stderr?.trim() ?? ""}. ` +
355
+ `Try: sudo loginctl enable-linger ${target}`,
356
+ );
357
+ }
358
+
359
+ // Re-exec as target user.
360
+ const self = process.argv[1]!;
361
+ const newArgs = stripAsUserFlag(argv);
362
+ const result = spawnSync("sudo", ["-u", target, "--login", self, ...newArgs], {
363
+ stdio: "inherit",
364
+ env: process.env,
365
+ });
366
+ if (result.status !== null) process.exit(result.status);
367
+ process.exit(1);
368
+ }
369
+
370
+ // enforceRootGuard: refuse install as root unless --allow-root or --as-user.
371
+ // Matches install.sh's root guard. Install is the only command that needs
372
+ // this — other commands are read-only or scoped to user data.
373
+ function enforceRootGuard(opts: GlobalOptions, logger: Logger): void {
374
+ if (!process.getuid || process.getuid() !== 0) return;
375
+ if (opts.asUser) return; // handled above
376
+ if (opts.allowRoot) {
377
+ logger.warn("running install as root with --allow-root — data will live under /root");
378
+ return;
379
+ }
380
+ process.stderr.write(
381
+ `ework-aio install refuses to run as root by default.\n\n` +
382
+ `Why this matters:\n` +
383
+ ` - Data goes under /root/.local/share/ework-aio (unreadable by other users)\n` +
384
+ ` - opencode is searched in root's PATH (usually not installed there)\n` +
385
+ ` - npm packages install to system-wide prefix owned by root\n\n` +
386
+ `Option A (recommended): run as a regular user.\n` +
387
+ ` npm config set prefix '~/.local'\n` +
388
+ ` npm install -g ework-aio\n` +
389
+ ` ework-aio install\n\n` +
390
+ `Option B: install with sudo but target a regular user.\n` +
391
+ ` sudo ework-aio install --as-user ${os.userInfo().username}\n\n` +
392
+ `Option C: really install as root.\n` +
393
+ ` sudo ework-aio install --allow-root\n`,
394
+ );
395
+ process.exit(1);
396
+ }
397
+
398
+ // main: parse + dispatch. Returns exit code.
399
+ export async function main(
400
+ argv: string[],
401
+ logger: Logger = defaultLogger,
402
+ ): Promise<number> {
403
+ let parsed: ParsedArgs;
404
+ try {
405
+ parsed = parseArgs(argv);
406
+ } catch (err) {
407
+ if (err instanceof InstallError) {
408
+ logger.error(err.message);
409
+ return err.code;
410
+ }
411
+ throw err;
412
+ }
413
+
414
+ const { subcommand, opts, positionals, configArgs } = parsed;
415
+
416
+ // --as-user triggers a re-exec; this never returns.
417
+ if (opts.asUser && (subcommand === "install" || subcommand === undefined)) {
418
+ handleAsUser(opts, argv);
419
+ return 1;
420
+ }
421
+
422
+ try {
423
+ switch (subcommand) {
424
+ case "install": {
425
+ enforceRootGuard(opts, logger);
426
+ await runInstall(opts, logger);
427
+ return 0;
428
+ }
429
+ case "uninstall": {
430
+ await runUninstall(opts, logger);
431
+ return 0;
432
+ }
433
+ case "status":
434
+ case "ps": {
435
+ await runStatus(opts, logger);
436
+ return 0;
437
+ }
438
+ case "logs": {
439
+ const svc = positionals[1] === "daemon" ? "daemon" : "web";
440
+ await runLogs(opts, logger, svc);
441
+ return 0;
442
+ }
443
+ case "env": {
444
+ await runEnv(opts, logger);
445
+ return 0;
446
+ }
447
+ case "config": {
448
+ if (configArgs.subcommand === "help") {
449
+ printConfigHelp(logger);
450
+ return 0;
451
+ }
452
+ await runConfig(opts, logger, configArgs);
453
+ return 0;
454
+ }
455
+ case "start": {
456
+ const target = parseServiceTarget(positionals[1]);
457
+ await runStart(opts, logger, target);
458
+ return 0;
459
+ }
460
+ case "stop": {
461
+ const target = parseServiceTarget(positionals[1]);
462
+ await runStop(opts, logger, target);
463
+ return 0;
464
+ }
465
+ case "restart": {
466
+ const target = parseServiceTarget(positionals[1]);
467
+ await runRestart(opts, logger, target);
468
+ return 0;
469
+ }
470
+ case "migrate": {
471
+ return runDelegateScript("migrate-from-gitea.ts", argv.slice(1));
472
+ }
473
+ case "backfill-timestamps": {
474
+ return runDelegateScript("backfill-timestamps.ts", argv.slice(1));
475
+ }
476
+ default:
477
+ logger.error(`Unknown command: ${subcommand} (try --help)`);
478
+ return 1;
479
+ }
480
+ } catch (err) {
481
+ if (err instanceof InstallError) {
482
+ logger.error(err.message);
483
+ return err.code;
484
+ }
485
+ logger.error(`unexpected error: ${(err as Error).message}`);
486
+ logger.error((err as Error).stack ?? "");
487
+ return 1;
488
+ }
489
+ }
490
+
491
+ // runDelegateScript: hand off to a sibling TS script via bun. Used for
492
+ // migrate + backfill-timestamps which live in scripts/ and have their own
493
+ // arg surfaces we don't want to absorb into cli.ts.
494
+ function runDelegateScript(scriptName: string, args: string[]): number {
495
+ const scriptPath = new URL(`../scripts/${scriptName}`, import.meta.url).pathname;
496
+ const result = spawnSync("bun", [scriptPath, ...args], {
497
+ stdio: ["inherit", "inherit", "inherit"],
498
+ env: process.env,
499
+ });
500
+ if (result.status !== null) return result.status;
501
+ return 1;
502
+ }
503
+
504
+ // Entry-point is bin/ework-aio only. Tests import main() directly.