diff --git a/.dockerignore b/.dockerignore index ecf365f..80d5bad 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,3 +15,4 @@ db/.exports coverage logs *.log +assets diff --git a/.env.example b/.env.example index 8e59297..c773b20 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,11 @@ +# Development settings. The installer writes its own .env; see README.md. + PORT=7227 GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +# SMTP host, user and password EMAIL_DOMAIN= EMAIL_USER= EMAIL_PASS= @@ -14,29 +17,14 @@ DEV_EMAIL_RECIPIENT= ENCRYPTION_KEY= ENCRYPTION_SALT= -SOURCE_DIRECTORY=/home/user/projects/bunext-sqlite -DEPLOY_SERVER_IP=192.168.5.27 - -DATA_DIR=/data - -# Set to 1 on the one container that should run cron tasks in production -MAIN_SERVER= - # Web Push: generated into Settings > Environment on first start. Set a # fixed pair here only if you want to keep one across installs. VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= -# Optional public envs exposed to the client through __root.server.ts -PAYSTACK_PUBLIC_KEY= -R2_PUBLIC_DOMAIN= - # Seed script overrides (bun run seed) SEED_SUPER_ADMIN_EMAIL= SEED_SUPER_ADMIN_USERNAME= SEED_SUPER_ADMIN_FIRST_NAME= SEED_SUPER_ADMIN_LAST_NAME= SEED_SUPER_ADMIN_PASSWORD= - -# Production: sitemap lastmod cache file on the /data volume -PAGE_LASTMOD_FILE= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e3c258f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.3 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check installer syntax + run: bash -n install.sh + + - name: Type-check + run: bun run typecheck + + - name: Test + run: bun run test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5be541d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,82 @@ +name: Release image + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: release-image + cancel-in-progress: false + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Gitea + uses: docker/login-action@v3 + with: + registry: git.tben.me + username: ${{ secrets.GITEA_USERNAME }} + password: ${{ secrets.GITEA_TOKEN }} + + - name: Work out image tags + id: tags + env: + EVENT: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + sha="sha-${GITHUB_SHA::12}" + if [ "$EVENT" = "release" ] && [ -n "$RELEASE_TAG" ]; then + version="$RELEASE_TAG" + minor="$(printf '%s' "$version" | sed -E 's/^v?([0-9]+)\.([0-9]+).*/\1.\2/')" + tags="$(printf '%s\n' \ + "ghcr.io/moduletrace/cobble:latest" \ + "ghcr.io/moduletrace/cobble:${version}" \ + "ghcr.io/moduletrace/cobble:${minor}" \ + "ghcr.io/moduletrace/cobble:${sha}" \ + "git.tben.me/moduletrace/cobble:latest" \ + "git.tben.me/moduletrace/cobble:${version}" \ + "git.tben.me/moduletrace/cobble:${minor}" \ + "git.tben.me/moduletrace/cobble:${sha}")" + else + tags="$(printf '%s\n' \ + "ghcr.io/moduletrace/cobble:${sha}" \ + "git.tben.me/moduletrace/cobble:${sha}")" + fi + { + echo "tags<> "$GITHUB_OUTPUT" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.tags.outputs.tags }} + provenance: false + sbom: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/AGENTS.md b/AGENTS.md index 25d0b06..929b65c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,9 +111,13 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl - **Schedules (routines in the UI):** `cron_jobs` (per user and bot, `workspace_id` null for its own chat, `title`, `instructions` (the brief), `schedule` (a `Bun.cron` expression, UTC), `computer_id` (not a foreign key; null runs on the bot's current one), `enabled`, `max_runs` (1 for a one-off), `run_count`, `next_run_at`, `last_run_at`, `last_job_id`, `last_error`). Helpers in `src/functions/backend/db/cron-jobs/` (`create-cron-job`, `update-cron-job`, `delete-cron-job`, `grab-cron-jobs`, `grab-user-cron-job`, `fire-cron-job`), all ownership-checked, and with `bot_id` limited to that bot's own. `nextCronRun` (`src/utils/next-cron-run.ts`, `Bun.cron.parse`) validates a schedule and refuses one that runs more often than `AppData.CronJobMinIntervalMs`; a bot has at most `AppData.CronJobMaxPerBot`. Every bot gets `create_schedule`, `list_schedules`, `update_schedule`, `delete_schedule` (`grabBotTools`, every run; descriptions via `describe-cron-job.ts`). The `run-cron-jobs` cron fires due ones (`fireCronJob`: claims the run atomically by moving `next_run_at`, skips it while the last run's job is still open, queues the brief as a task with `enqueueBotJob` in the bot's active session there, turns off after `max_runs` or when there's no next run, keeps why it couldn't in `last_error`, logs each run under `cron`). A run missed while the server was down runs once, then carries on from now. `/admin/routines` lists them (pause/resume, edit, delete; `api/admin/routines/update-routine`, `delete-routine`); bots create them from chat. - **Not built yet:** task steps (nothing writes `bot_task_steps` yet). -### Production container +### Installer and release -`deploy/run-app-container.sh` builds the image and (re)runs the app's container (`deploy/deploy-prod.sh` calls it on the server); rerun it to update. The app container and every computer container share the `cobble` network. Mounts: `.env` at `/app/.env` (read only; bun loads it), the host data dir (`COBBLE_DATA_DIR`, default `/data/cobble`) at `/data`, and the docker socket. Everything that must survive an update lives on `/data`: media and computers' home dirs (`DATA_DIR=/data`), the database with its backups and exports (`DB_DIR=/data/db`, read by `bun-sqlite.config.ts`; the schema always comes from `db/schema.ts`), and bun-sqlite's live schema record (`entrypoint.sh` links `/app/.bun-sqlite` there). `MAIN_SERVER=1` is set on that one container. +`install.sh` is the only supported install and update path: `curl -fsSL https://raw.githubusercontent.com/moduletrace/cobble/main/install.sh | sudo bash`. It is idempotent, so the same script is the updater, and `--uninstall [--purge]` is the way back out. It installs Docker when missing (Linux), creates the `cobble` network, writes `$COBBLE_HOME/install.conf` and a generated `.env` (mode 600), and starts one `cobble` container. Existing secrets are never regenerated; the database is backed up to `$COBBLE_HOME/backups` before a container is replaced. The default home is `/opt/cobble`, configurable with `--home`, and everything that must survive an update lives in its `data/` directory (`db`, `media`, `computers`). + +The container runs with `--network cobble`, `-p BIND:PORT`, `.env` at `/app/.env` read only, the host data directory at `/data`, the docker socket at `/var/run/docker.sock`, `MAIN_SERVER=1` and `PORT`, `--init`, a `unless-stopped` restart policy and bounded json-file logs. `/data` must stay a bind mount: `resolveHostPath` turns paths under the app's own mounts into host paths for computer containers, and a named volume would break them. `DATA_DIR=/data` and `DB_DIR=/data/db` come from the Dockerfile; `entrypoint.sh` keeps bun-sqlite's schema record on the same volume. + +Images are built by GitHub Actions on a published release (`.github/workflows/release.yml`), for `linux/amd64` and `linux/arm64`, and pushed to both `ghcr.io/moduletrace/cobble` and `git.tben.me/moduletrace/cobble` with `latest`, version, `major.minor` and commit-sha tags. `.github/workflows/ci.yml` runs `bun run typecheck` and `bun run test` on every push and pull request, and checks that `install.sh` parses. The Dockerfile pins Bun 1.4.3 to match CI. The repository is mirrored on Gitea, and the installer defaults to the GHCR image with `--image git.tben.me/moduletrace/cobble` as the Gitea alternative. ### Envs diff --git a/Dockerfile b/Dockerfile index c4f2c43..50cdb89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,9 @@ -FROM oven/bun:1.3.14-debian +FROM oven/bun:1.4.3-debian + +LABEL org.opencontainers.image.title="Cobble" \ + org.opencontainers.image.description="A self-hosted crew of small AI worker bots." \ + org.opencontainers.image.source="https://github.com/Moduletrace/cobble" \ + org.opencontainers.image.licenses="MIT" RUN apt-get update \ && apt-get install -y --no-install-recommends curl bash nano wget zip unzip \ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..13fd160 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Moduletrace + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d7c451c..16cec16 100644 --- a/README.md +++ b/README.md @@ -1,126 +1,167 @@ -# bunext-sqlite +# Cobble -A preset for [bunext](https://github.com/moduletrace/bunext) running [bun-sqlite](https://github.com/moduletrace/bun-sqlite) as the database management system. Use this as the starting point for new full-stack Bun projects: filesystem routing, SQLite with generated types, password login, an admin CRUD API, WebSockets, cron, audit logging, and email all wired up out of the box. +A self-hosted crew of small AI worker bots. -## Stack +Cobble gives you a coordinator bot and a crew of workers, each with its own +job, models, memory and workspace. The crew talks to you in a chat, shares +workspaces, runs routines on a schedule, and drives its own computers: +Docker sandboxes with a browser, terminal, code editor and desktop that a +bot can use and hand back to you when it needs help. -| Layer | Choice | -| --------- | -------------------------------------------------- | -| Runtime | Bun (never npm/npx - use `bun`) | -| Framework | `@moduletrace/bunext` (filesystem routing, SSR) | -| Database | `@moduletrace/bun-sqlite` (schema + generated types) | -| UI | Tailwind CSS v4 + `twui` component library | -| Icons | `lucide-react` | -| Validation| `zod` (v4) | -| Email | `nodemailer` (React-rendered templates) | +It is an install, not a SaaS. One SQLite database, one Docker container, and +your data on your own machine. -## Requirements +- Website: [cobble.moduletrace.org](https://cobble.moduletrace.org) +- Source: [GitHub](https://github.com/Moduletrace/cobble) and [Gitea](https://git.tben.me/Moduletrace/cobble) +- License: MIT -- Bun >= 1.3 -- No database server required - `bun-sqlite` manages a local SQLite file at `db/` (`db/bsqlite` by default). +## Install -## Local Development +On a Linux machine with Docker (the installer adds Docker when it is +missing): -1. `cp .env.example .env` (if present) or create `.env` with `ENCRYPTION_KEY`/`ENCRYPTION_SALT` values. -2. `bun install` -3. `bun run db:schema` - creates tables and regenerates `db/types/db.ts`. -4. `bun run dev` - starts the dev server (default port `7227`). -5. Open the app. With no accounts yet, every page sends you to `/auth/signup` to create the super admin (user #1). That is the only time signup is shown. +```bash +curl -fsSL https://raw.githubusercontent.com/moduletrace/cobble/main/install.sh | sudo bash +``` -Headless alternative to step 5: `SEED_SUPER_ADMIN_PASSWORD=... bun run seed` creates the super admin from the CLI (default `super.admin@example.com`, override with `SEED_SUPER_ADMIN_EMAIL`, `SEED_SUPER_ADMIN_USERNAME`, `SEED_SUPER_ADMIN_FIRST_NAME`, `SEED_SUPER_ADMIN_LAST_NAME`). +Open the address it prints, usually `http://:7227`, and create the +super admin account. The first signup also creates the coordinator and the +main computer, which pulls the sandbox image in the background. Give it a +few minutes. -Log in at `/auth/login` with your email or username and password. +Then open **Settings > Models**, add an OpenRouter key (or another provider) +and wait for the model list to sync. This is what the bots think with. -## Available Scripts +### Requirements -| Purpose | Command | -| ----------------------------- | ---------------------------------- | -| Dev server | `bun run dev` | -| Production | `bun run start` | -| Type-check only | `bun run typecheck` | -| Run tests | `bun run test` | -| Run a single test | `bun test ` | -| Regenerate favicon/brand | `bun run gen:favicon` | -| DB schema (tables) | `bun run db:schema` | -| DB admin UI | `bun run db:admin` | -| DB backup | `bun run db:backup` | -| Seed super admin user | `bun run seed` | -| Query DB (CLI) | `bun llm-code-assistant/query-db.ts ""` | -| Deploy to production | `./deploy/deploy-prod.sh` | +- Linux amd64 or arm64, with Docker. The installer installs Docker from + get.docker.com when it is missing. +- 2 CPU cores, 8 GB RAM and 20 GB free disk for the app plus one computer. + Each computer is its own container with its own memory limit. +- Docker Desktop on macOS works for development and small installs, but + Linux is the supported host. +- Port 7227, or another port with `--port`. +- HTTPS in front of the app before you expose it to the internet. Web Push + and installable PWA features need it. A Cloudflare Tunnel or Caddy in + front of `127.0.0.1:7227` is enough. -## App Structure +The installer binds to `0.0.0.0` so the app is reachable from your phone on +the same network. Use `--bind 127.0.0.1` when a reverse proxy is the only +thing that should reach it. -| Directory | Purpose | -| ------------------------------ | ------------------------------------------------------------- | -| `db/schema.ts` | SQLite schema - the source of truth for `db/types/db.ts` | -| `src/server.ts` | Entrypoint: `bunextInit()`, `Bun.serve` (media, `/ws` auth'd upgrade), cron startup | -| `src/pages/` | Filesystem routes (`__root.server.ts` auth guard, `__root.tsx` layout/context) | -| `src/pages/api/` | API routes - incl. the CRUD catch-all at `api/admin/crud/[[...paths]].ts` | -| `src/websocket/` | WebSocket handler: upgrade-time auth, room system, event dispatch | -| `src/cron/` | Cron tasks - `Bun.cron` schedules, folder-per-task | -| `@moduletrace/twui` | The `twui` component library (external package, shared across projects) | -| `src/components/general/` | Shared page components built on twui | -| `src/functions/backend/` | Server-only logic: auth, DB queries, email, PDF generation, media, logger | -| `src/functions/frontend/` | Client-safe: CRUD/fetch handlers, media upload helpers, document actions | -| `src/hooks/` | Shared React hooks (`use-admin-crud-get`, `use-form-init`, ...) | -| `src/schemas/` | zod request schemas for public/auth endpoints | -| `src/utils/` | Utility modules: cookies, crypt, safe-compare, ejson, money | -| `src/utils/rbac/` | RBAC engine: pass-through users + declarative assignment matrix | -| `src/dict/` | Dictionaries: user types (+ assignment matrices), media types, document statuses, websocket rooms, log categories | -| `src/data/` | Static data: `site-data.ts` (brand literals), `app-data.ts` (constants) | -| `src/scripts/` | CLI scripts: brand asset generator, seed | -| `llm-code-assistant/` | `query-db.ts` - read-only SQL CLI for coding assistants | +## Update and uninstall -## Architecture +The same script is the updater. It keeps the database, media, computers' +home directories and generated secrets, backs up the database before +replacing the container, and does nothing when the image has not changed. -### Auth +```bash +curl -fsSL https://raw.githubusercontent.com/moduletrace/cobble/main/install.sh | sudo bash +``` -- **Auth guard:** `src/pages/__root.server.ts` - runs per request. There are no public pages: anything outside `/admin` and `/auth` redirects to `/admin`. With no accounts, everything redirects to `/auth/signup`; once one exists, signup redirects away. `/admin/*` requires login; `/auth/*` redirects logged-in users. -- **Session:** encrypted cookie (`AES-256-GCM`, PBKDF2-derived key) + a matching CSRF cookie, verified constant-time on every request (`src/functions/backend/auth/user-auth.ts`). 60-day expiry. -- **Login:** email or username plus password (`api/auth/password-login.ts`), no email step. Set/change password at `/admin/settings/change-password`. Google login (`api/auth/google-login.ts`) works for existing accounts only when `GOOGLE_CLIENT_ID` is set; it never creates accounts. -- **Signup:** first-run only (`api/auth/signup.tsx`): creates the super admin with a password and logs them in. Refused once any user exists. -- **RBAC:** `src/utils/rbac/` - `super_admin` passes everything; other grants/revocations are governed by `UserTypeAssignmentMatrix`/`UserTypeDeletionMatrix` in `src/dict/user-types-dict.ts`. -- **Audit trail:** every auth event (signup, login, failure, logout) is written to the `logs` table via `src/functions/backend/logger.ts`. +```bash +# Stop the app and keep the data +curl -fsSL https://raw.githubusercontent.com/moduletrace/cobble/main/install.sh | sudo bash -s -- --uninstall -### Admin CRUD API +# Stop the app and delete the data +curl -fsSL https://raw.githubusercontent.com/moduletrace/cobble/main/install.sh | sudo bash -s -- --uninstall --purge +``` -`api/admin/crud/[[...paths]].ts` dispatches `GET/POST/PUT/DELETE` by table name. Tables without a custom handler fall through to generic CRUD (super admin only). Customize a table by adding a folder under `src/pages/api/admin/crud/(tables)//` with per-method authorization. +### Options -### Documents (Letters / Emails / Invoices / Quotes) +| Option | Meaning | +| --------------------- | -------------------------------------------------------------- | +| `--home DIR` | Install directory, default `/opt/cobble` | +| `--data-dir DIR` | Data directory mounted at `/data`, default `HOME/data` | +| `--port PORT` | Host port, default `7227` | +| `--bind ADDRESS` | Host address to publish on, default `0.0.0.0` | +| `--image IMAGE` | Container image, default `ghcr.io/moduletrace/cobble` | +| `--version TAG` | Image tag, default `latest` | +| `--extra-network NET` | Join another Docker network, for a tunnel or reverse proxy | +| `--force` | Recreate the container even when it is current | +| `--no-pull` | Use an image that is already on the machine | +| `--skip-backup` | Update without a database backup | +| `--no-docker-install` | Fail instead of installing Docker | +| `--uninstall` | Stop and remove the app container | +| `--purge` | With `--uninstall`, also delete the data directory | -A complete document suite ships on top of the generic CRUD: `letters`, `emails`, `email_attachments`, `invoices`, `invoice_items`, `quotes`, `quote_items`, `invoice_quotes` tables with admin sections at `/admin/{letters,emails,invoices,quotes}`. Invoices and quotes get auto-generated numbers (from `SiteData["SiteSlug"]`), sortable line items, quote-to-invoice linking, branded PDF generation (`src/functions/backend/pdf/`), and sending via React-rendered HTML email (`src/functions/backend/email/`, shell in `src/email/root.tsx`). See `AGENTS.md` for the full map. +## Data and settings -### Backups +Everything that must survive an update lives under the install directory: -Super admins can create, list, download, and restore database snapshots from `/admin/backups` (`src/functions/backend/backups/`, API under `api/admin/backups/`). Backups are written to `db/.backups`; restore accepts a listed snapshot or an uploaded SQLite file. Every action is audit-logged. +``` +/opt/cobble/ + .env # generated once, mode 600 + install.conf # installer state + backups/ # pre-update database snapshots + data/ + db/cobble + db/.backups/ + media/ + computers// # each computer's home directory +``` -### Settings +`.env` holds the port and the encryption keys. The keys encrypt sessions and +settings, so keep a copy and do not rotate them without re-entering provider +keys and other secrets. Provider keys, MCP servers, tools and app settings +are managed in the app, not in `.env`. -`/admin/settings` redirects to `/admin/settings/general` (theme: System, Light or Dark, saved per device). `/admin/settings/profile` is the current user's profile (editable by every logged-in user, including `read_only`). `/admin/settings/change-password` sets or changes the user's own password. The sub pages are listed in the rail (`src/dict/settings-pages-dict.ts`). +Edit `.env` and run `docker restart cobble` to apply changes. The installer +only appends missing values and keeps what is already there. -### WebSockets +The app container is started with the Docker socket mounted and +`MAIN_SERVER=1`, so it can create computer containers and run cron, the +queue and Web Push. -`/ws` upgrades only after `userAuth` succeeds; the user session is frozen into the socket. Rooms are declared in `src/dict/websocket-rooms.ts` and managed by the dual-index store in `src/websocket/(data)/rooms.ts`. Client events dispatch through `src/websocket/(functions)/handle-message.ts`; add per-event handlers as `src/websocket/client-requests//index.ts`. The twui `useWebSocket` hook is the client counterpart. +## Images -### Cron +Every release publishes a multi-arch image to both registries: -`src/cron/index.ts` registers tasks (gated to the `MAIN_SERVER` container in production). Each task is a folder with a default-exported async function scheduled with `Bun.cron` through `registerCronJob` (`src/cron/register-cron-job.ts`), which logs before each run and catches errors. Schedules are interpreted in UTC. The bundled heartbeat task doubles as a DB-liveness probe. +- `ghcr.io/moduletrace/cobble` +- `git.tben.me/moduletrace/cobble` -## UI Conventions +Tags are `latest`, the release version, `major.minor` and the commit sha. +Install from Gitea with +`--image git.tben.me/moduletrace/cobble`, or build from source with +`docker build -t cobble:local .` and `--image cobble --version local --no-pull`. -- Build with `twui` components (`Section`, `Container`, `Row`, `Stack`, `Card`, `Button`, ...) and `src/components/general/` - never raw HTML for layout. -- Themable tokens live only in `src/styles/main.css` (`@theme inline`). No hardcoded hex colors or radii in components. -- Brand name/slug live only in `src/data/site-data.ts`; the logo mark only in `public/logos/logo.svg`. +## Development -## Page Directory Rules +```bash +cp .env.example .env +# set ENCRYPTION_KEY and ENCRYPTION_SALT (openssl rand -hex 32) +bun install +bun run db:schema +bun run dev +``` -Route-local code under `src/pages` uses non-routed meta folders: `(sections)`, `(partials)`, `(data)`, `(functions)`, `(utils)`, `(hooks)`. Keep route files thin. Dynamic routes use `[param]`; catch-alls use `[[...paths]]`. Server functions live in `index.server.ts` beside `index.tsx`. +Open `http://localhost:7227`. With no users, every page goes to +`/auth/signup` to create the super admin. For a headless setup: -## Verification +```bash +SEED_SUPER_ADMIN_PASSWORD=... bun run seed +``` -`bun run typecheck` and `bun run test` are the verification steps (no linter/formatter configured). CI runs both on every push and pull request (`.github/workflows/ci.yml`). Unit tests are colocated `*.test.ts` files run individually with `bun test `. +| Command | Purpose | +| -------------------- | ------------------------------------ | +| `bun run dev` | Development server | +| `bun run start` | Production server on the host | +| `bun run typecheck` | Type-check | +| `bun run test` | Run the test suite | +| `bun run seed` | Create the super admin from the CLI | +| `bun run seed:bots` | Add a sample crew | +| `bun run db:schema` | Sync the schema and types | +| `bun run gen:favicon` | Regenerate favicons and PWA assets | -## Maintainer Notes +The app is Bun, [`@moduletrace/bunext`](https://github.com/Moduletrace/bunext), +SQLite via [`@moduletrace/bun-sqlite`](https://github.com/Moduletrace/bun-sqlite), +Tailwind CSS and the `@moduletrace/twui` component library. See +[AGENTS.md](AGENTS.md) for the full architecture, routes, queue, harness and +data model. -- `.env` and `secrets/` are gitignored. Never commit them. -- Don't patch `@moduletrace/twui` in `node_modules` - it is a shared publishable library. Appearance overrides belong in `src/styles/main.css`. \ No newline at end of file +CI runs type-check and tests on every push and pull request. Releases are +built by GitHub Actions and pushed to both registries. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/deploy/deploy-prod.sh b/deploy/deploy-prod.sh deleted file mode 100755 index 1d57423..0000000 --- a/deploy/deploy-prod.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/bin/bash -set -euo pipefail - -PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -ENV_FILE="$PROJECT_ROOT/.env" - -read_env_var() { - local key="$1" line value - line="$(grep -E "^[[:space:]]*(export[[:space:]]+)?${key}=" "$ENV_FILE" | tail -n 1 || true)" - value="${line#*=}" - value="${value%$'\r'}" - if [[ "$value" =~ ^\"(.*)\"$ ]] || [[ "$value" =~ ^\'(.*)\'$ ]]; then - value="${BASH_REMATCH[1]}" - fi - printf '%s' "$value" -} - -if [ ! -f "$ENV_FILE" ]; then - echo "Error: $ENV_FILE not found." >&2 - exit 1 -fi - -DEPLOY_NAME="cobble" -SOURCE_PATH="${SOURCE_DIRECTORY:-$(read_env_var SOURCE_DIRECTORY)}" -SERVER_IP="${DEPLOY_SERVER_IP:-$(read_env_var DEPLOY_SERVER_IP)}" -SSH_USER="${SSH_USER:-$(read_env_var SSH_USER)}" -SSH_USER="${SSH_USER:-root}" -SERVER_PATH="/docker/$DEPLOY_NAME" -SSH_KEY="$HOME/.ssh/tben" -SSH_OPTS=(-i "$SSH_KEY") -REMOTE="$SSH_USER@$SERVER_IP" - -if [ ! -d "$SOURCE_PATH" ] || [ "$(realpath -m "$SOURCE_PATH")" = "/" ]; then - echo "Error: SOURCE_DIRECTORY is invalid, missing, or set to root path ('/')." >&2 - exit 1 -fi - -if [ -z "$SERVER_IP" ]; then - echo "Error: DEPLOY_SERVER_IP is not set (shell env or $ENV_FILE)." >&2 - exit 1 -fi - -if [ ! -f "$SOURCE_PATH/secrets/prod.env" ]; then - echo "Error: $SOURCE_PATH/secrets/prod.env not found." >&2 - exit 1 -fi - -cd "$SOURCE_PATH" - -rsync -avz -e "ssh -i $SSH_KEY" --delete \ - --exclude='secrets/' --exclude='.env' \ - --filter=':- .gitignore' \ - "$SOURCE_PATH/" "$REMOTE:$SERVER_PATH/" - -rsync -avz -e "ssh -i $SSH_KEY" --chmod=F600 \ - "$SOURCE_PATH/secrets/prod.env" "$REMOTE:$SERVER_PATH/.env" - -ssh "${SSH_OPTS[@]}" "$REMOTE" bash -s -- "$DEPLOY_NAME" << 'EOF' -set -euo pipefail -DEPLOY_NAME="$1" - -cd "/docker/$DEPLOY_NAME" -COBBLE_DATA_DIR="/data/$DEPLOY_NAME/.data" COBBLE_EXTRA_NETWORK=cloudflare \ - ./deploy/run-app-container.sh -EOF \ No newline at end of file diff --git a/deploy/run-app-container.sh b/deploy/run-app-container.sh deleted file mode 100755 index f2f310a..0000000 --- a/deploy/run-app-container.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash -# Builds the app's image and (re)starts its container. Rerun it to update: -# the database, media and computers' home directories (the data dir) and -# .env stay on the host. The container needs the docker socket, since it -# creates every computer as a container on the cobble network. -# -# COBBLE_DATA_DIR host data dir, mounted at /data (default /data/cobble) -# COBBLE_BIND host address the app is published on (default 127.0.0.1) -# COBBLE_EXTRA_NETWORK another docker network to join, e.g. a tunnel's -set -euo pipefail - -PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -ENV_FILE="$PROJECT_ROOT/.env" -NAME="cobble" -NETWORK="cobble" -DATA_PATH="${COBBLE_DATA_DIR:-/data/cobble}" -BIND="${COBBLE_BIND:-127.0.0.1}" -DOCKER_SOCKET="/var/run/docker.sock" - -if [ ! -f "$ENV_FILE" ]; then - echo "Error: $ENV_FILE not found. Copy .env.example and set ENCRYPTION_KEY and ENCRYPTION_SALT." >&2 - exit 1 -fi - -if [ ! -S "$DOCKER_SOCKET" ]; then - echo "Error: no docker socket at $DOCKER_SOCKET. Computers run in docker, so the app needs it." >&2 - exit 1 -fi - -PORT="$(grep -E '^[[:space:]]*PORT=' "$ENV_FILE" | tail -n 1 | cut -d= -f2- | tr -d "\"' \r")" -PORT="${PORT:-7227}" - -mkdir -p "$DATA_PATH" - -docker network inspect "$NETWORK" >/dev/null 2>&1 || docker network create "$NETWORK" >/dev/null - -docker build -t "$NAME" "$PROJECT_ROOT" - -docker stop -t 30 "$NAME" >/dev/null 2>&1 || true -docker rm -f "$NAME" >/dev/null 2>&1 || true - -docker run --name "$NAME" -d \ - --network "$NETWORK" \ - -p "$BIND:$PORT:$PORT" \ - -v "$ENV_FILE:/app/.env:ro" \ - -v "$DATA_PATH:/data" \ - -v "$DOCKER_SOCKET:$DOCKER_SOCKET" \ - -e MAIN_SERVER=1 \ - --cpus 1 --memory 1g \ - --restart unless-stopped \ - "$NAME:latest" - -if [ -n "${COBBLE_EXTRA_NETWORK:-}" ]; then - docker network connect "$COBBLE_EXTRA_NETWORK" "$NAME" -fi - -docker image prune -f >/dev/null - -echo "Cobble is starting on http://$BIND:$PORT (data in $DATA_PATH)" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..8c2b3c4 --- /dev/null +++ b/install.sh @@ -0,0 +1,706 @@ +#!/usr/bin/env bash +# Cobble installer and updater. +# +# curl -fsSL https://raw.githubusercontent.com/moduletrace/cobble/main/install.sh | sudo bash +# +# The script is idempotent: running it again updates Cobble in place and +# keeps the data, the database and the generated secrets. It installs +# Docker when it is missing, creates the cobble network and starts the app +# container with the docker socket mounted, so the app can create the +# computers it runs. +set -Eeuo pipefail + +CONTAINER="cobble" +NETWORK="cobble" +DEFAULT_PORT="7227" +DEFAULT_BIND="0.0.0.0" +DEFAULT_IMAGE="ghcr.io/moduletrace/cobble" +DEFAULT_VERSION="latest" +INSTALL_URL="https://raw.githubusercontent.com/moduletrace/cobble/main/install.sh" +HEALTH_TIMEOUT_SECONDS="${COBBLE_HEALTH_TIMEOUT:-900}" +BACKUPS_KEPT=5 + +if [ -t 1 ]; then + C_RESET=$'\033[0m' + C_BOLD=$'\033[1m' + C_DIM=$'\033[2m' + C_RED=$'\033[31m' + C_GREEN=$'\033[32m' + C_YELLOW=$'\033[33m' + C_BLUE=$'\033[34m' +else + C_RESET="" + C_BOLD="" + C_DIM="" + C_RED="" + C_GREEN="" + C_YELLOW="" + C_BLUE="" +fi + +say() { printf '%s==>%s %s\n' "$C_BLUE" "$C_RESET" "$*"; } +ok() { printf '%s ok%s %s\n' "$C_GREEN" "$C_RESET" "$*"; } +warn() { printf '%swarn%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; } +fail() { + printf '%sfail%s %s\n' "$C_RED" "$C_RESET" "$*" >&2 + exit 1 +} + +on_error() { + local code=$? + printf '%sfail%s line %s exited with status %s\n' "$C_RED" "$C_RESET" "${BASH_LINENO[0]:-?}" "$code" >&2 + exit "$code" +} +trap on_error ERR + +usage() { + cat </dev/null | awk '{print $2}')" + [ -n "$user_home" ] && HOME_DIR="$user_home/.cobble" +fi + +if [ -z "$HOME_DIR" ]; then + if [ "$(uname -s)" = "Darwin" ]; then + HOME_DIR="$HOME/.cobble" + else + HOME_DIR="/opt/cobble" + fi +fi + +case "$HOME_DIR" in + /*) ;; + *) fail "--home must be an absolute path" ;; +esac + +ENV_FILE="$HOME_DIR/.env" +STATE_FILE="$HOME_DIR/install.conf" + +if [ -f "$STATE_FILE" ]; then + # shellcheck disable=SC1090 + . "$STATE_FILE" +fi + +DATA_DIR="${COBBLE_DATA_DIR:-}" +PORT="${COBBLE_PORT:-}" +BIND="${COBBLE_BIND:-}" +IMAGE="${COBBLE_IMAGE:-}" +VERSION="${COBBLE_VERSION:-}" +EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}" +DOCKER_SOCKET="${COBBLE_DOCKER_SOCKET:-${DOCKER_SOCKET:-/var/run/docker.sock}}" + +ACTION="install" +FORCE=0 +NO_PULL=0 +NO_DOCKER_INSTALL=0 +SKIP_BACKUP=0 +PURGE=0 + +env_get() { + local key="$1" + [ -f "$ENV_FILE" ] || return 0 + sed -nE "s/^[[:space:]]*${key}=(.*)$/\1/p" "$ENV_FILE" | tail -n 1 | sed -e 's/^"//' -e 's/"$//' +} + +if [ ! -f "$STATE_FILE" ] && [ -f "$ENV_FILE" ] && [ -z "$PORT" ]; then + PORT="$(env_get PORT)" +fi + +DATA_DIR="${DATA_DIR:-$HOME_DIR/data}" +PORT="${PORT:-$DEFAULT_PORT}" +BIND="${BIND:-$DEFAULT_BIND}" +IMAGE="${IMAGE:-$DEFAULT_IMAGE}" +VERSION="${VERSION:-$DEFAULT_VERSION}" + +while [ $# -gt 0 ]; do + case "$1" in + --home) + HOME_DIR="${2:-}" + shift 2 + ;; + --data-dir) + DATA_DIR="${2:-}" + shift 2 + ;; + --port) + PORT="${2:-}" + shift 2 + ;; + --bind) + BIND="${2:-}" + shift 2 + ;; + --image) + IMAGE="${2:-}" + shift 2 + ;; + --version) + VERSION="${2:-}" + shift 2 + ;; + --extra-network) + EXTRA_NETWORK="${2:-}" + shift 2 + ;; + --force) + FORCE=1 + shift + ;; + --no-pull) + NO_PULL=1 + shift + ;; + --skip-backup) + SKIP_BACKUP=1 + shift + ;; + --no-docker-install) + NO_DOCKER_INSTALL=1 + shift + ;; + --uninstall) + ACTION="uninstall" + shift + ;; + --purge) + PURGE=1 + shift + ;; + --help | -h) + usage + exit 0 + ;; + *) + fail "Unknown option: $1. Use --help." + ;; + esac +done + +[ -n "$HOME_DIR" ] || fail "--home needs a value" +[ -n "$DATA_DIR" ] || fail "--data-dir needs a value" +[ -n "$PORT" ] || fail "--port needs a value" +[ -n "$BIND" ] || fail "--bind needs a value" +[ -n "$IMAGE" ] || fail "--image needs a value" +[ -n "$VERSION" ] || fail "--version needs a value" + +case "$HOME_DIR" in + /*) ;; + *) fail "--home must be an absolute path" ;; +esac + +case "$DATA_DIR" in + /*) ;; + *) fail "--data-dir must be an absolute path" ;; +esac + +case "$PORT" in + '' | *[!0-9]*) fail "--port must be a number" ;; +esac +[ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || fail "--port must be between 1 and 65535" +case "$BIND" in + '' | *[!0-9a-fA-F:.]*) fail "--bind must be an IP address" ;; +esac +case "$IMAGE" in + '' | *[!A-Za-z0-9._/-]*) fail "--image may only contain letters, numbers, dot, dash, underscore and slash" ;; +esac +case "$VERSION" in + '' | *[!A-Za-z0-9._-]*) fail "--version may only contain letters, numbers, dot, dash and underscore" ;; +esac +if [ -n "$EXTRA_NETWORK" ]; then + case "$EXTRA_NETWORK" in + *[!A-Za-z0-9_.-]*) fail "--extra-network is not a valid network name" ;; + esac +fi +if [ "$PURGE" = 1 ] && [ "$ACTION" != "uninstall" ]; then + fail "--purge only works with --uninstall" +fi + +ENV_FILE="$HOME_DIR/.env" +STATE_FILE="$HOME_DIR/install.conf" +DATA_DIR="${DATA_DIR%/}" +BACKUP_DIR="$HOME_DIR/backups" + +# ---------------------------------------------------------------- platform + +OS="$(uname -s)" +case "$OS" in + Linux | Darwin) ;; + *) fail "Unsupported operating system: $OS. Cobble runs on Linux and macOS with Docker." ;; +esac + +case "$(uname -m)" in + x86_64 | amd64) ARCH="amd64" ;; + aarch64 | arm64) ARCH="arm64" ;; + *) fail "Unsupported architecture: $(uname -m)" ;; +esac + +if [ "$OS" = "Linux" ] && [ "$(id -u)" -ne 0 ]; then + fail "Run the installer as root: curl -fsSL $INSTALL_URL | sudo bash" +fi + +SELINUX_SUFFIX="" +if [ "$OS" = "Linux" ]; then + if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null || true)" != "Disabled" ]; then + SELINUX_SUFFIX=",z" + elif [ -e /sys/fs/selinux/enforce ]; then + SELINUX_SUFFIX=",z" + fi +fi + +ENV_MOUNT="$ENV_FILE:/app/.env:ro" +DATA_MOUNT="$DATA_DIR:/data" +if [ -n "$SELINUX_SUFFIX" ]; then + ENV_MOUNT="$ENV_FILE:/app/.env:ro,z" + DATA_MOUNT="$DATA_DIR:/data,z" +fi + +# ---------------------------------------------------------------- helpers + +gen_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + else + od -An -N32 -tx1 /dev/urandom | tr -d ' \n' + fi +} + +env_has() { + [ -f "$ENV_FILE" ] && grep -qE "^[[:space:]]*$1=" "$ENV_FILE" +} + +ensure_env() { + local key="$1" value="$2" + if ! env_has "$key"; then + printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE" + fi +} + +ensure_secret() { + local key="$1" value + value="$(env_get "$key")" + if [ -z "$value" ]; then + set_env "$key" "$(gen_secret)" + fi +} + +set_env() { + local key="$1" value="$2" escaped + escaped="$(printf '%s' "$value" | sed -e 's/[\/&]/\\&/g')" + if env_has "$key"; then + sed -i.bak -E "s|^[[:space:]]*${key}=.*$|${key}=${escaped}|" "$ENV_FILE" + rm -f "$ENV_FILE.bak" + else + printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE" + fi +} + +container_exists() { + docker container inspect "$CONTAINER" >/dev/null 2>&1 +} + +container_running() { + [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" = "true" ] +} + +container_image_id() { + docker inspect -f '{{.Image}}' "$CONTAINER" +} + +port_in_use() { + if [ "$OS" = "Darwin" ]; then + lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1 + elif command -v ss >/dev/null 2>&1; then + ss -ltnH "sport = :$PORT" 2>/dev/null | grep -q . + elif command -v netstat >/dev/null 2>&1; then + netstat -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$PORT$" + else + return 1 + fi +} + +docker_socket_mounted() { + docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/run/docker.sock"}}{{.Source}}{{end}}{{end}}' "$CONTAINER" +} + +container_matches() { + container_running || return 1 + [ "$(container_image_id)" = "$TARGET_IMAGE_ID" ] || return 1 + [ "$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER")" = "$DATA_DIR" ] || return 1 + [ "$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/app/.env"}}{{.Source}}{{end}}{{end}}' "$CONTAINER")" = "$ENV_FILE" ] || return 1 + [ "$(docker_socket_mounted)" = "$DOCKER_SOCKET" ] || return 1 + [ "$(docker inspect -f '{{with index .HostConfig.PortBindings "'"$PORT"'/tcp"}}{{range .}}{{.HostIp}}:{{.HostPort}}{{end}}{{end}}' "$CONTAINER")" = "$BIND:$PORT" ] || return 1 + [ "$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$CONTAINER")" = "unless-stopped" ] || return 1 + docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "MAIN_SERVER=1" || return 1 + docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "PORT=$PORT" || return 1 + docker inspect -f '{{range $name, $net := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$CONTAINER" | grep -qx "$NETWORK" || return 1 + if [ -n "$EXTRA_NETWORK" ]; then + docker inspect -f '{{range $name, $net := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$CONTAINER" | grep -qx "$EXTRA_NETWORK" || return 1 + fi + return 0 +} + +health_host() { + case "$BIND" in + 0.0.0.0 | :: | "") printf '127.0.0.1' ;; + *) printf '%s' "$BIND" ;; + esac +} + +host_ips() { + if [ "$OS" = "Darwin" ]; then + ipconfig getifaddr en0 2>/dev/null || true + elif command -v hostname >/dev/null 2>&1; then + hostname -I 2>/dev/null | tr ' ' '\n' | grep -vE '^$|^127\.' | head -n 3 || true + fi +} + +wait_for_health() { + local url="http://$(health_host):$PORT/api/health" start=0 + say "Waiting for Cobble to answer at $url" + while [ "$start" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do + if curl -fsS --max-time 3 -o /dev/null "$url" 2>/dev/null; then + ok "Cobble is healthy" + return 0 + fi + if ! container_running; then + docker logs --tail 80 "$CONTAINER" 2>&1 || true + fail "The Cobble container stopped during startup" + fi + sleep 3 + start=$((start + 3)) + done + docker logs --tail 120 "$CONTAINER" 2>&1 || true + fail "Cobble did not become healthy within ${HEALTH_TIMEOUT_SECONDS}s" +} + +backup_database() { + local data_source="$DATA_DIR" + if container_exists; then + local mounted + mounted="$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER" 2>/dev/null || true)" + [ -n "$mounted" ] && data_source="$mounted" + fi + + [ -f "$data_source/db/cobble" ] || return 0 + + mkdir -p "$BACKUP_DIR" + say "Backing up the database" + + local backup_mount="$data_source:/data" + if [ -n "$SELINUX_SUFFIX" ]; then + backup_mount="$backup_mount,z" + fi + + local image_id="" + if container_running; then + if ! docker exec "$CONTAINER" bunx bun-sqlite backup >/dev/null 2>&1; then + [ "$SKIP_BACKUP" = 1 ] && { + warn "Could not back up the database, continuing because of --skip-backup" + return 0 + } + fail "Could not back up the database. Use --skip-backup to update anyway." + fi + elif container_exists; then + image_id="$(container_image_id)" + if ! docker run --rm --entrypoint bunx \ + -v "$backup_mount" -e DB_DIR=/data/db \ + "$image_id" bun-sqlite backup >/dev/null 2>&1; then + [ "$SKIP_BACKUP" = 1 ] && { + warn "Could not back up the database, continuing because of --skip-backup" + return 0 + } + fail "Could not back up the database. Use --skip-backup to update anyway." + fi + else + return 0 + fi + + local newest + newest="$(ls -1t "$data_source"/db/.backups/cobble-* 2>/dev/null | head -n 1 || true)" + if [ -z "$newest" ]; then + warn "No database backup file was written" + return 0 + fi + + cp -a "$newest" "$BACKUP_DIR/$(basename "$newest")" 2>/dev/null || true + ok "Backup: $BACKUP_DIR/$(basename "$newest")" + + local old + old="$(ls -1t "$BACKUP_DIR"/cobble-* 2>/dev/null | tail -n +$((BACKUPS_KEPT + 1)) || true)" + if [ -n "$old" ]; then + printf '%s\n' "$old" | while IFS= read -r file; do + rm -f "$file" + done + fi +} + +safe_remove_dir() { + local dir="$1" + case "$dir" in + / | /opt | /home | /root | /var | /usr | /etc | /data | /mnt | /srv | /tmp) + fail "Refusing to delete $dir" + ;; + esac + case "$dir" in + /*/*) ;; + *) fail "Refusing to delete $dir" ;; + esac + rm -rf "$dir" +} + +connect_extra_network() { + [ -n "$EXTRA_NETWORK" ] || return 0 + docker network inspect "$EXTRA_NETWORK" >/dev/null 2>&1 || fail "Network '$EXTRA_NETWORK' does not exist" + docker network connect "$EXTRA_NETWORK" "$CONTAINER" >/dev/null 2>&1 || true +} + +run_container() { + docker run -d \ + --name "$CONTAINER" \ + --restart unless-stopped \ + --init \ + --network "$NETWORK" \ + -p "$BIND:$PORT:$PORT" \ + -v "$ENV_MOUNT" \ + -v "$DATA_MOUNT" \ + -v "$DOCKER_SOCKET:/var/run/docker.sock" \ + -e MAIN_SERVER=1 \ + -e PORT="$PORT" \ + --log-driver json-file \ + --log-opt max-size=10m \ + --log-opt max-file=3 \ + "$IMAGE:$VERSION" >/dev/null +} + +# ---------------------------------------------------------------- docker + +ensure_docker() { + if ! command -v docker >/dev/null 2>&1; then + if [ "$NO_DOCKER_INSTALL" = 1 ]; then + fail "Docker is not installed. Install it and rerun, or drop --no-docker-install." + fi + if [ "$OS" != "Linux" ]; then + fail "Docker is not installed. Install Docker Desktop, then rerun." + fi + say "Installing Docker" + local script + script="$(mktemp)" + curl -fsSL https://get.docker.com -o "$script" + sh "$script" + rm -f "$script" + fi + + if ! docker info >/dev/null 2>&1; then + if [ "$OS" = "Darwin" ]; then + fail "Docker Desktop is not running. Start it, then rerun." + fi + if command -v systemctl >/dev/null 2>&1; then + systemctl enable --now docker >/dev/null 2>&1 || true + elif command -v service >/dev/null 2>&1; then + service docker start >/dev/null 2>&1 || true + fi + fi + + docker info >/dev/null 2>&1 || fail "Docker is installed but not running" + + if [ ! -S "$DOCKER_SOCKET" ]; then + fail "No docker socket at $DOCKER_SOCKET" + fi +} + +# ---------------------------------------------------------------- uninstall + +uninstall() { + if container_exists; then + say "Stopping Cobble" + docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + fi + + local computers + computers="$(docker ps -aq --filter 'label=cobble.computer_id' 2>/dev/null || true)" + if [ -n "$computers" ]; then + say "Removing computer containers" + while IFS= read -r id; do + [ -n "$id" ] && docker rm -f "$id" >/dev/null 2>&1 || true + done <<<"$computers" + fi + + if docker network inspect "$NETWORK" >/dev/null 2>&1; then + local attached + attached="$(docker network inspect -f '{{len .Containers}}' "$NETWORK" 2>/dev/null || echo 1)" + if [ "$attached" = "0" ]; then + docker network rm "$NETWORK" >/dev/null 2>&1 || true + fi + fi + + if [ "$PURGE" = 1 ]; then + safe_remove_dir "$HOME_DIR" + if [ "$DATA_DIR" != "$HOME_DIR" ] && [ "$DATA_DIR" != "$HOME_DIR/data" ]; then + safe_remove_dir "$DATA_DIR" + fi + ok "Removed Cobble and its data" + else + ok "Cobble stopped. Data kept in $HOME_DIR" + fi + exit 0 +} + +# ---------------------------------------------------------------- main + +command -v curl >/dev/null 2>&1 || fail "curl is required" + +ensure_docker + +if [ "$ACTION" = "uninstall" ]; then + uninstall +fi + +mkdir -p "$HOME_DIR" "$DATA_DIR" "$BACKUP_DIR" +chmod 755 "$HOME_DIR" "$DATA_DIR" "$BACKUP_DIR" + +if ! docker network inspect "$NETWORK" >/dev/null 2>&1; then + say "Creating the $NETWORK network" + docker network create "$NETWORK" >/dev/null +fi + +if [ -n "$EXTRA_NETWORK" ]; then + docker network inspect "$EXTRA_NETWORK" >/dev/null 2>&1 || fail "Network '$EXTRA_NETWORK' does not exist" +fi + +if [ ! -f "$ENV_FILE" ]; then + (umask 077 + cat >"$ENV_FILE" <"$STATE_FILE" +chmod 600 "$STATE_FILE" + +if ! container_exists && port_in_use; then + fail "Port $PORT is already in use. Pass --port with a free port." +fi + +if [ "$NO_PULL" = 1 ]; then + docker image inspect "$IMAGE:$VERSION" >/dev/null 2>&1 || fail "Image $IMAGE:$VERSION is not available locally" +else + say "Pulling $IMAGE:$VERSION" + if ! docker pull "$IMAGE:$VERSION" >/dev/null; then + fail "Could not pull $IMAGE:$VERSION" + fi +fi +TARGET_IMAGE_ID="$(docker image inspect -f '{{.Id}}' "$IMAGE:$VERSION")" + +if [ "$FORCE" = 0 ] && container_matches; then + connect_extra_network + if curl -fsS --max-time 3 -o /dev/null "http://$(health_host):$PORT/api/health" 2>/dev/null; then + ok "Cobble is already up to date" + say "Healthy at http://$(health_host):$PORT" + exit 0 + fi + warn "The container is running but not answering; recreating it" +fi + +if container_exists; then + backup_database + say "Replacing the Cobble container" + docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true +fi + +say "Starting Cobble" +run_container +connect_extra_network + +wait_for_health + +ok "Cobble is running" +printf '\n' +printf ' %s%s%s\n' "$C_BOLD" "Open Cobble" "$C_RESET" +if [ "$BIND" = "0.0.0.0" ]; then + while IFS= read -r ip; do + [ -n "$ip" ] && printf ' http://%s:%s\n' "$ip" "$PORT" + done < <(host_ips) +fi +printf ' http://127.0.0.1:%s\n' "$PORT" +printf '\n' +printf ' First visit: create the super admin account.\n' +printf ' Data: %s\n' "$DATA_DIR" +printf ' Settings: %s\n' "$ENV_FILE" +printf ' Backups: %s\n' "$BACKUP_DIR" +printf ' Logs: docker logs -f %s\n' "$CONTAINER" +printf ' Update: curl -fsSL %s | sudo bash\n' "$INSTALL_URL" +printf ' Uninstall: curl -fsSL %s | sudo bash -s -- --uninstall\n' "$INSTALL_URL" +printf '\n' +if [ "$BIND" = "0.0.0.0" ]; then + printf ' %sThis port is reachable on your network. Put HTTPS in front before exposing it to the internet.%s\n\n' "$C_DIM" "$C_RESET" +fi diff --git a/package.json b/package.json index 820f9e5..af88b0d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "db:backup": "bunx bun-sqlite backup", "seed": "bun src/scripts/seed-super-admin.tsx", "seed:bots": "bun src/scripts/seed-bots.ts", - "deploy:prod": "./deploy/deploy-prod.sh", "start": "NODE_ENV=production bun src/server.ts", "typecheck": "tsc --noEmit", "test": "bun test"