From 531210c974292aef421dc43df39d4111fbe1195c Mon Sep 17 00:00:00 2001 From: Birfy Date: Tue, 20 Oct 2026 11:00:29 +0800 Subject: [PATCH 1/2] Add a Dockerfile, compose file and guide to run FlyBot in a container (#352) --- .dockerignore | 11 +++ .github/scripts/docker-profile.test.mjs | 92 +++++++++++++++++++ .github/workflows/docker.yml | 69 +++++++++++++++ Dockerfile | 39 +++++++++ README.md | 2 + README.zh.md | 2 + changelog.d/352-docker.added.md | 1 + compose.yaml | 22 +++++ docker/entrypoint.mjs | 23 +++++ docker/healthcheck.mjs | 8 ++ docker/profile.mjs | 48 ++++++++++ docker/web.patch.yml | 14 +++ docs/docker.md | 112 ++++++++++++++++++++++++ docs/docker.zh.md | 112 ++++++++++++++++++++++++ 14 files changed, 555 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/scripts/docker-profile.test.mjs create mode 100644 .github/workflows/docker.yml create mode 100644 Dockerfile create mode 100644 changelog.d/352-docker.added.md create mode 100644 compose.yaml create mode 100644 docker/entrypoint.mjs create mode 100644 docker/healthcheck.mjs create mode 100644 docker/profile.mjs create mode 100644 docker/web.patch.yml create mode 100644 docs/docker.md create mode 100644 docs/docker.zh.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ad386623 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# Only what the image build needs (#352): the packages, their pack check and docker/. +.git +**/node_modules +**/.dsh* +**/.env +**/.credentials.yaml +core/dev/harness/node_modules +ds-bot +docs/images +*.log +/deepseek-harness diff --git a/.github/scripts/docker-profile.test.mjs b/.github/scripts/docker-profile.test.mjs new file mode 100644 index 00000000..91b939f2 --- /dev/null +++ b/.github/scripts/docker-profile.test.mjs @@ -0,0 +1,92 @@ +// docker/: the container's web profile (#352) and the files the image and compose rely on. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { imagePackages, linkPackages, prepareProfile } from '../../docker/profile.mjs' + +const root = new URL('../../', import.meta.url) +const read = (path) => readFileSync(new URL(path, root), 'utf8') + +function packagesDir() { + const dir = mkdtempSync(join(tmpdir(), 'flybot-pkgs-')) + for (const [folder, name] of [['ds-bot-0.2.1', 'ds-bot'], ['flybot-tree-0.1.0', '@flybot/tree'], ['flybot-social-0.1.0', '@flybot/social']]) { + mkdirSync(join(dir, folder)) + writeFileSync(join(dir, folder, 'package.json'), JSON.stringify({ name })) + } + mkdirSync(join(dir, 'not-a-package')) + return dir +} + +test('imagePackages lists the unpacked packages by name', () => { + const dir = packagesDir() + try { + assert.deepEqual(imagePackages(dir).map((p) => p.name), ['@flybot/social', '@flybot/tree', 'ds-bot']) + assert.equal(imagePackages(dir).find((p) => p.name === 'ds-bot').dir, join(dir, 'ds-bot-0.2.1')) + } finally { rmSync(dir, { recursive: true, force: true }) } +}) + +test('a new profile gets the web bundles plus every image package, linked', () => { + const next = linkPackages(undefined, [{ name: 'ds-bot', dir: '/opt/p/core' }, { name: '@flybot/tree', dir: '/opt/p/tree' }]) + assert.deepEqual(next.dependencies, { 'ds-bot': 'link:/opt/p/core', '@flybot/tree': 'link:/opt/p/tree' }) + assert.deepEqual(next.dsh.profile.bundles, ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'ds-bot', '@flybot/tree']) +}) + +test('an existing profile keeps your own plugins and bundle order; old FlyBot entries are relinked', () => { + const current = { + name: 'dsh-profile-web', + private: true, + dependencies: { 'ds-bot': 'file:/old/ds-bot-0.2.0.tgz', 'my-plugin': '^1.0.0' }, + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'my-plugin', 'ds-bot'] }, other: true }, + } + const before = structuredClone(current) + const next = linkPackages(current, [{ name: 'ds-bot', dir: '/opt/p/core' }, { name: '@flybot/social', dir: '/opt/p/social' }]) + assert.deepEqual(current, before, 'the input is not changed') + assert.deepEqual(next.dependencies, { 'ds-bot': 'link:/opt/p/core', 'my-plugin': '^1.0.0', '@flybot/social': 'link:/opt/p/social' }) + assert.deepEqual(next.dsh.profile.bundles, ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'my-plugin', 'ds-bot', '@flybot/social']) + assert.equal(next.dsh.other, true) +}) + +test('prepareProfile writes package.json and pnpm-workspace.yaml and keeps your settings', () => { + const pkgs = packagesDir() + const home = mkdtempSync(join(tmpdir(), 'flybot-home-')) + try { + const profile = join(home, 'profiles', 'web') + mkdirSync(profile, { recursive: true }) + writeFileSync(join(profile, 'cordis.patch.yml'), '- id: flybot-social\n') + writeFileSync(join(profile, 'pnpm-workspace.yaml'), 'packages:\n - .\n# mine\n') + assert.equal(prepareProfile(home, pkgs), profile) + const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) + assert.equal(manifest.dependencies['ds-bot'], `link:${join(pkgs, 'ds-bot-0.2.1')}`) + assert.equal(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8'), '- id: flybot-social\n') + assert.equal(readFileSync(join(profile, 'pnpm-workspace.yaml'), 'utf8'), 'packages:\n - .\n# mine\n') + prepareProfile(home, pkgs) + assert.deepEqual(JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')), manifest, 'a second start changes nothing') + rmSync(join(profile, 'pnpm-workspace.yaml')) + prepareProfile(home, pkgs) + assert.match(readFileSync(join(profile, 'pnpm-workspace.yaml'), 'utf8'), /nodeLinker: hoisted\nautoInstallPeers: false/) + } finally { + rmSync(pkgs, { recursive: true, force: true }) + rmSync(home, { recursive: true, force: true }) + } +}) + +test('secure defaults: loopback port, non-root user, no secret in the image, no registry push', () => { + const compose = read('compose.yaml') + assert.match(compose, /^ {6}- "127\.0\.0\.1:\$\{FLYBOT_PORT:-3080\}:3080"$/m, 'the port is published on the host loopback only') + assert.match(compose, /^ {6}- data:\/data$/m, 'one data volume') + assert.match(compose, /^ {6}- DEEPSEEK_API_KEY$/m, 'the key is passed through, never written into the file') + const dockerfile = read('Dockerfile') + assert.match(dockerfile, /^USER node$/m) + assert.match(dockerfile, /^VOLUME \["\/data"\]$/m) + assert.match(dockerfile, /^HEALTHCHECK /m) + assert.doesNotMatch(dockerfile, /^(ENV|ARG)\s.*(KEY|TOKEN|SECRET)/im, 'no key in the image') + const pinned = JSON.parse(read('core/dev/harness/package.json')).dependencies['@deepseek-ai/dsh'] + assert.match(dockerfile, new RegExp(`^ARG DSH_VERSION=${pinned.replace(/\./g, '\\.')}$`, 'm'), 'DSH is pinned like the tests and the README') + const ignore = read('.dockerignore') + for (const entry of ['**/.env', '**/.credentials.yaml', '**/.dsh*', '.git']) assert.ok(ignore.split('\n').includes(entry), `.dockerignore lists ${entry}`) + const workflow = read('.github/workflows/docker.yml') + assert.doesNotMatch(workflow, /docker (push|login)|docker\/(login|build-push)-action|packages: write/, 'CI builds and checks only') + assert.match(read('docker/web.patch.yml'), /^ {4}host: 0\.0\.0\.0$/m) +}) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..ca3bb619 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,69 @@ +# Container image check (#352): builds the image from this checkout and runs a start check +# (compose up until the health check passes, the page answers on the host's loopback, the +# process is not root). Nothing is pushed to a registry and nothing is deployed. +name: Docker + +on: + pull_request: + paths: + - Dockerfile + - compose.yaml + - .dockerignore + - docker/** + - .github/scripts/check-pack.mjs + - .github/workflows/docker.yml + push: + branches: [main] + paths: + - Dockerfile + - compose.yaml + - .dockerignore + - docker/** + - .github/scripts/check-pack.mjs + - .github/workflows/docker.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: true + +jobs: + image: + name: build and start check + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + FLYBOT_TAG: ci + FLYBOT_PORT: '3080' + COMPOSE_PROJECT_NAME: flybot-ci + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Build the image + run: docker compose build + - name: Start and wait for the health check + run: docker compose up -d --wait --wait-timeout 180 + - name: Start check + run: | + set -eu + # Unauthenticated, the web server answers 401 on the host's loopback port. + code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$FLYBOT_PORT/") + echo "GET / -> $code" + [ "$code" = 401 ] + # The sign-in link was printed (checked, never shown: it carries the token). + docker compose logs flybot | grep -q 'dsh web: http://127.0.0.1:3080/?token=' + # Not root, and the FlyBot packages are linked from the image. + uid=$(docker compose exec -T flybot id -u) + echo "uid: $uid" + [ "$uid" != 0 ] + docker compose exec -T flybot node -e "const p=require('/data/profiles/web/package.json');const d=p.dependencies;for(const n of ['ds-bot','@flybot/social','@flybot/tree','@flybot/timeline'])if(!String(d[n]).startsWith('link:/opt/flybot/packages/'))throw new Error(n+' is not linked: '+d[n]);console.log('linked:',Object.keys(d).join(', '))" + - name: Logs (sign-in token removed) + if: always() + run: docker compose logs flybot 2>&1 | sed -E 's/token=[^ &)]*/token=REDACTED/g' | tail -n 60 + - name: Stop + if: always() + run: docker compose down -v diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..af4d0ca5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# FlyBot in a container (#352): one person, one instance. `dsh web` with the FlyBot core, +# social, tree and timeline packages of this checkout. Build it yourself; nothing is published. +# docker compose up -d --build (see docs/docker.md) +# No keys or tokens are baked in: DEEPSEEK_API_KEY comes from the environment or from the +# credentials file on the data volume. + +ARG NODE_IMAGE=node:24-bookworm-slim + +# Stage 1: pack the packages exactly as a DSH profile installs them (the release tarballs). +FROM ${NODE_IMAGE} AS pack +WORKDIR /src +COPY . . +RUN node .github/scripts/check-pack.mjs /pkgs \ + && for tgz in /pkgs/*.tgz; do \ + dir="/out/$(basename "$tgz" .tgz)"; mkdir -p "$dir" && tar -xzf "$tgz" -C "$dir" --strip-components=1; \ + done + +# Stage 2: the runtime. +FROM ${NODE_IMAGE} +# DSH as pinned in the README; pnpm is what `dsh plugin` uses to manage the profile. +ARG DSH_VERSION=0.2.0-rc.2 +ARG PNPM_VERSION=10.34.6 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git tini \ + && rm -rf /var/lib/apt/lists/* \ + && npm install -g --no-fund --no-audit "@deepseek-ai/dsh@${DSH_VERSION}" "pnpm@${PNPM_VERSION}" \ + && npm cache clean --force +COPY --from=pack /out/ /opt/flybot/packages/ +COPY docker/ /opt/flybot/docker/ +# The node image's unprivileged user (uid 1000). /data is the one volume: DSH home (profile, +# Bots, Sessions, credentials), the pnpm store and the Bots' workspace. +RUN mkdir -p /data && chown node:node /data +ENV DSH_HOME=/data HOME=/data NODE_ENV=production +USER node +WORKDIR /data +VOLUME ["/data"] +EXPOSE 3080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 CMD ["node", "/opt/flybot/docker/healthcheck.mjs"] +ENTRYPOINT ["tini", "--", "node", "/opt/flybot/docker/entrypoint.mjs"] diff --git a/README.md b/README.md index 1ed83328..2dda9a19 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,8 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 web --port 3080 Open the printed `http://127.0.0.1:3080/?token=…` link. The first time you open Chief, pick a model (or open **Models and providers…**). +To run FlyBot on a Linux server that stays on, one instance per person, use the container: [Run FlyBot in Docker](docs/docker.md). + ### 4. Self-host Bot Social (for friends' Bots) `@flybot/social` needs a [Bot Social](https://github.com/Birfy/bot-social) server. It does not ship one. Deploy Bot Social yourself (see its README), then set a public base URL so invite links work: diff --git a/README.zh.md b/README.zh.md index 9cd64140..2f4f4105 100644 --- a/README.zh.md +++ b/README.zh.md @@ -129,6 +129,8 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 web --port 3080 打开打印出的 `http://127.0.0.1:3080/?token=…`。第一次打开 Chief 时选择模型。 +想在一直开着的 Linux 服务器上运行(每人一个实例),可以用容器:[用 Docker 运行 FlyBot](docs/docker.zh.md)。 + ### 4. 自建 Bot Social(好友功能需要) `@flybot/social` **依赖**你自己部署的 [Bot Social](https://github.com/Birfy/bot-social),仓库里不附带服务器。按 Bot Social 的 README 部署,并设置对外地址: diff --git a/changelog.d/352-docker.added.md b/changelog.d/352-docker.added.md new file mode 100644 index 00000000..d6841442 --- /dev/null +++ b/changelog.d/352-docker.added.md @@ -0,0 +1 @@ +FlyBot can run in a container that you build yourself: a `Dockerfile` and `compose.yaml` start `dsh web` with the FlyBot core and plugins as a normal user, with one data volume, no keys in the image, the port on the host's `127.0.0.1` only and a health check. A guide in English and Chinese, [Run FlyBot in Docker](docs/docker.md), covers start, stop, upgrade, roll back and back up; CI builds the image and runs a start check, and nothing is published ([#352](https://github.com/ThinkFlowLab/FlyBot/issues/352)). diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..721add79 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,22 @@ +# FlyBot, one instance per person (#352). Guide: docs/docker.md (中文: docs/docker.zh.md). +# docker compose up -d --build build this checkout and start +# docker compose logs flybot | grep token= the sign-in link +# More instances on one machine: a project name and a port each, for example +# FLYBOT_PORT=3081 docker compose -p alice up -d --build +services: + flybot: + build: . + image: flybot:${FLYBOT_TAG:-local} + restart: unless-stopped + ports: + # Host loopback only. For remote access use your own HTTPS proxy or an SSH tunnel; + # never publish this port on a public address. + - "127.0.0.1:${FLYBOT_PORT:-3080}:3080" + environment: + # Passed only when set in your shell or in .env next to this file; otherwise save the + # key in the app (it is kept on the data volume). + - DEEPSEEK_API_KEY + volumes: + - data:/data +volumes: + data: diff --git a/docker/entrypoint.mjs b/docker/entrypoint.mjs new file mode 100644 index 00000000..1cca78ef --- /dev/null +++ b/docker/entrypoint.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +// Container entry point (#352): link the image's FlyBot packages into the web profile on the +// data volume, then run `dsh web` in the foreground. Keys come from the environment or from +// the credentials file on the volume; nothing secret is baked into the image. +import { execFileSync, spawn } from 'node:child_process' +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { prepareProfile } from './profile.mjs' + +const dshHome = process.env.DSH_HOME || '/data' +const packagesDir = process.env.FLYBOT_PACKAGES || '/opt/flybot/packages' +const profile = prepareProfile(dshHome, packagesDir) +// Links need no download; a plugin you added yourself comes from the pnpm store on the volume. +execFileSync('pnpm', ['install', '--prefer-offline', '--silent'], { cwd: profile, stdio: 'inherit' }) +const workspace = join(dshHome, 'workspace') +mkdirSync(workspace, { recursive: true }) + +// The web server listens on all interfaces of the container only (web.patch.yml); compose +// publishes that port on the host's 127.0.0.1. The sign-in link is DSH's own ?token= link. +const args = ['--profile', 'web', '--patch', join(import.meta.dirname, 'web.patch.yml'), '--no-open', '--port', '3080', ...process.argv.slice(2)] +const child = spawn('dsh', args, { cwd: workspace, stdio: 'inherit' }) +for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill(signal)) +child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0))) diff --git a/docker/healthcheck.mjs b/docker/healthcheck.mjs new file mode 100644 index 00000000..2f781924 --- /dev/null +++ b/docker/healthcheck.mjs @@ -0,0 +1,8 @@ +// Docker health check (#352): healthy when the web server answers. Without the sign-in cookie +// it answers 401, which still proves it is up. +try { + const response = await fetch('http://127.0.0.1:3080/', { redirect: 'manual', signal: AbortSignal.timeout(4000) }) + process.exit(response.status < 500 ? 0 : 1) +} catch { + process.exit(1) +} diff --git a/docker/profile.mjs b/docker/profile.mjs new file mode 100644 index 00000000..bdeff3cb --- /dev/null +++ b/docker/profile.mjs @@ -0,0 +1,48 @@ +// Prepares the DSH `web` profile inside the FlyBot container (#352). The profile lives on the +// data volume ($DSH_HOME/profiles/web), so your settings (cordis.patch.yml) and any plugin you +// added yourself survive an image upgrade. The FlyBot packages are not copied into it: each +// start links them to the code baked into the image, so a new image is a new FlyBot. +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +// What `dsh plugin --profile web add` writes for a new web profile. +const BASE_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] +const WORKSPACE_YAML = 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n' + +/** The FlyBot packages unpacked in the image: [{ name, dir }], sorted by name. */ +export function imagePackages(packagesDir) { + return readdirSync(packagesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && existsSync(join(packagesDir, entry.name, 'package.json'))) + .map((entry) => ({ name: JSON.parse(readFileSync(join(packagesDir, entry.name, 'package.json'), 'utf8')).name, dir: join(packagesDir, entry.name) })) + .sort((a, b) => a.name.localeCompare(b.name)) +} + +/** + * The profile manifest with every image package linked and listed as a bundle. Other + * dependencies and bundles are kept as they are; a FlyBot package that is already a bundle + * keeps its place. + * @param manifest - the current profile package.json, or undefined for a new profile. + * @param packages - from imagePackages(). + */ +export function linkPackages(manifest, packages) { + const next = structuredClone(manifest ?? { name: 'dsh-profile-web', private: true }) + next.dependencies = { ...next.dependencies } + for (const { name, dir } of packages) next.dependencies[name] = `link:${dir}` + next.dsh = { ...next.dsh, profile: { ...next.dsh?.profile } } + const bundles = [...(next.dsh.profile.bundles ?? BASE_BUNDLES)] + for (const { name } of packages) if (!bundles.includes(name)) bundles.push(name) + next.dsh.profile.bundles = bundles + return next +} + +/** Writes the profile files; returns the profile directory. */ +export function prepareProfile(dshHome, packagesDir) { + const dir = join(dshHome, 'profiles', 'web') + mkdirSync(dir, { recursive: true }) + const manifestPath = join(dir, 'package.json') + const current = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : undefined + writeFileSync(manifestPath, JSON.stringify(linkPackages(current, imagePackages(packagesDir)), null, 2) + '\n') + const workspace = join(dir, 'pnpm-workspace.yaml') + if (!existsSync(workspace)) writeFileSync(workspace, WORKSPACE_YAML) + return dir +} diff --git a/docker/web.patch.yml b/docker/web.patch.yml new file mode 100644 index 00000000..92bd36f7 --- /dev/null +++ b/docker/web.patch.yml @@ -0,0 +1,14 @@ +# Container-only overlay (#352). Inside the container DSH listens on every interface of the +# container's own network, so the published port can reach it; compose.yaml publishes that +# port on the host's 127.0.0.1 only. Never publish it on a public address: use your own +# HTTPS proxy or an SSH tunnel (docs/docker.md). A patch replaces the entry's whole config, +# so the other values repeat what the web profile composes. +- id: webserver + inject: + - webStartup + config: + host: 0.0.0.0 + port: !!js ctx.webStartup.port ?? 3080 + compression: gzip + compressionLevel: 1 + compressionThresholdBytes: 1024 diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 00000000..0ddaf610 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,112 @@ +# Run FlyBot in Docker + +[中文](docker.zh.md) + +One person runs one FlyBot on a Linux machine that stays on, so Bots and schedules keep working with the laptop closed. The container runs `dsh web` (DSH 0.2.0-rc.2, as in the README) with the FlyBot core and the social, tree and timeline plugins of the checkout you build from. Instances talk to each other only through [Bot Social](https://github.com/Birfy/bot-social), which is not in the image; host it yourself. + +You build the image yourself. FlyBot does not publish an image. + +## What you get + +- **One data volume**, `/data`: the DSH home (Bots, Sessions, settings, credentials), the Bots' working folder `/data/workspace` and the pnpm store. +- **A normal user**, not root (uid 1000). +- **No keys in the image.** You give the DeepSeek key at run time. +- **The port on `127.0.0.1` only.** For access from another machine use your own HTTPS reverse proxy or an SSH tunnel. Never publish the port on a public address: whoever reaches it with the sign-in link controls your Bots, and they can run commands. +- **A health check**: `docker compose ps` shows `healthy` once the web server answers. + +## Start + +You need Docker with the compose plugin and a clone of this repository. + +```sh +git clone https://github.com/ThinkFlowLab/FlyBot && cd FlyBot +git checkout vX.Y.Z # a release tag (see Releases); main also works +FLYBOT_TAG=$(git describe --tags) docker compose up -d --build +docker compose logs flybot | grep token= +``` + +`FLYBOT_TAG` names the image after the version you built, so an older one stays available for a roll back. Without it the image is `flybot:local`; to keep the version for later `docker compose up` runs, also write the `FLYBOT_TAG=…` line into `.env`. + +Open the printed `http://127.0.0.1:3080/?token=…` link. The link changes on every start; the browser stays signed in after the first visit. With another host port (`FLYBOT_PORT`), change the port in the link. + +**DeepSeek key.** Either enter it in the app (it is saved on the data volume; see [Set a DeepSeek API key](deepseek-api-key.md)), or put `DEEPSEEK_API_KEY=sk-…` in a `.env` file next to `compose.yaml` (never commit it) and run `docker compose up -d` again. A key from the environment wins and cannot be changed in the app. + +**Bot Social.** Set the server as in the README, in `/data/profiles/web/cordis.patch.yml`: + +```sh +docker compose exec -T flybot sh -c 'cat >> /data/profiles/web/cordis.patch.yml' <<'YAML' +- id: flybot-social + config: + server: https://your-server.example.com + displayName: Your Name +YAML +docker compose restart flybot +``` + +**From another machine.** An SSH tunnel needs nothing on the server: + +```sh +ssh -N -L 3080:127.0.0.1:3080 you@your-server +``` + +Then open the link from the logs on your own machine. A reverse proxy (Caddy, nginx) on the server works too; give it HTTPS, forward to `127.0.0.1:3080`, and start DSH with that name trusted, for example by adding to `compose.yaml` under `flybot`: `command: ["--trusted-host", "flybot.example.com"]`. + +**More people on one machine.** One project name, port and volume each: + +```sh +FLYBOT_PORT=3081 docker compose -p alice up -d --build +FLYBOT_PORT=3082 docker compose -p bob up -d +``` + +## Stop + +```sh +docker compose stop # stop; start again with: docker compose start +docker compose down # remove the container; the data volume stays +``` + +`docker compose down -v` also deletes the data volume, with every Bot. Back up first. + +## Upgrade + +Back up first (see below), then build the new tag. The FlyBot packages come from the image, so a new image is a new FlyBot; your settings and any plugin you added stay on the volume. + +```sh +git fetch --tags && git checkout vX.Y.Z # the new tag +FLYBOT_TAG=$(git describe --tags) docker compose up -d --build +``` + +## Roll back + +The previous image is still there under its tag (`docker image ls flybot` lists them): + +```sh +FLYBOT_TAG=vX.Y.Z docker compose up -d # the previous tag +``` + +If the newer version already changed data on the volume, restore the backup you made before the upgrade. + +## Back up and restore + +Stop the instance, copy the volume to a file, start again: + +```sh +docker compose stop +docker compose run --rm -T --no-deps --entrypoint tar flybot -czf - -C /data . > flybot-backup.tgz +docker compose start +``` + +Restore into the (empty or old) volume: + +```sh +docker compose stop +docker compose run --rm -T --no-deps --entrypoint sh flybot -c 'find /data -mindepth 1 -delete && tar -xzf - -C /data' < flybot-backup.tgz +docker compose start +``` + +The backup holds your credentials (model keys, Bot Social login): keep it private. + +## Not in this image + +- Publishing to an image registry, and deploying. +- Desktop-only features (DSH Desktop, opening files in local apps). diff --git a/docs/docker.zh.md b/docs/docker.zh.md new file mode 100644 index 00000000..4970fc68 --- /dev/null +++ b/docs/docker.zh.md @@ -0,0 +1,112 @@ +# 用 Docker 运行 FlyBot + +[English](docker.md) + +每人在一台一直开着的 Linux 机器上运行一个 FlyBot,合上笔记本后 Bot 和定时任务照常工作。容器里运行 `dsh web`(DSH 0.2.0-rc.2,与 README 相同),带上你构建所用代码里的 FlyBot 核心以及 social、tree、timeline 插件。实例之间只通过 [Bot Social](https://github.com/Birfy/bot-social) 通信;Bot Social 不在镜像里,需要你自己部署。 + +镜像由你自己构建,FlyBot 不发布镜像。 + +## 包含什么 + +- **一个数据卷** `/data`:DSH 目录(Bot、会话、设置、凭据)、Bot 的工作目录 `/data/workspace` 和 pnpm 缓存。 +- **普通用户运行**,不是 root(uid 1000)。 +- **镜像里没有密钥。** DeepSeek Key 在运行时提供。 +- **端口只绑定 `127.0.0.1`。** 从别的机器访问请用你自己的 HTTPS 反向代理或 SSH 隧道。不要把端口暴露到公网:拿到登录链接的人就能控制你的 Bot,而 Bot 能执行命令。 +- **健康检查**:Web 服务能响应后,`docker compose ps` 显示 `healthy`。 + +## 启动 + +需要 Docker(带 compose 插件)和本仓库的代码。 + +```sh +git clone https://github.com/ThinkFlowLab/FlyBot && cd FlyBot +git checkout vX.Y.Z # 某个发布版本(见 Releases);main 也可以 +FLYBOT_TAG=$(git describe --tags) docker compose up -d --build +docker compose logs flybot | grep token= +``` + +`FLYBOT_TAG` 用构建的版本给镜像命名,这样旧镜像会保留下来,方便回滚。不设置时镜像名是 `flybot:local`;想让以后的 `docker compose up` 也用这个版本,把 `FLYBOT_TAG=…` 这一行也写进 `.env`。 + +打开打印出的 `http://127.0.0.1:3080/?token=…`。每次启动链接都会变;第一次打开后浏览器会保持登录。如果用 `FLYBOT_PORT` 换了主机端口,把链接里的端口一起改掉。 + +**DeepSeek Key。** 可以在应用里填写(保存在数据卷上,见 [Set a DeepSeek API key](deepseek-api-key.md),英文),也可以在 `compose.yaml` 旁边建一个 `.env` 文件写上 `DEEPSEEK_API_KEY=sk-…`(不要提交到仓库),然后再运行一次 `docker compose up -d`。环境变量里的 Key 优先,并且不能在应用里修改。 + +**Bot Social。** 按 README 的方式在 `/data/profiles/web/cordis.patch.yml` 里设置服务器: + +```sh +docker compose exec -T flybot sh -c 'cat >> /data/profiles/web/cordis.patch.yml' <<'YAML' +- id: flybot-social + config: + server: https://your-server.example.com + displayName: Your Name +YAML +docker compose restart flybot +``` + +**从别的机器访问。** SSH 隧道不需要在服务器上装任何东西: + +```sh +ssh -N -L 3080:127.0.0.1:3080 you@your-server +``` + +然后在自己的电脑上打开日志里的链接。也可以在服务器上用反向代理(Caddy、nginx):配置 HTTPS,转发到 `127.0.0.1:3080`,并让 DSH 信任这个域名,例如在 `compose.yaml` 的 `flybot` 下加上 `command: ["--trusted-host", "flybot.example.com"]`。 + +**一台机器上多人使用。** 每人一个项目名、端口和数据卷: + +```sh +FLYBOT_PORT=3081 docker compose -p alice up -d --build +FLYBOT_PORT=3082 docker compose -p bob up -d +``` + +## 停止 + +```sh +docker compose stop # 停止;再启动用 docker compose start +docker compose down # 删除容器;数据卷保留 +``` + +`docker compose down -v` 会连数据卷一起删除,所有 Bot 都会丢失。请先备份。 + +## 升级 + +先备份(见下文),再构建新的版本。FlyBot 的代码来自镜像,所以新镜像就是新版本的 FlyBot;你的设置和自己添加的插件留在数据卷上。 + +```sh +git fetch --tags && git checkout vX.Y.Z # 新版本 +FLYBOT_TAG=$(git describe --tags) docker compose up -d --build +``` + +## 回滚 + +之前的镜像仍然以原来的标签保留着(`docker image ls flybot` 可以列出): + +```sh +FLYBOT_TAG=vX.Y.Z docker compose up -d # 之前的版本 +``` + +如果新版本已经改动了数据卷里的数据,请恢复升级前的备份。 + +## 备份与恢复 + +先停止实例,把数据卷打包成文件,再启动: + +```sh +docker compose stop +docker compose run --rm -T --no-deps --entrypoint tar flybot -czf - -C /data . > flybot-backup.tgz +docker compose start +``` + +恢复到数据卷(空的或旧的): + +```sh +docker compose stop +docker compose run --rm -T --no-deps --entrypoint sh flybot -c 'find /data -mindepth 1 -delete && tar -xzf - -C /data' < flybot-backup.tgz +docker compose start +``` + +备份里有你的凭据(模型 Key、Bot Social 登录信息),请妥善保管。 + +## 这个镜像不包含 + +- 发布到镜像仓库,以及部署。 +- 仅桌面版才有的功能(DSH Desktop、用本地应用打开文件)。 From 572142d69a63dbdd0223719c48c7c77ce78d8c53 Mon Sep 17 00:00:00 2001 From: Birfy Date: Tue, 20 Oct 2026 11:00:49 +0800 Subject: [PATCH 2/2] Document the container image in the install subsystem (#352) --- docs/subsystems/README.md | 2 +- docs/subsystems/install.md | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index f47ac4da..43dfbebf 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -11,7 +11,7 @@ Detail pages for each FlyBot subsystem. Each page lists `owned_paths`, what the | tree | [tree.md](tree.md) | `plugins/tree/` | | timeline | [timeline.md](timeline.md) | `plugins/timeline/` | | social | [social.md](social.md) | `plugins/social/` | -| install | [install.md](install.md) | `core/scripts/`, arch-doc tests, `plugins/README.md` | +| install | [install.md](install.md) | `core/scripts/`, arch-doc tests, `plugins/README.md`, the container image (`Dockerfile`, `compose.yaml`, `docker/`) | | team | [team.md](team.md) | `docs/subsystems/team.md` — **planned contract only** (no code paths; multi-human is not shipped) | | interop | [interop.md](interop.md) | `dev/interop/`, `scripts/lint-interop.mjs` — offline development harness only | | live-e2e | [live-e2e.md](live-e2e.md) | `dev/live-e2e/`, `scripts/lint-live-e2e.mjs` — real-model browser suite (#204) | diff --git a/docs/subsystems/install.md b/docs/subsystems/install.md index 97ffb46a..156addfc 100644 --- a/docs/subsystems/install.md +++ b/docs/subsystems/install.md @@ -1,6 +1,10 @@ --- subsystem: install owned_paths: + - .dockerignore + - Dockerfile + - compose.yaml + - docker/ - core/scripts/ - core/test/check-arch-docs.test.js - core/test/install-doctor.test.js @@ -9,13 +13,14 @@ owned_paths: # install -Tooling for setup checks and the architecture-docs gate. +Tooling for setup checks, the architecture-docs gate and the container image (#352). ## Owns - `core/scripts/` — helper scripts (e.g. `check-arch-docs.mjs`). - Arch-doc unit test and install-doctor test. - `plugins/README.md` — plugin inventory index. +- The container image: `Dockerfile`, `compose.yaml`, `.dockerignore`, `docker/` ([guide](../docker.md)). ## Does not own @@ -32,6 +37,11 @@ Tooling for setup checks and the architecture-docs gate. | `plugins/README.md` | Plugin list. | | `core/scripts/install-doctor.mjs` | Install doctor: Node floor, the packages (`core`, `plugins/social`, `plugins/tree`, `plugins/timeline`) and the install commands (core, social, tree; `@flybot/timeline` is opt-in and not in the default commands, #281). | | `core/scripts/local-ci.sh` | Local run of CI's steps, including each plugin's suite. | +| `Dockerfile` | Packs `core` and the plugins with `check-pack.mjs` (the release tarballs), then a runtime with DSH (pinned like the README), pnpm and tini, as user `node`, one volume `/data`. | +| `compose.yaml` | One instance: port on the host's `127.0.0.1`, volume `data`, `DEEPSEEK_API_KEY` passed through only when set. | +| `docker/entrypoint.mjs`, `docker/profile.mjs` | Each start links the image's packages into `/data/profiles/web` (other plugins and `cordis.patch.yml` kept), `pnpm install`, then `dsh web` in `/data/workspace`. | +| `docker/web.patch.yml` | Container-only overlay: `webserver.host: 0.0.0.0` (the CLI refuses `--host 0.0.0.0`; inside the container it reaches only the container's network). | +| `docker/healthcheck.mjs` | Healthy when `GET /` answers (401 without the cookie). | ## Entry points @@ -42,10 +52,11 @@ Tooling for setup checks and the architecture-docs gate. 1. Owned-file changes require a matching subsystem doc in the same PR (or a `Docs-not-needed:` trailer). 2. Empty diff vs base passes. 3. A new peer plugin is added to the install doctor (and its test), `check-pack.mjs`, the CI plugin matrix, `local-ci.sh` and `plugins/README.md` in the same PR. +4. The container image holds no key or token, runs as a normal user, and `compose.yaml` publishes the port on the host's `127.0.0.1` only. CI (`.github/workflows/docker.yml`) builds and start-checks it; nothing is pushed to a registry (owner decision on #352). ## Tests -`node --import ./test/harness-resolve.mjs --test test/check-arch-docs.test.js`. +`node --import ./test/harness-resolve.mjs --test test/check-arch-docs.test.js`. Container: `node --test .github/scripts/docker-profile.test.mjs` (repo root) and the Docker workflow. ## Common mistakes