From 1e8c913ca22dfe2bb6e6e5b4fdded26c3e220f31 Mon Sep 17 00:00:00 2001 From: birdleandro-bit Date: Fri, 18 Sep 2026 13:13:16 -0300 Subject: [PATCH] feat(services): add open-wa as a 6th embedded service (#13222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(services): add open-wa as a 6th embedded service Adds @open-wa/wa-automate (WhatsApp Web automation via headless Chromium) following the existing embedded-service pattern, mirroring Mux's lifecycle-managed-only shape (no Layer 4 executor — this is not a routing target). Flags/env verified directly against the installed 4.76.0 source rather than trusted from web docs, which mix this stable v4 line with an unreleased v5 alpha CLI surface. healthIntervalMs is set to 60s (vs. the usual 5s) for this service: open-wa's HTTP server does not start listening until the full WhatsApp handshake resolves, which blocks on a human scanning the pairing QR code on first pairing. At the default 5s interval the supervisor's 3-consecutive-failure threshold would declare "error" ~15s into every legitimate start. This is a local, single-service config change — a proper fix (a startup-grace knob distinct from the steady-state poll interval) belongs in ServiceSupervisor/HealthChecker as a follow-up affecting all embedded services. * fix(db): renumber open-wa seed migration to avoid collision with 163 Migration 163 was already taken by 163_radar_feed_cache_generated_at.sql on release/v3.8.51 (tip is at 179). Renumbered to 180, the next free slot. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(db): renumber open-wa seed migration to avoid collision with 180 Slot 180 was reused by 180_memory_fts_au_conditional_memory_id.sql (merged 2026-09-16), so this PR's seed migration moves to the owner's assigned slot 185. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: birdleandro-bit --- docs/frameworks/EMBEDDED-SERVICES.md | 51 +++- docs/openapi.yaml | 197 +++++++++++++++ .../dashboard/providers/services/page.tsx | 5 +- .../services/tabs/OpenwaServiceTab.tsx | 34 +++ src/app/api/services/openwa/_lib.ts | 40 +++ .../openwa/auto-restart-adopted/route.ts | 28 +++ .../api/services/openwa/auto-start/route.ts | 28 +++ src/app/api/services/openwa/install/route.ts | 6 + src/app/api/services/openwa/restart/route.ts | 22 ++ src/app/api/services/openwa/start/route.ts | 22 ++ src/app/api/services/openwa/status/route.ts | 41 ++++ src/app/api/services/openwa/stop/route.ts | 19 ++ src/app/api/services/openwa/update/route.ts | 45 ++++ .../db/migrations/180_openwa_service_seed.sql | 14 ++ src/lib/services/apiKey.ts | 10 +- src/lib/services/bootstrap.ts | 38 +++ src/lib/services/installers/openwa.ts | 227 ++++++++++++++++++ tests/unit/services/installers/openwa.test.ts | 167 +++++++++++++ 18 files changed, 985 insertions(+), 9 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/providers/services/tabs/OpenwaServiceTab.tsx create mode 100644 src/app/api/services/openwa/_lib.ts create mode 100644 src/app/api/services/openwa/auto-restart-adopted/route.ts create mode 100644 src/app/api/services/openwa/auto-start/route.ts create mode 100644 src/app/api/services/openwa/install/route.ts create mode 100644 src/app/api/services/openwa/restart/route.ts create mode 100644 src/app/api/services/openwa/start/route.ts create mode 100644 src/app/api/services/openwa/status/route.ts create mode 100644 src/app/api/services/openwa/stop/route.ts create mode 100644 src/app/api/services/openwa/update/route.ts create mode 100644 src/lib/db/migrations/180_openwa_service_seed.sql create mode 100644 src/lib/services/installers/openwa.ts create mode 100644 tests/unit/services/installers/openwa.test.ts diff --git a/docs/frameworks/EMBEDDED-SERVICES.md b/docs/frameworks/EMBEDDED-SERVICES.md index 05332ab994..dc04a38677 100644 --- a/docs/frameworks/EMBEDDED-SERVICES.md +++ b/docs/frameworks/EMBEDDED-SERVICES.md @@ -1,13 +1,13 @@ --- title: "Embedded Services" -description: "Reference for 9Router, CLIProxyAPI, Mux, and Bifrost" +description: "Reference for 9Router, CLIProxyAPI, Mux, Bifrost, and open-wa" --- # Embedded Services > **Version:** v3.8.44 -> **Last updated:** 2026-07-03 -> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI, Mux, Bifrost). +> **Last updated:** 2026-09-09 +> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI, Mux, Bifrost, open-wa). Embedded services are locally-installed process sidecar tools that OmniRoute installs, supervises, and exposes as first-class routing targets. Unlike external providers (which are reached over the internet @@ -32,7 +32,7 @@ via API keys), embedded services run on the same machine as OmniRoute and commun ### Why embedded services? -Five services are embedded: +Six services are embedded: | Service | npm package | Default port | Purpose | | --------------- | ---------------------------------- | :----------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -41,8 +41,9 @@ Five services are embedded: | **Mux** | `mux` (headless `mux server`) | 8322 | Local agent-orchestration daemon (coder/mux). Lifecycle-managed only — not a routing target (no LLM proxying). | | **Bifrost** | `@maximhq/bifrost` | 8080 | Go AI-gateway relay backend. When running, auto-selected by the relay route (`/v1/relay/`) | | **Dario** | `@askalf/dario` | 3456 | Claude-subscription proxy — alternative/failover to CLIProxyAPI for Claude-Code-shaped traffic; the injected key becomes `DARIO_ADMIN_TOKEN` gating its `/admin/*` OAuth control plane | +| **open-wa** | `@open-wa/wa-automate` | 8323 | WhatsApp Web automation (headless Chromium via Puppeteer). Lifecycle-managed only — not a routing target. | -All five follow the same supervisory model: +All six follow the same supervisory model: - OmniRoute installs them under `DATA_DIR/services/{name}/` (isolated from OmniRoute's own `package.json`) - OmniRoute spawns and monitors them as child processes @@ -112,7 +113,7 @@ All five follow the same supervisory model: │ modelSync.ts Periodic GET /v1/models → service_models table │ │ ringBuffer.ts Circular log buffer (5 MB per service) │ │ healthCheck.ts Polling HTTP health probe │ -│ installers/ ninerouter.ts, cliproxy.ts, mux.ts │ +│ installers/ ninerouter.ts, cliproxy.ts, mux.ts, openwa.ts │ │ (installer adapters) │ └──────────────────────┬─────────────────────────────────────────────┘ │ OpenAI-compatible HTTP (loopback) @@ -151,6 +152,7 @@ All five follow the same supervisory model: | `src/lib/services/installers/ninerouter.ts` | npm install/update/uninstall for 9Router | | `src/lib/services/installers/cliproxy.ts` | npm install/update/uninstall for CLIProxyAPI | | `src/lib/services/installers/mux.ts` | npm install/update/uninstall for Mux | +| `src/lib/services/installers/openwa.ts` | npm install/update/uninstall for open-wa | | `src/app/api/services/9router/_lib.ts` | `getOrInitSupervisor()` helper | | `src/app/api/services/[name]/logs/route.ts` | Shared SSE logs endpoint | | `open-sse/executors/ninerouter.ts` | Provider executor (Layer 4) | @@ -503,7 +505,42 @@ Same lifecycle shape as the other services (`install`, `start`, `stop`, `restart control plane under `admin/`: `admin/accounts`, `admin/import-from-omniroute`, `admin/login-start`, `admin/login-complete` (all behind `DARIO_ADMIN_TOKEN`). -### 4.6 Reverse proxy (9Router dashboard embed) +### 4.6 open-wa endpoints (7 routes) + +open-wa (`@open-wa/wa-automate`) drives a headless Chromium instance (via +Puppeteer) to automate WhatsApp Web. It uses the same endpoint shape as Mux (no +`rotate-key` route yet). It is lifecycle-managed only — not a routing target, +no Layer 4 executor/provider entry. + +| Method | Path | Description | +| ------ | --------------------------------- | ----------------------------------------------------- | +| `POST` | `/api/services/openwa/install` | Install open-wa from npm (`@open-wa/wa-automate`) | +| `POST` | `/api/services/openwa/start` | Start open-wa on port 8323 (default) | +| `POST` | `/api/services/openwa/stop` | Stop open-wa | +| `POST` | `/api/services/openwa/restart` | Restart open-wa | +| `POST` | `/api/services/openwa/update` | Update to newer version | +| `GET` | `/api/services/openwa/status` | Live + DB status | +| `POST` | `/api/services/openwa/auto-start` | Toggle auto-start | +| `GET` | `/api/services/openwa/logs` | SSE log tail (via shared `[name]/logs` dynamic route) | + +**API key:** injected as `WA_KEY` — open-wa's generic `WA_*`-prefixed env +override maps it onto the `--key`/`-k` CLI option +(`dist/cli/setup.js::envArgs()`, verified against the installed 4.76.0 +package). Prefixed `ow_` when generated by `generateServiceApiKey()`. open-wa +reads the key back from a `key`/`api_key` HTTP header (not `Authorization: +Bearer`); `/api-docs*` is explicitly exempted from the check +(`setupAuthenticationLayer` in `dist/cli/server.js`), so the health probe +needs no auth header. + +**Pairing:** open-wa is unofficial and unaffiliated with WhatsApp — the +connected number carries a ban risk from WhatsApp's own automation detection. +On first start, the pairing QR code is printed to stdout and surfaced through +the existing Logs panel/SSE stream — there is no dedicated QR-image endpoint +in this integration yet. + +--- + +### 4.7 Reverse proxy (9Router dashboard embed) The dashboard embeds the 9Router web UI inside an iframe via an internal reverse proxy at: diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 95d9778f48..b4082fbaab 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -4812,6 +4812,203 @@ paths: "500": description: Update failed + /api/services/openwa/install: + post: + tags: [Embedded Services] + summary: Install open-wa from npm + description: >- + Installs the `@open-wa/wa-automate` npm package (WhatsApp Web + automation via headless Chromium/Puppeteer) under + DATA_DIR/services/openwa/. Bundles a Puppeteer Chromium download, so + this install is much slower than the other embedded services. + **LOCAL_ONLY** — loopback only. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + version: + type: string + default: latest + responses: + "200": + description: Install succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + "400": + description: Invalid request body + "500": + description: npm install failed + + /api/services/openwa/start: + post: + tags: [Embedded Services] + summary: Start open-wa + description: >- + Spawns `wa-automate --port --host 127.0.0.1 + --session-data-path data`. The HTTP API does not start listening + until the WhatsApp client handshake resolves, which blocks on a + human scanning the pairing QR code (shown in the logs panel) on + first pairing — state stays `starting` well past the other + services' typical cold-start window. Idempotent if already running. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service started + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + "409": + description: open-wa is not installed + "503": + description: Start failed + + /api/services/openwa/stop: + post: + tags: [Embedded Services] + summary: Stop open-wa + description: >- + Gracefully stops open-wa. Idempotent. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service stopped + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/openwa/restart: + post: + tags: [Embedded Services] + summary: Restart open-wa + description: >- + stop() then start() under the operation lock. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service restarted + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/openwa/update: + post: + tags: [Embedded Services] + summary: Update open-wa to a newer npm version + description: >- + Stops, installs newer version, restarts. + **LOCAL_ONLY** — loopback only. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + version: + type: string + default: latest + responses: + "200": + description: Update succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + "500": + description: Update failed + + /api/services/openwa/status: + get: + tags: [Embedded Services] + summary: Get open-wa status + description: >- + Returns live supervisor state and DB metadata. `health` only reflects + whether `/api-docs/` answered — it does not indicate whether a + WhatsApp session is paired. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Status response + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/openwa/auto-start: + post: + tags: [Embedded Services] + summary: Toggle open-wa auto-start + description: >- + When enabled, open-wa starts automatically on the next OmniRoute boot. + **LOCAL_ONLY** — loopback only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: + type: boolean + responses: + "200": + description: Auto-start flag updated + content: + application/json: + schema: + type: object + properties: + autoStart: + type: boolean + "400": + description: Invalid request body + + /api/services/openwa/auto-restart-adopted: + post: + tags: [Embedded Services] + summary: Toggle open-wa auto-restart-when-adopted + description: >- + When enabled, an externally-adopted (not OmniRoute-spawned) open-wa + process is restarted under OmniRoute's own supervisor on the next + health-check cycle instead of being left as adopted-only. + **LOCAL_ONLY** — loopback only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: + type: boolean + responses: + "204": + description: Flag updated + "400": + description: Invalid request body + "500": + description: Update failed + /api/services/bifrost/install: post: tags: [Embedded Services] diff --git a/src/app/(dashboard)/dashboard/providers/services/page.tsx b/src/app/(dashboard)/dashboard/providers/services/page.tsx index f2a97128b4..45f36362a4 100644 --- a/src/app/(dashboard)/dashboard/providers/services/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/services/page.tsx @@ -8,8 +8,9 @@ import { NinerouterServiceTab } from "./tabs/NinerouterServiceTab"; import { MuxServiceTab } from "./tabs/MuxServiceTab"; import { BifrostServiceTab } from "./tabs/BifrostServiceTab"; import { DarioServiceTab } from "./tabs/DarioServiceTab"; +import { OpenwaServiceTab } from "./tabs/OpenwaServiceTab"; -type Tab = "cliproxy" | "9router" | "mux" | "bifrost" | "dario"; +type Tab = "cliproxy" | "9router" | "mux" | "bifrost" | "dario" | "openwa"; const TABS: { id: Tab; label: string; icon: string }[] = [ { id: "cliproxy", label: "CLIProxyAPI", icon: "swap_horiz" }, @@ -17,6 +18,7 @@ const TABS: { id: Tab; label: string; icon: string }[] = [ { id: "mux", label: "Mux", icon: "hub" }, { id: "bifrost", label: "Bifrost", icon: "bolt" }, { id: "dario", label: "Dario", icon: "shield_person" }, + { id: "openwa", label: "open-wa", icon: "chat" }, ]; export default function ServicesPage() { @@ -64,6 +66,7 @@ export default function ServicesPage() { {active === "mux" && } {active === "bifrost" && } {active === "dario" && } + {active === "openwa" && } ); diff --git a/src/app/(dashboard)/dashboard/providers/services/tabs/OpenwaServiceTab.tsx b/src/app/(dashboard)/dashboard/providers/services/tabs/OpenwaServiceTab.tsx new file mode 100644 index 0000000000..e8d9e0442f --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/services/tabs/OpenwaServiceTab.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { ServiceStatusCard } from "../components/ServiceStatusCard"; +import { ServiceLifecycleButtons } from "../components/ServiceLifecycleButtons"; +import { ServiceLogsPanel } from "../components/ServiceLogsPanel"; +import { AutoStartToggle } from "../components/AutoStartToggle"; +import { AutoRestartAdoptedToggle } from "../components/AutoRestartAdoptedToggle"; + +const NAME = "openwa"; + +export function OpenwaServiceTab() { + return ( +
+ {/* English literal, not an i18n key — mirrors the "adopted process" note + in ServiceStatusCard.tsx. open-wa is unofficial and unaffiliated with + WhatsApp; automating a personal number carries a ban risk. */} +

+ warning + + open-wa (@open-wa/wa-automate) is an unofficial WhatsApp Web automation library, not + affiliated with or endorsed by WhatsApp. The connected number can be banned by WhatsApp + for automated use. On first start, scan the pairing QR code from the Logs panel below — + the status stays "starting" until the scan completes (up to a few minutes), not + an error. + +

+ + + + + +
+ ); +} diff --git a/src/app/api/services/openwa/_lib.ts b/src/app/api/services/openwa/_lib.ts new file mode 100644 index 0000000000..34560226d0 --- /dev/null +++ b/src/app/api/services/openwa/_lib.ts @@ -0,0 +1,40 @@ +/** + * Shared helpers for /api/services/openwa/* route handlers. + * Creates a supervisor on demand if bootstrap hasn't registered one yet. + */ + +import { getSupervisor, registerSupervisor } from "@/lib/services/registry"; +import { ServiceSupervisor } from "@/lib/services/ServiceSupervisor"; +import { resolveSpawnArgs, OPENWA_DEFAULT_PORT } from "@/lib/services/installers/openwa"; +import { getOrCreateApiKey } from "@/lib/services/apiKey"; + +const TOOL = "openwa"; +const PORT = parseInt(process.env.OPENWA_SERVICE_PORT ?? String(OPENWA_DEFAULT_PORT), 10); + +export async function getOrInitSupervisor(): Promise { + const existing = getSupervisor(TOOL); + if (existing) return existing; + + const apiKey = await getOrCreateApiKey(TOOL); + + const sup = new ServiceSupervisor({ + tool: TOOL, + port: PORT, + spawnArgs: () => resolveSpawnArgs(apiKey, PORT), + healthUrl: () => `http://127.0.0.1:${PORT}/api-docs/`, + // 60s, not the usual 5s — open-wa doesn't listen on its HTTP port until + // the full WhatsApp handshake resolves, which blocks on a human QR scan + // on first pairing. See bootstrap.ts's SERVICES[] "openwa" entry comment + // for the full verified explanation (must stay in sync with this value). + healthIntervalMs: 60_000, + stopTimeoutMs: 30_000, + logsBufferBytes: 5_242_880, + // #6205: mirrors bootstrap.ts's own supervisor construction — adopt a + // healthy prior instance instead of crashing on-demand creation (e.g. a + // direct API hit before bootstrap runs) into a raw EADDRINUSE. + probeBeforeSpawn: true, + }); + + registerSupervisor(sup); + return sup; +} diff --git a/src/app/api/services/openwa/auto-restart-adopted/route.ts b/src/app/api/services/openwa/auto-restart-adopted/route.ts new file mode 100644 index 0000000000..cb605a9ebc --- /dev/null +++ b/src/app/api/services/openwa/auto-restart-adopted/route.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { updateServiceField } from "@/lib/db/versionManager"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const BodySchema = z.object({ enabled: z.boolean() }); + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return createErrorResponse({ status: 400, message: "Invalid JSON body" }); + } + + const parsed = BodySchema.safeParse(body); + if (!parsed.success) { + return createErrorResponse({ status: 400, message: parsed.error.message }); + } + + try { + await updateServiceField("openwa", "autoRestartAdopted", parsed.data.enabled); + return new Response(null, { status: 204 }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/openwa/auto-start/route.ts b/src/app/api/services/openwa/auto-start/route.ts new file mode 100644 index 0000000000..1c7a51dee5 --- /dev/null +++ b/src/app/api/services/openwa/auto-start/route.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { updateServiceField } from "@/lib/db/versionManager"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const BodySchema = z.object({ enabled: z.boolean() }); + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return createErrorResponse({ status: 400, message: "Invalid JSON body" }); + } + + const parsed = BodySchema.safeParse(body); + if (!parsed.success) { + return createErrorResponse({ status: 400, message: parsed.error.message }); + } + + try { + await updateServiceField("openwa", "autoStart", parsed.data.enabled); + return new Response(null, { status: 204 }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/openwa/install/route.ts b/src/app/api/services/openwa/install/route.ts new file mode 100644 index 0000000000..ff94790e99 --- /dev/null +++ b/src/app/api/services/openwa/install/route.ts @@ -0,0 +1,6 @@ +import { install } from "@/lib/services/installers/openwa"; +import { handleServiceInstall } from "@/app/api/services/_shared/installRoute"; + +export async function POST(request: Request): Promise { + return handleServiceInstall(request, install); +} diff --git a/src/app/api/services/openwa/restart/route.ts b/src/app/api/services/openwa/restart/route.ts new file mode 100644 index 0000000000..29e513b8dc --- /dev/null +++ b/src/app/api/services/openwa/restart/route.ts @@ -0,0 +1,22 @@ +import { getServiceRow } from "@/lib/db/versionManager"; +import { getOrInitSupervisor } from "../_lib"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "openwa"; + +export async function POST(): Promise { + try { + const row = await getServiceRow(TOOL); + if (!row || row.status === "not_installed") { + return createErrorResponse({ status: 409, message: "open-wa não está instalado." }); + } + + const sup = await getOrInitSupervisor(); + const status = await sup.restart(); + return Response.json(status); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 503, message: msg }); + } +} diff --git a/src/app/api/services/openwa/start/route.ts b/src/app/api/services/openwa/start/route.ts new file mode 100644 index 0000000000..d7cfe2d889 --- /dev/null +++ b/src/app/api/services/openwa/start/route.ts @@ -0,0 +1,22 @@ +import { getServiceRow } from "@/lib/db/versionManager"; +import { getOrInitSupervisor } from "../_lib"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "openwa"; + +export async function POST(): Promise { + try { + const row = await getServiceRow(TOOL); + if (!row || row.status === "not_installed") { + return createErrorResponse({ status: 409, message: "open-wa não está instalado." }); + } + + const sup = await getOrInitSupervisor(); + const status = await sup.start(); + return Response.json(status); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 503, message: msg }); + } +} diff --git a/src/app/api/services/openwa/status/route.ts b/src/app/api/services/openwa/status/route.ts new file mode 100644 index 0000000000..3ef584c897 --- /dev/null +++ b/src/app/api/services/openwa/status/route.ts @@ -0,0 +1,41 @@ +import { getSupervisor } from "@/lib/services/registry"; +import { getServiceRow } from "@/lib/db/versionManager"; +import { + getInstalledVersion, + getLatestVersion, + OPENWA_DEFAULT_PORT, +} from "@/lib/services/installers/openwa"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "openwa"; + +export async function GET(): Promise { + try { + const sup = getSupervisor(TOOL); + const row = await getServiceRow(TOOL); + + const liveStatus = sup?.getStatus() ?? null; + const installedVersion = await getInstalledVersion(); + const latestVersion = await getLatestVersion(); + + return Response.json({ + tool: TOOL, + state: liveStatus?.state ?? row?.status ?? "unknown", + pid: liveStatus?.pid ?? null, + port: liveStatus?.port ?? row?.port ?? OPENWA_DEFAULT_PORT, + health: liveStatus?.health ?? "unknown", + startedAt: liveStatus?.startedAt ?? null, + lastError: liveStatus?.lastError ?? row?.errorMessage ?? null, + installedVersion: installedVersion ?? row?.installedVersion ?? null, + latestVersion, + updateAvailable: !!installedVersion && !!latestVersion && installedVersion !== latestVersion, + autoStart: row?.autoStart ?? false, + adopted: liveStatus?.adopted ?? false, + autoRestartAdopted: row?.autoRestartAdopted ?? false, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/openwa/stop/route.ts b/src/app/api/services/openwa/stop/route.ts new file mode 100644 index 0000000000..1b9474ece3 --- /dev/null +++ b/src/app/api/services/openwa/stop/route.ts @@ -0,0 +1,19 @@ +import { getSupervisor } from "@/lib/services/registry"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "openwa"; + +export async function POST(): Promise { + try { + const sup = getSupervisor(TOOL); + if (!sup) { + return Response.json({ tool: TOOL, state: "stopped" }); + } + const status = await sup.stop(); + return Response.json(status); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/openwa/update/route.ts b/src/app/api/services/openwa/update/route.ts new file mode 100644 index 0000000000..e2be481543 --- /dev/null +++ b/src/app/api/services/openwa/update/route.ts @@ -0,0 +1,45 @@ +import { getSupervisor } from "@/lib/services/registry"; +import { getOrInitSupervisor } from "../_lib"; +import { + getInstalledVersion, + getLatestVersion, + update as downloadUpdate, +} from "@/lib/services/installers/openwa"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +export async function POST(): Promise { + try { + const [installed, latest] = await Promise.all([getInstalledVersion(), getLatestVersion()]); + + if (installed && latest && installed === latest) { + return Response.json({ updated: false, installedVersion: installed, latestVersion: latest }); + } + + const sup = getSupervisor("openwa"); + const wasRunning = sup?.getStatus().state === "running"; + + if (wasRunning && sup) { + await sup.stop(); + } + + const result = await downloadUpdate(); + + if (wasRunning) { + const freshSup = await getOrInitSupervisor(); + await freshSup.start().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[Services] Could not restart openwa after update:", msg); + }); + } + + return Response.json({ + updated: true, + oldVersion: installed ?? null, + newVersion: result.installedVersion, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/lib/db/migrations/180_openwa_service_seed.sql b/src/lib/db/migrations/180_openwa_service_seed.sql new file mode 100644 index 0000000000..aa625e9372 --- /dev/null +++ b/src/lib/db/migrations/180_openwa_service_seed.sql @@ -0,0 +1,14 @@ +-- Migration 185: Seed the open-wa (@open-wa/wa-automate) embedded service row. +-- +-- open-wa is a WhatsApp Web automation library (npm package +-- `@open-wa/wa-automate`, CLI server mode `wa-automate --port `) +-- managed via the ServiceSupervisor framework, same shape as Mux (114) and +-- Bifrost (115). It is lifecycle-managed only — not a routing target, so +-- provider_expose stays 0. Seeds a `not_installed` / `auto_start=0` +-- placeholder row so the dashboard tab and /api/services/openwa/status have +-- a row to read before install. + +INSERT OR IGNORE INTO version_manager + (tool, status, port, auto_start, auto_update, provider_expose) +VALUES + ('openwa', 'not_installed', 8323, 0, 0, 0); diff --git a/src/lib/services/apiKey.ts b/src/lib/services/apiKey.ts index 144cfd290e..9d2b6c5426 100644 --- a/src/lib/services/apiKey.ts +++ b/src/lib/services/apiKey.ts @@ -31,7 +31,15 @@ export async function getOrCreateApiKey(tool: string): Promise { // Dario reuses this mechanism to generate+persist its DARIO_ADMIN_TOKEN // (any long random string works — it gates the /admin/* control plane). const prefix = - tool === "9router" ? "nr" : tool === "mux" ? "mx" : tool === "dario" ? "da" : "cp"; + tool === "9router" + ? "nr" + : tool === "mux" + ? "mx" + : tool === "dario" + ? "da" + : tool === "openwa" + ? "ow" + : "cp"; const key = generateServiceApiKey(prefix); await updateServiceField(tool, "apiKey", encrypt(key) ?? key); return key; diff --git a/src/lib/services/bootstrap.ts b/src/lib/services/bootstrap.ts index df58e66d26..ddb41bad0d 100644 --- a/src/lib/services/bootstrap.ts +++ b/src/lib/services/bootstrap.ts @@ -12,6 +12,7 @@ import { import { resolveSpawnArgs as muxSpawnArgs, MUX_DEFAULT_PORT } from "./installers/mux"; import { resolveSpawnArgs as bifrostSpawnArgs, BIFROST_DEFAULT_PORT } from "./installers/bifrost"; import { resolveSpawnArgs as darioSpawnArgs, DARIO_DEFAULT_PORT } from "./installers/dario"; +import { resolveSpawnArgs as openwaSpawnArgs, OPENWA_DEFAULT_PORT } from "./installers/openwa"; import { getOrCreateApiKey } from "./apiKey"; import { scheduleServiceModelSync, stopServiceModelSync } from "./modelSync"; import type { ServiceStatus } from "./types"; @@ -34,6 +35,7 @@ const CLIPROXY_PORT = parseInt(process.env.CLIPROXYAPI_PORT ?? String(CLIPROXY_D const MUX_PORT = parseInt(process.env.MUX_SERVICE_PORT ?? String(MUX_DEFAULT_PORT), 10); const BIFROST_PORT = parseInt(process.env.BIFROST_PORT ?? String(BIFROST_DEFAULT_PORT), 10); const DARIO_PORT = parseInt(process.env.DARIO_PORT ?? String(DARIO_DEFAULT_PORT), 10); +const OPENWA_PORT = parseInt(process.env.OPENWA_SERVICE_PORT ?? String(OPENWA_DEFAULT_PORT), 10); type ServiceEntry = { tool: string; @@ -96,6 +98,39 @@ const SERVICES: ServiceEntry[] = [ logsBufferBytes: 5_242_880, needsApiKey: true, }, + { + // open-wa (@open-wa/wa-automate): WhatsApp Web automation via headless + // Chromium. Lifecycle-managed only — like Mux, it is not an LLM proxy and + // has no Layer 4 executor/provider entry. /api-docs/ (Swagger UI) is the + // only documented "proof of life" route for this package version; it + // only confirms the Express server answered, not that a WhatsApp session + // is paired (pairing status is surfaced via the logs panel — see + // installers/openwa.ts). + // + // Verified against the installed 4.76.0 source (dist/cli/index.js): the + // HTTP server does not call `server.listen()` until AFTER the full + // WhatsApp client handshake resolves — which, on first pairing, blocks on + // a human scanning the QR code shown in the logs panel. Every health + // probe before that point is a plain connection-refused, and + // HealthChecker's FAILURE_THRESHOLD (3, src/lib/services/healthCheck.ts) + // means the supervisor would otherwise declare "error" ~3×healthIntervalMs + // after every legitimate start — including a normal, successful one. + // healthIntervalMs is set high (vs. the 5s every other service uses) so + // that grace period (3×healthIntervalMs, ServiceSupervisor.waitForHealthy) + // is generous enough for a human to notice and scan the QR + // (~3 minutes) instead of always racing to "error". This is a + // ServiceSupervisor framework limitation (no separate "startup grace" + // knob distinct from the steady-state poll interval) — a real fix + // belongs in ServiceSupervisor/HealthChecker as a follow-up affecting + // all 5 services, not scoped here. + tool: "openwa", + port: OPENWA_PORT, + healthPath: "/api-docs/", + healthIntervalMs: 60_000, + stopTimeoutMs: 30_000, + logsBufferBytes: 5_242_880, + needsApiKey: true, + }, ]; function buildSpawnArgsFactory( @@ -114,6 +149,9 @@ function buildSpawnArgsFactory( if (cfg.tool === "dario") { return () => darioSpawnArgs(apiKey, cfg.port); } + if (cfg.tool === "openwa") { + return () => openwaSpawnArgs(apiKey, cfg.port); + } return () => cliproxySpawnArgs(cfg.port, apiKey); } diff --git a/src/lib/services/installers/openwa.ts b/src/lib/services/installers/openwa.ts new file mode 100644 index 0000000000..ec93c1b93e --- /dev/null +++ b/src/lib/services/installers/openwa.ts @@ -0,0 +1,227 @@ +/** + * open-wa (`@open-wa/wa-automate`) installer adapter for the ServiceSupervisor + * framework. + * + * open-wa (https://github.com/open-wa/wa-automate-nodejs) is a WhatsApp Web + * automation library published on npm as `@open-wa/wa-automate`, with a + * documented CLI server mode (`wa-automate --port `) that drives a + * headless Chromium instance via Puppeteer. It is lifecycle-managed only — + * like Mux, it is not an LLM proxy and has no Layer 4 executor/provider entry. + * + * It is installed the same way as every other embedded service — `npm + * install` into a DATA_DIR-scoped directory via `runNpm` (Hard Rule #13: no + * shell interpolation, array args + `env` option only) — never a git-clone + + * build. The install pulls in Puppeteer's own Chromium download, which is + * much larger/slower than a typical npm install, so a longer install timeout + * is used than the shared default. + * + * The library is unofficial and unaffiliated with WhatsApp (Hippocratic + + * Do No Harm license) — the connected number carries a ban risk from + * WhatsApp's own automation detection. That risk is surfaced in the + * dashboard tab, not enforced here. + * + * Binary location: $DATA_DIR/services/openwa/node_modules/@open-wa/wa-automate/bin/server.js + * Data dir: $DATA_DIR/services/openwa/data (Chromium profile / WhatsApp session) + * DB row: version_manager WHERE tool = 'openwa' + */ + +import fs from "node:fs"; +import path from "node:path"; +import { DATA_DIR } from "@/lib/db/core"; +import { upsertVersionManagerTool } from "@/lib/db/versionManager"; +import { runNpm, InstallError } from "./utils"; + +export const OPENWA_PACKAGE = "@open-wa/wa-automate"; +export const OPENWA_DEFAULT_PORT = 8323; +export const OPENWA_INSTALL_DIR = path.join(DATA_DIR, "services", "openwa"); + +// Puppeteer's own Chromium download routinely exceeds the shared 5-minute +// default install timeout (`DEFAULT_TIMEOUT_MS` in `./utils`). +const INSTALL_TIMEOUT_MS = 600_000; + +export interface InstallResult { + installedVersion: string; + installPath: string; + durationMs: number; +} + +export interface SpawnArgs { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + cwd: string; +} + +// In-memory latest-version cache, 1h TTL — mirrors mux.ts / ninerouter.ts. +let latestVersionCache: { value: string; expiresAt: number } | null = null; +const VERSION_CACHE_TTL_MS = 3_600_000; + +function getServerPath(): string { + return path.join( + OPENWA_INSTALL_DIR, + "node_modules", + "@open-wa", + "wa-automate", + "bin", + "server.js" + ); +} + +function getInstalledPkgPath(): string { + return path.join(OPENWA_INSTALL_DIR, "node_modules", "@open-wa", "wa-automate", "package.json"); +} + +export async function getInstalledVersion(): Promise { + try { + const raw = fs.readFileSync(getInstalledPkgPath(), "utf8"); + const parsed = JSON.parse(raw) as { version?: string }; + return typeof parsed.version === "string" ? parsed.version : null; + } catch { + return null; + } +} + +export async function getLatestVersion(): Promise { + if (latestVersionCache && latestVersionCache.expiresAt > Date.now()) { + return latestVersionCache.value; + } + try { + const { stdout } = await runNpm(["view", OPENWA_PACKAGE, "version"], { timeoutMs: 30_000 }); + const version = stdout.trim(); + if (version) { + latestVersionCache = { value: version, expiresAt: Date.now() + VERSION_CACHE_TTL_MS }; + } + return version || null; + } catch { + return null; + } +} + +/** + * Download and install open-wa from npm. + * Upserts the version_manager row with tool='openwa'. + */ +export async function install(version = "latest"): Promise { + const startMs = Date.now(); + + // Create install dir + minimal package.json (idempotent) — same shape as mux.ts. + fs.mkdirSync(OPENWA_INSTALL_DIR, { recursive: true }); + const hostPkgPath = path.join(OPENWA_INSTALL_DIR, "package.json"); + if (!fs.existsSync(hostPkgPath)) { + fs.writeFileSync( + hostPkgPath, + JSON.stringify( + { name: "omniroute-openwa-host", version: "0.0.0", private: true, dependencies: {} }, + null, + 2 + ), + "utf8" + ); + } + + await runNpm( + ["install", `${OPENWA_PACKAGE}@${version}`, "--omit=dev", "--no-audit", "--no-fund"], + // `--prefix` is passed via `prefix` (→ npm_config_prefix env) instead of an + // argv path so an install dir with spaces survives the Windows shell (#5379). + { cwd: OPENWA_INSTALL_DIR, prefix: OPENWA_INSTALL_DIR, timeoutMs: INSTALL_TIMEOUT_MS } + ); + + const installedVersion = await getInstalledVersion(); + if (!installedVersion) { + throw new InstallError( + "Could not read installed version from node_modules/@open-wa/wa-automate/package.json", + "open-wa instalado mas versão não pôde ser lida.", + 500 + ); + } + + await upsertVersionManagerTool({ + tool: "openwa", + installedVersion, + binaryPath: getServerPath(), + status: "stopped", + port: OPENWA_DEFAULT_PORT, + }); + + // Invalidate cache so next getLatestVersion() re-fetches + latestVersionCache = null; + + return { + installedVersion, + installPath: OPENWA_INSTALL_DIR, + durationMs: Date.now() - startMs, + }; +} + +export async function update(): Promise { + return install("latest"); +} + +export async function uninstall(): Promise { + const nmDir = path.join(OPENWA_INSTALL_DIR, "node_modules"); + if (fs.existsSync(nmDir)) { + fs.rmSync(nmDir, { recursive: true, force: true }); + } + await upsertVersionManagerTool({ + tool: "openwa", + status: "not_installed", + installedVersion: null, + binaryPath: null, + }); +} + +/** + * Build spawn args for ServiceSupervisor.start(). + * + * Flags/env verified directly against the installed 4.76.0 package + * (`dist/cli/cli-options.js`, `dist/cli/server.js`, `bin/config-schema.json`) + * rather than trusted from web docs, which mix this stable v4 line with an + * unreleased v5 alpha CLI surface: + * + * - `--host`/`-h` (default `"localhost"`) and `--port`/`-p` (default `8002`) + * set the bind address/port. open-wa binds to `127.0.0.1` explicitly (never + * `0.0.0.0`) — the dashboard route is already loopback-gated (Hard Rule + * #17), and this is defense-in-depth since a leaked port would expose + * direct WhatsApp send/receive control. + * - The API key is a generic `WA_*`-prefixed env override + * (`dist/cli/setup.js::envArgs()` maps any `WA_` env var onto the + * camelCased CLI option ``), so `WA_KEY` maps onto the `--key`/`-k` + * flag — never passed as a CLI flag, so it never appears in + * `ps`/process listings. open-wa's own auth middleware + * (`dist/cli/server.js::setupAuthenticationLayer`) reads it back from a + * `key` or `api_key` HTTP header (not `Authorization: Bearer`) and + * explicitly exempts `/api-docs*` — the health probe route — from the + * check, so no auth header is needed for `healthUrl`. + * - `--session-data-path` (env `WA_SESSION_DATA_PATH`, confirmed via + * `bin/config-schema.json`) is documented as "relative to the current + * working directory ... used to store and read your `.data.json` files" — + * passed as `"data"` (relative, per that documented usage) with `cwd` set + * to this service's DATA_DIR subfolder (like every other embedded + * service, instead of the OS user home directory), so the paired + * WhatsApp session survives restarts without a re-scanned QR code. + * - Headless is already the default (`--headful` defaults to `false`) — no + * flag needed. + */ +export function resolveSpawnArgs(apiKey: string, port: number): SpawnArgs { + const serverPath = getServerPath(); + fs.mkdirSync(path.join(OPENWA_INSTALL_DIR, "data"), { recursive: true }); + + return { + command: process.execPath, + args: [ + serverPath, + "--port", + String(port), + "--host", + "127.0.0.1", + "--session-data-path", + "data", + ], + env: { + ...process.env, + NODE_ENV: "production", + WA_KEY: apiKey, + }, + cwd: OPENWA_INSTALL_DIR, + }; +} diff --git a/tests/unit/services/installers/openwa.test.ts b/tests/unit/services/installers/openwa.test.ts new file mode 100644 index 0000000000..debcb4d06c --- /dev/null +++ b/tests/unit/services/installers/openwa.test.ts @@ -0,0 +1,167 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execSync } from "node:child_process"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-installer-")); +const FAKE_BIN_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-fake-bin-")); +const MOCK_OPENWA_VERSION = "4.76.0"; + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.NODE_ENV = "test"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +// Prepend fake bin dir to PATH so our fake `npm` is found by runNpm +const originalPath = process.env.PATH ?? ""; +process.env.PATH = `${FAKE_BIN_DIR}:${originalPath}`; + +// Fake npm script: for `install` — creates expected package.json; +// for `view` — prints a version; for other args — no-op. +const fakeNpmScript = `#!/bin/sh +set -e +CMD="$1" +shift +if [ "$CMD" = "install" ]; then + # Resolve the install prefix like real npm: an explicit --prefix arg wins, + # otherwise fall back to the npm_config_prefix env var (#5379 passes the + # prefix via env instead of argv so paths with spaces survive the win shell). + PREFIX="" + while [ $# -gt 0 ]; do + if [ "$1" = "--prefix" ]; then PREFIX="$2"; shift 2; else shift; fi + done + if [ -z "$PREFIX" ]; then PREFIX="$npm_config_prefix"; fi + PKG_DIR="$PREFIX/node_modules/@open-wa/wa-automate" + mkdir -p "$PKG_DIR/bin" + echo '{"name":"@open-wa/wa-automate","version":"${MOCK_OPENWA_VERSION}"}' > "$PKG_DIR/package.json" + touch "$PKG_DIR/bin/server.js" + exit 0 +fi +if [ "$CMD" = "view" ]; then + echo "${MOCK_OPENWA_VERSION}" + exit 0 +fi +exit 0 +`; +const fakeNpmPath = path.join(FAKE_BIN_DIR, "npm"); +fs.writeFileSync(fakeNpmPath, fakeNpmScript, { mode: 0o755 }); + +// Verify fake npm is on PATH +execSync("which npm", { env: process.env }); + +// DB bootstrap (must be before openwa import due to db/core eager init) +const core = await import("../../../../src/lib/db/core.ts"); +const db = core.getDbInstance(); +db.prepare( + `INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose) + VALUES ('openwa', 'not_installed', 8323, 0, 0, 0)` +).run(); + +const { + install, + update, + uninstall, + getInstalledVersion, + getLatestVersion, + resolveSpawnArgs, + OPENWA_INSTALL_DIR, +} = await import("../../../../src/lib/services/installers/openwa.ts"); + +test.after(() => { + process.env.PATH = originalPath; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.rmSync(FAKE_BIN_DIR, { recursive: true, force: true }); +}); + +test("install creates package.json structure", async () => { + const result = await install(MOCK_OPENWA_VERSION); + + // Host package.json must exist + const hostPkg = path.join(OPENWA_INSTALL_DIR, "package.json"); + assert.ok(fs.existsSync(hostPkg), "host package.json should exist"); + const parsedHost = JSON.parse(fs.readFileSync(hostPkg, "utf8")) as { + name: string; + private: boolean; + }; + assert.equal(parsedHost.name, "omniroute-openwa-host"); + assert.ok(parsedHost.private); + + assert.equal(result.installedVersion, MOCK_OPENWA_VERSION); + assert.equal(result.installPath, OPENWA_INSTALL_DIR); + assert.ok(result.durationMs >= 0); +}); + +test("install captures real version from node_modules/@open-wa/wa-automate/package.json", async () => { + const ver = await getInstalledVersion(); + assert.equal(ver, MOCK_OPENWA_VERSION, "should read version from installed package"); +}); + +test("update calls npm install with latest (idempotent)", async () => { + const result = await update(); + assert.equal(result.installedVersion, MOCK_OPENWA_VERSION); +}); + +test("uninstall removes node_modules and marks not_installed in DB", async () => { + const nmDir = path.join(OPENWA_INSTALL_DIR, "node_modules"); + assert.ok(fs.existsSync(nmDir), "node_modules should exist before uninstall"); + + await uninstall(); + + assert.ok(!fs.existsSync(nmDir), "node_modules should be removed"); + + const { getVersionManagerTool } = await import("../../../../src/lib/db/versionManager.ts"); + const row = await getVersionManagerTool("openwa"); + assert.equal(row?.status, "not_installed"); + assert.equal(row?.installedVersion, null); + assert.equal(row?.binaryPath, null); + + // package.json host file should remain (preserves metadata) + assert.ok( + fs.existsSync(path.join(OPENWA_INSTALL_DIR, "package.json")), + "host package.json should be kept after uninstall" + ); +}); + +test("getLatestVersion returns version string from npm view", async () => { + const ver = await getLatestVersion(); + assert.equal(ver, MOCK_OPENWA_VERSION); +}); + +test("resolveSpawnArgs returns expected env, args and command", () => { + const args = resolveSpawnArgs("sk-test-api-key", 8323); + + assert.equal(args.command, process.execPath, "command must be current node binary"); + assert.ok(args.args[0]?.includes("server.js"), "args[0] should point to server.js"); + assert.deepEqual( + args.args.slice(1), + ["--port", "8323", "--host", "127.0.0.1", "--session-data-path", "data"], + "flags verified against the installed 4.76.0 dist/cli/cli-options.js + bin/config-schema.json" + ); + // The API key must ride the generic WA_*-prefixed env override (WA_KEY → + // camelCased "key" → the -k/--key CLI option), never argv, so it never + // appears in `ps`/process listings (Hard Rule #13). + assert.equal(args.env.WA_KEY, "sk-test-api-key"); + assert.equal(args.env.NODE_ENV, "production"); + assert.equal(args.cwd, OPENWA_INSTALL_DIR); +}); + +test("EACCES error returns friendly InstallError", async () => { + const { InstallError } = await import("../../../../src/lib/services/installers/utils.ts"); + + const err = new InstallError("raw error", "friendly message", 403); + assert.equal(err.httpStatus, 403); + assert.equal(err.friendly, "friendly message"); + assert.ok(err instanceof Error); + assert.equal(err.name, "InstallError"); +}); + +test("InstallError timeout shape has correct httpStatus 504", async () => { + const { InstallError: IE } = await import("../../../../src/lib/services/installers/utils.ts"); + const err = new IE("npm process killed", "Instalação demorou demais. Tente novamente.", 504); + assert.equal(err.httpStatus, 504); + assert.equal(err.friendly, "Instalação demorou demais. Tente novamente."); + assert.ok(err instanceof Error); + assert.equal(err.name, "InstallError"); +});