From 0f1a88740b6fe9a51bea28debc3faa48033a0fd0 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Sun, 19 Apr 2026 14:31:08 -0400 Subject: [PATCH] feat(daemon): publishable Docker image + compose stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships `ghcr.io/axiom-labs/arc-daemon` for headless installs that want a persistent daemon without the full CLI. The image runs `arc-daemon --foreground` on :7272 as a non-root user (uid 1000) with state at /home/arc/.arc, signals handled via tini, and a /health-based HEALTHCHECK. Adds: - packages/daemon/src/cli.ts — standalone foreground entrypoint with a tiny flag parser (--port/--host/--arc-dir). Registered as `arc-daemon` bin and used as the image ENTRYPOINT. - packages/daemon/tsup.config.ts — daemon-scoped build that inlines workspace siblings and externalises real deps (ws, better-sqlite3, zod) so Node's loader resolves CJS modules at runtime. - packages/daemon/Dockerfile — multi-stage (node:20-bookworm-slim). Stage 1 installs a filtered pnpm subgraph, rebuilds better-sqlite3, runs `pnpm deploy --prod` for a pruned tree. Stage 2 is tini + curl + non-root user + VOLUME /home/arc/.arc. - packages/daemon/.dockerignore — shrinks the build context. - packages/daemon/docker-compose.yml — loopback-only daemon service with an opt-in watchtower sidecar behind the `auto-update` profile. - packages/daemon/DOCKER.md — operator guide: pull/run, port mapping, volume semantics, security notes (loopback default, proxy for LAN). - .github/workflows/docker-daemon.yml — GHCR publish on v* tags plus workflow_dispatch, matrixed over linux/amd64 + linux/arm64 with a manifest-merge job. - packages/daemon/package.json — adds `@axiom-labs/arc-client` and `ws` as explicit deps (previously transitive through client) and registers `arc-daemon` as a bin entry. E2E locally: `npx tsc --noEmit` passes and the built CLI serves the expected `{"ok":true,"protocol":1,...}` payload on /health. Full `docker build` unverified here — Docker Desktop's Linux engine isn't running in this worktree; CI will exercise the full path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docker-daemon.yml | 136 ++++++++++++++++++++++++ packages/daemon/.dockerignore | 61 +++++++++++ packages/daemon/DOCKER.md | 134 ++++++++++++++++++++++++ packages/daemon/Dockerfile | 107 +++++++++++++++++++ packages/daemon/docker-compose.yml | 66 ++++++++++++ packages/daemon/package.json | 7 +- packages/daemon/src/cli.ts | 154 ++++++++++++++++++++++++++++ packages/daemon/tsup.config.ts | 21 ++++ pnpm-lock.yaml | 6 ++ 9 files changed, 691 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docker-daemon.yml create mode 100644 packages/daemon/.dockerignore create mode 100644 packages/daemon/DOCKER.md create mode 100644 packages/daemon/Dockerfile create mode 100644 packages/daemon/docker-compose.yml create mode 100644 packages/daemon/src/cli.ts create mode 100644 packages/daemon/tsup.config.ts diff --git a/.github/workflows/docker-daemon.yml b/.github/workflows/docker-daemon.yml new file mode 100644 index 0000000..5792047 --- /dev/null +++ b/.github/workflows/docker-daemon.yml @@ -0,0 +1,136 @@ +name: Publish daemon image + +# Builds + pushes the headless ARC daemon image to GHCR. +# - Tags prefixed `v*` → stable + semver-tagged images +# - Manual runs → push a debug tag for trying PRs before cutting a release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Extra tag to publish (in addition to sha-)" + required: false + default: "" + +permissions: + contents: read + packages: write + id-token: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/arc-daemon + +jobs: + build-and-push: + name: Build and push (${{ matrix.platform }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # arm64 runs via QEMU on ubuntu-latest — slower, but avoids + # splitting the pipeline across runner pools. + platform: + - linux/amd64 + - linux/arm64 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute platform tag fragment + id: platform + run: | + platform="${{ matrix.platform }}" + echo "pair=${platform##*/}" >> $GITHUB_OUTPUT + + - name: Build and push per-platform image + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: packages/daemon/Dockerfile + platforms: ${{ matrix.platform }} + push: true + provenance: false + outputs: | + type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest artifact + uses: actions/upload-artifact@v4 + with: + name: digests-${{ steps.platform.outputs.pair }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Publish manifest list + needs: build-and-push + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=tag + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }} + type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag != '' }} + type=sha,format=short + + - name: Create manifest list + working-directory: /tmp/digests + run: | + tags_args=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") + digest_refs=$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) + docker buildx imagetools create $tags_args $digest_refs + + - name: Inspect image + run: | + tag=$(jq -r '.tags[0]' <<< "$DOCKER_METADATA_OUTPUT_JSON") + docker buildx imagetools inspect "$tag" diff --git a/packages/daemon/.dockerignore b/packages/daemon/.dockerignore new file mode 100644 index 0000000..478ee53 --- /dev/null +++ b/packages/daemon/.dockerignore @@ -0,0 +1,61 @@ +# Keep the Docker build context small. The Dockerfile lives under +# packages/daemon/ but `docker build` is invoked from the repo root so +# that the monorepo is visible — these ignores run against the root. +# +# What we DO need: package.json files, pnpm workspace manifests, +# and source under packages/{core,client,daemon}/. + +# Dependencies + build output (rebuilt inside the image) +**/node_modules +**/dist +**/.turbo + +# Tests — not needed for a production image +**/tests +**/*.test.ts +**/*.test.tsx + +# VCS +.git +.gitignore + +# Editor + OS +.idea +.vscode +.DS_Store +Thumbs.db + +# Local env + secrets +.env +.env.* +*.local + +# Coverage + reports +coverage +.nyc_output + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Landing site + docs — not part of the daemon image +site +user-docs + +# Other packages not used by the daemon +packages/cli +packages/dashboard +packages/mcp +packages/relay +packages/adapter-* + +# Dev-only files at the root +Dockerfile +nginx.conf +docker-compose.yml +.github + +# Don't pull the daemon's own compose + docs into the image +packages/daemon/docker-compose.yml +packages/daemon/DOCKER.md diff --git a/packages/daemon/DOCKER.md b/packages/daemon/DOCKER.md new file mode 100644 index 0000000..31cc8ac --- /dev/null +++ b/packages/daemon/DOCKER.md @@ -0,0 +1,134 @@ +# Running `arc-daemon` in Docker + +The ARC daemon ships as a container image at +`ghcr.io/axiom-labs/arc-daemon`. It's intended for headless hosts +(workstations, home servers, small teams) where a long-running daemon +serves one or more ARC clients over the binary-mux WebSocket protocol. + +The CLI itself — `arc ...` commands, TUI, chat, etc. — is **not** +included. Install it separately from npm on your client machines. + +## Quick start + +```bash +docker run -d \ + --name arc-daemon \ + --restart unless-stopped \ + -p 127.0.0.1:7272:7272 \ + -v arc-state:/home/arc/.arc \ + ghcr.io/axiom-labs/arc-daemon:latest +``` + +Then from your host: + +```bash +curl http://127.0.0.1:7272/health +# → {"ok":true,"version":"...","protocol":1, ...} +``` + +Or via `docker compose` — an example lives in +[`docker-compose.yml`](./docker-compose.yml) alongside this file: + +```bash +cd packages/daemon +docker compose up -d +``` + +## Port mapping + +The daemon listens on port **7272** inside the container. The default +is unchanged from a local (`arc daemon start`) install so tooling +keeps working. + +| Flag | Effect | +| --- | --- | +| `-p 127.0.0.1:7272:7272` | Loopback only (default, safest) | +| `-p 7272:7272` | All interfaces — see security note below | +| `-p 1.2.3.4:7272:7272` | Bind to a specific host NIC | + +Override the in-container port with `ARC_PORT=` if you need to +avoid a collision inside a pod/network (the `HEALTHCHECK` picks it up). + +## Volume semantics + +State lives under `/home/arc/.arc` inside the container — profiles, +the SQLite DB (`arc.db`), auth keypair, and the daemon log. +Persist it with either a named volume (preferred) or a bind mount: + +```bash +# Named volume — Docker-managed, survives container replacement. +-v arc-state:/home/arc/.arc + +# Bind mount — maps onto the host filesystem. The directory must be +# owned by uid 1000 (the `arc` user inside the image). +-v /srv/arc:/home/arc/.arc +``` + +If you bind-mount and see permission errors, `chown -R 1000:1000 /srv/arc`. + +## Security notes + +* **Default bind is 0.0.0.0:7272 inside the container.** This is safe + when the host-side `-p` flag maps to `127.0.0.1` (as in the quick + start). The daemon itself only accepts connections whose HTTP `Host` + header resolves to a loopback address. +* **Exposing to a LAN requires a reverse proxy.** The daemon's pairing + flow is designed for trusted networks. If you want remote clients, + terminate TLS and mTLS (or an OIDC proxy) *in front* of the + container — don't publish port 7272 to the public internet. +* **Non-root.** The process runs as uid 1000 (`arc`). All capabilities + are dropped in the example compose file and `no-new-privileges` is + set. +* **Secrets.** The daemon generates a long-lived keypair on first + start and stores it inside the state volume (`auth.json`). Back this + up if you want seamless re-issuance after a volume loss. + +## Upgrading + +Because state lives in a named volume, upgrading is just: + +```bash +docker pull ghcr.io/axiom-labs/arc-daemon:latest +docker rm -f arc-daemon +docker run -d \ + --name arc-daemon \ + --restart unless-stopped \ + -p 127.0.0.1:7272:7272 \ + -v arc-state:/home/arc/.arc \ + ghcr.io/axiom-labs/arc-daemon:latest +``` + +The bundled compose stack includes a (disabled-by-default) +[watchtower](https://containrrr.dev/watchtower/) sidecar that polls +GHCR hourly and auto-rolls new stable tags. Enable it with: + +```bash +docker compose --profile auto-update up -d +``` + +## Tags + +| Tag | Cadence | +| --- | --- | +| `latest` | Latest stable release | +| `1`, `1.0`, `1.0.0` | Major / minor / patch pins | +| `sha-<7>` | Every CI build (no promotion) | + +Prereleases (`-alpha`, `-beta`, `-rc`) do **not** update `latest` — +pin to the explicit version if you want to track them. + +## Building locally + +```bash +# From the repo root. The daemon Dockerfile needs the monorepo context. +docker build -f packages/daemon/Dockerfile -t arc-daemon:dev . + +# Smoke test +docker run --rm -d \ + --name arc-dt \ + -p 17272:7272 \ + -v /tmp/arc-docker-test:/home/arc/.arc \ + arc-daemon:dev +sleep 3 && curl -s http://127.0.0.1:17272/health +docker stop arc-dt +``` diff --git a/packages/daemon/Dockerfile b/packages/daemon/Dockerfile new file mode 100644 index 0000000..0c5f710 --- /dev/null +++ b/packages/daemon/Dockerfile @@ -0,0 +1,107 @@ +# syntax=docker/dockerfile:1.7 +# +# ghcr.io/axiom-labs/arc-daemon — headless ARC daemon. +# +# Runs `arc-daemon --foreground` on port 7272 with state mounted at +# /home/arc/.arc. Designed for workstations, home servers, and small teams +# that want a persistent daemon without installing the full CLI. +# +# Build from the repo root so the monorepo context is available: +# docker build -f packages/daemon/Dockerfile -t ghcr.io/axiom-labs/arc-daemon:dev . + +# ─── Stage 1: build ────────────────────────────────────────────────────────── +FROM node:20-bookworm-slim AS build + +# better-sqlite3 builds a native addon via node-gyp. Needs python + C++ toolchain. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + python3 \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN corepack enable && corepack prepare pnpm@10.29.3 --activate + +WORKDIR /workspace + +# Workspace manifests + root tsconfig first — keeps the install layer +# cacheable across source changes that don't touch dependencies. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./ +COPY packages/core/package.json packages/core/ +COPY packages/client/package.json packages/client/ +COPY packages/daemon/package.json packages/daemon/ + +# Install deps for the three packages we'll build. Skip postinstall scripts — +# the root `postinstall` prints welcome text unrelated to the daemon image. +RUN pnpm install --frozen-lockfile --ignore-scripts \ + --filter @axiom-labs/arc-core \ + --filter @axiom-labs/arc-client \ + --filter @axiom-labs/arc-daemon + +# Source (after install so changes don't bust the install cache) +COPY packages/core ./packages/core +COPY packages/client ./packages/client +COPY packages/daemon ./packages/daemon + +# Rebuild the native better-sqlite3 addon against the container's Node +# ABI, then compile daemon TS → ESM via tsup. +RUN pnpm --filter @axiom-labs/arc-daemon rebuild better-sqlite3 \ + && pnpm --filter @axiom-labs/arc-daemon build + +# Produce a self-contained deployment tree with only production deps. +# `pnpm deploy` resolves the workspace graph into a regular node_modules +# (no symlinks to sibling packages), ready to drop into the runtime stage. +# --legacy is required on pnpm >= 10 because the new default uses +# inject-workspace-packages which conflicts with `--prod` pruning. +RUN pnpm --filter @axiom-labs/arc-daemon deploy --prod --legacy /deploy \ + && rm -rf /deploy/dist \ + && cp -a packages/daemon/dist /deploy/dist + +# ─── Stage 2: runtime ──────────────────────────────────────────────────────── +FROM node:20-bookworm-slim AS runtime + +# Minimal runtime extras: +# - tini → PID 1, forwards signals, reaps zombies +# - curl → HEALTHCHECK probe for /health +RUN apt-get update \ + && apt-get install -y --no-install-recommends tini curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Non-root user. uid 1000 matches the default host user on most Linuxes so +# bind-mounting `~/.arc` "just works" without chown dance. +RUN groupadd --system --gid 1000 arc \ + && useradd --system --uid 1000 --gid arc --home-dir /home/arc --shell /usr/sbin/nologin arc \ + && mkdir -p /home/arc/.arc /app \ + && chown -R arc:arc /home/arc /app + +WORKDIR /app + +# The deploy tree from stage 1 is already a flat, production-only +# install of the daemon package (dist + node_modules + package.json). +COPY --from=build --chown=arc:arc /deploy /app + +# Persistent state lives here; the volume survives container replacement so +# upgrading to a newer image keeps profiles, DB, and auth keys intact. +VOLUME ["/home/arc/.arc"] + +ENV NODE_ENV=production \ + ARC_DIR=/home/arc/.arc \ + ARC_HOST=0.0.0.0 \ + ARC_PORT=7272 + +# Binding to 0.0.0.0 inside the container is intentional: the container's +# network namespace is already the boundary. The daemon's Host-header +# allowlist still rejects non-loopback Host values, so publishing with +# `-p 127.0.0.1:7272:7272` (the documented default) keeps access to +# callers on the host loopback. For LAN/remote access, front with an +# authenticating reverse proxy that rewrites Host to 127.0.0.1. + +USER arc + +EXPOSE 7272 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -fsS "http://127.0.0.1:${ARC_PORT}/health" >/dev/null || exit 1 + +ENTRYPOINT ["/usr/bin/tini", "--", "node", "/app/dist/cli.js"] +CMD ["--foreground"] diff --git a/packages/daemon/docker-compose.yml b/packages/daemon/docker-compose.yml new file mode 100644 index 0000000..a3f8753 --- /dev/null +++ b/packages/daemon/docker-compose.yml @@ -0,0 +1,66 @@ +# Example compose stack for the headless ARC daemon. +# +# Usage: +# cd packages/daemon +# docker compose up -d # pull + start daemon +# docker compose --profile auto-update up -d # also run watchtower +# docker compose logs -f arc # tail daemon logs +# docker compose down # stop (volume persists) +# +# The daemon binds port 7272 on the host loopback only. To expose on a +# LAN, change "127.0.0.1:7272:7272" to "0.0.0.0:7272:7272" AND put an +# authenticating proxy in front — the daemon's auth handshake is meant +# for trusted networks, not the open internet. + +name: arc + +services: + arc: + image: ghcr.io/axiom-labs/arc-daemon:latest + container_name: arc-daemon + restart: unless-stopped + ports: + # Loopback-only by default. Swap to "0.0.0.0:7272:7272" to expose. + - "127.0.0.1:7272:7272" + volumes: + # Named volume keeps ~/.arc between container replacements. + - arc-state:/home/arc/.arc + environment: + # Inside the container we must bind all interfaces; the host-side + # port mapping controls which networks can actually reach it. + ARC_HOST: "0.0.0.0" + ARC_PORT: "7272" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:7272/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + labels: + # Opt this container in to watchtower's label-scope. + com.centurylinklabs.watchtower.enable: "true" + # Best-effort hardening. Uncomment read-only-root if you can live + # without the daemon writing to /tmp (it doesn't today, but may). + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + # read_only: true + + # Optional: auto-pull new daemon images on release. + # Remove the "profiles: [auto-update]" stanza to enable by default. + watchtower: + image: containrrr/watchtower:latest + container_name: arc-watchtower + restart: unless-stopped + profiles: ["auto-update"] + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + WATCHTOWER_CLEANUP: "true" + WATCHTOWER_POLL_INTERVAL: "3600" # seconds; hourly + WATCHTOWER_LABEL_ENABLE: "true" + +volumes: + arc-state: + driver: local diff --git a/packages/daemon/package.json b/packages/daemon/package.json index c9a065c..4d0f928 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -5,13 +5,18 @@ "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "arc-daemon": "./dist/cli.js" + }, "scripts": { - "build": "tsup src/index.ts --format esm --target node20 --clean", + "build": "tsup src/index.ts src/cli.ts --format esm --target node20 --clean", "typecheck": "tsc --noEmit" }, "dependencies": { + "@axiom-labs/arc-client": "workspace:*", "@axiom-labs/arc-core": "workspace:*", "better-sqlite3": "^11.3.0", + "ws": "^8.18.0", "zod": "^3.25.0" }, "devDependencies": { diff --git a/packages/daemon/src/cli.ts b/packages/daemon/src/cli.ts new file mode 100644 index 0000000..d5a5823 --- /dev/null +++ b/packages/daemon/src/cli.ts @@ -0,0 +1,154 @@ +/** + * Standalone daemon entrypoint. + * + * Used as the ENTRYPOINT for the `ghcr.io/axiom-labs/arc-daemon` Docker + * image and as the `arc-daemon` bin for headless installs that don't want + * the full CLI. Runs in the foreground only; the richer `arc daemon start` + * command handles backgrounding / status / logs. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { startDaemon, type DaemonOptions } from "./bootstrap.js"; + +interface ParsedArgs { + port?: number; + host?: string; + arcDir?: string; + help: boolean; + version: boolean; +} + +function parseArgs(argv: readonly string[]): ParsedArgs { + const out: ParsedArgs = { help: false, version: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + // Accepted for compatibility with the richer `arc daemon start` + // command; this binary is foreground-only, so the flag is a no-op. + case "-f": + case "--foreground": + break; + case "--port": { + const next = argv[++i]; + if (!next) throw new Error("--port requires a value"); + const port = Number.parseInt(next, 10); + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + throw new Error(`invalid --port value: ${next}`); + } + out.port = port; + break; + } + case "--host": { + const next = argv[++i]; + if (!next) throw new Error("--host requires a value"); + out.host = next; + break; + } + case "--arc-dir": { + const next = argv[++i]; + if (!next) throw new Error("--arc-dir requires a value"); + out.arcDir = next; + break; + } + case "-h": + case "--help": + out.help = true; + break; + case "-v": + case "--version": + out.version = true; + break; + default: + if (arg && arg.startsWith("-")) { + throw new Error(`unknown flag: ${arg}`); + } + break; + } + } + return out; +} + +function readPkgVersion(): string { + try { + const pkgPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "package.json", + ); + const raw = fs.readFileSync(pkgPath, "utf8"); + const parsed = JSON.parse(raw) as { version?: string }; + return parsed.version ?? "0.0.0"; + } catch { + return "0.0.0"; + } +} + +const HELP_TEXT = `arc-daemon — ARC daemon standalone entrypoint (foreground only) + +Usage: + arc-daemon [--port ] [--host ] [--arc-dir ] + +Flags: + -f, --foreground Accepted for compatibility; this binary always + runs in the foreground. + --port TCP port to bind. Default: 7272 (or $ARC_PORT). + --host Host to bind. Default: 127.0.0.1 (or $ARC_HOST). + Use 0.0.0.0 in containers if exposing to LAN. + --arc-dir ARC state directory. Default: $ARC_DIR or ~/.arc. + -h, --help Show this help. + -v, --version Print daemon version and exit. + +Security: + The daemon only accepts connections whose HTTP Host header resolves to a + loopback address. To reach it from another machine, put it behind an + authenticated reverse proxy or add the remote host to the allow-list. +`; + +async function main(): Promise { + let args: ParsedArgs; + try { + args = parseArgs(process.argv.slice(2)); + } catch (err) { + process.stderr.write(`arc-daemon: ${(err as Error).message}\n`); + process.stderr.write(HELP_TEXT); + process.exit(2); + } + + if (args.help) { + process.stdout.write(HELP_TEXT); + return; + } + + const version = readPkgVersion(); + + if (args.version) { + process.stdout.write(`${version}\n`); + return; + } + + const opts: DaemonOptions = { version }; + if (args.port !== undefined) opts.port = args.port; + if (args.host !== undefined) opts.host = args.host; + if (args.arcDir !== undefined) opts.arcDir = args.arcDir; + + try { + const handle = await startDaemon(opts); + process.stdout.write( + `arc-daemon listening on ${handle.config.host}:${handle.config.port} ` + + `(arcDir=${handle.config.arcDir})\n`, + ); + } catch (err) { + process.stderr.write(`arc-daemon: failed to start — ${(err as Error).message}\n`); + process.exit(1); + } + + // startDaemon installs SIGINT/SIGTERM handlers that exit(0) on shutdown; + // this promise keeps the event loop alive until then. + await new Promise(() => {}); +} + +main().catch((err: Error) => { + process.stderr.write(`arc-daemon: unhandled error — ${err.message}\n`); + process.exit(1); +}); diff --git a/packages/daemon/tsup.config.ts b/packages/daemon/tsup.config.ts new file mode 100644 index 0000000..e4043d0 --- /dev/null +++ b/packages/daemon/tsup.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts", "src/cli.ts"], + format: ["esm"], + target: "node20", + clean: true, + splitting: false, + sourcemap: false, + // Unminified so Node's ESM loader can follow dynamic `require()` calls + // inside CJS deps (ws, better-sqlite3). The minified helper mangles them. + minify: false, + // Bundle our workspace siblings (so no pre-build step is required on + // their dists) but let Node resolve real third-party deps from + // node_modules at runtime. + noExternal: ["@axiom-labs/arc-core", "@axiom-labs/arc-client"], + external: ["ws", "better-sqlite3", "zod", "@napi-rs/keyring"], + banner: { + js: "#!/usr/bin/env node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bc0959..49dfd51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,12 +122,18 @@ importers: packages/daemon: dependencies: + '@axiom-labs/arc-client': + specifier: workspace:* + version: link:../client '@axiom-labs/arc-core': specifier: workspace:* version: link:../core better-sqlite3: specifier: ^11.3.0 version: 11.10.0 + ws: + specifier: ^8.18.0 + version: 8.20.0 zod: specifier: ^3.25.0 version: 3.25.76