ework-aio 0.2.5 → 0.2.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-aio",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "All-in-one installer for ework (issue tracker) + ework-daemon (AI bridge) + opencode-ework (plugin). One command: npm i -g ework-aio && ework-aio install.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,6 +41,8 @@
41
41
  "start": "bun bin/ework-aio.js",
42
42
  "check": "tsc --noEmit",
43
43
  "test": "bun test",
44
+ "test:e2e": "bash scripts/e2e-install.sh",
45
+ "test:regression": "bash scripts/regression.sh",
44
46
  "postinstall": "echo '\\n ework-aio installed. Run \\033[1mework-aio install\\033[0m to set up services.\\n Help: ework-aio --help\\n'"
45
47
  },
46
48
  "dependencies": {
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env bash
2
+ # End-to-end install test.
3
+ #
4
+ # Spawns a clean debian container (via Dockerfile.regression) and runs the
5
+ # FULL `ework-aio install` flow — not a hand-rolled .env like the older
6
+ # regression.sh. This is the test that would have caught every bug we
7
+ # shipped between v0.2.0 and v0.2.6:
8
+ #
9
+ # - v0.2.4 plugin key fix → caught by issue-create → daemon receives webhook
10
+ # - v0.2.5 daemon binary fix → caught by /api/status probe (was printing help)
11
+ # - v0.2.6 webhook secret → caught by "no invalid signature in daemon.log"
12
+ # - v0.2.6 access log → caught by "no EACCES in web.log"
13
+ #
14
+ # Usage: ./scripts/e2e-install.sh [container-runtime] [npm-tag]
15
+ # container-runtime: docker (default) | podman
16
+ # npm-tag: latest (default) | 0.2.6 | file:.. (for local builds)
17
+
18
+ set -euo pipefail
19
+
20
+ RUNTIME="${1:-docker}"
21
+ NPM_TAG="${2:-latest}"
22
+ IMAGE="${IMAGE:-ework-aio:e2e}"
23
+
24
+ c_grn=$'\033[32m'; c_red=$'\033[31m'; c_ylw=$'\033[33m'; c_rst=$'\033[0m'
25
+ pass() { printf '%sPASS%s %s\n' "$c_grn" "$c_rst" "$*"; }
26
+ fail() { printf '%sFAIL%s %s\n' "$c_red" "$c_rst" "$*"; exit 1; }
27
+ info() { printf '%s…%s %s\n' "$c_ylw" "$c_rst" "$*" >&2; }
28
+
29
+ if ! "$RUNTIME" image inspect "$IMAGE" >/dev/null 2>&1; then
30
+ info "Building $IMAGE"
31
+ # Build context = repo root (this script's parent dir + ..). Lets us invoke
32
+ # the script from anywhere (npm run test:e2e, CI, etc.) without cwd issues.
33
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
34
+ "$RUNTIME" build --network=host -f "$REPO_ROOT/Dockerfile.regression" \
35
+ -t "$IMAGE" "$REPO_ROOT"
36
+ fi
37
+
38
+ info "Using image: $IMAGE (testing npm tag: $NPM_TAG)"
39
+
40
+ # Unique ports so we don't collide with a host-side ework stack when run with
41
+ # --network host. Override via env if needed.
42
+ WORK_PORT="${WORK_PORT:-14002}"
43
+ DAEMON_PORT="${DAEMON_PORT:-14101}"
44
+
45
+ # opencode binary is host-specific (path may differ across machines). Mount
46
+ # readonly so the container preflight sees it on PATH. Override via env.
47
+ OPENCODE_HOST_BIN="${OPENCODE_HOST_BIN:-/home/dog/.local/bin/opencode}"
48
+
49
+ "$RUNTIME" run --rm -i --network host \
50
+ -v "$OPENCODE_HOST_BIN:/usr/local/bin/opencode:ro" \
51
+ -e WORK_PORT="$WORK_PORT" \
52
+ -e DAEMON_PORT="$DAEMON_PORT" \
53
+ -e NPM_TAG="$NPM_TAG" \
54
+ -e HTTP_PROXY="${HTTP_PROXY:-}" \
55
+ -e HTTPS_PROXY="${HTTPS_PROXY:-}" \
56
+ -e http_proxy="${HTTP_PROXY:-}" \
57
+ -e https_proxy="${HTTPS_PROXY:-}" \
58
+ -e NO_PROXY="127.0.0.1,localhost" \
59
+ "$IMAGE" bash -euo pipefail <<'EOSCRIPT'
60
+ set -euo pipefail
61
+
62
+ c_grn=$'\033[32m'; c_red=$'\033[31m'; c_ylw=$'\033[33m'; c_rst=$'\033[0m'
63
+ pass() { printf '%sPASS%s %s\n' "$c_grn" "$c_rst" "$*"; }
64
+ fail() { printf '%sFAIL%s %s\n' "$c_red" "$c_rst" "$*"; exit 1; }
65
+ info() { printf '%s…%s %s\n' "$c_ylw" "$c_rst" "$*" >&2; }
66
+
67
+ WORK_PORT="${WORK_PORT:-14002}"
68
+ DAEMON_PORT="${DAEMON_PORT:-14101}"
69
+ NPM_TAG="${NPM_TAG:-latest}"
70
+ DATA_DIR=/tmp/aio-e2e
71
+
72
+ info "clean state"
73
+ rm -rf "$DATA_DIR"
74
+ mkdir -p "$DATA_DIR"
75
+
76
+ info "opencode binary on PATH"
77
+ opencode --version | head -1
78
+
79
+ info "npm install -g ework-web@${NPM_TAG} ework-daemon@latest ework-aio@${NPM_TAG}"
80
+ # Note: ework-web and ework-daemon aren't version-locked to ework-aio. We
81
+ # always install latest of the services and only parameterize ework-aio so
82
+ # we can E2E-test a PR branch against published services.
83
+ for pkg in ework-web ework-daemon opencode-ework; do
84
+ npm install -g "$pkg@latest" 2>&1 | tail -2
85
+ done
86
+ npm install -g "ework-aio@${NPM_TAG}" 2>&1 | tail -2
87
+
88
+ info "v0.2.5 regression: all 4 bins on PATH"
89
+ for b in ework-aio ework-web ework-daemon ework-daemon-server; do
90
+ command -v "$b" >/dev/null 2>&1 || fail "$b not on PATH"
91
+ pass "$b -> $(command -v "$b")"
92
+ done
93
+
94
+ info "run ework-aio install (the real installer — no hand-rolled .env)"
95
+ # --allow-root: container runs as root; refusing would test an unrealistic
96
+ # path. The installer's data still lands in /tmp/aio-e2e via --data-dir.
97
+ ework-aio install \
98
+ --allow-root \
99
+ --data-dir "$DATA_DIR" \
100
+ --port "$WORK_PORT" \
101
+ --daemon-port "$DAEMON_PORT" \
102
+ --bot-name e2e-bot \
103
+ --yes 2>&1 | tee /tmp/install.log
104
+
105
+ pass "install exited 0"
106
+
107
+ info "v0.2.6 regression: webhook secrets match across web and daemon .env"
108
+ WEB_SEC=$(grep ^WORK_DAEMON_WEBHOOK_SECRET= "$DATA_DIR/ework-web/.env" | cut -d= -f2-)
109
+ DAE_SEC=$(grep ^GITEA_WEBHOOK_SECRET= "$DATA_DIR/ework-daemon/.env" | cut -d= -f2-)
110
+ [[ -n "$WEB_SEC" ]] || fail "WORK_DAEMON_WEBHOOK_SECRET empty in web .env"
111
+ [[ -n "$DAE_SEC" ]] || fail "GITEA_WEBHOOK_SECRET empty in daemon .env"
112
+ [[ "$WEB_SEC" == "$DAE_SEC" ]] \
113
+ || fail "webhook secrets differ (web=$WEB_SEC daemon=$DAE_SEC)"
114
+ pass "secrets match (${#WEB_SEC} chars)"
115
+
116
+ info "v0.2.6 regression: WORK_ACCESS_LOG set, not defaulting to /tmp/ework-access.log"
117
+ ACCESS_LOG_VAL=$(grep ^WORK_ACCESS_LOG= "$DATA_DIR/ework-web/.env" | cut -d= -f2-)
118
+ [[ -n "$ACCESS_LOG_VAL" ]] || fail "WORK_ACCESS_LOG missing from web .env"
119
+ [[ "$ACCESS_LOG_VAL" != "/tmp/ework-access.log" ]] \
120
+ || fail "WORK_ACCESS_LOG still defaults to /tmp/ework-access.log"
121
+ pass "WORK_ACCESS_LOG=$ACCESS_LOG_VAL"
122
+
123
+ info "wait for ework-web /login"
124
+ for i in $(seq 1 60); do
125
+ if curl -sf -o /dev/null "http://127.0.0.1:$WORK_PORT/login"; then
126
+ pass "/login responds (after ${i} half-seconds)"
127
+ break
128
+ fi
129
+ sleep 0.5
130
+ [[ $i -eq 60 ]] && { tail -30 "$DATA_DIR/run/web.log" 2>/dev/null; fail "ework-web did not come up"; }
131
+ done
132
+
133
+ info "wait for daemon /api/status"
134
+ for i in $(seq 1 60); do
135
+ if curl -sf -o /dev/null "http://127.0.0.1:$DAEMON_PORT/api/status"; then
136
+ pass "/api/status responds (after ${i} half-seconds)"
137
+ break
138
+ fi
139
+ sleep 0.5
140
+ [[ $i -eq 60 ]] && { tail -30 "$DATA_DIR/run/daemon.log" 2>/dev/null; fail "daemon did not come up"; }
141
+ done
142
+
143
+ info "v0.2.6 regression: no EACCES in web.log"
144
+ sleep 1
145
+ if grep -q "EACCES" "$DATA_DIR/run/web.log" 2>/dev/null; then
146
+ fail "EACCES in web.log:"
147
+ grep EACCES "$DATA_DIR/run/web.log" | head -3
148
+ fi
149
+ pass "no EACCES in web.log"
150
+
151
+ info "v0.2.6 regression: no invalid signature in daemon.log (pre-issue-create)"
152
+ if grep -q "invalid signature" "$DATA_DIR/run/daemon.log" 2>/dev/null; then
153
+ fail "invalid signature BEFORE issue create — autoWire webhook must have fired"
154
+ grep "invalid signature" "$DATA_DIR/run/daemon.log" | head -3
155
+ fi
156
+ pass "no invalid signature yet"
157
+
158
+ info "build admin auth cookie"
159
+ WORK_TOKEN=$(grep ^WORK_TOKEN= "$DATA_DIR/ework-web/.env" | cut -d= -f2-)
160
+ WORK_COOKIE_SECRET=$(grep ^WORK_COOKIE_SECRET= "$DATA_DIR/ework-web/.env" | cut -d= -f2-)
161
+ COOKIE_SIG=$(printf '%s' "$WORK_TOKEN" \
162
+ | openssl dgst -sha256 -hmac "$WORK_COOKIE_SECRET" -binary \
163
+ | base64 | tr '+/' '-_' | tr -d '=')
164
+ AUTH_COOKIE="ework_auth=${WORK_TOKEN}.${COOKIE_SIG}"
165
+
166
+ info "create test project (triggers autoWire webhook registration)"
167
+ PROJ_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
168
+ "http://127.0.0.1:$WORK_PORT/projects" \
169
+ -H "Cookie: $AUTH_COOKIE" \
170
+ --data-urlencode 'owner=e2e' \
171
+ --data-urlencode 'name=test')
172
+ [[ "$PROJ_CODE" == "303" || "$PROJ_CODE" == "302" ]] \
173
+ || fail "project create failed: HTTP $PROJ_CODE"
174
+ pass "project create -> $PROJ_CODE"
175
+
176
+ info "create issue (triggers webhook POST to daemon)"
177
+ ISSUE_HEAD=$(curl -sS -i -X POST \
178
+ "http://127.0.0.1:$WORK_PORT/e2e/test/issues" \
179
+ -H "Cookie: $AUTH_COOKIE" \
180
+ --data-urlencode 'title=e2e test' \
181
+ --data-urlencode 'body=trigger webhook')
182
+ ISSUE_STATUS=$(printf '%s\n' "$ISSUE_HEAD" | head -1 | awk '{print $2}')
183
+ [[ "$ISSUE_STATUS" == "303" ]] \
184
+ || fail "issue create did not return 303: $ISSUE_STATUS"
185
+ pass "issue create -> 303"
186
+
187
+ info "wait for webhook delivery + daemon processing"
188
+ sleep 5
189
+
190
+ info "v0.2.6 regression: webhook was accepted (no invalid signature after issue create)"
191
+ if grep -q "invalid signature" "$DATA_DIR/run/daemon.log" 2>/dev/null; then
192
+ fail "invalid signature AFTER issue create — webhook secret mismatch"
193
+ grep "invalid signature" "$DATA_DIR/run/daemon.log" | head -5
194
+ fi
195
+ pass "no invalid signature after issue create"
196
+
197
+ info "daemon received the issue event"
198
+ ACTIVE=$(curl -sS "http://127.0.0.1:$DAEMON_PORT/api/status" | jq -r '.issues // 0')
199
+ [[ "$ACTIVE" -ge 1 ]] \
200
+ || fail "daemon did not register the issue (issues=$ACTIVE)"
201
+ pass "daemon registered issue (issues=$ACTIVE)"
202
+
203
+ echo
204
+ echo "===== E2E PASSED ====="
205
+ echo " ework-aio version: $(ework-aio --version 2>/dev/null || echo unknown)"
206
+ echo " data dir: $DATA_DIR"
207
+ echo " web: http://127.0.0.1:$WORK_PORT/login"
208
+ echo " daemon: http://127.0.0.1:$DAEMON_PORT/api/status"
209
+ echo " logs: $DATA_DIR/run/{web,daemon}.log"
210
+ EOSCRIPT
211
+
212
+ echo
213
+ echo "${c_grn}E2E COMPLETE${c_rst}"
package/src/cli.ts CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  type ConfigArgs,
36
36
  } from "./commands/config.ts";
