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/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
+ }