ework-aio 0.1.6 → 0.1.9

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 CHANGED
@@ -100,7 +100,7 @@ Keeping these in a separate, explicitly-invoked step is intentional:
100
100
  ```
101
101
  ework-aio install [options] Install or upgrade the stack
102
102
  ework-aio uninstall Stop services and remove units (data preserved)
103
- ework-aio status Show service status
103
+ ework-aio status Show service status (systemd mode)
104
104
  ework-aio logs [web|daemon] Tail logs
105
105
  ework-aio env Print key paths (no secrets)
106
106
  ework-aio config <subcommand> Read / change runtime .env keys
@@ -109,6 +109,12 @@ ework-aio config <subcommand> Read / change runtime .env keys
109
109
  config set <KEY> <VALUE> Set a key, then restart affected service
110
110
  (use --no-restart to defer)
111
111
  config restart <web|daemon|both> Restart one or both services
112
+
113
+ # PID-file mode (no systemd required — see below)
114
+ ework-aio start [web|daemon|both] [--foreground] Start services in background
115
+ ework-aio stop [web|daemon|both] Stop services (SIGTERM, 5s, SIGKILL)
116
+ ework-aio restart [web|daemon|both] Stop + start
117
+ ework-aio ps Show PID-file mode status
112
118
  ```
113
119
 
114
120
  ### Install options
@@ -212,6 +218,45 @@ npm uninstall -g ework-aio ework-web ework-daemon opencode-ework
212
218
  # Remove the plugin entry from ~/.config/opencode/opencode.json manually
213
219
  ```
214
220
 
221
+ ## PID-file mode (no systemd)
222
+
223
+ `install` / `status` / `config` / `uninstall` go through `systemctl`, which assumes systemd. If you're on a system without systemd — **macOS, WSL1, Alpine without systemd, Docker containers, dev laptops** — use the parallel PID-file mode instead:
224
+
225
+ | Mode | Start | Stop | Status | Restart |
226
+ | ----------- | ----------------------------- | ----------------------------- | ------------------- | -------------------------------- |
227
+ | systemd | `ework-aio install` | `ework-aio uninstall` | `ework-aio status` | `ework-aio config restart both` |
228
+ | **PID-file**| `ework-aio start` | `ework-aio stop` | `ework-aio ps` | `ework-aio restart` |
229
+
230
+ PID-file mode writes per-service PID + log under `~/.local/share/ework-aio/run/`:
231
+
232
+ ```
233
+ ~/.local/share/ework-aio/run/
234
+ ├── web.pid # ework-web PID
235
+ ├── web.log # nohup stdout+stderr
236
+ ├── daemon.pid
237
+ └── daemon.log
238
+ ```
239
+
240
+ ```bash
241
+ # One-time setup: scaffold .env + data dir (no systemd required if you skip the unit install)
242
+ ework-aio install --no-start
243
+
244
+ # Then control with PID-file mode:
245
+ ework-aio start # start web + daemon in background
246
+ ework-aio start web # just web
247
+ ework-aio start daemon --foreground # run in current terminal
248
+ ework-aio stop # SIGTERM, 5s grace, SIGKILL if needed
249
+ ework-aio restart web # stop + start
250
+ ework-aio ps # show PID + log path for each service
251
+ ```
252
+
253
+ Override the data dir with `EWORK_AIO_DATA_DIR=/path ework-aio start`.
254
+
255
+ **Notes**:
256
+ - `install` (with `--no-start`) is still required once to scaffold `.env` and the data directory. The systemd units it writes are inert when nothing manages them.
257
+ - PID-file mode does NOT auto-restart on crash. Consider a process supervisor (`pm2`, `supervisord`, `runit`, `launchd`) if you need that.
258
+ - Both modes can coexist (systemd-managed services ignore PID files, and vice versa) but you should pick one to avoid port conflicts.
259
+
215
260
  ## Alternatives
216
261
 
217
262
  - **Docker AIO**: the `ework-web` repo ships `docker/build.sh` + `docker/run.sh` for a single-container deployment. Useful when you don't want to manage host systemd.
package/bin/ework-aio CHANGED
@@ -1,7 +1,14 @@
1
1
  #!/usr/bin/env bun
2
2
  import { spawn, spawnSync } from "bun";
3
- import { existsSync, readFileSync } from "fs";
4
- import { resolve, dirname, join } from "path";
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "fs";
11
+ import { dirname, join, resolve } from "path";
5
12
  import { fileURLToPath } from "url";
6
13
 
7
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -14,8 +21,9 @@ function usage() {
14
21
 
15
22
  Usage:
16
23
  ework-aio install [options] Install and start ework-web + ework-daemon
24
+ (uses systemd; see start/stop for non-systemd)
17
25
  ework-aio uninstall Stop services and remove units (keeps data)
18
- ework-aio status Show service status
26
+ ework-aio status Show service status (systemd mode)
19
27
  ework-aio logs [svc] Tail logs (svc: web | daemon)
20
28
  ework-aio env Print the .env path and key names (no values)
21
29
  ework-aio config <subcommand> Read / change runtime .env keys
@@ -24,6 +32,19 @@ Usage:
24
32
  config set <KEY> <VALUE> Set a key, then restart affected service
25
33
  (use --no-restart to defer)
26
34
  config restart <web|daemon|both>
35
+ ework-aio start [svc] Start services in background WITHOUT systemd
36
+ (PID-file mode, works on WSL/macOS/Alpine).
37
+ svc: web | daemon | both (default both).
38
+ --foreground runs in current terminal.
39
+ ework-aio stop [svc] Stop services started via 'start'
40
+ (SIGTERM, 5s grace, then SIGKILL)
41
+ ework-aio restart [svc] Stop + start
42
+ ework-aio ps Show PID-file mode status (web+daemon)
43
+ ework-aio migrate [options] Migrate issues from a Gitea instance into
44
+ ework-web via the Gitea REST API. Idempotent.
45
+ Run \`ework-aio migrate --help\` for details.
46
+ ework-aio backfill-timestamps Fix created_at/updated_at on already-migrated
47
+ data. Use after migrate when ework.db is local.
27
48
  ework-aio --version Print version
28
49
 
29
50
  Install options:
@@ -35,6 +56,9 @@ Install options:
35
56
  --bot-name <login> Bot username (default: ework-daemon)
36
57
  --no-start Install units but don't start services
37
58
  --yes Skip all prompts (use generated defaults)
59
+
60
+ PID-file mode (--foreground works with start):
61
+ --data-dir <path> Same as install option
38
62
  `);