37
37
 
38
- const VERSION = "0.2.5-dev";
38
+ const VERSION = "0.2.7-dev";
39
39
 
40
40
  const USAGE = `ework-aio <command> [options]
41
41
 
@@ -357,6 +357,25 @@ export async function runInstall(
357
357
  await patchEnvKey(paths.daemonEnvFile, "GITEA_TOKEN", botToken);
358
358
  }
359
359
 
360
+ // 10.5 Reconcile webhook secret across web and daemon .env files.
361
+ // Pre-v0.2.6 installs generated two independent hex(20) values for
362
+ // WORK_DAEMON_WEBHOOK_SECRET (web) and GITEA_WEBHOOK_SECRET (daemon),
363
+ // so signature verification always failed. Forward-fill won't fix this
364
+ // because both keys already exist. We treat web as the source of truth
365
+ // (ework-web's DB has webhook rows signed with whatever
366
+ // WORK_DAEMON_WEBHOOK_SECRET was at creation time) and overwrite daemon.
367
+ {
368
+ const webSecret = await readEnvKey(paths.webEnvFile, "WORK_DAEMON_WEBHOOK_SECRET");
369
+ const daemonSecret = await readEnvKey(paths.daemonEnvFile, "GITEA_WEBHOOK_SECRET");
370
+ if (webSecret && daemonSecret && webSecret !== daemonSecret) {
371
+ await patchEnvKey(paths.daemonEnvFile, "GITEA_WEBHOOK_SECRET", webSecret);
372
+ logger.warn(`webhook secret mismatch — overwrote daemon GITEA_WEBHOOK_SECRET to match web`);
373
+ } else if (webSecret && !daemonSecret) {
374
+ await patchEnvKey(paths.daemonEnvFile, "GITEA_WEBHOOK_SECRET", webSecret);
375
+ logger.ok(`propagated WORK_DAEMON_WEBHOOK_SECRET → GITEA_WEBHOOK_SECRET`);
376
+ }
377
+ }
378
+
360
379
  // 11. (Systemd only) Write + install daemon unit.
361
380
  if (opts.useSystemd && paths.daemonUnitFile && systemdOk && !opts.noStart) {
362
381
  const userInfo = os.userInfo();
package/src/config.ts CHANGED
@@ -38,6 +38,26 @@ export interface EnvKeySpec {
38
38
 
39
39
  const hex = (bytes: number): string => randomBytes(bytes).toString("hex");
40
40
 
41
+ // ework-web signs outgoing webhook POSTs with WORK_DAEMON_WEBHOOK_SECRET
42
+ // and ework-daemon verifies them with GITEA_WEBHOOK_SECRET. They MUST be
43
+ // the same value — pre-v0.2.6 each key had an independent hex(20) generator,
44
+ // so the two secrets never matched and every webhook was rejected with
45
+ // "invalid signature" on the daemon side.
46
+ //
47
+ // The closure-memoized `sharedWebhookSecret` returns the same value across
48
+ // both calls within a single install run, so first install is correct.
49
+ // Reconciliation for already-broken installs lives in install.ts (it reads
50
+ // both .env files and, if they differ, overwrites the daemon side from the
51
+ // web side — web is the source of truth because ework-web's DB has webhooks
52
+ // signed with whatever WORK_DAEMON_WEBHOOK_SECRET was at creation time).
53
+ let _sharedWebhookSecret: string | null = null;
54
+ function sharedWebhookSecret(): string {
55
+ if (_sharedWebhookSecret === null) {
56
+ _sharedWebhookSecret = hex(20);
57
+ }
58
+ return _sharedWebhookSecret;
59
+ }
60
+
41
61
  export const WEB_ENV_KEYS: readonly EnvKeySpec[] = [
42
62
  { envVar: "WORK_PORT", file: "web", generate: (c) => String(c.workPort) },
43
63
  { envVar: "WORK_HOST", file: "web", generate: () => "127.0.0.1" },
@@ -48,9 +68,13 @@ export const WEB_ENV_KEYS: readonly EnvKeySpec[] = [
48
68
  { envVar: "WORK_DB_PATH", file: "web", generate: (c) => c.paths.webDbPath },
49
69
  { envVar: "WORK_ATTACHMENT_ROOT", file: "web", generate: (c) => c.paths.webAttachmentRoot },
50
70
  { envVar: "WORK_FILE_ROOTS", file: "web", generate: (c) => `/tmp,${c.paths.dataDir}` },
71
+ // Without this, ework-web falls back to /tmp/ework-access.log which is
72
+ // owned by whichever user touched it first. On shared boxes the runtime
73
+ // user can't append → "EACCES: permission denied" on every request.
74
+ { envVar: "WORK_ACCESS_LOG", file: "web", generate: (c) => `${c.paths.runDir}/web-access.log` },
51
75
  { envVar: "WORK_DAEMON_BOT_LOGIN", file: "web", generate: (c) => c.botName },
52
76
  { envVar: "WORK_DAEMON_WEBHOOK_URL", file: "web", generate: (c) => `http://127.0.0.1:${c.daemonPort}` },
