ework-aio 0.2.9 → 0.2.11
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 +1 -1
- package/scripts/e2e-browser.sh +97 -3
- package/scripts/fake-llm-server.ts +160 -0
- package/src/commands/install.ts +24 -10
- package/src/preflight.ts +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ework-aio",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
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",
|
package/scripts/e2e-browser.sh
CHANGED
|
@@ -67,6 +67,7 @@ info() { printf '%s…%s %s\n' "$c_ylw" "$c_rst" "$*" >&2; }
|
|
|
67
67
|
WORK_PORT="${WORK_PORT:-14002}"
|
|
68
68
|
DAEMON_PORT="${DAEMON_PORT:-14101}"
|
|
69
69
|
FAKE_LLM_PORT="${FAKE_LLM_PORT:-8400}"
|
|
70
|
+
BOT_LOGIN="${BOT_LOGIN:-e2e-bot}"
|
|
70
71
|
NPM_TAG="${NPM_TAG:-latest}"
|
|
71
72
|
DATA_DIR=/tmp/aio-browser
|
|
72
73
|
|
|
@@ -131,7 +132,7 @@ pass "both services responding"
|
|
|
131
132
|
|
|
132
133
|
info "configure opencode to use fake LLM"
|
|
133
134
|
# Overwrite ~/.config/opencode/opencode.json with fake provider + plugin.
|
|
134
|
-
# install.ts only registers
|
|
135
|
+
# install.ts only registers opencode-ework; we add the fake provider here so
|
|
135
136
|
# the test stays isolated from any real API credentials.
|
|
136
137
|
OPENCODE_CONFIG_DIR="$HOME/.config/opencode"
|
|
137
138
|
mkdir -p "$OPENCODE_CONFIG_DIR"
|
|
@@ -149,6 +150,9 @@ cat > "$OPENCODE_CONFIG_DIR/opencode.json" <<OCJSON
|
|
|
149
150
|
"models": {
|
|
150
151
|
"fake-model": {
|
|
151
152
|
"name": "Fake Model"
|
|
153
|
+
},
|
|
154
|
+
"e2e-daemon-override": {
|
|
155
|
+
"name": "E2E Daemon Override"
|
|
152
156
|
}
|
|
153
157
|
}
|
|
154
158
|
}
|
|
@@ -197,6 +201,28 @@ COOKIE_SIG=$(printf '%s' "$WORK_TOKEN" \
|
|
|
197
201
|
| base64 | tr '+/' '-_' | tr -d '=')
|
|
198
202
|
AUTH_COOKIE="ework_auth=${WORK_TOKEN}.${COOKIE_SIG}"
|
|
199
203
|
|
|
204
|
+
info "configure global defaultModel via /settings (model config E2E)"
|
|
205
|
+
# Set defaultModel to a UNIQUE model name (different from the smoke test's
|
|
206
|
+
# fake/fake-model) so we can distinguish daemon-driven spawn from smoke test.
|
|
207
|
+
# If ework-daemon honors the new --model flag, fake-llm will see
|
|
208
|
+
# `model=e2e-daemon-override` in the request body. If it doesn't, fake-llm
|
|
209
|
+
# keeps seeing `model=fake-model` (opencode falls back to opencode.json).
|
|
210
|
+
E2E_MODEL_ID="fake/e2e-daemon-override"
|
|
211
|
+
SETTINGS_RESP=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
|
|
212
|
+
"http://127.0.0.1:$WORK_PORT/settings" \
|
|
213
|
+
-H "Cookie: $AUTH_COOKIE" \
|
|
214
|
+
--data-urlencode "defaultModel=$E2E_MODEL_ID")
|
|
215
|
+
[[ "$SETTINGS_RESP" == "303" ]] \
|
|
216
|
+
|| fail "POST /settings defaultModel=$E2E_MODEL_ID failed (status=$SETTINGS_RESP)"
|
|
217
|
+
pass "global defaultModel configured: $E2E_MODEL_ID"
|
|
218
|
+
# Pre-populate the model_cache so the settings UI's select shows our entry
|
|
219
|
+
# (also exercises the refresh endpoint added in ework-web 0.2.0).
|
|
220
|
+
curl -sS -o /dev/null -X POST \
|
|
221
|
+
"http://127.0.0.1:$WORK_PORT/settings/models/refresh" \
|
|
222
|
+
-H "Cookie: $AUTH_COOKIE" || true
|
|
223
|
+
# Mark timestamp so we can scope fake-llm log analysis to AFTER this point.
|
|
224
|
+
E2E_MODEL_CONFIG_TS=$(date +%s)
|
|
225
|
+
|
|
200
226
|
# Create project (idempotent — if it exists, 303 is still returned).
|
|
201
227
|
curl -sS -o /dev/null -w 'project: %{http_code}\n' -X POST \
|
|
202
228
|
"http://127.0.0.1:$WORK_PORT/projects" \
|
|
@@ -232,6 +258,13 @@ OPENCODE_DB="$HOME/.local/share/opencode/opencode.db"
|
|
|
232
258
|
sql_query() {
|
|
233
259
|
bun -e "const db=require('bun:sqlite');const d=new db.Database('$1',{readonly:true,create:false});try{process.stdout.write(JSON.stringify(d.prepare(\`$2\`).all()))}catch(e){process.stdout.write('')}" 2>/dev/null
|
|
234
260
|
}
|
|
261
|
+
# The smoke test in the previous step already wrote a session row. To avoid
|
|
262
|
+
# grabbing that one, capture its ID first and wait for a NEW session ID to
|
|
263
|
+
# appear (the daemon spawns its own opencode against a different --dir, so
|
|
264
|
+
# its session ID will differ).
|
|
265
|
+
PREV_SESSION_ID=$(sql_query "$OPENCODE_DB" "SELECT id FROM session ORDER BY time_updated DESC LIMIT 1;" \
|
|
266
|
+
| jq -r '.[0].id // empty' 2>/dev/null || echo "")
|
|
267
|
+
echo " smoke test session: ${PREV_SESSION_ID:-(none yet)}"
|
|
235
268
|
SESSION_ID=""
|
|
236
269
|
# 180s window: opencode spawn + fake-LLM roundtrip + write to DB. The
|
|
237
270
|
# daemon's poller picks up the issue within ~30s, then opencode takes
|
|
@@ -239,7 +272,7 @@ SESSION_ID=""
|
|
|
239
272
|
for i in $(seq 1 180); do
|
|
240
273
|
ROWS=$(sql_query "$OPENCODE_DB" "SELECT id FROM session ORDER BY time_updated DESC LIMIT 1;")
|
|
241
274
|
SESSION_ID=$(echo "$ROWS" | jq -r '.[0].id // empty' 2>/dev/null || echo "")
|
|
242
|
-
if [[ -n "$SESSION_ID" ]]; then
|
|
275
|
+
if [[ -n "$SESSION_ID" && "$SESSION_ID" != "$PREV_SESSION_ID" ]]; then
|
|
243
276
|
pass "session created: $SESSION_ID (after ${i}s)"
|
|
244
277
|
break
|
|
245
278
|
fi
|
|
@@ -249,7 +282,7 @@ for i in $(seq 1 180); do
|
|
|
249
282
|
tail -40 "$DATA_DIR/run/daemon.log" 2>/dev/null || echo "(no daemon.log)"
|
|
250
283
|
echo "--- fake-llm log (last 10) ---"
|
|
251
284
|
tail -10 /tmp/fake-llm.log
|
|
252
|
-
fail "opencode did not write a session";
|
|
285
|
+
fail "daemon's opencode did not write a new session (last seen: $SESSION_ID)";
|
|
253
286
|
}
|
|
254
287
|
done
|
|
255
288
|
|
|
@@ -265,6 +298,24 @@ for i in $(seq 1 30); do
|
|
|
265
298
|
[[ $i -eq 30 ]] && fail "session never got assistant reply (msgs=$MSG_COUNT)";
|
|
266
299
|
done
|
|
267
300
|
|
|
301
|
+
info "verify daemon honored --model override from ework-web defaultModel"
|
|
302
|
+
# fake-llm logs `model=<X>` for each /v1/chat/completions request. The smoke
|
|
303
|
+
# test ran with fake/fake-model. The daemon-driven spawn (after we set
|
|
304
|
+
# defaultModel=fake/e2e-daemon-override in /settings) MUST show
|
|
305
|
+
# model=e2e-daemon-override in subsequent requests. If we still see only
|
|
306
|
+
# model=fake-model → daemon didn't pass --model through (regression).
|
|
307
|
+
if grep -q "model=e2e-daemon-override" /tmp/fake-llm.log; then
|
|
308
|
+
pass "daemon spawn honored ework-web defaultModel (fake-llm saw model=e2e-daemon-override)"
|
|
309
|
+
else
|
|
310
|
+
echo " --- fake-llm log (model= lines only) ---"
|
|
311
|
+
grep -E 'model=' /tmp/fake-llm.log | tail -10 || echo "(no model= entries)"
|
|
312
|
+
echo " --- daemon.log (last 30) ---"
|
|
313
|
+
tail -30 "$DATA_DIR/run/daemon.log" 2>/dev/null || echo "(no daemon.log)"
|
|
314
|
+
echo " --- ework-web + ework-daemon installed versions ---"
|
|
315
|
+
npm ls -g ework-web ework-daemon 2>&1 | grep -E 'ework-' | head -5
|
|
316
|
+
fail "daemon did NOT pass --model through (fake-llm never saw model=e2e-daemon-override)"
|
|
317
|
+
fi
|
|
318
|
+
|
|
268
319
|
info "verify session is browsable via awork-web"
|
|
269
320
|
# awork-web's OpencodeClient reads opencode.db and calls `opencode export`.
|
|
270
321
|
# Hit the /sessions list and the specific session page. Both require the
|
|
@@ -285,6 +336,20 @@ if ! echo "$SESSIONS_HTML" | grep -q "$SESSION_ID"; then
|
|
|
285
336
|
fi
|
|
286
337
|
pass "/sessions lists the new session"
|
|
287
338
|
|
|
339
|
+
# Diagnostic: capture raw `opencode export <id>` stdout to see whether plugins
|
|
340
|
+
# emit banners into the JSON stream in this test environment. Production hit a
|
|
341
|
+
# bug where ework-web's JSON.parse choked on these preamble lines; if they
|
|
342
|
+
# appear here, the test must catch the failure rather than silently passing.
|
|
343
|
+
EXPORT_RAW=$(opencode export "$SESSION_ID" 2>/dev/null </dev/null || true)
|
|
344
|
+
EXPORT_LINE_COUNT=$(printf '%s\n' "$EXPORT_RAW" | wc -l)
|
|
345
|
+
JSON_START_LINE=$(printf '%s\n' "$EXPORT_RAW" | grep -nE '^\s*[{]' | head -1 | cut -d: -f1 || echo 0)
|
|
346
|
+
echo " opencode export raw stdout: $EXPORT_LINE_COUNT lines, JSON object opens at line $JSON_START_LINE"
|
|
347
|
+
if [[ "$EXPORT_LINE_COUNT" -gt 1 && "$JSON_START_LINE" -gt 1 ]]; then
|
|
348
|
+
echo " --- first 5 lines (non-JSON preamble) ---"
|
|
349
|
+
printf '%s\n' "$EXPORT_RAW" | head -5 | sed 's/^/ /'
|
|
350
|
+
echo " WARNING: stdout has non-JSON preamble — ework-web's old JSON.parse would fail here"
|
|
351
|
+
fi
|
|
352
|
+
|
|
288
353
|
SESSION_PAGE_HTML=$(curl -sS -H "Cookie: $AUTH_COOKIE" "http://127.0.0.1:$WORK_PORT/sessions/$SESSION_ID")
|
|
289
354
|
# Look for any marker that the fake LLM reply rendered. The reply body always
|
|
290
355
|
# starts with "E2E fake-LLM reply" so we grep for that substring.
|
|
@@ -292,6 +357,35 @@ echo "$SESSION_PAGE_HTML" | grep -q "E2E fake-LLM" \
|
|
|
292
357
|
|| fail "session page does not contain fake-LLM reply text"
|
|
293
358
|
pass "session page renders fake-LLM reply content"
|
|
294
359
|
|
|
360
|
+
info "check whether the bot actually replied on the issue (auto-reply)"
|
|
361
|
+
# ework-web exposes the issue comments via /api/v1/repos/<o>/<r>/issues/<n>/comments
|
|
362
|
+
# (Gitea-compatible). Two distinct kinds of bot-authored comments exist:
|
|
363
|
+
# 1. [system] "picked up this issue" — posted by the daemon on session start
|
|
364
|
+
# (NOT an LLM reply; doesn't count).
|
|
365
|
+
# 2. [bot] actual reply — posted by the LLM via the opencode-ework `reply`
|
|
366
|
+
# tool. This is the auto-reply behaviour we're validating.
|
|
367
|
+
COMMENTS_JSON=$(curl -sS -H "Cookie: $AUTH_COOKIE" \
|
|
368
|
+
"http://127.0.0.1:$WORK_PORT/api/v1/repos/e2e/browser-test/issues/1/comments")
|
|
369
|
+
COMMENTS_COUNT=$(echo "$COMMENTS_JSON" | jq 'length' 2>/dev/null || echo 0)
|
|
370
|
+
BOT_COMMENTS_COUNT=$(echo "$COMMENTS_JSON" \
|
|
371
|
+
| jq --arg bot "$BOT_LOGIN" '[.[] | select(.user.login == $bot)] | length' 2>/dev/null || echo 0)
|
|
372
|
+
LLM_REPLIES=$(echo "$COMMENTS_JSON" \
|
|
373
|
+
| jq --arg bot "$BOT_LOGIN" \
|
|
374
|
+
'[.[] | select(.user.login == $bot and (.body | startswith("[bot]")))] | length' 2>/dev/null || echo 0)
|
|
375
|
+
echo " comments on issue #1: total=$COMMENTS_COUNT bot=$BOT_COMMENTS_COUNT llm-reply=$LLM_REPLIES"
|
|
376
|
+
echo " --- all bot comment bodies (first 400 chars each) ---"
|
|
377
|
+
echo "$COMMENTS_JSON" | jq -r --arg bot "$BOT_LOGIN" \
|
|
378
|
+
'.[] | select(.user.login == $bot) | " • " + (.body | .[0:400])' 2>/dev/null
|
|
379
|
+
if [[ "$LLM_REPLIES" -ge 1 ]]; then
|
|
380
|
+
pass "bot auto-replied on issue #1 ($LLM_REPLIES [bot]-prefixed LLM reply)"
|
|
381
|
+
else
|
|
382
|
+
echo " --- daemon.log (last 40) ---"
|
|
383
|
+
tail -40 "$DATA_DIR/run/daemon.log" 2>/dev/null || echo "(no daemon.log)"
|
|
384
|
+
echo " --- fake-llm.log (last 20) ---"
|
|
385
|
+
tail -20 /tmp/fake-llm.log 2>/dev/null
|
|
386
|
+
fail "bot did NOT post an LLM-driven reply on issue #1 (got $BOT_COMMENTS_COUNT bot comments but 0 starting with [bot])"
|
|
387
|
+
fi
|
|
388
|
+
|
|
295
389
|
info "drive headless browser through the UI"
|
|
296
390
|
# bun (not tsx) because the script imports `bun:sqlite` to peek at opencode.db.
|
|
297
391
|
cp /host-test/browser-flow.ts /tmp/test-runner/
|
|
@@ -76,6 +76,9 @@ async function handleChatCompletion(req: Request): Promise<Response> {
|
|
|
76
76
|
log(` body: stream=${body?.stream} model=${body?.model} msgs=${body?.messages?.length} tools=${body?.tools?.length ?? 0}`);
|
|
77
77
|
|
|
78
78
|
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
79
|
+
const tools = Array.isArray(body?.tools) ? body.tools : [];
|
|
80
|
+
const lastMsg = messages[messages.length - 1];
|
|
81
|
+
const lastRole = lastMsg?.role;
|
|
79
82
|
const userMsgs = messages.filter((m: any) => m?.role === "user");
|
|
80
83
|
const lastUser = userMsgs[userMsgs.length - 1];
|
|
81
84
|
const userText = typeof lastUser?.content === "string"
|
|
@@ -84,6 +87,36 @@ async function handleChatCompletion(req: Request): Promise<Response> {
|
|
|
84
87
|
? lastUser.content.map((p: any) => p?.text ?? "").join(" ")
|
|
85
88
|
: "";
|
|
86
89
|
|
|
90
|
+
const hasReplyTool = tools.some((t: any) => t?.function?.name === "reply");
|
|
91
|
+
|
|
92
|
+
// Tool-call path: when `reply` is registered and the last message is the
|
|
93
|
+
// user's initial prompt (not a tool_result), emit a tool_use so opencode
|
|
94
|
+
// actually invokes the reply tool → ework-web posts a [bot] comment on the
|
|
95
|
+
// issue. Without this the daemon only posts its [system] "picked up"
|
|
96
|
+
// notification and the LLM-driven auto-reply loop never fires.
|
|
97
|
+
//
|
|
98
|
+
// After opencode executes the tool it makes a follow-up call with
|
|
99
|
+
// role=tool in the messages — at that point we drop back to text.
|
|
100
|
+
if (hasReplyTool && lastRole === "user") {
|
|
101
|
+
const ref = parseIssueRef(userText);
|
|
102
|
+
if (ref) {
|
|
103
|
+
log(` emitting reply tool_use → ${ref.owner}/${ref.repo}#${ref.number}`);
|
|
104
|
+
const toolReplyBody =
|
|
105
|
+
`[bot] E2E fake-LLM auto-reply.\n\n` +
|
|
106
|
+
`Picked up ${ref.owner}/${ref.repo}#${ref.number}. ` +
|
|
107
|
+
`This is a stub reply emitted by scripts/fake-llm-server.ts to exercise ` +
|
|
108
|
+
`the opencode-ework \`reply\` tool end-to-end. The real value of this ` +
|
|
109
|
+
`test is that opencode received a tool_use, executed the reply tool, ` +
|
|
110
|
+
`and ework-web posted this comment as ${process.env.BOT_USERNAME ?? "bot"}.`;
|
|
111
|
+
return toolCallResponse(body?.model ?? "fake-model", "reply", {
|
|
112
|
+
owner: ref.owner,
|
|
113
|
+
repo: ref.repo,
|
|
114
|
+
number: ref.number,
|
|
115
|
+
body: toolReplyBody,
|
|
116
|
+
}, body?.stream === true);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
87
120
|
// Deterministic, context-aware reply: echo a snippet of the user's last
|
|
88
121
|
// message so the session transcript has traceable content (not just a
|
|
89
122
|
// fixed string). Truncated so the reply stays readable in the UI.
|
|
@@ -128,6 +161,133 @@ async function handleChatCompletion(req: Request): Promise<Response> {
|
|
|
128
161
|
});
|
|
129
162
|
}
|
|
130
163
|
|
|
164
|
+
// Parse an issue ref of the form `<owner>/<repo>#<n>` out of arbitrary text
|
|
165
|
+
// (typically the user prompt built by ework-daemon's buildInitialPrompt,
|
|
166
|
+
// which includes "(gitea:owner/repo#N)"). Returns null if no match.
|
|
167
|
+
function parseIssueRef(text: string): { owner: string; repo: string; number: number } | null {
|
|
168
|
+
const m = text.match(/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)/);
|
|
169
|
+
if (!m) return null;
|
|
170
|
+
const [, owner, repo, num] = m;
|
|
171
|
+
const number = parseInt(num, 10);
|
|
172
|
+
if (!Number.isFinite(number)) return null;
|
|
173
|
+
return { owner, repo, number };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Emit a tool_use response (OpenAI function-calling format). Supports both
|
|
177
|
+
// streaming and non-streaming because opencode defaults to stream=true but
|
|
178
|
+
// curl smoke tests use non-streaming.
|
|
179
|
+
function toolCallResponse(model: string, toolName: string, args: Record<string, unknown>, stream: boolean): Response {
|
|
180
|
+
const id = `chatcmpl-fake-${crypto.randomUUID()}`;
|
|
181
|
+
const created = Math.floor(Date.now() / 1000);
|
|
182
|
+
const callId = `call_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
|
|
183
|
+
const argsJson = JSON.stringify(args);
|
|
184
|
+
const usage = {
|
|
185
|
+
prompt_tokens: roughTokens(argsJson),
|
|
186
|
+
completion_tokens: roughTokens(argsJson),
|
|
187
|
+
total_tokens: roughTokens(argsJson) * 2,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (!stream) {
|
|
191
|
+
return json({
|
|
192
|
+
id,
|
|
193
|
+
object: "chat.completion",
|
|
194
|
+
created,
|
|
195
|
+
model,
|
|
196
|
+
choices: [
|
|
197
|
+
{
|
|
198
|
+
index: 0,
|
|
199
|
+
message: {
|
|
200
|
+
role: "assistant",
|
|
201
|
+
content: null,
|
|
202
|
+
tool_calls: [
|
|
203
|
+
{
|
|
204
|
+
id: callId,
|
|
205
|
+
type: "function",
|
|
206
|
+
function: { name: toolName, arguments: argsJson },
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
finish_reason: "tool_calls",
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
usage,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const encoder = new TextEncoder();
|
|
218
|
+
const readable = new ReadableStream({
|
|
219
|
+
start(controller) {
|
|
220
|
+
// Initial chunk: declare the tool_call with name + empty arguments.
|
|
221
|
+
controller.enqueue(
|
|
222
|
+
encoder.encode(
|
|
223
|
+
sseLine({
|
|
224
|
+
id,
|
|
225
|
+
object: "chat.completion.chunk",
|
|
226
|
+
created,
|
|
227
|
+
model,
|
|
228
|
+
choices: [{
|
|
229
|
+
index: 0,
|
|
230
|
+
delta: {
|
|
231
|
+
role: "assistant",
|
|
232
|
+
content: null,
|
|
233
|
+
tool_calls: [{
|
|
234
|
+
index: 0,
|
|
235
|
+
id: callId,
|
|
236
|
+
type: "function",
|
|
237
|
+
function: { name: toolName, arguments: "" },
|
|
238
|
+
}],
|
|
239
|
+
},
|
|
240
|
+
finish_reason: null,
|
|
241
|
+
}],
|
|
242
|
+
}),
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
// Arguments chunk: full JSON in one shot (splitting is optional).
|
|
246
|
+
controller.enqueue(
|
|
247
|
+
encoder.encode(
|
|
248
|
+
sseLine({
|
|
249
|
+
id,
|
|
250
|
+
object: "chat.completion.chunk",
|
|
251
|
+
created,
|
|
252
|
+
model,
|
|
253
|
+
choices: [{
|
|
254
|
+
index: 0,
|
|
255
|
+
delta: {
|
|
256
|
+
tool_calls: [{ index: 0, function: { arguments: argsJson } }],
|
|
257
|
+
},
|
|
258
|
+
finish_reason: null,
|
|
259
|
+
}],
|
|
260
|
+
}),
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
// Final chunk: finish_reason + usage.
|
|
264
|
+
controller.enqueue(
|
|
265
|
+
encoder.encode(
|
|
266
|
+
sseLine({
|
|
267
|
+
id,
|
|
268
|
+
object: "chat.completion.chunk",
|
|
269
|
+
created,
|
|
270
|
+
model,
|
|
271
|
+
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
|
272
|
+
usage,
|
|
273
|
+
}),
|
|
274
|
+
),
|
|
275
|
+
);
|
|
276
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
277
|
+
controller.close();
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
return new Response(readable, {
|
|
282
|
+
headers: {
|
|
283
|
+
"content-type": "text/event-stream",
|
|
284
|
+
"cache-control": "no-cache",
|
|
285
|
+
"connection": "keep-alive",
|
|
286
|
+
"access-control-allow-origin": "*",
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
131
291
|
function streamResponse(model: string, reply: string, usage: any): Response {
|
|
132
292
|
const id = `chatcmpl-fake-${crypto.randomUUID()}`;
|
|
133
293
|
const created = Math.floor(Date.now() / 1000);
|
package/src/commands/install.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { resolvePaths, type PathConfig } from "../paths.ts";
|
|
|
24
24
|
import { type InstallContext } from "../config.ts";
|
|
25
25
|
import { ensureEnvFile, parseEnvFile, patchEnvKey } from "../env.ts";
|
|
26
26
|
import { startProcess, isProcessRunning, readPidFile } from "../pidfile.ts";
|
|
27
|
-
import { checkPreflight, resolveCommand, REQUIRED_COMMANDS } from "../preflight.ts";
|
|
27
|
+
import { checkPreflight, resolveCommand, resolveBundledBin, REQUIRED_COMMANDS } from "../preflight.ts";
|
|
28
28
|
import {
|
|
29
29
|
generateUnitFile,
|
|
30
30
|
writeUnitFile,
|
|
@@ -96,31 +96,45 @@ export async function runInstall(
|
|
|
96
96
|
const opencodeBin = preflight.found.get("opencode")!;
|
|
97
97
|
logger.ok(`preflight: bun, npm, opencode all on PATH`);
|
|
98
98
|
|
|
99
|
-
// 2. Resolve ework-web / ework-daemon binaries
|
|
99
|
+
// 2. Resolve ework-web / ework-daemon binaries.
|
|
100
100
|
//
|
|
101
101
|
// ework-daemon ships TWO bins: `ework-daemon` (client CLI: status/issues/...)
|
|
102
102
|
// and `ework-daemon-server` (actual HTTP server). We start the SERVER, not
|
|
103
103
|
// the client — pre-v0.2.5 we spawned the client, which prints help and
|
|
104
104
|
// exits, leaving the daemon "looking dead" with a help-text log.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
//
|
|
106
|
+
// B-1: ework-web / ework-daemon are declared dependencies of ework-aio, so
|
|
107
|
+
// they are always bundled in our own node_modules. Resolve from there first
|
|
108
|
+
// (self-contained), falling back to PATH for dev/standalone installs.
|
|
109
|
+
// Relying on PATH alone broke after uninstall+reinstall: npm does not
|
|
110
|
+
// reliably recreate global bin symlinks for reinstalled deps even though
|
|
111
|
+
// the package is present in node_modules — so install wrongly demanded
|
|
112
|
+
// "install ework-web first" for a package that is ework-aio's own job to
|
|
113
|
+
// provide.
|
|
114
|
+
const webBin = resolveBundledBin("ework-web", "bin/ework-web.js")
|
|
115
|
+
?? resolveCommand("ework-web");
|
|
116
|
+
const daemonClientBin = resolveBundledBin("ework-daemon", "src/cli.ts")
|
|
117
|
+
?? resolveCommand("ework-daemon");
|
|
118
|
+
const daemonServerBin = resolveBundledBin("ework-daemon", "bin/ework-daemon-server.js")
|
|
119
|
+
?? resolveCommand("ework-daemon-server");
|
|
108
120
|
if (!webBin) {
|
|
109
121
|
throw new InstallError(
|
|
110
|
-
`ework-web
|
|
122
|
+
`ework-web not found. It ships as part of ework-aio; your ework-aio install is incomplete. ` +
|
|
123
|
+
`Reinstall with: npm install -g ework-aio@latest`,
|
|
111
124
|
);
|
|
112
125
|
}
|
|
113
126
|
if (!daemonClientBin && !daemonServerBin) {
|
|
114
127
|
throw new InstallError(
|
|
115
|
-
`ework-daemon not
|
|
128
|
+
`ework-daemon not found. It ships as part of ework-aio; your ework-aio install is incomplete. ` +
|
|
129
|
+
`Reinstall with: npm install -g ework-aio@latest`,
|
|
116
130
|
);
|
|
117
131
|
}
|
|
118
132
|
if (!daemonServerBin) {
|
|
119
133
|
// Client CLI present but server bin missing — old / partial install.
|
|
120
134
|
throw new InstallError(
|
|
121
|
-
`ework-daemon-server binary not found
|
|
122
|
-
`Your ework-
|
|
123
|
-
`npm install -g ework-
|
|
135
|
+
`ework-daemon-server binary not found, but ework-daemon (client) is present. ` +
|
|
136
|
+
`Your ework-aio install is incomplete or outdated. Reinstall with: ` +
|
|
137
|
+
`npm install -g ework-aio@latest`,
|
|
124
138
|
);
|
|
125
139
|
}
|
|
126
140
|
logger.ok(`web bin : ${webBin}`);
|
package/src/preflight.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// Root of the ework-aio package itself. Derived from this file's location
|
|
6
|
+
// (<root>/src/preflight.ts). Used to resolve bins that ework-aio ships as
|
|
7
|
+
// declared dependencies (ework-web, ework-daemon) from our own node_modules,
|
|
8
|
+
// so install does not depend on npm having created a global bin symlink.
|
|
9
|
+
// Overridable via AIO_PACKAGE_ROOT (for tests / non-standard install prefix).
|
|
10
|
+
const DEFAULT_PACKAGE_ROOT = path.join(import.meta.dir, "..");
|
|
2
11
|
|
|
3
12
|
export interface PreflightResult {
|
|
4
13
|
missing: string[];
|
|
@@ -23,6 +32,19 @@ export function resolveCommand(cmd: string): string | null {
|
|
|
23
32
|
return path === "" ? null : path;
|
|
24
33
|
}
|
|
25
34
|
|
|
35
|
+
// Resolve a bin that ework-aio ships as a declared dependency, from our own
|
|
36
|
+
// node_modules. ework-web / ework-daemon are listed as deps in package.json,
|
|
37
|
+
// so they are always bundled. We must use them instead of requiring a global
|
|
38
|
+
// PATH bin: npm does not reliably recreate global bin symlinks after
|
|
39
|
+
// uninstall+reinstall, which previously made `ework-aio install` wrongly tell
|
|
40
|
+
// the user to "install ework-web first" even though it was already bundled
|
|
41
|
+
// (B-1). Returns the absolute bin path, or null if not bundled.
|
|
42
|
+
export function resolveBundledBin(pkgName: string, binRelPath: string): string | null {
|
|
43
|
+
const root = process.env.AIO_PACKAGE_ROOT || DEFAULT_PACKAGE_ROOT;
|
|
44
|
+
const candidate = path.join(root, "node_modules", pkgName, binRelPath);
|
|
45
|
+
return fs.existsSync(candidate) ? candidate : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
export function checkPreflight(
|
|
27
49
|
required: string[],
|
|
28
50
|
opts: PreflightOptions = {},
|