39
63
  }
40
64
 
@@ -56,6 +80,356 @@ if (!existsSync(installSh)) {
56
80
  }
57
81
 
58
82
  const knownSubs = ["install", "uninstall", "status", "logs", "env", "config"];
83
+
84
+ // ─── PID-file mode (no systemd required) ───────────────────────────────────
85
+ //
86
+ // start/stop/restart/ps bypass install.sh and the systemd dependency entirely.
87
+ // Each service writes its PID + log under $DATA_DIR/run/, managed via the
88
+ // standard node:child_process detached-spawn pattern (sets the child free of
89
+ // the parent's process group so the parent can exit while the service keeps
90
+ // running). This is the path for systems without systemd: macOS, WSL1, Alpine,
91
+ // containers, dev laptops.
92
+
93
+ const SVC_CONFIG = {
94
+ web: {
95
+ binName: "ework-web",
96
+ binFallback: "ework-web/bin/ework-web.js",
97
+ dataSubdir: "ework-web",
98
+ label: "ework-web",
99
+ },
100
+ daemon: {
101
+ binName: "ework-daemon-server",
102
+ binFallback: "ework-daemon/bin/ework-daemon-server.js",
103
+ dataSubdir: "ework-daemon",
104
+ label: "ework-daemon",
105
+ },
106
+ } as const;
107
+
108
+ type SvcName = keyof typeof SVC_CONFIG;
109
+
110
+ function resolveDataDir(): string {
111
+ const xdg = process.env.XDG_DATA_HOME || `${process.env.HOME}/.local/share`;
112
+ return process.env.EWORK_AIO_DATA_DIR || `${xdg}/ework-aio`;
113
+ }
114
+
115
+ function runDir(): string {
116
+ const d = join(resolveDataDir(), "run");
117
+ if (!existsSync(d)) mkdirSync(d, { recursive: true });
118
+ return d;
119
+ }
120
+
121
+ function svcEnvPath(svc: SvcName): string {
122
+ return join(resolveDataDir(), SVC_CONFIG[svc].dataSubdir, ".env");
123
+ }
124
+
125
+ function svcPidPath(svc: SvcName): string {
126
+ return join(runDir(), `${svc}.pid`);
127
+ }
128
+
129
+ function svcLogPath(svc: SvcName): string {
130
+ return join(runDir(), `${svc}.log`);
131
+ }
132
+
133
+ // KEY=VALUE parser, ignores comments + blanks, never throws.
134
+ function parseEnvFile(path: string): Record<string, string> {
135
+ const out: Record<string, string> = {};
136
+ if (!existsSync(path)) return out;
137
+ for (const line of readFileSync(path, "utf8").split("\n")) {
138
+ const trimmed = line.trim();
139
+ if (!trimmed || trimmed.startsWith("#")) continue;
140
+ const eq = trimmed.indexOf("=");
141
+ if (eq < 0) continue;
142
+ const k = trimmed.slice(0, eq).trim();
143
+ let v = trimmed.slice(eq + 1).trim();
144
+ if (
145
+ (v.startsWith('"') && v.endsWith('"')) ||
146
+ (v.startsWith("'") && v.endsWith("'"))
147
+ ) {
148
+ v = v.slice(1, -1);
149
+ }
150
+ if (k) out[k] = v;
151
+ }
152
+ return out;
153
+ }
154
+
155
+ function resolveSvcBin(svc: SvcName): string | null {
156
+ const cfg = SVC_CONFIG[svc];
157
+ // PATH lookup first (covers PATH-installed global bins). `command -v` is a
158
+ // shell builtin so we have to invoke via sh -c.
159
+ const lookup = spawnSync(["sh", "-c", `command -v ${cfg.binName}`], {
160
+ env: process.env,
161
+ stdout: "pipe",
162
+ stderr: "ignore",
163
+ });
164
+ if (lookup.success) {
165
+ const p = lookup.stdout.toString().trim();
166
+ if (p && existsSync(p)) return p;
167
+ }
168
+ // npm global root fallback — covers npm-installed packages whose bin dir
169
+ // isn't on PATH (common with nvm-style installs).
170
+ const npmRoot = spawnSync(["npm", "root", "-g"], {
171
+ env: process.env,
172
+ stdout: "pipe",
173
+ stderr: "ignore",
174
+ });
175
+ if (npmRoot.success) {
176
+ const fallback = join(npmRoot.stdout.toString().trim(), cfg.binFallback);
177
+ if (existsSync(fallback)) return fallback;
178
+ }
179
+ return null;
180
+ }
181
+
182
+ function pidAlive(pid: number): boolean {
183
+ if (!pid || pid <= 0) return false;
184
+ try {
185
+ process.kill(pid, 0);
186
+ return true;
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ function readPid(svc: SvcName): number | null {
193
+ const p = svcPidPath(svc);
194
+ if (!existsSync(p)) return null;
195
+ const raw = readFileSync(p, "utf8").trim();
196
+ const n = Number(raw);
197
+ return Number.isFinite(n) && n > 0 ? n : null;
198
+ }
199
+
200
+ function writePid(svc: SvcName, pid: number): void {
201
+ writeFileSync(svcPidPath(svc), `${pid}\n`, { encoding: "utf8" });
202
+ }
203
+
204
+ function clearPid(svc: SvcName): void {
205
+ const p = svcPidPath(svc);
206
+ if (existsSync(p)) rmSync(p);
207
+
208
+ const staleSocket = join(runDir(), `${svc}.port`);
209
+ if (existsSync(staleSocket)) rmSync(staleSocket);
210
+ }
211
+
212
+ function waitForExit(pid: number, timeoutMs: number): boolean {
213
+ const deadline = Date.now() + timeoutMs;
214
+ while (Date.now() < deadline) {
215
+ if (!pidAlive(pid)) return true;
216
+ const slept = Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
217
+ void slept;
218
+ }
219
+ return !pidAlive(pid);
220
+ }
221
+
222
+ function parseSvcArg(argv: string[]): { svc: "both" | SvcName; foreground: boolean } {
223
+ let svc: "both" | SvcName = "both";
224
+ let foreground = false;
225
+ for (const a of argv.slice(1)) {
226
+ if (a === "--foreground" || a === "-f") foreground = true;
227
+ else if (a === "web" || a === "daemon") svc = a;
228
+ else if (a === "both") svc = "both";
229
+ else {
230
+ console.error(`Unknown argument: ${a}`);
231
+ console.error("Usage: ework-aio <start|stop|restart> [web|daemon|both] [--foreground]");
232
+ process.exit(2);
233
+ }
234
+ }
235
+ return { svc, foreground };
236
+ }
237
+
238
+ function startOne(svc: SvcName, foreground: boolean): boolean {
239
+ const existing = readPid(svc);
240
+ if (existing && pidAlive(existing)) {
241
+ console.log(`${SVC_CONFIG[svc].label} already running (pid ${existing})`);
242
+ return true;
243
+ }
244
+ if (existing) clearPid(svc);
245
+
246
+ const bin = resolveSvcBin(svc);
247
+ if (!bin) {
248
+ console.error(
249
+ `${SVC_CONFIG[svc].label}: cannot find bin '${SVC_CONFIG[svc].binName}' in PATH or npm global root.`
250
+ );
251
+ console.error(` Install with: npm i -g ${SVC_CONFIG[svc].binName.split("-")[0]}`);
252
+ return false;
253
+ }
254
+ const envPath = svcEnvPath(svc);
255
+ if (!existsSync(envPath)) {
256
+ console.error(
257
+ `${SVC_CONFIG[svc].label}: missing .env at ${envPath}.`
258
+ );
259
+ console.error(" Run `ework-aio install --no-start` first to scaffold config + data dir.");
260
+ return false;
261
+ }
262
+
263
+ // Sane perms on the bin (npm sometimes ships without +x).
264
+ try {
265
+ chmodSync(bin, 0o755);
266
+ } catch {
267
+ // best-effort
268
+ }
269
+
270
+ const mergedEnv = { ...process.env, ...parseEnvFile(envPath) };
271
+ const logPath = svcLogPath(svc);
272
+
273
+ if (foreground) {
274
+ console.log(`Starting ${SVC_CONFIG[svc].label} in foreground (Ctrl+C to stop)`);
275
+ const r = spawnSync([bin], {
276
+ stdio: ["inherit", "inherit", "inherit"],
277
+ env: mergedEnv,
278
+ });
279
+ return r.status === 0;
280
+ }
281
+
282
+ // Background: detached spawn via node:child_process so the parent can exit
283
+ // cleanly while the service keeps running. stdout+stderr append to logPath.
284
+ const { spawn: nodeSpawn } = require("node:child_process");
285
+ const { openSync } = require("node:fs");
286
+ const logFd = openSync(logPath, "a");
287
+ const child = nodeSpawn(bin, [], {
288
+ env: mergedEnv,
289
+ detached: true,
290
+ stdio: ["ignore", logFd, logFd],
291
+ });
292
+ child.unref();
293
+ if (!child.pid) {
294
+ console.error(`${SVC_CONFIG[svc].label}: spawn failed`);
295
+ return false;
296
+ }
297
+ writePid(svc, child.pid);
298
+ console.log(`${SVC_CONFIG[svc].label} started (pid ${child.pid}, log ${logPath})`);
299
+
300
+ // Grace window: if the process dies within 800ms it almost certainly failed
301
+ // to start (bad env, port in use, missing dep). Surface the last log lines.
302
+ const start = Date.now();
303
+ while (Date.now() - start < 800) {
304
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 80);
305
+ }
306
+ if (!pidAlive(child.pid)) {
307
+ console.error(`${SVC_CONFIG[svc].label}: exited within 800ms — likely a startup error.`);
308
+ console.error(` Tail of ${logPath}:`);
309
+ const tail = spawnSync(["tail", "-n", "20", logPath], { stdout: "pipe" });
310
+ if (tail.status === 0) console.error(tail.stdout.toString());
311
+ clearPid(svc);
312
+ return false;
313
+ }
314
+ return true;
315
+ }
316
+
317
+ function stopOne(svc: SvcName): boolean {
318
+ const pid = readPid(svc);
319
+ if (!pid) {
320
+ console.log(`${SVC_CONFIG[svc].label} not running (no PID file)`);
321
+ return true;
322
+ }
323
+ if (!pidAlive(pid)) {
324
+ console.log(`${SVC_CONFIG[svc].label} not running (stale PID file, removing)`);
325
+ clearPid(svc);
326
+ return true;
327
+ }
328
+ try {
329
+ process.kill(pid, "SIGTERM");
330
+ } catch (e) {
331
+ console.error(`${SVC_CONFIG[svc].label}: SIGTERM failed: ${(e as Error).message}`);
332
+ return false;
333
+ }
334
+ console.log(`${SVC_CONFIG[svc].label}: SIGTERM sent (pid ${pid})`);
335
+ if (waitForExit(pid, 5000)) {
336
+ clearPid(svc);
337
+ console.log(`${SVC_CONFIG[svc].label} stopped`);
338
+ return true;
339
+ }
340
+ console.warn(`${SVC_CONFIG[svc].label}: did not exit in 5s, sending SIGKILL`);
341
+ try {
342
+ process.kill(pid, "SIGKILL");
343
+ } catch {
344
+ // best-effort
345
+ }
346
+ if (waitForExit(pid, 2000)) {
347
+ clearPid(svc);
348
+ console.log(`${SVC_CONFIG[svc].label} killed`);
349
+ return true;
350
+ }
351
+ console.error(`${SVC_CONFIG[svc].label}: failed to kill pid ${pid}`);
352
+ return false;
353
+ }
354
+
355
+ function psAll(): void {
356
+ console.log("ework-aio PID-file mode status:");
357
+ for (const svc of Object.keys(SVC_CONFIG) as SvcName[]) {
358
+ const pid = readPid(svc);
359
+ const alive = pid !== null && pidAlive(pid);
360
+ const cfg = SVC_CONFIG[svc];
361
+ if (alive) {
362
+ console.log(` ${cfg.label.padEnd(18)} running pid ${pid} log ${svcLogPath(svc)}`);
363
+ } else if (pid !== null) {
364
+ console.log(` ${cfg.label.padEnd(18)} dead stale pid ${pid} (run: ework-aio stop ${svc})`);
365
+ } else {
366
+ console.log(` ${cfg.label.padEnd(18)} stopped`);
367
+ }
368
+ }
369
+ }
370
+
371
+ function runPidMode(command: "start" | "stop" | "restart", argv: string[]): number {
372
+ const { svc, foreground } = parseSvcArg(argv);
373
+ if (command === "ps") {
374
+ psAll();
375
+ return 0;
376
+ }
377
+ const targets: SvcName[] = svc === "both" ? ["web", "daemon"] : [svc];
378
+ let rc = 0;
379
+ if (command === "stop") {
380
+ for (const s of targets) if (!stopOne(s)) rc = 1;
381
+ return rc;
382
+ }
383
+ if (command === "start") {
384
+ for (const s of targets) if (!startOne(s, foreground)) rc = 1;
385
+ return rc;
386
+ }
387
+ // restart
388
+ for (const s of targets) stopOne(s);
389
+ for (const s of targets) if (!startOne(s, foreground)) rc = 1;
390
+ return rc;
391
+ }
392
+
393
+ if (
394
+ subcommand === "start" ||
395
+ subcommand === "stop" ||
396
+ subcommand === "restart" ||
397
+ subcommand === "ps"
398
+ ) {
399
+ process.exit(runPidMode(subcommand as "start" | "stop" | "restart", args));
400
+ }
401
+
402
+ if (subcommand === "migrate") {
403
+ const migrateScript = resolve(__dirname, "..", "scripts", "migrate-from-gitea.ts");
404
+ if (!existsSync(migrateScript)) {
405
+ console.error(`Cannot find migrate script (looked at ${migrateScript}).`);
406
+ console.error("This usually means the package was installed incorrectly.");
407
+ process.exit(1);
408
+ }
409
+ const rest = args.slice(1);
410
+ const result = spawnSync(["bun", migrateScript, ...rest], {
411
+ stdio: ["inherit", "inherit", "inherit"],
412
+ env: process.env,
413
+ });
414
+ if (result.status !== null) process.exit(result.status);
415
+ process.exit(1);
416
+ }
417
+
418
+ if (subcommand === "backfill-timestamps") {
419
+ const script = resolve(__dirname, "..", "scripts", "backfill-timestamps.ts");
420
+ if (!existsSync(script)) {
421
+ console.error(`Cannot find backfill-timestamps script (looked at ${script}).`);
422
+ process.exit(1);
423
+ }
424
+ const rest = args.slice(1);
425
+ const result = spawnSync(["bun", script, ...rest], {
426
+ stdio: ["inherit", "inherit", "inherit"],
427
+ env: process.env,
428
+ });
429
+ if (result.status !== null) process.exit(result.status);
430
+ process.exit(1);
431
+ }
432
+
59
433
  const finalArgs = knownSubs.includes(subcommand) ? args : ["install", ...args];
60
434
 
61
435
  const result = spawnSync(["bash", installSh, ...finalArgs], {
package/bin/install.sh CHANGED
@@ -100,6 +100,39 @@ if [[ "$SCOPENAME" == "--user" && "${EUID:-$(id -u)}" == "0" ]]; then
100
100
  SCOPENAME="--system"
101
101
  fi
102
102
 
103
+ # ─── Ensure --user mode can actually talk to systemd ─────────────────────────
104
+ # When invoked from ssh/cron/non-PAM-login shells, XDG_RUNTIME_DIR is often
105
+ # empty and systemctl --user fails with "Failed to connect to bus" or — worse —
106
+ # "Unit not found" (when a stale bus from another session responds but doesn't
107
+ # know about installed units). Auto-export the runtime dir + bus socket if we
108
+ # can find them. No-op for --system scope.
109
+ ensure_user_session() {
110
+ [[ "$SCOPENAME" == "--user" ]] || return 0
111
+ local uid
112
+ uid="$(id -u)"
113
+ local rundir="/run/user/$uid"
114
+
115
+ if [[ -z "${XDG_RUNTIME_DIR:-}" && -d "$rundir" ]]; then
116
+ export XDG_RUNTIME_DIR="$rundir"
117
+ fi
118
+ if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" && -S "${XDG_RUNTIME_DIR}/bus" ]]; then
119
+ export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
120
+ fi
121
+
122
+ # Final sanity: can we reach systemd --user at all?
123
+ if ! systemctl --user is-system-running >/dev/null 2>&1 \
124
+ && ! systemctl --user list-units >/dev/null 2>&1; then
125
+ warn "systemctl --user cannot reach the user session bus."
126
+ warn " XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-<empty>}"
127
+ warn " DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS:-<empty>}"
128
+ warn "Likely causes: ssh without PAM, no linger, or session never started."
129
+ warn "Fix: 'sudo loginctl enable-linger \$USER' then relogin; or reinstall with --system."
130
+ return 1
131
+ fi
132
+ return 0
133
+ }
134
+ ensure_user_session || true # warn but don't hard-fail — let later steps report specifics
135
+
103
136
  # ─── Pre-flight: command dependencies ───────────────────────────────────────
104
137
  need_cmd() {
105
138
  command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1. $2"
@@ -260,7 +293,45 @@ EOF
260
293
  fi
261
294
  }
262
295
 
263
- ctl() { systemctl "$SCOPENAME" "$@"; }
296
+ # ctl wraps `systemctl $SCOPENAME` with two hardenings over the bare command:
297
+ # 1. If output contains "Unit ... not found", retry once after daemon-reload
298
+ # — fresh user sessions (no linger, post-ssh-reconnect) often have a stale
299
+ # unit cache and this is the standard fix.
300
+ # 2. If output mentions bus connection failure, print an actionable hint
301
+ # instead of letting the raw "No medium found" / "Failed to connect to
302
+ # bus" message through unchanged.
303
+ ctl() {
304
+ local out rc
305
+ out="$(systemctl "$SCOPENAME" "$@" 2>&1)"
306
+ rc=$?
307
+ if [[ $rc -ne 0 ]]; then
308
+ if [[ "$out" == *"Failed to connect to bus"* || "$out" == *"No medium found"* ]]; then
309
+ cat >&2 <<EOF
310
+ ${c_red}systemctl $SCOPENAME failed to reach the user bus.${c_reset}
311
+ $out
312
+ Hint: run from a logged-in session, or:
313
+ export XDG_RUNTIME_DIR=/run/user/$(id -u)
314
+ export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus
315
+ or reinstall with: sudo ework-aio install --system
316
+ EOF
317
+ return $rc
318
+ fi
319
+ if [[ "$out" == *"not found"* ]]; then
320
+ systemctl "$SCOPENAME" daemon-reload >/dev/null 2>&1 || true
321
+ out="$(systemctl "$SCOPENAME" "$@" 2>&1)"
322
+ rc=$?
323
+ if [[ $rc -ne 0 ]]; then
324
+ cat >&2 <<EOF
325
+ ${c_red}systemctl $SCOPENAME $*: unit not found after daemon-reload.${c_reset}
326
+ The unit file may be missing from $UNIT_DIR. Re-run:
327
+ ework-aio install --no-start # rewrites units, preserves .env/data
328
+ EOF
329
+ return $rc
330
+ fi
331
+ fi
332
+ fi
333
+ printf '%s\n' "$out"
334
+ }
264
335
 
265
336
  # ─── Config mode: read/change runtime .env keys ─────────────────────────────
266
337
  # Each entry: KEY|SERVICE|DESCRIPTION. Only keys in this list are settable via
@@ -343,14 +414,24 @@ service_for_key() {
343
414
 
344
415
  restart_service() {
345
416
  local svc="$1"
417
+ local rc=0
346
418
  case "$svc" in
347
- web) ctl restart ework-web.service && ok "Restarted ework-web" ;;
348
- daemon) ctl restart ework-daemon.service && ok "Restarted ework-daemon" ;;
419
+ web) ctl restart ework-web.service && ok "Restarted ework-web" || rc=$? ;;
420
+ daemon) ctl restart ework-daemon.service && ok "Restarted ework-daemon" || rc=$? ;;
349
421
  both)
350
- ctl restart ework-web.service ework-daemon.service && ok "Restarted ework-web + ework-daemon"
422
+ ctl restart ework-web.service ework-daemon.service \
423
+ && ok "Restarted ework-web + ework-daemon" || rc=$?
351
424
  ;;
352
425
  *) return 1 ;;
353
426
  esac
427
+ if [[ $rc -ne 0 ]]; then
428
+ warn "restart of '$svc' failed (exit $rc)."
429
+ warn "The .env changes are saved; services still need to be reloaded."
430
+ warn "Recovery: 'ework-aio config restart $svc' from a logged-in shell,"
431
+ warn "or 'sudo ework-aio install --system' to escape user-bus limitations."
432
+ return $rc
433
+ fi
434
+ return 0
354
435
  }
355
436
 
356
437
  config_help() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-aio",
3
- "version": "0.1.6",
3
+ "version": "0.1.9",
4
4
  "description": "All-in-one installer for ework (issue tracker) + ework-daemon (AI bridge) + opencode-ework (plugin). One command: npm i -g ework-aio && ework-aio install.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "files": [
28
28
  "bin",
29
+ "scripts",
29
30
  "src",
30
31
  "README.md",
31
32
  "LICENSE"