53
- { envVar: "WORK_DAEMON_WEBHOOK_SECRET", file: "web", secret: true, generate: () => hex(20) },
77
+ { envVar: "WORK_DAEMON_WEBHOOK_SECRET", file: "web", secret: true, generate: sharedWebhookSecret },
54
78
  ] as const;
55
79
 
56
80
  export const DAEMON_ENV_KEYS: readonly EnvKeySpec[] = [
@@ -62,7 +86,7 @@ export const DAEMON_ENV_KEYS: readonly EnvKeySpec[] = [
62
86
  // These tokens come from the bot bootstrap flow — empty placeholder here,
63
87
  // filled in by write_daemon_env after PAT is minted.
64
88
  { envVar: "GITEA_TOKEN", file: "daemon", secret: true, generate: () => "" },
65
- { envVar: "GITEA_WEBHOOK_SECRET", file: "daemon", secret: true, generate: (c) => hex(20) },
89
+ { envVar: "GITEA_WEBHOOK_SECRET", file: "daemon", secret: true, generate: sharedWebhookSecret },
66
90
  { envVar: "BOT_USERNAME", file: "daemon", generate: (c) => c.botName },
67
91
  { envVar: "BOT_TOKEN", file: "daemon", secret: true, generate: () => "" },
68
92
  { envVar: "OPENCODE_BINARY", file: "daemon", generate: (c) => c.opencodeBin },