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/bin/install.sh DELETED
@@ -1,1200 +0,0 @@
1
- #!/usr/bin/env bash
2
- # ework-aio — host installer for the ework stack.
3
- #
4
- # Boots:
5
- # • ework-web — multi-project issue tracker (web UI + Gitea-compat REST)
6
- # • ework-daemon — issue-driven AI bridge (spawns opencode)
7
- # • opencode-ework — plugin registered in ~/.config/opencode/opencode.json
8
- #
9
- # Idempotent. Re-runs preserve existing .env, bot token, and DB.
10
- # Data lives under $DATA_DIR (default ~/.local/share/ework-aio).
11
- # Services run as the current user via systemd --user (or --system if root).
12
-
13
- set -euo pipefail
14
-
15
- # ERR trap: when `set -e` kills the script, print the line + last command so
16
- # the user sees why instead of an empty prompt. Tricky failure modes (root
17
- # scope + polkit redirect, missing systemd PID 1, journalctl-as-root) all
18
- # manifest as silent exit at a systemctl call — without this trace, the user
19
- # just sees the prompt return and assumes install succeeded.
20
- trap 'rc=$?; if [[ $rc -ne 0 ]]; then echo "${c_red}✗${c_reset} install.sh exited with code $rc at line $LINENO (most recent command: ${BASH_COMMAND})" >&2; echo " If running in PID-file mode (default), this is a real bug — please report." >&2; echo " If running with '\''systemd'\'' subcommand and systemd is unavailable, retry without it." >&2; fi' ERR
21
-
22
- # ─── Pretty output ──────────────────────────────────────────────────────────
23
- c_reset=$'\033[0m'; c_bold=$'\033[1m'; c_dim=$'\033[2m'
24
- c_red=$'\033[31m'; c_grn=$'\033[32m'; c_ylw=$'\033[33m'; c_blu=$'\033[34m'
25
- log() { printf '%s•%s %s\n' "$c_blu" "$c_reset" "$*"; }
26
- ok() { printf '%s✓%s %s\n' "$c_grn" "$c_reset" "$*"; }
27
- warn() { printf '%s!%s %s\n' "$c_ylw" "$c_reset" "$*" >&2; }
28
- die() { printf '%s✗%s %s\n' "$c_red" "$c_reset" "$*" >&2; exit 1; }
29
- hr() { printf '%s──%s\n' "$c_dim" "$c_reset"; }
30
-
31
- # ─── Defaults & arg parsing ─────────────────────────────────────────────────
32
- MODE="install"
33
- SCOPENAME="--user"
34
- DATA_DIR=""
35
- WORK_PORT="3002"
36
- DAEMON_PORT="3101"
37
- BOT_NAME="ework-daemon"
38
- NO_START=0
39
- ASSUME_YES=0
40
- NO_RESTART=0
41
- AS_USER=""
42
- ALLOW_ROOT=0
43
- CFG_ARGS=()
44
- MODE_ARGS=()
45
- # USE_SYSTEMD=1 only when user runs `install systemd`. Default install is
46
- # pure PID-file mode (no systemctl calls, no unit files, no linger prompt).
47
- # This is the inverse of v0.1.x behavior where install always tried systemd
48
- # first. PID-file mode is simpler, works on hosts without systemd, and
49
- # matches the dominant failure mode we've seen in the wild.
50
- USE_SYSTEMD=0
51
-
52
- usage() {
53
- cat <<'EOF'
54
- ework-aio <command> [options]
55
-
56
- Commands:
57
- install [systemd] [options] Install or upgrade the ework stack (default)
58
- Add 'systemd' to also write+enable systemd
59
- units. Without 'systemd', runs in pure
60
- PID-file mode (no systemctl calls).
61
- uninstall Stop services and remove units (data preserved)
62
- status Show service status
63
- logs [web|daemon] Tail logs
64
- env Print key paths (no secrets)
65
- config <subcommand> Read / change runtime config (.env keys)
66
- config list List all settable keys + current values
67
- config get <KEY> Print current value of one key
68
- config set <KEY> <VALUE> Set a key, then restart affected service
69
- (unless --no-restart is given)
70
- config restart <web|daemon> Restart one or both services
71
-
72
- Install options:
73
- systemd Also install systemd units + enable them.
74
- Without this flag, install is PID-file only.
75
- --user (with systemd) user-level units (default)
76
- --system (with systemd) system-level units (needs sudo)
77
- --data-dir <path> Override data directory (default: ~/.local/share/ework-aio)
78
- --port <n> ework-web port (default: 3002)
79
- --daemon-port <n> ework-daemon port (default: 3101)
80
- --bot-name <login> Bot username (default: ework-daemon)
81
- --no-start Install but don't start services
82
- --yes Skip all prompts (use generated defaults)
83
- --as-user <login> (sudo only) drop priv: re-exec install as <login>
84
- after enabling linger. Data + opencode + npm
85
- resolved under that user's HOME.
86
- --allow-root (sudo only) override default refuse-on-root
87
-
88
- Global options:
89
- --no-restart With `config set`: edit .env but skip the restart
90
- -h, --help Show this help
91
- EOF
92
- }
93
-
94
- while [[ $# -gt 0 ]]; do
95
- case "$1" in
96
- install|uninstall|status|logs|env) MODE="$1"; shift ;;
97
- config)
98
- MODE="config"
99
- shift
100
- # Consume remaining args as config subcommand + its positionals.
101
- # Global flags (--data-dir, --user, --system, etc.) are still
102
- # respected here so `config set K V --data-dir X` works as expected.
103
- while [[ $# -gt 0 ]]; do
104
- case "$1" in
105
- --no-restart) NO_RESTART=1; shift ;;
106
- -h|--help) CFG_ARGS=("help"); shift ;;
107
- --user) SCOPENAME="--user"; shift ;;
108
- --system) SCOPENAME="--system"; shift ;;
109
- --data-dir) DATA_DIR="$2"; shift 2 ;;
110
- --port) WORK_PORT="$2"; shift 2 ;;
111
- --daemon-port) DAEMON_PORT="$2"; shift 2 ;;
112
- --bot-name) BOT_NAME="$2"; shift 2 ;;
113
- --yes|-y) ASSUME_YES=1; shift ;;
114
- *) CFG_ARGS+=("$1"); shift ;;
115
- esac
116
- done
117
- ;;
118
- --user) SCOPENAME="--user"; shift ;;
119
- --system) SCOPENAME="--system"; shift ;;
120
- --data-dir) DATA_DIR="$2"; shift 2 ;;
121
- --port) WORK_PORT="$2"; shift 2 ;;
122
- --daemon-port) DAEMON_PORT="$2"; shift 2 ;;
123
- --bot-name) BOT_NAME="$2"; shift 2 ;;
124
- --no-start) NO_START=1; shift ;;
125
- --no-restart) NO_RESTART=1; shift ;;
126
- --yes|-y) ASSUME_YES=1; shift ;;
127
- --as-user) AS_USER="$2"; shift 2 ;;
128
- --allow-root) ALLOW_ROOT=1; shift ;;
129
- -h|--help) usage; exit 0 ;;
130
- --*) die "Unknown option: $1 (try --help)" ;;
131
- *) MODE_ARGS+=("$1"); shift ;;
132
- esac
133
- done
134
-
135
- # ─── Root guard: refuse install as root by default ──────────────────────────
136
- # Running install as root puts data under /root/.local/share/ework-aio, bakes
137
- # root-only paths into systemd units, and looks for opencode in root's PATH
138
- # (usually missing). The right answer is almost always: don't use sudo.
139
- # --as-user re-execs the script as the named user after enabling linger.
140
- # --allow-root is an explicit acknowledgement that you've set up root's
141
- # environment (opencode installed, npm prefix configured, etc.).
142
- if [[ "$MODE" == "install" && "${EUID:-$(id -u)}" == "0" ]]; then
143
- if [[ -n "$AS_USER" ]]; then
144
- AS_USER_HOME="$(getent passwd "$AS_USER" 2>/dev/null | cut -d: -f6 || true)"
145
- AS_USER_UID="$(getent passwd "$AS_USER" 2>/dev/null | cut -d: -f3 || true)"
146
- if [[ -z "$AS_USER_HOME" || -z "$AS_USER_UID" ]]; then
147
- die "--as-user: user '$AS_USER' not found in passwd database"
148
- fi
149
- if [[ "$AS_USER_UID" == "0" ]]; then
150
- die "--as-user: target user is root — just use --allow-root instead"
151
- fi
152
- log "Enable linger for '$AS_USER' so --user services survive logout..."
153
- loginctl enable-linger "$AS_USER" \
154
- || die "Failed to enable linger for '$AS_USER'. Try: sudo loginctl enable-linger $AS_USER"
155
- ok "Linger enabled for '$AS_USER'"
156
- log "Re-execing as '$AS_USER' (HOME=$AS_USER_HOME)..."
157
- # Strip --as-user <name> from argv, preserve everything else.
158
- new_args=()
159
- skip=0
160
- for a in "$@"; do
161
- if [[ "$skip" == "1" ]]; then skip=0; continue; fi
162
- if [[ "$a" == "--as-user" ]]; then skip=1; continue; fi
163
- new_args+=("$a")
164
- done
165
- # Resolve our own path (can't rely on $0 after sudo changes PATH).
166
- SELF="$(command -v ework-aio 2>/dev/null || true)"
167
- [[ -n "$SELF" ]] || SELF="${BASH_SOURCE[0]:-$0}"
168
- exec sudo -u "$AS_USER" --login -- "$SELF" "${new_args[@]}"
169
- fi
170
- if [[ "$ALLOW_ROOT" != "1" ]]; then
171
- cat >&2 <<EOF
172
- ${c_red}ework-aio install refuses to run as root by default.${c_reset}
173
-
174
- Why this matters:
175
- - Data goes under /root/.local/share/ework-aio (unreadable by other users)
176
- - opencode is searched in root's PATH (usually not installed there)
177
- - npm packages install to system-wide prefix owned by root
178
- - systemd units bake root-only paths that other users can't run
179
-
180
- ${c_bold}Option A (recommended):${c_reset} run as a regular user.
181
- npm config set prefix '~/.local' # one-time, makes -g user-writable
182
- npm install -g ework-aio # no sudo needed
183
- ework-aio install # uses --user systemd scope
184
-
185
- ${c_bold}Option B:${c_reset} install with sudo but target a regular user.
186
- sudo ework-aio install --as-user $(logname 2>/dev/null || echo '<your-user>')
187
- # All data + opencode + npm resolved under that user's HOME.
188
- # systemd --user scope (linger auto-enabled for the target user).
189
-
190
- ${c_bold}Option C:${c_reset} really install as root (you've set up root's env).
191
- sudo ework-aio install --allow-root
192
- EOF
193
- exit 1
194
- fi
195
- warn "Running install as root with --allow-root — data will live under /root."
196
- fi
197
-
198
- # Resolve scope: default by uid
199
- if [[ "$SCOPENAME" == "--user" && "${EUID:-$(id -u)}" == "0" ]]; then
200
- SCOPENAME="--system"
201
- fi
202
-
203
- # ─── Ensure --user mode can actually talk to systemd ─────────────────────────
204
- # When invoked from ssh/cron/non-PAM-login shells, XDG_RUNTIME_DIR is often
205
- # empty and systemctl --user fails with "Failed to connect to bus" or — worse —
206
- # "Unit not found" (when a stale bus from another session responds but doesn't
207
- # know about installed units). Auto-export the runtime dir + bus socket if we
208
- # can find them. No-op for --system scope.
209
- ensure_user_session() {
210
- [[ "$USE_SYSTEMD" == "1" ]] || return 0
211
- [[ "$SCOPENAME" == "--user" ]] || return 0
212
- local uid
213
- uid="$(id -u)"
214
- local rundir="/run/user/$uid"
215
-
216
- if [[ -z "${XDG_RUNTIME_DIR:-}" && -d "$rundir" ]]; then
217
- export XDG_RUNTIME_DIR="$rundir"
218
- fi
219
- # ${XDG_RUNTIME_DIR:-} not bare ${XDG_RUNTIME_DIR}: the first if above only
220
- # exports when /run/user/$uid exists. On hosts without linger / without any
221
- # login session, /run/user/$uid is absent, XDG_RUNTIME_DIR stays unset, and
222
- # `set -u` would kill install here before the systemd_reachable() fallback
223
- # can route to PID-file mode.
224
- if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" && -S "${XDG_RUNTIME_DIR:-}/bus" ]]; then
225
- export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
226
- fi
227
-
228
- # Final sanity: can we reach systemd --user at all?
229
- if ! systemctl --user is-system-running >/dev/null 2>&1 \
230
- && ! systemctl --user list-units >/dev/null 2>&1; then
231
- warn "systemctl --user cannot reach the user session bus."
232
- warn " XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-<empty>}"
233
- warn " DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS:-<empty>}"
234
- warn "Likely causes: ssh without PAM, no linger, or session never started."
235
- warn "Fix: 'sudo loginctl enable-linger \$USER' then relogin; or reinstall with --system."
236
- return 1
237
- fi
238
- return 0
239
- }
240
- ensure_user_session || true # warn but don't hard-fail — let later steps report specifics
241
-
242
- # ─── Post-parse: detect `install systemd` sub-variant ──────────────────────
243
- # `install systemd` (or `systemd install`, or `install ... systemd`) opts in
244
- # to writing+enabling systemd units. Without this token, install is pure
245
- # PID-file mode (no systemctl calls, no unit files, no linger prompt).
246
- if [[ "$MODE" == "install" ]]; then
247
- new_mode_args=()
248
- for a in "${MODE_ARGS[@]:-}"; do
249
- [[ -z "$a" ]] && continue
250
- if [[ "$a" == "systemd" ]]; then
251
- USE_SYSTEMD=1
252
- else
253
- new_mode_args+=("$a")
254
- fi
255
- done
256
- MODE_ARGS=("${new_mode_args[@]:-}")
257
- fi
258
-
259
- # ─── Pre-flight: command dependencies ───────────────────────────────────────
260
- need_cmd() {
261
- command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1. $2"
262
- }
263
- need_cmd awk "Should be present on any Linux."
264
-
265
- # systemctl only required when user opts into systemd variant. Default
266
- # install path is PID-file only and works on hosts without systemd.
267
- if [[ "$MODE" == "install" ]]; then
268
- [[ "$USE_SYSTEMD" == "1" ]] && need_cmd systemctl "Install with 'systemd' subcommand requires systemctl. Drop 'systemd' to install in PID-file mode."
269
- need_cmd bun "Install from https://bun.sh"
270
- need_cmd npm "Install Node.js or Bun (ships npm)"
271
- need_cmd opencode "Install from https://opencode.ai"
272
- need_cmd openssl "Install the openssl package."
273
- need_cmd curl "Install curl."
274
- need_cmd jq "Install jq (for opencode.json merge)."
275
- fi
276
-
277
- # EWORK_AIO_BIN: always resolvable (used by status/logs/config/uninstall
278
- # to delegate to PID-file mode operations when systemd is unreachable).
279
- EWORK_AIO_BIN="$(command -v ework-aio 2>/dev/null || true)"
280
- [[ -n "$EWORK_AIO_BIN" ]] || EWORK_AIO_BIN="$(npm root -g 2>/dev/null)/ework-aio/bin/ework-aio"
281
-
282
- # systemd_reachable: 0 if `systemctl $SCOPENAME` can talk to systemd, 1 if not.
283
- # Used by status/logs/config/uninstall to decide whether to use systemd
284
- # (preferred) or fall back to PID-file mode via the ework-aio dispatcher.
285
- # Hosts where this returns 1: containers without systemd PID 1, polkit
286
- # redirects under sudo that drop the bus, hosts where the user isn't
287
- # permissioned for the system bus.
288
- systemd_reachable() {
289
- command -v systemctl >/dev/null 2>&1 || return 1
290
- systemctl "$SCOPENAME" is-system-running >/dev/null 2>&1 \
291
- || systemctl "$SCOPENAME" list-units --no-pager >/dev/null 2>&1
292
- }
293
-
294
- # Verify the 3 npm packages are reachable. If not, try to install them now.
295
- ensure_pkg() {
296
- local pkg="$1"
297
- if ! command -v "$pkg" >/dev/null 2>&1 \
298
- && ! [[ -d "$(npm root -g 2>/dev/null)/$pkg" ]]; then
299
- log "npm package '$pkg' not detected; installing globally now..."
300
- npm install -g "$pkg" || die "npm install -g $pkg failed"
301
- fi
302
- }
303
- case "$MODE" in
304
- install)
305
- # Pre-clean stale npm rename temp-dirs from any previous failed install.
306
- # npm's atomic-install pattern renames the existing package dir to a
307
- # `.<pkg>-<rand>` sibling before extracting the new tarball; if a prior
308
- # install crashed mid-flight, that temp dir stays behind and the NEXT
309
- # install fails with ENOTEMPTY on the rename. Symptoms:
310
- # npm error code ENOTEMPTY
311
- # npm error syscall rename
312
- # npm error path .../node_modules/ework-web
313
- # npm error dest .../node_modules/.ework-web-Cx2Tkt83
314
- clean_npm_stale_dirs() {
315
- local npm_root
316
- npm_root="$(npm root -g 2>/dev/null)" || return 0
317
- [[ -d "$npm_root" ]] || return 0
318
- # Stale temp patterns: .ework-web-XXXX, .ework-daemon-XXXX, .opencode-ework-XXXX,
319
- # .ework-aio-XXXX, plus the .Trash suffix some npm versions use.
320
- local found=()
321
- while IFS= read -r p; do
322
- [[ -n "$p" ]] && found+=("$p")
323
- done < <(find "$npm_root" -maxdepth 1 \( \
324
- -name '.ework-web-*' -o \
325
- -name '.ework-daemon-*' -o \
326
- -name '.ework-aio-*' -o \
327
- -name '.opencode-ework-*' -o \
328
- -name 'ework-*.Trash*' -o \
329
- -name 'opencode-ework.Trash*' \) 2>/dev/null || true)
330
- if [[ ${#found[@]} -gt 0 ]]; then
331
- log "Found ${#found[@]} stale npm temp dir(s) under $npm_root from a previous failed install:"
332
- printf ' %s\n' "${found[@]}"
333
- log "Removing..."
334
- local d
335
- for d in "${found[@]}"; do
336
- rm -rf "$d" 2>/dev/null || warn "could not remove $d (try: sudo rm -rf $d)"
337
- done
338
- fi
339
- }
340
-
341
- npm_install_global() {
342
- # One retry after a cleanup pass — covers the ENOTEMPTY case where npm
343
- # itself produced a fresh temp dir during this same failed run.
344
- if ! npm install -g "$1@latest"; then
345
- warn "npm install -g $1@latest failed; cleaning stale temp dirs and retrying once..."
346
- clean_npm_stale_dirs
347
- npm install -g "$1@latest"
348
- fi
349
- }
350
-
351
- clean_npm_stale_dirs
352
- log "Installing/updating npm packages globally..."
353
- # Install each as a top-level global so bins are linked to PATH.
354
- # (npm v9 doesn't hoist nested deps' bins from `npm i -g ework-aio`.)
355
- # Always run `npm install -g` (not gated on presence) so re-runs pick up new versions.
356
- for pkg in ework-web ework-daemon opencode-ework ework-aio; do
357
- npm_install_global "$pkg" || die "npm install -g $pkg@latest failed (even after cleanup retry; manual fix: sudo rm -rf $(npm root -g)/.$pkg-* $(npm root -g)/$pkg.Trash*)"
358
- done
359
- ok "npm packages ready"
360
-
361
- # Absolute paths to the bin shims (resolved once, baked into systemd units)
362
- EWORK_WEB_BIN="$(command -v ework-web 2>/dev/null || true)"
363
- EWORK_DAEMON_BIN="$(command -v ework-daemon-server 2>/dev/null || true)"
364
- [[ -n "$EWORK_WEB_BIN" ]] || EWORK_WEB_BIN="$(npm root -g)/ework-web/bin/ework-web.js"
365
- [[ -n "$EWORK_DAEMON_BIN" ]] || EWORK_DAEMON_BIN="$(npm root -g)/ework-daemon/bin/ework-daemon-server.js"
366
- [[ -x "$EWORK_WEB_BIN" || -f "$EWORK_WEB_BIN" ]] || die "ework-web bin not found at $EWORK_WEB_BIN"
367
- [[ -x "$EWORK_DAEMON_BIN" || -f "$EWORK_DAEMON_BIN" ]] || die "ework-daemon-server bin not found at $EWORK_DAEMON_BIN"
368
- export EWORK_WEB_BIN EWORK_DAEMON_BIN
369
- ;;
370
- esac
371
-
372
- # ─── Paths ──────────────────────────────────────────────────────────────────
373
- XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
374
- XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
375
- DATA_DIR="${DATA_DIR:-$XDG_DATA_HOME/ework-aio}"
376
- WEB_DATA_DIR="$DATA_DIR/ework-web"
377
- DAEMON_DATA_DIR="$DATA_DIR/ework-daemon"
378
- WEB_ENV="$WEB_DATA_DIR/.env"
379
- DAEMON_ENV="$DAEMON_DATA_DIR/.env"
380
- BOT_TOKEN_FILE="$DATA_DIR/.bot-token"
381
- OPENCODE_CFG="$XDG_CONFIG_HOME/opencode/opencode.json"
382
-
383
- # systemd unit dir
384
- if [[ "$SCOPENAME" == "--user" ]]; then
385
- UNIT_DIR="$XDG_CONFIG_HOME/systemd/user"
386
- else
387
- UNIT_DIR="/etc/systemd/system"
388
- fi
389
- mkdir -p "$UNIT_DIR"
390
-
391
- # ─── Helpers ────────────────────────────────────────────────────────────────
392
- gen_token() { openssl rand -hex "${1:-24}"; }
393
-
394
- write_web_env() {
395
- mkdir -p "$(dirname "$WEB_ENV")"
396
- if [[ -f "$WEB_ENV" ]]; then
397
- log "Preserving existing $WEB_ENV"
398
- # Older installs (pre-0.1.16) may have a .env that predates some keys
399
- # ework-web's Zod schema now requires (WORK_COOKIE_SECRET in particular).
400
- # Fill any missing required keys with fresh defaults so the preserved
401
- # .env doesn't crash the next start. User-set keys are never overwritten.
402
- ensure_web_env_keys
403
- return
404
- fi
405
- local tok secret webhook
406
- tok=$(gen_token 20)
407
- secret=$(gen_token 24)
408
- webhook=$(gen_token 20)
409
- cat > "$WEB_ENV" <<EOF
410
- # Generated by ework-aio at $(date -u +%Y-%m-%dT%H:%M:%SZ)
411
- WORK_PORT=$WORK_PORT
412
- WORK_HOST=127.0.0.1
413
- WORK_TOKEN=$tok
414
- WORK_COOKIE_SECRET=$secret
415
- WORK_OPERATOR_LOGIN=$USER
416
- WORK_WRITES_ENABLED=true
417
- WORK_DB_PATH=$WEB_DATA_DIR/ework.db
418
- WORK_ATTACHMENT_ROOT=$WEB_DATA_DIR/attachments
419
- WORK_FILE_ROOTS=/tmp,$DATA_DIR
420
- WORK_DAEMON_BOT_LOGIN=$BOT_NAME
421
- WORK_DAEMON_WEBHOOK_URL=http://127.0.0.1:$DAEMON_PORT
422
- WORK_DAEMON_WEBHOOK_SECRET=$webhook
423
- EOF
424
- chmod 600 "$WEB_ENV"
425
- ok "Wrote $WEB_ENV"
426
- }
427
-
428
- # ensure_env_key: append KEY=value to $file iff KEY is not already present.
429
- # Used to forward-fill schema-required keys on preserved .env files without
430
- # clobbering any user-set values.
431
- ensure_env_key() {
432
- local file="$1" key="$2" val="$3"
433
- if ! grep -qE "^${key}=" "$file" 2>/dev/null; then
434
- printf '%s=%s\n' "$key" "$val" >> "$file"
435
- log " + added missing key: $key"
436
- fi
437
- }
438
-
439
- ensure_web_env_keys() {
440
- # Only fills keys that are missing; never overwrites existing values.
441
- # Tokens / secrets get fresh random values (safe because if they were
442
- # missing, web couldn't have started, so nothing depends on them yet).
443
- local tok secret webhook
444
- tok=$(gen_token 20)
445
- secret=$(gen_token 24)
446
- webhook=$(gen_token 20)
447
- ensure_env_key "$WEB_ENV" WORK_PORT "$WORK_PORT"
448
- ensure_env_key "$WEB_ENV" WORK_HOST "127.0.0.1"
449
- ensure_env_key "$WEB_ENV" WORK_TOKEN "$tok"
450
- ensure_env_key "$WEB_ENV" WORK_COOKIE_SECRET "$secret"
451
- ensure_env_key "$WEB_ENV" WORK_OPERATOR_LOGIN "$USER"
452
- ensure_env_key "$WEB_ENV" WORK_WRITES_ENABLED "true"
453
- ensure_env_key "$WEB_ENV" WORK_DB_PATH "$WEB_DATA_DIR/ework.db"
454
- ensure_env_key "$WEB_ENV" WORK_ATTACHMENT_ROOT "$WEB_DATA_DIR/attachments"
455
- ensure_env_key "$WEB_ENV" WORK_FILE_ROOTS "/tmp,$DATA_DIR"
456
- ensure_env_key "$WEB_ENV" WORK_DAEMON_BOT_LOGIN "$BOT_NAME"
457
- ensure_env_key "$WEB_ENV" WORK_DAEMON_WEBHOOK_URL "http://127.0.0.1:$DAEMON_PORT"
458
- ensure_env_key "$WEB_ENV" WORK_DAEMON_WEBHOOK_SECRET "$webhook"
459
- }
460
-
461
- write_daemon_env() {
462
- mkdir -p "$(dirname "$DAEMON_ENV")"
463
- local bot_token="$1"
464
- local webhook
465
- # Reuse webhook secret from web env so daemon's signature matches web's verify
466
- webhook=$(awk -F= '/^WORK_DAEMON_WEBHOOK_SECRET=/{print $2}' "$WEB_ENV" 2>/dev/null || true)
467
- [[ -n "$webhook" ]] || webhook=$(gen_token 20)
468
- if [[ -f "$DAEMON_ENV" ]]; then
469
- log "Preserving existing $DAEMON_ENV"
470
- # Same forward-fill pattern as web: required keys get filled if missing.
471
- ensure_env_key "$DAEMON_ENV" DAEMON_ENV "production"
472
- ensure_env_key "$DAEMON_ENV" DAEMON_PORT "$DAEMON_PORT"
473
- ensure_env_key "$DAEMON_ENV" DAEMON_HOST "127.0.0.1"
474
- ensure_env_key "$DAEMON_ENV" DAEMON_DB_PATH "$DAEMON_DATA_DIR/ework-daemon.db"
475
- ensure_env_key "$DAEMON_ENV" GITEA_URL "http://127.0.0.1:$WORK_PORT"
476
- ensure_env_key "$DAEMON_ENV" GITEA_TOKEN "$bot_token"
477
- ensure_env_key "$DAEMON_ENV" GITEA_WEBHOOK_SECRET "$webhook"
478
- ensure_env_key "$DAEMON_ENV" BOT_USERNAME "$BOT_NAME"
479
- ensure_env_key "$DAEMON_ENV" BOT_TOKEN "$bot_token"
480
- ensure_env_key "$DAEMON_ENV" OPENCODE_BINARY "$(command -v opencode)"
481
- ensure_env_key "$DAEMON_ENV" OPENCODE_BASE_WORKDIR "$DATA_DIR/opencode-workdir"
482
- return
483
- fi
484
- cat > "$DAEMON_ENV" <<EOF
485
- # Generated by ework-aio at $(date -u +%Y-%m-%dT%H:%M:%SZ)
486
- DAEMON_ENV=production
487
- DAEMON_PORT=$DAEMON_PORT
488
- DAEMON_HOST=127.0.0.1
489
- DAEMON_DB_PATH=$DAEMON_DATA_DIR/ework-daemon.db
490
- GITEA_URL=http://127.0.0.1:$WORK_PORT
491
- GITEA_TOKEN=$bot_token
492
- GITEA_WEBHOOK_SECRET=$webhook
493
- BOT_USERNAME=$BOT_NAME
494
- BOT_TOKEN=$bot_token
495
- OPENCODE_BINARY=$(command -v opencode)
496
- OPENCODE_BASE_WORKDIR=$DATA_DIR/opencode-workdir
497
- EOF
498
- chmod 600 "$DAEMON_ENV"
499
- ok "Wrote $DAEMON_ENV"
500
- }
501
-
502
- write_unit_file() {
503
- # $1=name $2=description $3=execstart $4=workdir $5=envfile $6=unit-file-path
504
- local name="$1" desc="$2" execstart="$3" workdir="$4" envfile="$5" out="$6"
505
- local extra_env="${7:-}"
506
- cat > "$out" <<EOF
507
- [Unit]
508
- Description=$desc
509
- After=network-online.target
510
- Wants=network-online.target
511
-
512
- [Service]
513
- Type=simple
514
- WorkingDirectory=$workdir
515
- ExecStart=$execstart
516
- Restart=on-failure
517
- RestartSec=5
518
- KillMode=process
519
- EnvironmentFile=$envfile
520
- Environment="PATH=/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin:$HOME/.bun/bin"
521
- Environment="XDG_DATA_HOME=$XDG_DATA_HOME"
522
- Environment="XDG_CONFIG_HOME=$XDG_CONFIG_HOME"
523
- Environment="HOME=$HOME"$extra_env
524
- StandardOutput=journal
525
- StandardError=journal
526
- SyslogIdentifier=$name
527
-
528
- [Install]
529
- ${SCOPENAME:+WantedBy=default.target}
530
- EOF
531
- # If system-level, WantedBy is multi-user.target
532
- if [[ "$SCOPENAME" == "--system" ]]; then
533
- sed -i 's/^WantedBy=default.target/WantedBy=multi-user.target/' "$out"
534
- fi
535
- }
536
-
537
- # ctl wraps `systemctl $SCOPENAME` with three hardenings over the bare command:
538
- # 1. If output contains "Unit ... not found", retry once after daemon-reload
539
- # — fresh user sessions (no linger, post-ssh-reconnect) often have a stale
540
- # unit cache and this is the standard fix.
541
- # 2. If output mentions any D-Bus / user-bus connection failure, print an
542
- # actionable hint. The user-bus daemon emits several variants across
543
- # hosts and bash versions ("Failed to connect to bus", "No medium found",
544
- # "Failed to get D-Bus connection: Operation not permitted",
545
- # "Call to org.freedesktop.DBus ... failed"); we match the common stem
546
- # "bus" / "D-Bus" so a new variant can't slip past.
547
- # 3. ALWAYS return systemctl's real exit code. Previously the trailing
548
- # `printf` masked the failure (returning 0) when no pattern matched,
549
- # which made every caller's `|| fallback` silently not fire.
550
- ctl() {
551
- local out rc
552
- out="$(systemctl "$SCOPENAME" "$@" 2>&1)"
553
- rc=$?
554
- if [[ $rc -ne 0 ]]; then
555
- if [[ "$out" == *([Bb]us|D-Bus|No medium found)* ]]; then
556
- cat >&2 <<EOF
557
- ${c_red}systemctl $SCOPENAME failed to reach the user bus.${c_reset}
558
- $out
559
- Hint: run from a logged-in session, or:
560
- export XDG_RUNTIME_DIR=/run/user/$(id -u)
561
- export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus
562
- or reinstall with: sudo ework-aio install --system
563
- EOF
564
- return $rc
565
- fi
566
- if [[ "$out" == *"not found"* ]]; then
567
- systemctl "$SCOPENAME" daemon-reload >/dev/null 2>&1 || true
568
- out="$(systemctl "$SCOPENAME" "$@" 2>&1)"
569
- rc=$?
570
- if [[ $rc -ne 0 ]]; then
571
- cat >&2 <<EOF
572
- ${c_red}systemctl $SCOPENAME $*: unit not found after daemon-reload.${c_reset}
573
- The unit file may be missing from $UNIT_DIR. Re-run:
574
- ework-aio install --no-start # rewrites units, preserves .env/data
575
- EOF
576
- return $rc
577
- fi
578
- fi
579
- fi
580
- printf '%s\n' "$out"
581
- return "$rc"
582
- }
583
-
584
- # ─── Config mode: read/change runtime .env keys ─────────────────────────────
585
- # Each entry: KEY|SERVICE|DESCRIPTION. Only keys in this list are settable via
586
- # `config set`. Secrets (WORK_TOKEN, *_WEBHOOK_SECRET, BOT_TOKEN), DB paths,
587
- # and the web<->daemon contract (GITEA_URL/TOKEN, WORK_DAEMON_WEBHOOK_*) are
588
- # deliberately excluded — changing them by hand breaks the install.
589
- SETTABLE_KEYS=(
590
- "WORK_PORT|web|ework-web listen port (default 3002)"
591
- "WORK_HOST|web|ework-web bind address (default 127.0.0.1; use 0.0.0.0 for LAN)"
592
- "WORK_OPERATOR_LOGIN|web|login auto-promoted to admin"
593
- "WORK_OPENCODE_BIN|web|opencode binary path used by ework-web"
594
- "WORK_TRANSLATE_URL|web|OpenAI-compat /v1/chat/completions endpoint for translate"
595
- "WORK_TRANSLATE_MODEL|web|translate model name"
596
- "WORK_TTS_SPEED|web|TTS playback rate (default 1.0)"
597
- "WORK_FILE_ROOTS|web|comma-separated file-viewer roots"
598
- "WORK_COMMENT_SORT|web|comment sort order: desc|asc"
599
- "DAEMON_PORT|daemon|ework-daemon listen port (default 3101)"
600
- "DAEMON_HOST|daemon|ework-daemon bind address (default 127.0.0.1)"
601
- "OPENCODE_BINARY|daemon|opencode binary path"
602
- "OPENCODE_BASE_WORKDIR|daemon|opencode working directory base"
603
- "COMPLETION_CHECK_API_KEY|daemon|completion-check API key"
604
- "COMPLETION_CHECK_BASE_URL|daemon|completion-check API base URL"
605
- "COMPLETION_CHECK_MODEL|daemon|completion-check model name"
606
- )
607
-
608
- key_service() {
609
- local k="$1" e
610
- for e in "${SETTABLE_KEYS[@]}"; do
611
- [[ "$e" == "$k|"* ]] || continue
612
- local rest="${e#*|}"
613
- echo "${rest%%|*}"
614
- return
615
- done
616
- }
617
- key_default() {
618
- local k="$1" e
619
- for e in "${SETTABLE_KEYS[@]}"; do
620
- [[ "$e" == "$k|"* ]] || continue
621
- local rest="${e#*|}"
622
- echo "${rest#*|}"
623
- return
624
- done
625
- }
626
- key_settable() { local k="$1"; local e; for e in "${SETTABLE_KEYS[@]}"; do [[ "$e" == "$k|"* ]] && return 0; done; return 1; }
627
-
628
- env_file_for() {
629
- case "$1" in
630
- web) echo "$WEB_ENV" ;;
631
- daemon) echo "$DAEMON_ENV" ;;
632
- *) return 1 ;;
633
- esac
634
- }
635
-
636
- env_get() {
637
- local file="$1" key="$2"
638
- [[ -f "$file" ]] || return 1
639
- awk -F= -v k="$key" '$1==k {sub(/^[^=]*=/,""); print; found=1} END{exit !found}' "$file"
640
- }
641
-
642
- env_set() {
643
- local file="$1" key="$2" val="$3"
644
- [[ -f "$file" ]] || { mkdir -p "$(dirname "$file")"; touch "$file"; chmod 600 "$file"; }
645
- if grep -q "^${key}=" "$file"; then
646
- sed -i "s|^${key}=.*|${key}=${val}|" "$file"
647
- else
648
- printf '%s=%s\n' "$key" "$val" >> "$file"
649
- fi
650
- }
651
-
652
- service_for_key() {
653
- case "$1" in
654
- WORK_PORT)
655
- echo "both" ;;
656
- DAEMON_PORT)
657
- echo "both" ;;
658
- *)
659
- echo "$(key_service "$1")" ;;
660
- esac
661
- }
662
-
663
- restart_service() {
664
- local svc="$1"
665
- local rc=0
666
- if ! systemd_reachable; then
667
- log "systemd not reachable — using PID-file mode restart"
668
- if [[ "$svc" == "both" ]]; then
669
- "$EWORK_AIO_BIN" restart both && ok "Restarted ework-web + ework-daemon (PID-file mode)" || rc=$?
670
- else
671
- "$EWORK_AIO_BIN" restart "$svc" && ok "Restarted ework-$svc (PID-file mode)" || rc=$?
672
- fi
673
- if [[ $rc -ne 0 ]]; then
674
- warn "PID-file restart of '$svc' failed (exit $rc)."
675
- warn "The .env changes are saved; run 'ework-aio restart $svc' manually."
676
- fi
677
- return $rc
678
- fi
679
- case "$svc" in
680
- web) ctl restart ework-web.service && ok "Restarted ework-web" || rc=$? ;;
681
- daemon) ctl restart ework-daemon.service && ok "Restarted ework-daemon" || rc=$? ;;
682
- both)
683
- ctl restart ework-web.service ework-daemon.service \
684
- && ok "Restarted ework-web + ework-daemon" || rc=$?
685
- ;;
686
- *) return 1 ;;
687
- esac
688
- if [[ $rc -ne 0 ]]; then
689
- warn "restart of '$svc' failed (exit $rc)."
690
- warn "The .env changes are saved; services still need to be reloaded."
691
- warn "Recovery: 'ework-aio config restart $svc' from a logged-in shell,"
692
- warn "or 'sudo ework-aio install --system' to escape user-bus limitations."
693
- return $rc
694
- fi
695
- return 0
696
- }
697
-
698
- config_help() {
699
- cat <<'EOF'
700
- ework-aio config <subcommand>
701
-
702
- Subcommands:
703
- list List all settable keys + current values
704
- get <KEY> Print current value of one key
705
- set <KEY> <VALUE> Set a key in .env, then restart the affected
706
- service (unless --no-restart is given)
707
- restart <web|daemon|both> Restart one or both services
708
-
709
- Examples:
710
- ework-aio config list
711
- ework-aio config get WORK_PORT
712
- ework-aio config set WORK_PORT 8080
713
- ework-aio config set WORK_TRANSLATE_URL http://127.0.0.1:8000/v1 --no-restart
714
- ework-aio config restart both
715
-
716
- Note: changing WORK_PORT or DAEMON_PORT also rewrites the cross-link the other
717
- service uses (GITEA_URL on daemon side, WORK_DAEMON_WEBHOOK_URL on web side),
718
- and restarts both. Secrets and DB paths are not settable here — rerun
719
- `ework-aio install` (with `rm .env` first if you need new tokens).
720
- EOF
721
- }
722
-
723
- config_list() {
724
- hr; log "Settable config keys"; hr
725
- printf ' %-28s %-8s %s\n' "KEY" "SERVICE" "VALUE"
726
- local e k svc val env_file
727
- for e in "${SETTABLE_KEYS[@]}"; do
728
- k="${e%%|*}"
729
- svc="$(key_service "$k")"
730
- env_file="$(env_file_for "$svc")"
731
- val="$(env_get "$env_file" "$k" 2>/dev/null || echo '')"
732
- [[ -z "$val" ]] && val="(unset)"
733
- printf ' %-28s %-8s %s\n' "$k" "$svc" "$val"
734
- done
735
- hr
736
- printf 'Use %sconfig set <KEY> <VALUE>%s to change a key.\n' "$c_bold" "$c_reset"
737
- }
738
-
739
- config_get() {
740
- local k="$1"
741
- [[ -n "$k" ]] || die "Usage: ework-aio config get <KEY>"
742
- key_settable "$k" || die "Key '$k' is not settable. Run 'ework-aio config list' for the allow-list."
743
- local svc env_file val
744
- svc="$(key_service "$k")"
745
- env_file="$(env_file_for "$svc")"
746
- val="$(env_get "$env_file" "$k" 2>/dev/null)" || { warn "$k is not currently set in $env_file"; exit 0; }
747
- printf '%s\n' "$val"
748
- }
749
-
750
- config_set() {
751
- local k="$1" v="$2"
752
- [[ -n "$k" && -n "$v" ]] || die "Usage: ework-aio config set <KEY> <VALUE>"
753
- key_settable "$k" || die "Key '$k' is not settable. Run 'ework-aio config list' for the allow-list."
754
-
755
- local svc env_file
756
- svc="$(key_service "$k")"
757
- env_file="$(env_file_for "$svc")"
758
-
759
- log "Setting $k=$v in $env_file"
760
- cp "$env_file" "$env_file.bak.$(date +%s)" 2>/dev/null || true
761
- env_set "$env_file" "$k" "$v"
762
- ok "$k updated"
763
-
764
- case "$k" in
765
- WORK_PORT)
766
- log "Propagating to daemon (GITEA_URL)"
767
- cp "$DAEMON_ENV" "$DAEMON_ENV.bak.$(date +%s)" 2>/dev/null || true
768
- env_set "$DAEMON_ENV" "GITEA_URL" "http://127.0.0.1:$v"
769
- ok "DAEMON_ENV GITEA_URL updated"
770
- ;;
771
- DAEMON_PORT)
772
- log "Propagating to web (WORK_DAEMON_WEBHOOK_URL)"
773
- cp "$WEB_ENV" "$WEB_ENV.bak.$(date +%s)" 2>/dev/null || true
774
- env_set "$WEB_ENV" "WORK_DAEMON_WEBHOOK_URL" "http://127.0.0.1:$v"
775
- ok "WEB_ENV WORK_DAEMON_WEBHOOK_URL updated"
776
- ;;
777
- esac
778
-
779
- if [[ "$NO_RESTART" == "1" ]]; then
780
- local to_restart
781
- to_restart="$(service_for_key "$k")"
782
- warn "--no-restart: changes saved but service not reloaded. Run 'ework-aio config restart $to_restart' to apply."
783
- return
784
- fi
785
-
786
- local target
787
- target="$(service_for_key "$k")"
788
- log "Restarting $target..."
789
- restart_service "$target" || warn "restart failed — services may need manual reload"
790
- }
791
-
792
- config_restart() {
793
- local svc="${1:-both}"
794
- case "$svc" in
795
- web|daemon|both) restart_service "$svc" ;;
796
- *) die "Usage: ework-aio config restart <web|daemon|both>" ;;
797
- esac
798
- }
799
-
800
- run_config() {
801
- local sub="${CFG_ARGS[0]:-list}"
802
- case "$sub" in
803
- list) config_list ;;
804
- get) config_get "${CFG_ARGS[1]:-}" ;;
805
- set) config_set "${CFG_ARGS[1]:-}" "${CFG_ARGS[2]:-}" ;;
806
- restart) config_restart "${CFG_ARGS[1]:-both}" ;;
807
- help|-h|--help) config_help ;;
808
- *) die "Unknown config subcommand: $sub (try: ework-aio config help)" ;;
809
- esac
810
- }
811
-
812
- # ─── Modes ──────────────────────────────────────────────────────────────────
813
- case "$MODE" in
814
- env)
815
- hr; log "ework-aio paths"; hr
816
- printf ' data dir : %s\n' "$DATA_DIR"
817
- printf ' web env : %s\n' "$WEB_ENV"
818
- printf ' daemon env : %s\n' "$DAEMON_ENV"
819
- printf ' bot token : %s\n' "$BOT_TOKEN_FILE"
820
- printf ' opencode cfg : %s\n' "$OPENCODE_CFG"
821
- printf ' systemd scope : %s\n' "$SCOPENAME"
822
- printf ' unit dir : %s\n' "$UNIT_DIR"
823
- exit 0
824
- ;;
825
-
826
- config)
827
- run_config
828
- exit 0
829
- ;;
830
-
831
- status)
832
- hr; log "ework-aio status"; hr
833
- if ! systemd_reachable; then
834
- log "(systemd unreachable — showing PID-file mode status)"
835
- "$EWORK_AIO_BIN" ps 2>/dev/null || warn "ework-aio ps failed"
836
- hr
837
- log "Port listeners:"
838
- p=""
839
- for p in $(grep -hE '^(WORK_PORT|DAEMON_PORT)=' "$WEB_ENV" "$DAEMON_ENV" 2>/dev/null | cut -d= -f2 | sort -u); do
840
- if curl -sf -o /dev/null --max-time 1 "http://127.0.0.1:$p/login" 2>/dev/null; then
841
- printf ' :%s ✓ listening\n' "$p"
842
- else
843
- printf ' :%s ✗ not responding\n' "$p"
844
- fi
845
- done
846
- exit 0
847
- fi
848
- ctl is-active ework-web.service || true
849
- ctl is-active ework-daemon.service || true
850
- ctl status --no-pager --lines=0 ework-web.service 2>/dev/null || true
851
- hr
852
- ctl status --no-pager --lines=0 ework-daemon.service 2>/dev/null || true
853
- exit 0
854
- ;;
855
-
856
- logs)
857
- svc="${MODE_ARGS[0]:-ework-web.service}"
858
- [[ "$svc" == "daemon" || "$svc" == "ework-daemon" ]] && svc="ework-daemon.service"
859
- [[ "$svc" == "web" || "$svc" == "ework-web" ]] && svc="ework-web.service"
860
- if ! systemd_reachable; then
861
- svc_short="${svc%.service}"
862
- svc_short="${svc_short#ework-}"
863
- log_file="$DATA_DIR/run/${svc_short}.log"
864
- if [[ ! -f "$log_file" ]]; then
865
- die "systemd unreachable and no PID-file log at $log_file. Start services with: ework-aio start $svc_short"
866
- fi
867
- log "(systemd unreachable — tailing PID-file log $log_file)"
868
- exec tail -n 200 -f "$log_file"
869
- fi
870
- exec journalctl "$SCOPENAME" -u "$svc" -f
871
- ;;
872
-
873
- uninstall)
874
- hr; log "Uninstalling ework-aio services (keeping data)"; hr
875
- "$EWORK_AIO_BIN" stop both 2>/dev/null || true
876
- if systemd_reachable; then
877
- ctl stop ework-web.service ework-daemon.service 2>/dev/null || true
878
- ctl disable ework-web.service ework-daemon.service 2>/dev/null || true
879
- rm -f "$UNIT_DIR/ework-web.service" "$UNIT_DIR/ework-daemon.service"
880
- ctl daemon-reload 2>/dev/null || true
881
- else
882
- log "(systemd unreachable — skipped unit cleanup; PID-file mode services stopped above)"
883
- rm -f "$UNIT_DIR/ework-web.service" "$UNIT_DIR/ework-daemon.service" 2>/dev/null || true
884
- fi
885
- ok "Services removed. Data preserved at $DATA_DIR"
886
- warn "To fully remove: rm -rf $DATA_DIR && npm uninstall -g ework-aio ework-web ework-daemon opencode-ework"
887
- exit 0
888
- ;;
889
- esac
890
-
891
- # ─── Install mode ───────────────────────────────────────────────────────────
892
- hr
893
- log "ework-aio install"
894
- log " mode : $([[ "$USE_SYSTEMD" == "1" ]] && echo "systemd" || echo "PID-file (no systemd)")"
895
- log " data dir : $DATA_DIR"
896
- log " web bin : $EWORK_WEB_BIN"
897
- log " daemon bin : $EWORK_DAEMON_BIN"
898
- log " opencode : $(command -v opencode) ($($(command -v opencode) --version 2>&1 | head -1))"
899
- hr
900
-
901
- if [[ "$USE_SYSTEMD" == "1" && "$SCOPENAME" == "--user" ]] && ! loginctl show-user "$USER" 2>/dev/null | grep -q '^Linger=yes'; then
902
- warn "User-level systemd requires lingering to keep services alive after logout."
903
- if [[ "$ASSUME_YES" == "1" ]]; then
904
- warn "Run this manually: sudo loginctl enable-linger $USER"
905
- else
906
- read -rp "Enable linger now? (needs sudo) [Y/n] " ans
907
- if [[ "${ans:-Y}" =~ ^[Yy]?$ ]]; then
908
- sudo loginctl enable-linger "$USER" || warn "enable-linger failed; services will stop on logout"
909
- fi
910
- fi
911
- fi
912
-
913
- mkdir -p "$WEB_DATA_DIR" "$DAEMON_DATA_DIR" "$DATA_DIR/opencode-workdir"
914
-
915
- write_web_env
916
-
917
- # ─── Write ework-web systemd unit (only with `install systemd`) ────────────
918
- SYSTEMD_OK=0
919
- if [[ "$USE_SYSTEMD" == "1" ]]; then
920
- WEB_UNIT="$UNIT_DIR/ework-web.service"
921
- write_unit_file \
922
- "ework-web" \
923
- "ework — multi-project issue tracker" \
924
- "$EWORK_WEB_BIN" \
925
- "$WEB_DATA_DIR" \
926
- "$WEB_ENV" \
927
- "$WEB_UNIT"
928
- ok "Wrote $WEB_UNIT"
929
-
930
- # systemd calls here are best-effort. If systemd isn't functional on this host
931
- # (containers without systemd PID 1, polkit redirects under sudo, missing
932
- # /etc/systemd/system) we still want install to complete so the user can run
933
- # `ework-aio start` (PID-file mode) against the scaffolded .env.
934
- SYSTEMD_OK=1
935
- if ! ctl daemon-reload; then
936
- warn "systemctl daemon-reload failed — systemd may be unavailable on this host."
937
- SYSTEMD_OK=0
938
- fi
939
-
940
- if [[ "$NO_START" == "0" && "$SYSTEMD_OK" == "1" ]]; then
941
- log "Starting ework-web (systemd)..."
942
- ctl enable ework-web.service || { warn "systemctl enable failed"; SYSTEMD_OK=0; }
943
- if [[ "$SYSTEMD_OK" == "1" ]]; then
944
- ctl restart ework-web.service || { warn "systemctl restart failed"; SYSTEMD_OK=0; }
945
- fi
946
- if [[ "$SYSTEMD_OK" == "1" ]]; then
947
- for i in $(seq 1 60); do
948
- if curl -sf -o /dev/null "http://127.0.0.1:$WORK_PORT/login"; then
949
- ok "ework-web listening on :$WORK_PORT (after ${i} half-seconds)"
950
- break
951
- fi
952
- sleep 0.5
953
- [[ $i -eq 60 ]] && { warn "ework-web did not come up via systemd in 30s. Falling back to PID-file mode."; SYSTEMD_OK=0; }
954
- done
955
- fi
956
- else
957
- warn "--no-start: unit enabled but not started"
958
- ctl enable ework-web.service 2>/dev/null || true
959
- fi
960
- fi
961
-
962
- # ─── Start ework-web in PID-file mode ──────────────────────────────────────
963
- # Default path (no `systemd` arg) OR systemd fallback. We bring web up via
964
- # setsid+nohup so the bot bootstrap below can HTTP-probe it. Web stays
965
- # running in PID-file mode after install — use `ework-aio stop` to kill it.
966
- if [[ "$NO_START" == "0" && "$SYSTEMD_OK" == "0" ]]; then
967
- log "Starting ework-web (PID-file mode)..."
968
- WEB_LOG_FILE="$DATA_DIR/run/web.log"
969
- mkdir -p "$(dirname "$WEB_LOG_FILE")"
970
- setsid nohup "$EWORK_WEB_BIN" >>"$WEB_LOG_FILE" 2>&1 </dev/null &
971
- WEB_PID=$!
972
- disown "$WEB_PID" 2>/dev/null || true
973
- printf '%s\n' "$WEB_PID" > "$DATA_DIR/run/web.pid"
974
- ok "ework-web started in PID-file mode (pid $WEB_PID, log $WEB_LOG_FILE)"
975
- for i in $(seq 1 60); do
976
- if curl -sf -o /dev/null "http://127.0.0.1:$WORK_PORT/login"; then
977
- ok "ework-web listening on :$WORK_PORT (after ${i} half-seconds, PID-file mode)"
978
- break
979
- fi
980
- sleep 0.5
981
- [[ $i -eq 60 ]] && die "ework-web did not come up in 30s. Tail of $WEB_LOG_FILE: $(tail -n 20 "$WEB_LOG_FILE" 2>/dev/null)"
982
- done
983
- fi
984
-
985
- # ─── Bootstrap bot user + PAT (idempotent) ─────────────────────────────────
986
- WORK_TOKEN_VAL=$(awk -F= '/^WORK_TOKEN=/{print $2}' "$WEB_ENV")
987
- WORK_COOKIE_SECRET_VAL=$(awk -F= '/^WORK_COOKIE_SECRET=/{print $2}' "$WEB_ENV")
988
- # ework-web's checkAuth accepts legacy "<token>.<hmac>" cookie form
989
- COOKIE_SIG=$(printf '%s' "$WORK_TOKEN_VAL" \
990
- | openssl dgst -sha256 -hmac "$WORK_COOKIE_SECRET_VAL" -binary \
991
- | base64 | tr '+/' '-_' | tr -d '=')
992
- AUTH_COOKIE="ework_auth=${WORK_TOKEN_VAL}.${COOKIE_SIG}"
993
-
994
- BOT_TOKEN=""
995
- BOT_BOOTSTRAP_OK=1
996
- if [[ -f "$BOT_TOKEN_FILE" ]]; then
997
- BOT_TOKEN=$(cat "$BOT_TOKEN_FILE")
998
- ok "Reusing saved bot token from $BOT_TOKEN_FILE"
999
- else
1000
- # Pre-flight: web must be reachable for the bot bootstrap HTTP calls below.
1001
- # In --no-start mode OR on hosts where systemd couldn't bring web up, skip
1002
- # the bootstrap entirely — daemon .env still gets written (with empty token)
1003
- # so the user can `ework-aio start` and re-run install later.
1004
- if ! curl -sf -o /dev/null "http://127.0.0.1:$WORK_PORT/login"; then
1005
- warn "ework-web not reachable at :$WORK_PORT — skipping bot bootstrap."
1006
- warn "Daemon will not be able to talk to web until you re-run install with web running."
1007
- BOT_BOOTSTRAP_OK=0
1008
- fi
1009
- fi
1010
-
1011
- if [[ "$BOT_BOOTSTRAP_OK" == "1" && ! -f "$BOT_TOKEN_FILE" ]]; then
1012
- log "Bootstrapping bot user '$BOT_NAME'..."
1013
- BOT_PW=$(openssl rand -hex 24)
1014
- CREATE_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
1015
- "http://127.0.0.1:$WORK_PORT/admin/users/create" \
1016
- -H "Cookie: $AUTH_COOKIE" \
1017
- --data-urlencode "login=$BOT_NAME" \
1018
- --data-urlencode "password=$BOT_PW" \
1019
- --data-urlencode "kind=bot" \
1020
- --data-urlencode "is_admin=0") || CREATE_CODE=000
1021
- case "$CREATE_CODE" in
1022
- 303) ok "Bot user '$BOT_NAME' created" ;;
1023
- 400|409) warn "Bot user '$BOT_NAME' already exists (continuing)" ;;
1024
- *) warn "Failed to create bot user: HTTP $CREATE_CODE — skipping PAT mint; daemon will not be able to talk to web until re-run"; BOT_BOOTSTRAP_OK=0 ;;
1025
- esac
1026
- fi
1027
-
1028
- if [[ "$BOT_BOOTSTRAP_OK" == "1" && ! -f "$BOT_TOKEN_FILE" ]]; then
1029
- log "Logging in as bot to mint PAT..."
1030
- COOKIE_JAR=$(mktemp)
1031
- LOGIN_CODE=$(curl -sS -c "$COOKIE_JAR" -X POST "http://127.0.0.1:$WORK_PORT/login" \
1032
- --data-urlencode "login=$BOT_NAME" \
1033
- --data-urlencode "password=$BOT_PW" \
1034
- -o /dev/null -w '%{http_code}') || LOGIN_CODE=000
1035
- BOT_COOKIE=$(awk '/ework_auth/ {print $7}' "$COOKIE_JAR")
1036
- rm -f "$COOKIE_JAR"
1037
- if [[ "$LOGIN_CODE" != "302" || -z "$BOT_COOKIE" ]]; then
1038
- warn "Bot login failed: HTTP $LOGIN_CODE — skipping PAT mint"
1039
- BOT_BOOTSTRAP_OK=0
1040
- fi
1041
- fi
1042
-
1043
- if [[ "$BOT_BOOTSTRAP_OK" == "1" && ! -f "$BOT_TOKEN_FILE" ]]; then
1044
- log "Minting PAT..."
1045
- PAT_RES=$(curl -sS -X POST "http://127.0.0.1:$WORK_PORT/me/tokens/create" \
1046
- -H "Cookie: ework_auth=$BOT_COOKIE" \
1047
- --data-urlencode "name=aio-$(date +%s)")
1048
- BOT_TOKEN=$(printf '%s' "$PAT_RES" | grep -oE 'id="t">[a-f0-9]{40}<' | grep -oE '[a-f0-9]{40}' | head -1 || true)
1049
- if [[ -z "$BOT_TOKEN" ]]; then
1050
- warn "Could not extract PAT from token-create response — daemon .env will have empty token"
1051
- else
1052
- printf '%s' "$BOT_TOKEN" > "$BOT_TOKEN_FILE"
1053
- chmod 600 "$BOT_TOKEN_FILE"
1054
- ok "Bot PAT saved to $BOT_TOKEN_FILE"
1055
- fi
1056
- fi
1057
-
1058
- # ─── Write ework-daemon .env ───────────────────────────────────────────────
1059
- write_daemon_env "$BOT_TOKEN"
1060
-
1061
- # ─── Write + start ework-daemon (systemd path, only with `install systemd`) ─
1062
- if [[ "$USE_SYSTEMD" == "1" ]]; then
1063
- DAEMON_UNIT="$UNIT_DIR/ework-daemon.service"
1064
- write_unit_file \
1065
- "ework-daemon" \
1066
- "ework-daemon — issue-driven AI dev daemon" \
1067
- "$EWORK_DAEMON_BIN" \
1068
- "$DAEMON_DATA_DIR" \
1069
- "$DAEMON_ENV" \
1070
- "$DAEMON_UNIT" \
1071
- " \"\""
1072
- # (trailing " \"\"" is a no-op separator; kept for future extra env injection)
1073
- ok "Wrote $DAEMON_UNIT"
1074
-
1075
- ctl daemon-reload 2>/dev/null || true
1076
- if [[ "$NO_START" == "0" && "$SYSTEMD_OK" == "1" ]]; then
1077
- log "Starting ework-daemon (systemd)..."
1078
- ctl enable ework-daemon.service 2>/dev/null || true
1079
- ctl restart ework-daemon.service 2>/dev/null || warn "ework-daemon restart via systemd failed; falling back to PID-file mode"
1080
- sleep 1
1081
- if ctl is-active --quiet ework-daemon.service 2>/dev/null; then
1082
- ok "ework-daemon active (systemd)"
1083
- else
1084
- warn "ework-daemon not active via systemd — falling back to PID-file mode"
1085
- SYSTEMD_OK=0
1086
- fi
1087
- else
1088
- ctl enable ework-daemon.service 2>/dev/null || true
1089
- fi
1090
- fi
1091
-
1092
- # ─── Start ework-daemon in PID-file mode (default path) ────────────────────
1093
- # Mirrors the web PID-file start. Skipped if systemd already brought it up.
1094
- if [[ "$NO_START" == "0" && "$SYSTEMD_OK" == "0" ]]; then
1095
- log "Starting ework-daemon (PID-file mode)..."
1096
- DAEMON_LOG_FILE="$DATA_DIR/run/daemon.log"
1097
- mkdir -p "$(dirname "$DAEMON_LOG_FILE")"
1098
- setsid nohup "$EWORK_DAEMON_BIN" >>"$DAEMON_LOG_FILE" 2>&1 </dev/null &
1099
- DAEMON_PID=$!
1100
- disown "$DAEMON_PID" 2>/dev/null || true
1101
- printf '%s\n' "$DAEMON_PID" > "$DATA_DIR/run/daemon.pid"
1102
- ok "ework-daemon started in PID-file mode (pid $DAEMON_PID, log $DAEMON_LOG_FILE)"
1103
- fi
1104
-
1105
- if [[ "$USE_SYSTEMD" == "1" && "$SYSTEMD_OK" == "0" ]]; then
1106
- hr
1107
- warn "systemd mode did not come up cleanly on this host."
1108
- warn "Services started in PID-file mode instead. systemd unit files were"
1109
- warn "written but are not active. To clean them up: ework-aio uninstall"
1110
- hr
1111
- fi
1112
-
1113
- # ─── Register opencode-ework plugin ────────────────────────────────────────
1114
- # Safety: every edit writes to a temp file, validates with `jq -e .`, only
1115
- # then atomically replaces the original. A timestamped .bak is always kept.
1116
- mkdir -p "$(dirname "$OPENCODE_CFG")"
1117
-
1118
- json_edit() {
1119
- # $1 = file, $2 = jq program, $3 = expected JSON type (optional, e.g. "object").
1120
- # Writes to temp, validates (parses + optional type check), only then swaps.
1121
- # Backup at $file.bak.<epoch>.
1122
- local file="$1" prog="$2" expected="${3:-}" tmp
1123
- tmp=$(mktemp)
1124
- if ! jq "$prog" "$file" > "$tmp" 2>/dev/null; then
1125
- rm -f "$tmp"
1126
- die "jq edit failed on $file (program: $prog)"
1127
- fi
1128
- if ! jq -e . "$tmp" >/dev/null 2>&1; then
1129
- rm -f "$tmp"
1130
- die "jq edit produced invalid JSON on $file — aborted, original untouched"
1131
- fi
1132
- if [[ -n "$expected" ]] && ! jq -e "type == \"$expected\"" "$tmp" >/dev/null 2>&1; then
1133
- rm -f "$tmp"
1134
- die "jq edit produced wrong type on $file (expected $expected) — aborted, original untouched"
1135
- fi
1136
- cp "$file" "$file.bak.$(date +%s)"
1137
- mv "$tmp" "$file"
1138
- }
1139
-
1140
- if [[ ! -f "$OPENCODE_CFG" ]]; then
1141
- log "Writing $OPENCODE_CFG (registering opencode-ework plugin)"
1142
- cat > "$OPENCODE_CFG" <<'EOF'
1143
- {
1144
- "$schema": "https://opencode.ai/config.json",
1145
- "plugin": ["opencode-ework@latest"]
1146
- }
1147
- EOF
1148
- elif grep -q '"opencode-ework@latest"' "$OPENCODE_CFG"; then
1149
- ok "opencode-ework@latest already in $OPENCODE_CFG"
1150
- elif grep -q '"opencode-ework"' "$OPENCODE_CFG"; then
1151
- log "Upgrading opencode-ework → opencode-ework@latest in $OPENCODE_CFG"
1152
- json_edit "$OPENCODE_CFG" \
1153
- '.plugin = ((.plugin // []) | map(if . == "opencode-ework" then "opencode-ework@latest" else . end))' \
1154
- object
1155
- ok "Plugin upgraded (backup at $OPENCODE_CFG.bak.*)"
1156
- else
1157
- log "Merging opencode-ework@latest into existing $OPENCODE_CFG"
1158
- json_edit "$OPENCODE_CFG" \
1159
- 'if .plugin then .plugin += ["opencode-ework@latest"] else . + {plugin:["opencode-ework@latest"]} end' \
1160
- object
1161
- ok "Plugin registered (backup at $OPENCODE_CFG.bak.*)"
1162
- fi
1163
-
1164
- if ! jq -e 'type == "object"' "$OPENCODE_CFG" >/dev/null 2>&1; then
1165
- warn "$OPENCODE_CFG is not a JSON object — check $OPENCODE_CFG.bak.* for restore"
1166
- fi
1167
-
1168
- # ─── Done ──────────────────────────────────────────────────────────────────
1169
- hr
1170
- ok "Install complete ($([[ "$USE_SYSTEMD" == "1" ]] && echo "systemd mode" || echo "PID-file mode"))."
1171
- hr
1172
- printf '\n%s→%s Open %shttp://127.0.0.1:%s/login%s\n' \
1173
- "$c_bold" "$c_reset" "$c_dim" "$WORK_PORT" "$c_reset"
1174
- printf ' Operator login: %s%s%s (auto-promoted admin; derived from $USER at install time)\n' \
1175
- "$c_bold" "$USER" "$c_reset"
1176
- printf ' Login token: %s%s%s\n' "$c_bold" "$WORK_TOKEN_VAL" "$c_reset"
1177
- printf ' Bot user: %s%s%s (auto-created, used by ework-daemon)\n' \
1178
- "$c_bold" "$BOT_NAME" "$c_reset"
1179
- printf ' Data dir: %s%s%s\n' "$c_dim" "$DATA_DIR" "$c_reset"
1180
- printf ' Logs: ework-aio logs web | ework-aio logs daemon\n'
1181
- printf ' Status: ework-aio status\n'
1182
- printf ' Stop: ework-aio stop\n'
1183
- printf ' Uninstall: ework-aio uninstall\n'
1184
- if [[ "$USE_SYSTEMD" != "1" ]]; then
1185
- hr
1186
- printf '\n%sPID-file mode note%s (services run via nohup, not systemd)\n' "$c_bold" "$c_reset"
1187
- printf ' • To enable auto-restart on boot, re-run with: ework-aio install systemd\n'
1188
- printf ' • Services stop when you kill them (no supervisor). Use ework-aio stop to stop cleanly.\n'
1189
- fi
1190
- hr
1191
- printf '\n%sNext steps (optional config)%s\n' "$c_bold" "$c_reset"
1192
- printf ' • 朗读 (TTS): %shttp://127.0.0.1:%s/admin/tts%s — needs an OpenAI-compat /audio/speech endpoint\n' \
1193
- "$c_dim" "$WORK_PORT" "$c_reset"
1194
- printf ' • 翻译: edit %s%s/.env%s, set WORK_TRANSLATE_URL + WORK_TRANSLATE_MODEL\n' \
1195
- "$c_dim" "$WEB_DATA_DIR" "$c_reset"
1196
- printf ' (OpenAI-compat /v1/chat/completions endpoint), then: ework-aio install\n'
1197
- printf ' • Per-project webhook: open %s/<owner>/<repo>/webhooks%s to wire downstream\n' \
1198
- "$c_dim" "$c_reset"
1199
- printf ' consumers (GitHub Actions, etc.). Gitea-compat payload + HMAC-SHA256 sig.\n'
1200
- hr