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/bin/install.sh DELETED
@@ -1,1146 +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
- return
399
- fi
400
- local tok secret webhook
401
- tok=$(gen_token 20)
402
- secret=$(gen_token 24)
403
- webhook=$(gen_token 20)
404
- cat > "$WEB_ENV" <<EOF
405
- # Generated by ework-aio at $(date -u +%Y-%m-%dT%H:%M:%SZ)
406
- WORK_PORT=$WORK_PORT
407
- WORK_HOST=127.0.0.1
408
- WORK_TOKEN=$tok
409
- WORK_COOKIE_SECRET=$secret
410
- WORK_OPERATOR_LOGIN=$USER
411
- WORK_WRITES_ENABLED=true
412
- WORK_DB_PATH=$WEB_DATA_DIR/ework.db
413
- WORK_ATTACHMENT_ROOT=$WEB_DATA_DIR/attachments
414
- WORK_FILE_ROOTS=/tmp,$DATA_DIR
415
- WORK_DAEMON_BOT_LOGIN=$BOT_NAME
416
- WORK_DAEMON_WEBHOOK_URL=http://127.0.0.1:$DAEMON_PORT
417
- WORK_DAEMON_WEBHOOK_SECRET=$webhook
418
- EOF
419
- chmod 600 "$WEB_ENV"
420
- ok "Wrote $WEB_ENV"
421
- }
422
-
423
- write_daemon_env() {
424
- mkdir -p "$(dirname "$DAEMON_ENV")"
425
- local bot_token="$1"
426
- local webhook
427
- # Reuse webhook secret from web env so daemon's signature matches web's verify
428
- webhook=$(awk -F= '/^WORK_DAEMON_WEBHOOK_SECRET=/{print $2}' "$WEB_ENV" 2>/dev/null || true)
429
- [[ -n "$webhook" ]] || webhook=$(gen_token 20)
430
- cat > "$DAEMON_ENV" <<EOF
431
- # Generated by ework-aio at $(date -u +%Y-%m-%dT%H:%M:%SZ)
432
- DAEMON_ENV=production
433
- DAEMON_PORT=$DAEMON_PORT
434
- DAEMON_HOST=127.0.0.1
435
- DAEMON_DB_PATH=$DAEMON_DATA_DIR/ework-daemon.db
436
- GITEA_URL=http://127.0.0.1:$WORK_PORT
437
- GITEA_TOKEN=$bot_token
438
- GITEA_WEBHOOK_SECRET=$webhook
439
- BOT_USERNAME=$BOT_NAME
440
- BOT_TOKEN=$bot_token
441
- OPENCODE_BINARY=$(command -v opencode)
442
- OPENCODE_BASE_WORKDIR=$DATA_DIR/opencode-workdir
443
- EOF
444
- chmod 600 "$DAEMON_ENV"
445
- ok "Wrote $DAEMON_ENV"
446
- }
447
-
448
- write_unit_file() {
449
- # $1=name $2=description $3=execstart $4=workdir $5=envfile $6=unit-file-path
450
- local name="$1" desc="$2" execstart="$3" workdir="$4" envfile="$5" out="$6"
451
- local extra_env="${7:-}"
452
- cat > "$out" <<EOF
453
- [Unit]
454
- Description=$desc
455
- After=network-online.target
456
- Wants=network-online.target
457
-
458
- [Service]
459
- Type=simple
460
- WorkingDirectory=$workdir
461
- ExecStart=$execstart
462
- Restart=on-failure
463
- RestartSec=5
464
- KillMode=process
465
- EnvironmentFile=$envfile
466
- Environment="PATH=/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin:$HOME/.bun/bin"
467
- Environment="XDG_DATA_HOME=$XDG_DATA_HOME"
468
- Environment="XDG_CONFIG_HOME=$XDG_CONFIG_HOME"
469
- Environment="HOME=$HOME"$extra_env
470
- StandardOutput=journal
471
- StandardError=journal
472
- SyslogIdentifier=$name
473
-
474
- [Install]
475
- ${SCOPENAME:+WantedBy=default.target}
476
- EOF
477
- # If system-level, WantedBy is multi-user.target
478
- if [[ "$SCOPENAME" == "--system" ]]; then
479
- sed -i 's/^WantedBy=default.target/WantedBy=multi-user.target/' "$out"
480
- fi
481
- }
482
-
483
- # ctl wraps `systemctl $SCOPENAME` with three hardenings over the bare command:
484
- # 1. If output contains "Unit ... not found", retry once after daemon-reload
485
- # — fresh user sessions (no linger, post-ssh-reconnect) often have a stale
486
- # unit cache and this is the standard fix.
487
- # 2. If output mentions any D-Bus / user-bus connection failure, print an
488
- # actionable hint. The user-bus daemon emits several variants across
489
- # hosts and bash versions ("Failed to connect to bus", "No medium found",
490
- # "Failed to get D-Bus connection: Operation not permitted",
491
- # "Call to org.freedesktop.DBus ... failed"); we match the common stem
492
- # "bus" / "D-Bus" so a new variant can't slip past.
493
- # 3. ALWAYS return systemctl's real exit code. Previously the trailing
494
- # `printf` masked the failure (returning 0) when no pattern matched,
495
- # which made every caller's `|| fallback` silently not fire.
496
- ctl() {
497
- local out rc
498
- out="$(systemctl "$SCOPENAME" "$@" 2>&1)"
499
- rc=$?
500
- if [[ $rc -ne 0 ]]; then
501
- if [[ "$out" == *([Bb]us|D-Bus|No medium found)* ]]; then
502
- cat >&2 <<EOF
503
- ${c_red}systemctl $SCOPENAME failed to reach the user bus.${c_reset}
504
- $out
505
- Hint: run from a logged-in session, or:
506
- export XDG_RUNTIME_DIR=/run/user/$(id -u)
507
- export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus
508
- or reinstall with: sudo ework-aio install --system
509
- EOF
510
- return $rc
511
- fi
512
- if [[ "$out" == *"not found"* ]]; then
513
- systemctl "$SCOPENAME" daemon-reload >/dev/null 2>&1 || true
514
- out="$(systemctl "$SCOPENAME" "$@" 2>&1)"
515
- rc=$?
516
- if [[ $rc -ne 0 ]]; then
517
- cat >&2 <<EOF
518
- ${c_red}systemctl $SCOPENAME $*: unit not found after daemon-reload.${c_reset}
519
- The unit file may be missing from $UNIT_DIR. Re-run:
520
- ework-aio install --no-start # rewrites units, preserves .env/data
521
- EOF
522
- return $rc
523
- fi
524
- fi
525
- fi
526
- printf '%s\n' "$out"
527
- return "$rc"
528
- }
529
-
530
- # ─── Config mode: read/change runtime .env keys ─────────────────────────────
531
- # Each entry: KEY|SERVICE|DESCRIPTION. Only keys in this list are settable via
532
- # `config set`. Secrets (WORK_TOKEN, *_WEBHOOK_SECRET, BOT_TOKEN), DB paths,
533
- # and the web<->daemon contract (GITEA_URL/TOKEN, WORK_DAEMON_WEBHOOK_*) are
534
- # deliberately excluded — changing them by hand breaks the install.
535
- SETTABLE_KEYS=(
536
- "WORK_PORT|web|ework-web listen port (default 3002)"
537
- "WORK_HOST|web|ework-web bind address (default 127.0.0.1; use 0.0.0.0 for LAN)"
538
- "WORK_OPERATOR_LOGIN|web|login auto-promoted to admin"
539
- "WORK_OPENCODE_BIN|web|opencode binary path used by ework-web"
540
- "WORK_TRANSLATE_URL|web|OpenAI-compat /v1/chat/completions endpoint for translate"
541
- "WORK_TRANSLATE_MODEL|web|translate model name"
542
- "WORK_TTS_SPEED|web|TTS playback rate (default 1.0)"
543
- "WORK_FILE_ROOTS|web|comma-separated file-viewer roots"
544
- "WORK_COMMENT_SORT|web|comment sort order: desc|asc"
545
- "DAEMON_PORT|daemon|ework-daemon listen port (default 3101)"
546
- "DAEMON_HOST|daemon|ework-daemon bind address (default 127.0.0.1)"
547
- "OPENCODE_BINARY|daemon|opencode binary path"
548
- "OPENCODE_BASE_WORKDIR|daemon|opencode working directory base"
549
- "COMPLETION_CHECK_API_KEY|daemon|completion-check API key"
550
- "COMPLETION_CHECK_BASE_URL|daemon|completion-check API base URL"
551
- "COMPLETION_CHECK_MODEL|daemon|completion-check model name"
552
- )
553
-
554
- key_service() {
555
- local k="$1" e
556
- for e in "${SETTABLE_KEYS[@]}"; do
557
- [[ "$e" == "$k|"* ]] || continue
558
- local rest="${e#*|}"
559
- echo "${rest%%|*}"
560
- return
561
- done
562
- }
563
- key_default() {
564
- local k="$1" e
565
- for e in "${SETTABLE_KEYS[@]}"; do
566
- [[ "$e" == "$k|"* ]] || continue
567
- local rest="${e#*|}"
568
- echo "${rest#*|}"
569
- return
570
- done
571
- }
572
- key_settable() { local k="$1"; local e; for e in "${SETTABLE_KEYS[@]}"; do [[ "$e" == "$k|"* ]] && return 0; done; return 1; }
573
-
574
- env_file_for() {
575
- case "$1" in
576
- web) echo "$WEB_ENV" ;;
577
- daemon) echo "$DAEMON_ENV" ;;
578
- *) return 1 ;;
579
- esac
580
- }
581
-
582
- env_get() {
583
- local file="$1" key="$2"
584
- [[ -f "$file" ]] || return 1
585
- awk -F= -v k="$key" '$1==k {sub(/^[^=]*=/,""); print; found=1} END{exit !found}' "$file"
586
- }
587
-
588
- env_set() {
589
- local file="$1" key="$2" val="$3"
590
- [[ -f "$file" ]] || { mkdir -p "$(dirname "$file")"; touch "$file"; chmod 600 "$file"; }
591
- if grep -q "^${key}=" "$file"; then
592
- sed -i "s|^${key}=.*|${key}=${val}|" "$file"
593
- else
594
- printf '%s=%s\n' "$key" "$val" >> "$file"
595
- fi
596
- }
597
-
598
- service_for_key() {
599
- case "$1" in
600
- WORK_PORT)
601
- echo "both" ;;
602
- DAEMON_PORT)
603
- echo "both" ;;
604
- *)
605
- echo "$(key_service "$1")" ;;
606
- esac
607
- }
608
-
609
- restart_service() {
610
- local svc="$1"
611
- local rc=0
612
- if ! systemd_reachable; then
613
- log "systemd not reachable — using PID-file mode restart"
614
- if [[ "$svc" == "both" ]]; then
615
- "$EWORK_AIO_BIN" restart both && ok "Restarted ework-web + ework-daemon (PID-file mode)" || rc=$?
616
- else
617
- "$EWORK_AIO_BIN" restart "$svc" && ok "Restarted ework-$svc (PID-file mode)" || rc=$?
618
- fi
619
- if [[ $rc -ne 0 ]]; then
620
- warn "PID-file restart of '$svc' failed (exit $rc)."
621
- warn "The .env changes are saved; run 'ework-aio restart $svc' manually."
622
- fi
623
- return $rc
624
- fi
625
- case "$svc" in
626
- web) ctl restart ework-web.service && ok "Restarted ework-web" || rc=$? ;;
627
- daemon) ctl restart ework-daemon.service && ok "Restarted ework-daemon" || rc=$? ;;
628
- both)
629
- ctl restart ework-web.service ework-daemon.service \
630
- && ok "Restarted ework-web + ework-daemon" || rc=$?
631
- ;;
632
- *) return 1 ;;
633
- esac
634
- if [[ $rc -ne 0 ]]; then
635
- warn "restart of '$svc' failed (exit $rc)."
636
- warn "The .env changes are saved; services still need to be reloaded."
637
- warn "Recovery: 'ework-aio config restart $svc' from a logged-in shell,"
638
- warn "or 'sudo ework-aio install --system' to escape user-bus limitations."
639
- return $rc
640
- fi
641
- return 0
642
- }
643
-
644
- config_help() {
645
- cat <<'EOF'
646
- ework-aio config <subcommand>
647
-
648
- Subcommands:
649
- list List all settable keys + current values
650
- get <KEY> Print current value of one key
651
- set <KEY> <VALUE> Set a key in .env, then restart the affected
652
- service (unless --no-restart is given)
653
- restart <web|daemon|both> Restart one or both services
654
-
655
- Examples:
656
- ework-aio config list
657
- ework-aio config get WORK_PORT
658
- ework-aio config set WORK_PORT 8080
659
- ework-aio config set WORK_TRANSLATE_URL http://127.0.0.1:8000/v1 --no-restart
660
- ework-aio config restart both
661
-
662
- Note: changing WORK_PORT or DAEMON_PORT also rewrites the cross-link the other
663
- service uses (GITEA_URL on daemon side, WORK_DAEMON_WEBHOOK_URL on web side),
664
- and restarts both. Secrets and DB paths are not settable here — rerun
665
- `ework-aio install` (with `rm .env` first if you need new tokens).
666
- EOF
667
- }
668
-
669
- config_list() {
670
- hr; log "Settable config keys"; hr
671
- printf ' %-28s %-8s %s\n' "KEY" "SERVICE" "VALUE"
672
- local e k svc val env_file
673
- for e in "${SETTABLE_KEYS[@]}"; do
674
- k="${e%%|*}"
675
- svc="$(key_service "$k")"
676
- env_file="$(env_file_for "$svc")"
677
- val="$(env_get "$env_file" "$k" 2>/dev/null || echo '')"
678
- [[ -z "$val" ]] && val="(unset)"
679
- printf ' %-28s %-8s %s\n' "$k" "$svc" "$val"
680
- done
681
- hr
682
- printf 'Use %sconfig set <KEY> <VALUE>%s to change a key.\n' "$c_bold" "$c_reset"
683
- }
684
-
685
- config_get() {
686
- local k="$1"
687
- [[ -n "$k" ]] || die "Usage: ework-aio config get <KEY>"
688
- key_settable "$k" || die "Key '$k' is not settable. Run 'ework-aio config list' for the allow-list."
689
- local svc env_file val
690
- svc="$(key_service "$k")"
691
- env_file="$(env_file_for "$svc")"
692
- val="$(env_get "$env_file" "$k" 2>/dev/null)" || { warn "$k is not currently set in $env_file"; exit 0; }
693
- printf '%s\n' "$val"
694
- }
695
-
696
- config_set() {
697
- local k="$1" v="$2"
698
- [[ -n "$k" && -n "$v" ]] || die "Usage: ework-aio config set <KEY> <VALUE>"
699
- key_settable "$k" || die "Key '$k' is not settable. Run 'ework-aio config list' for the allow-list."
700
-
701
- local svc env_file
702
- svc="$(key_service "$k")"
703
- env_file="$(env_file_for "$svc")"
704
-
705
- log "Setting $k=$v in $env_file"
706
- cp "$env_file" "$env_file.bak.$(date +%s)" 2>/dev/null || true
707
- env_set "$env_file" "$k" "$v"
708
- ok "$k updated"
709
-
710
- case "$k" in
711
- WORK_PORT)
712
- log "Propagating to daemon (GITEA_URL)"
713
- cp "$DAEMON_ENV" "$DAEMON_ENV.bak.$(date +%s)" 2>/dev/null || true
714
- env_set "$DAEMON_ENV" "GITEA_URL" "http://127.0.0.1:$v"
715
- ok "DAEMON_ENV GITEA_URL updated"
716
- ;;
717
- DAEMON_PORT)
718
- log "Propagating to web (WORK_DAEMON_WEBHOOK_URL)"
719
- cp "$WEB_ENV" "$WEB_ENV.bak.$(date +%s)" 2>/dev/null || true
720
- env_set "$WEB_ENV" "WORK_DAEMON_WEBHOOK_URL" "http://127.0.0.1:$v"
721
- ok "WEB_ENV WORK_DAEMON_WEBHOOK_URL updated"
722
- ;;
723
- esac
724
-
725
- if [[ "$NO_RESTART" == "1" ]]; then
726
- local to_restart
727
- to_restart="$(service_for_key "$k")"
728
- warn "--no-restart: changes saved but service not reloaded. Run 'ework-aio config restart $to_restart' to apply."
729
- return
730
- fi
731
-
732
- local target
733
- target="$(service_for_key "$k")"
734
- log "Restarting $target..."
735
- restart_service "$target" || warn "restart failed — services may need manual reload"
736
- }
737
-
738
- config_restart() {
739
- local svc="${1:-both}"
740
- case "$svc" in
741
- web|daemon|both) restart_service "$svc" ;;
742
- *) die "Usage: ework-aio config restart <web|daemon|both>" ;;
743
- esac
744
- }
745
-
746
- run_config() {
747
- local sub="${CFG_ARGS[0]:-list}"
748
- case "$sub" in
749
- list) config_list ;;
750
- get) config_get "${CFG_ARGS[1]:-}" ;;
751
- set) config_set "${CFG_ARGS[1]:-}" "${CFG_ARGS[2]:-}" ;;
752
- restart) config_restart "${CFG_ARGS[1]:-both}" ;;
753
- help|-h|--help) config_help ;;
754
- *) die "Unknown config subcommand: $sub (try: ework-aio config help)" ;;
755
- esac
756
- }
757
-
758
- # ─── Modes ──────────────────────────────────────────────────────────────────
759
- case "$MODE" in
760
- env)
761
- hr; log "ework-aio paths"; hr
762
- printf ' data dir : %s\n' "$DATA_DIR"
763
- printf ' web env : %s\n' "$WEB_ENV"
764
- printf ' daemon env : %s\n' "$DAEMON_ENV"
765
- printf ' bot token : %s\n' "$BOT_TOKEN_FILE"
766
- printf ' opencode cfg : %s\n' "$OPENCODE_CFG"
767
- printf ' systemd scope : %s\n' "$SCOPENAME"
768
- printf ' unit dir : %s\n' "$UNIT_DIR"
769
- exit 0
770
- ;;
771
-
772
- config)
773
- run_config
774
- exit 0
775
- ;;
776
-
777
- status)
778
- hr; log "ework-aio status"; hr
779
- if ! systemd_reachable; then
780
- log "(systemd unreachable — showing PID-file mode status)"
781
- "$EWORK_AIO_BIN" ps 2>/dev/null || warn "ework-aio ps failed"
782
- hr
783
- log "Port listeners:"
784
- p=""
785
- for p in $(grep -hE '^(WORK_PORT|DAEMON_PORT)=' "$WEB_ENV" "$DAEMON_ENV" 2>/dev/null | cut -d= -f2 | sort -u); do
786
- if curl -sf -o /dev/null --max-time 1 "http://127.0.0.1:$p/login" 2>/dev/null; then
787
- printf ' :%s ✓ listening\n' "$p"
788
- else
789
- printf ' :%s ✗ not responding\n' "$p"
790
- fi
791
- done
792
- exit 0
793
- fi
794
- ctl is-active ework-web.service || true
795
- ctl is-active ework-daemon.service || true
796
- ctl status --no-pager --lines=0 ework-web.service 2>/dev/null || true
797
- hr
798
- ctl status --no-pager --lines=0 ework-daemon.service 2>/dev/null || true
799
- exit 0
800
- ;;
801
-
802
- logs)
803
- svc="${MODE_ARGS[0]:-ework-web.service}"
804
- [[ "$svc" == "daemon" || "$svc" == "ework-daemon" ]] && svc="ework-daemon.service"
805
- [[ "$svc" == "web" || "$svc" == "ework-web" ]] && svc="ework-web.service"
806
- if ! systemd_reachable; then
807
- svc_short="${svc%.service}"
808
- svc_short="${svc_short#ework-}"
809
- log_file="$DATA_DIR/run/${svc_short}.log"
810
- if [[ ! -f "$log_file" ]]; then
811
- die "systemd unreachable and no PID-file log at $log_file. Start services with: ework-aio start $svc_short"
812
- fi
813
- log "(systemd unreachable — tailing PID-file log $log_file)"
814
- exec tail -n 200 -f "$log_file"
815
- fi
816
- exec journalctl "$SCOPENAME" -u "$svc" -f
817
- ;;
818
-
819
- uninstall)
820
- hr; log "Uninstalling ework-aio services (keeping data)"; hr
821
- "$EWORK_AIO_BIN" stop both 2>/dev/null || true
822
- if systemd_reachable; then
823
- ctl stop ework-web.service ework-daemon.service 2>/dev/null || true
824
- ctl disable ework-web.service ework-daemon.service 2>/dev/null || true
825
- rm -f "$UNIT_DIR/ework-web.service" "$UNIT_DIR/ework-daemon.service"
826
- ctl daemon-reload 2>/dev/null || true
827
- else
828
- log "(systemd unreachable — skipped unit cleanup; PID-file mode services stopped above)"
829
- rm -f "$UNIT_DIR/ework-web.service" "$UNIT_DIR/ework-daemon.service" 2>/dev/null || true
830
- fi
831
- ok "Services removed. Data preserved at $DATA_DIR"
832
- warn "To fully remove: rm -rf $DATA_DIR && npm uninstall -g ework-aio ework-web ework-daemon opencode-ework"
833
- exit 0
834
- ;;
835
- esac
836
-
837
- # ─── Install mode ───────────────────────────────────────────────────────────
838
- hr
839
- log "ework-aio install"
840
- log " mode : $([[ "$USE_SYSTEMD" == "1" ]] && echo "systemd" || echo "PID-file (no systemd)")"
841
- log " data dir : $DATA_DIR"
842
- log " web bin : $EWORK_WEB_BIN"
843
- log " daemon bin : $EWORK_DAEMON_BIN"
844
- log " opencode : $(command -v opencode) ($($(command -v opencode) --version 2>&1 | head -1))"
845
- hr
846
-
847
- if [[ "$USE_SYSTEMD" == "1" && "$SCOPENAME" == "--user" ]] && ! loginctl show-user "$USER" 2>/dev/null | grep -q '^Linger=yes'; then
848
- warn "User-level systemd requires lingering to keep services alive after logout."
849
- if [[ "$ASSUME_YES" == "1" ]]; then
850
- warn "Run this manually: sudo loginctl enable-linger $USER"
851
- else
852
- read -rp "Enable linger now? (needs sudo) [Y/n] " ans
853
- if [[ "${ans:-Y}" =~ ^[Yy]?$ ]]; then
854
- sudo loginctl enable-linger "$USER" || warn "enable-linger failed; services will stop on logout"
855
- fi
856
- fi
857
- fi
858
-
859
- mkdir -p "$WEB_DATA_DIR" "$DAEMON_DATA_DIR" "$DATA_DIR/opencode-workdir"
860
-
861
- write_web_env
862
-
863
- # ─── Write ework-web systemd unit (only with `install systemd`) ────────────
864
- SYSTEMD_OK=0
865
- if [[ "$USE_SYSTEMD" == "1" ]]; then
866
- WEB_UNIT="$UNIT_DIR/ework-web.service"
867
- write_unit_file \
868
- "ework-web" \
869
- "ework — multi-project issue tracker" \
870
- "$EWORK_WEB_BIN" \
871
- "$WEB_DATA_DIR" \
872
- "$WEB_ENV" \
873
- "$WEB_UNIT"
874
- ok "Wrote $WEB_UNIT"
875
-
876
- # systemd calls here are best-effort. If systemd isn't functional on this host
877
- # (containers without systemd PID 1, polkit redirects under sudo, missing
878
- # /etc/systemd/system) we still want install to complete so the user can run
879
- # `ework-aio start` (PID-file mode) against the scaffolded .env.
880
- SYSTEMD_OK=1
881
- if ! ctl daemon-reload; then
882
- warn "systemctl daemon-reload failed — systemd may be unavailable on this host."
883
- SYSTEMD_OK=0
884
- fi
885
-
886
- if [[ "$NO_START" == "0" && "$SYSTEMD_OK" == "1" ]]; then
887
- log "Starting ework-web (systemd)..."
888
- ctl enable ework-web.service || { warn "systemctl enable failed"; SYSTEMD_OK=0; }
889
- if [[ "$SYSTEMD_OK" == "1" ]]; then
890
- ctl restart ework-web.service || { warn "systemctl restart failed"; SYSTEMD_OK=0; }
891
- fi
892
- if [[ "$SYSTEMD_OK" == "1" ]]; then
893
- for i in $(seq 1 60); do
894
- if curl -sf -o /dev/null "http://127.0.0.1:$WORK_PORT/login"; then
895
- ok "ework-web listening on :$WORK_PORT (after ${i} half-seconds)"
896
- break
897
- fi
898
- sleep 0.5
899
- [[ $i -eq 60 ]] && { warn "ework-web did not come up via systemd in 30s. Falling back to PID-file mode."; SYSTEMD_OK=0; }
900
- done
901
- fi
902
- else
903
- warn "--no-start: unit enabled but not started"
904
- ctl enable ework-web.service 2>/dev/null || true
905
- fi
906
- fi
907
-
908
- # ─── Start ework-web in PID-file mode ──────────────────────────────────────
909
- # Default path (no `systemd` arg) OR systemd fallback. We bring web up via
910
- # setsid+nohup so the bot bootstrap below can HTTP-probe it. Web stays
911
- # running in PID-file mode after install — use `ework-aio stop` to kill it.
912
- if [[ "$NO_START" == "0" && "$SYSTEMD_OK" == "0" ]]; then
913
- log "Starting ework-web (PID-file mode)..."
914
- WEB_LOG_FILE="$DATA_DIR/run/web.log"
915
- mkdir -p "$(dirname "$WEB_LOG_FILE")"
916
- setsid nohup "$EWORK_WEB_BIN" >>"$WEB_LOG_FILE" 2>&1 </dev/null &
917
- WEB_PID=$!
918
- disown "$WEB_PID" 2>/dev/null || true
919
- printf '%s\n' "$WEB_PID" > "$DATA_DIR/run/web.pid"
920
- ok "ework-web started in PID-file mode (pid $WEB_PID, log $WEB_LOG_FILE)"
921
- for i in $(seq 1 60); do
922
- if curl -sf -o /dev/null "http://127.0.0.1:$WORK_PORT/login"; then
923
- ok "ework-web listening on :$WORK_PORT (after ${i} half-seconds, PID-file mode)"
924
- break
925
- fi
926
- sleep 0.5
927
- [[ $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)"
928
- done
929
- fi
930
-
931
- # ─── Bootstrap bot user + PAT (idempotent) ─────────────────────────────────
932
- WORK_TOKEN_VAL=$(awk -F= '/^WORK_TOKEN=/{print $2}' "$WEB_ENV")
933
- WORK_COOKIE_SECRET_VAL=$(awk -F= '/^WORK_COOKIE_SECRET=/{print $2}' "$WEB_ENV")
934
- # ework-web's checkAuth accepts legacy "<token>.<hmac>" cookie form
935
- COOKIE_SIG=$(printf '%s' "$WORK_TOKEN_VAL" \
936
- | openssl dgst -sha256 -hmac "$WORK_COOKIE_SECRET_VAL" -binary \
937
- | base64 | tr '+/' '-_' | tr -d '=')
938
- AUTH_COOKIE="ework_auth=${WORK_TOKEN_VAL}.${COOKIE_SIG}"
939
-
940
- BOT_TOKEN=""
941
- BOT_BOOTSTRAP_OK=1
942
- if [[ -f "$BOT_TOKEN_FILE" ]]; then
943
- BOT_TOKEN=$(cat "$BOT_TOKEN_FILE")
944
- ok "Reusing saved bot token from $BOT_TOKEN_FILE"
945
- else
946
- # Pre-flight: web must be reachable for the bot bootstrap HTTP calls below.
947
- # In --no-start mode OR on hosts where systemd couldn't bring web up, skip
948
- # the bootstrap entirely — daemon .env still gets written (with empty token)
949
- # so the user can `ework-aio start` and re-run install later.
950
- if ! curl -sf -o /dev/null "http://127.0.0.1:$WORK_PORT/login"; then
951
- warn "ework-web not reachable at :$WORK_PORT — skipping bot bootstrap."
952
- warn "Daemon will not be able to talk to web until you re-run install with web running."
953
- BOT_BOOTSTRAP_OK=0
954
- fi
955
- fi
956
-
957
- if [[ "$BOT_BOOTSTRAP_OK" == "1" && ! -f "$BOT_TOKEN_FILE" ]]; then
958
- log "Bootstrapping bot user '$BOT_NAME'..."
959
- BOT_PW=$(openssl rand -hex 24)
960
- CREATE_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
961
- "http://127.0.0.1:$WORK_PORT/admin/users/create" \
962
- -H "Cookie: $AUTH_COOKIE" \
963
- --data-urlencode "login=$BOT_NAME" \
964
- --data-urlencode "password=$BOT_PW" \
965
- --data-urlencode "kind=bot" \
966
- --data-urlencode "is_admin=0") || CREATE_CODE=000
967
- case "$CREATE_CODE" in
968
- 303) ok "Bot user '$BOT_NAME' created" ;;
969
- 400|409) warn "Bot user '$BOT_NAME' already exists (continuing)" ;;
970
- *) 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 ;;
971
- esac
972
- fi
973
-
974
- if [[ "$BOT_BOOTSTRAP_OK" == "1" && ! -f "$BOT_TOKEN_FILE" ]]; then
975
- log "Logging in as bot to mint PAT..."
976
- COOKIE_JAR=$(mktemp)
977
- LOGIN_CODE=$(curl -sS -c "$COOKIE_JAR" -X POST "http://127.0.0.1:$WORK_PORT/login" \
978
- --data-urlencode "login=$BOT_NAME" \
979
- --data-urlencode "password=$BOT_PW" \
980
- -o /dev/null -w '%{http_code}') || LOGIN_CODE=000
981
- BOT_COOKIE=$(awk '/ework_auth/ {print $7}' "$COOKIE_JAR")
982
- rm -f "$COOKIE_JAR"
983
- if [[ "$LOGIN_CODE" != "302" || -z "$BOT_COOKIE" ]]; then
984
- warn "Bot login failed: HTTP $LOGIN_CODE — skipping PAT mint"
985
- BOT_BOOTSTRAP_OK=0
986
- fi
987
- fi
988
-
989
- if [[ "$BOT_BOOTSTRAP_OK" == "1" && ! -f "$BOT_TOKEN_FILE" ]]; then
990
- log "Minting PAT..."
991
- PAT_RES=$(curl -sS -X POST "http://127.0.0.1:$WORK_PORT/me/tokens/create" \
992
- -H "Cookie: ework_auth=$BOT_COOKIE" \
993
- --data-urlencode "name=aio-$(date +%s)")
994
- BOT_TOKEN=$(printf '%s' "$PAT_RES" | grep -oE 'id="t">[a-f0-9]{40}<' | grep -oE '[a-f0-9]{40}' | head -1 || true)
995
- if [[ -z "$BOT_TOKEN" ]]; then
996
- warn "Could not extract PAT from token-create response — daemon .env will have empty token"
997
- else
998
- printf '%s' "$BOT_TOKEN" > "$BOT_TOKEN_FILE"
999
- chmod 600 "$BOT_TOKEN_FILE"
1000
- ok "Bot PAT saved to $BOT_TOKEN_FILE"
1001
- fi
1002
- fi
1003
-
1004
- # ─── Write ework-daemon .env ───────────────────────────────────────────────
1005
- write_daemon_env "$BOT_TOKEN"
1006
-
1007
- # ─── Write + start ework-daemon (systemd path, only with `install systemd`) ─
1008
- if [[ "$USE_SYSTEMD" == "1" ]]; then
1009
- DAEMON_UNIT="$UNIT_DIR/ework-daemon.service"
1010
- write_unit_file \
1011
- "ework-daemon" \
1012
- "ework-daemon — issue-driven AI dev daemon" \
1013
- "$EWORK_DAEMON_BIN" \
1014
- "$DAEMON_DATA_DIR" \
1015
- "$DAEMON_ENV" \
1016
- "$DAEMON_UNIT" \
1017
- " \"\""
1018
- # (trailing " \"\"" is a no-op separator; kept for future extra env injection)
1019
- ok "Wrote $DAEMON_UNIT"
1020
-
1021
- ctl daemon-reload 2>/dev/null || true
1022
- if [[ "$NO_START" == "0" && "$SYSTEMD_OK" == "1" ]]; then
1023
- log "Starting ework-daemon (systemd)..."
1024
- ctl enable ework-daemon.service 2>/dev/null || true
1025
- ctl restart ework-daemon.service 2>/dev/null || warn "ework-daemon restart via systemd failed; falling back to PID-file mode"
1026
- sleep 1
1027
- if ctl is-active --quiet ework-daemon.service 2>/dev/null; then
1028
- ok "ework-daemon active (systemd)"
1029
- else
1030
- warn "ework-daemon not active via systemd — falling back to PID-file mode"
1031
- SYSTEMD_OK=0
1032
- fi
1033
- else
1034
- ctl enable ework-daemon.service 2>/dev/null || true
1035
- fi
1036
- fi
1037
-
1038
- # ─── Start ework-daemon in PID-file mode (default path) ────────────────────
1039
- # Mirrors the web PID-file start. Skipped if systemd already brought it up.
1040
- if [[ "$NO_START" == "0" && "$SYSTEMD_OK" == "0" ]]; then
1041
- log "Starting ework-daemon (PID-file mode)..."
1042
- DAEMON_LOG_FILE="$DATA_DIR/run/daemon.log"
1043
- mkdir -p "$(dirname "$DAEMON_LOG_FILE")"
1044
- setsid nohup "$EWORK_DAEMON_BIN" >>"$DAEMON_LOG_FILE" 2>&1 </dev/null &
1045
- DAEMON_PID=$!
1046
- disown "$DAEMON_PID" 2>/dev/null || true
1047
- printf '%s\n' "$DAEMON_PID" > "$DATA_DIR/run/daemon.pid"
1048
- ok "ework-daemon started in PID-file mode (pid $DAEMON_PID, log $DAEMON_LOG_FILE)"
1049
- fi
1050
-
1051
- if [[ "$USE_SYSTEMD" == "1" && "$SYSTEMD_OK" == "0" ]]; then
1052
- hr
1053
- warn "systemd mode did not come up cleanly on this host."
1054
- warn "Services started in PID-file mode instead. systemd unit files were"
1055
- warn "written but are not active. To clean them up: ework-aio uninstall"
1056
- hr
1057
- fi
1058
-
1059
- # ─── Register opencode-ework plugin ────────────────────────────────────────
1060
- # Safety: every edit writes to a temp file, validates with `jq -e .`, only
1061
- # then atomically replaces the original. A timestamped .bak is always kept.
1062
- mkdir -p "$(dirname "$OPENCODE_CFG")"
1063
-
1064
- json_edit() {
1065
- # $1 = file, $2 = jq program, $3 = expected JSON type (optional, e.g. "object").
1066
- # Writes to temp, validates (parses + optional type check), only then swaps.
1067
- # Backup at $file.bak.<epoch>.
1068
- local file="$1" prog="$2" expected="${3:-}" tmp
1069
- tmp=$(mktemp)
1070
- if ! jq "$prog" "$file" > "$tmp" 2>/dev/null; then
1071
- rm -f "$tmp"
1072
- die "jq edit failed on $file (program: $prog)"
1073
- fi
1074
- if ! jq -e . "$tmp" >/dev/null 2>&1; then
1075
- rm -f "$tmp"
1076
- die "jq edit produced invalid JSON on $file — aborted, original untouched"
1077
- fi
1078
- if [[ -n "$expected" ]] && ! jq -e "type == \"$expected\"" "$tmp" >/dev/null 2>&1; then
1079
- rm -f "$tmp"
1080
- die "jq edit produced wrong type on $file (expected $expected) — aborted, original untouched"
1081
- fi
1082
- cp "$file" "$file.bak.$(date +%s)"
1083
- mv "$tmp" "$file"
1084
- }
1085
-
1086
- if [[ ! -f "$OPENCODE_CFG" ]]; then
1087
- log "Writing $OPENCODE_CFG (registering opencode-ework plugin)"
1088
- cat > "$OPENCODE_CFG" <<'EOF'
1089
- {
1090
- "$schema": "https://opencode.ai/config.json",
1091
- "plugin": ["opencode-ework@latest"]
1092
- }
1093
- EOF
1094
- elif grep -q '"opencode-ework@latest"' "$OPENCODE_CFG"; then
1095
- ok "opencode-ework@latest already in $OPENCODE_CFG"
1096
- elif grep -q '"opencode-ework"' "$OPENCODE_CFG"; then
1097
- log "Upgrading opencode-ework → opencode-ework@latest in $OPENCODE_CFG"
1098
- json_edit "$OPENCODE_CFG" \
1099
- '.plugin = ((.plugin // []) | map(if . == "opencode-ework" then "opencode-ework@latest" else . end))' \
1100
- object
1101
- ok "Plugin upgraded (backup at $OPENCODE_CFG.bak.*)"
1102
- else
1103
- log "Merging opencode-ework@latest into existing $OPENCODE_CFG"
1104
- json_edit "$OPENCODE_CFG" \
1105
- 'if .plugin then .plugin += ["opencode-ework@latest"] else . + {plugin:["opencode-ework@latest"]} end' \
1106
- object
1107
- ok "Plugin registered (backup at $OPENCODE_CFG.bak.*)"
1108
- fi
1109
-
1110
- if ! jq -e 'type == "object"' "$OPENCODE_CFG" >/dev/null 2>&1; then
1111
- warn "$OPENCODE_CFG is not a JSON object — check $OPENCODE_CFG.bak.* for restore"
1112
- fi
1113
-
1114
- # ─── Done ──────────────────────────────────────────────────────────────────
1115
- hr
1116
- ok "Install complete ($([[ "$USE_SYSTEMD" == "1" ]] && echo "systemd mode" || echo "PID-file mode"))."
1117
- hr
1118
- printf '\n%s→%s Open %shttp://127.0.0.1:%s/login%s\n' \
1119
- "$c_bold" "$c_reset" "$c_dim" "$WORK_PORT" "$c_reset"
1120
- printf ' Operator login: %s%s%s (auto-promoted admin; derived from $USER at install time)\n' \
1121
- "$c_bold" "$USER" "$c_reset"
1122
- printf ' Login token: %s%s%s\n' "$c_bold" "$WORK_TOKEN_VAL" "$c_reset"
1123
- printf ' Bot user: %s%s%s (auto-created, used by ework-daemon)\n' \
1124
- "$c_bold" "$BOT_NAME" "$c_reset"
1125
- printf ' Data dir: %s%s%s\n' "$c_dim" "$DATA_DIR" "$c_reset"
1126
- printf ' Logs: ework-aio logs web | ework-aio logs daemon\n'
1127
- printf ' Status: ework-aio status\n'
1128
- printf ' Stop: ework-aio stop\n'
1129
- printf ' Uninstall: ework-aio uninstall\n'
1130
- if [[ "$USE_SYSTEMD" != "1" ]]; then
1131
- hr
1132
- printf '\n%sPID-file mode note%s (services run via nohup, not systemd)\n' "$c_bold" "$c_reset"
1133
- printf ' • To enable auto-restart on boot, re-run with: ework-aio install systemd\n'
1134
- printf ' • Services stop when you kill them (no supervisor). Use ework-aio stop to stop cleanly.\n'
1135
- fi
1136
- hr
1137
- printf '\n%sNext steps (optional config)%s\n' "$c_bold" "$c_reset"
1138
- printf ' • 朗读 (TTS): %shttp://127.0.0.1:%s/admin/tts%s — needs an OpenAI-compat /audio/speech endpoint\n' \
1139
- "$c_dim" "$WORK_PORT" "$c_reset"
1140
- printf ' • 翻译: edit %s%s/.env%s, set WORK_TRANSLATE_URL + WORK_TRANSLATE_MODEL\n' \
1141
- "$c_dim" "$WEB_DATA_DIR" "$c_reset"
1142
- printf ' (OpenAI-compat /v1/chat/completions endpoint), then: ework-aio install\n'
1143
- printf ' • Per-project webhook: open %s/<owner>/<repo>/webhooks%s to wire downstream\n' \
1144
- "$c_dim" "$c_reset"
1145
- printf ' consumers (GitHub Actions, etc.). Gitea-compat payload + HMAC-SHA256 sig.\n'
1146
- hr