diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..258be26 --- /dev/null +++ b/.air.toml @@ -0,0 +1,17 @@ +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -o ./tmp/control-plane ./cmd/control-plane" +stop_signal = "SIGTERM" +kill_delay = 500 +ps1 = [] +entrypoint = "./tmp/control-plane" +include_ext = ["go"] +exclude_dir = ["tmp", "web", "docs", ".git", ".codegraph"] +exclude_file = [] +delay = 200 + +[log] +time = false +main_log_only = true diff --git a/.gitignore b/.gitignore index 94106da..7b77a71 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ web/dist/ web/node_modules/ web/test-results/ .env +.dev-credentials/ +tmp/ diff --git a/README.md b/README.md index ed5e09a..4956896 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,32 @@ DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build 打开 ,使用 `CONTROL_PLANE_USERNAME` / `CONTROL_PLANE_PASSWORD` 登录;局域网内用宿主机 IP 访问同一端口。首次使用:在「网关管理」用 Compose 里的 `GATEWAY_TOKEN` 注册 `http://docker-gateway:8081`,在「镜像版本」添加可用的指纹浏览器镜像引用,即可创建环境;网关会在镜像缺失时自动拉取。架构、API 契约、失败语义和 `docker.sock` 风险边界见 [《浏览器容器控制面》](docs/architecture/container-control.md)。 +## 本地开发(热加载) + +日常开发不再整仓重建镜像,改用源码热加载: + +```bash +npm --prefix web ci +pnpm dev +``` + +- `pnpm dev`:同时起后端(air 热重载)与前端(vite HMR); +- `pnpm dev:backend` / `pnpm dev:frontend`:单独启动其中一端; +- `pnpm dev:deps`:仅启动 postgres 与 docker-gateway 两个容器(`compose.dev.yaml` 会把 5432/8081 映射到宿主机)。 + +服务地址: + +| 端 | 地址 | 说明 | +| --- | --- | +| 前端 | | `/api` 由 vite 代理到本地 control-plane | +| 后端 control-plane | | Go 源码改动即自动重启 | +| docker-gateway | | 容器内常驻,重启不频繁 | +| postgres | `127.0.0.1:5432` | 容器内常驻,数据库重建直接销毁 volume | + +开发默认值(`.env` 缺失或留空时):登录 `admin` / `admin123`;控制面监听 `:8082`;凭据主密钥使用本地开发专用密钥;本地网关注册地址用 `http://127.0.0.1:8081`(容器名 `docker-gateway` 仅存在于 Compose 网络内)。 + +注意:本地开发后端占用 `:8082`,与整仓 `docker compose up` 的容器端口互斥,两者二选一运行。 + 最小验证: ```bash diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..d1ae8b7 --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,14 @@ +# 本地热加载开发用 override:只跑 postgres + docker-gateway,宿主机直接跑 control-plane 与前端。 +# 用法:docker compose -f compose.yaml -f compose.dev.yaml up -d postgres docker-gateway +services: + creator-hub: + # 本地开发不跑容器版 control-plane;profile 化后不启动。 + profiles: [compose-only] + + postgres: + ports: + - "5432:5432" + + docker-gateway: + ports: + - "8081:8081" diff --git a/package.json b/package.json new file mode 100644 index 0000000..372d393 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "creator-hub", + "private": true, + "version": "0.1.0", + "scripts": { + "dev:deps": "docker compose -f compose.yaml -f compose.dev.yaml up -d postgres docker-gateway", + "dev:backend": "node scripts/dev-backend.mjs", + "dev:frontend": "pnpm -C web run dev", + "dev": "node scripts/dev.mjs" + } +} diff --git a/scripts/dev-backend.mjs b/scripts/dev-backend.mjs new file mode 100644 index 0000000..c6d1c3b --- /dev/null +++ b/scripts/dev-backend.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// dev-backend: 读取 .env,补齐开发默认值,确保 postgres/docker-gateway 容器在跑,然后用 air 热加载运行 control-plane。 +import { spawn, spawnSync } from "node:child_process"; +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const envPath = path.join(root, ".env"); + +if (!existsSync(envPath)) { + console.error("缺少 .env:请参照 .env.example 创建(开发值任意填)。"); + process.exit(1); +} + +// 简易 KEY=value 解析(与 compose 的 env_file 语法一致,无需处理引号)。 +const overrides = {}; +for (const line of readFileSync(envPath, "utf8").split("\n")) { + const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim()); + if (m && !(m[1] in overrides)) overrides[m[1]] = m[2]; +} + +if (!overrides.CONTROL_PLANE_USERNAME) + overrides.CONTROL_PLANE_USERNAME = "admin"; +if (!overrides.CONTROL_PLANE_PASSWORD) + overrides.CONTROL_PLANE_PASSWORD = "admin123"; +// 主密钥必须是 32 字节的 base64;无效或缺失时换成本地开发专用密钥(与 .env 里的 dev 凭据同级机密性)。 +const devMasterKey = Buffer.from( + "creatorhub dev local master key", + "utf8", +).toString("base64"); // 恰 32 字节 +if ( + Buffer.from(overrides.CREATORHUB_CREDENTIAL_MASTER_KEY ?? "", "base64") + .length !== 32 +) { + if (overrides.CREATORHUB_CREDENTIAL_MASTER_KEY) { + console.error( + "[dev-backend] .env 的 CREATORHUB_CREDENTIAL_MASTER_KEY 不是 32 字节 base64,本地开发改用固定开发密钥。", + ); + } + overrides.CREATORHUB_CREDENTIAL_MASTER_KEY = devMasterKey; +} +if (!overrides.LISTEN_ADDR) overrides.LISTEN_ADDR = ":8082"; +if (!overrides.DATABASE_URL) + overrides.DATABASE_URL = + "postgres://creatorhub@127.0.0.1:5432/creatorhub?sslmode=disable"; +if (!overrides.CREATORHUB_CREDENTIAL_STORE_DIR) + overrides.CREATORHUB_CREDENTIAL_STORE_DIR = path.join( + root, + ".dev-credentials", + ); +if (!overrides.WEB_DIR) overrides.WEB_DIR = path.join(root, "web", "dist"); +if (!overrides.LOG_LEVEL) overrides.LOG_LEVEL = "debug"; +if (!overrides.DOCKER_GID) + overrides.DOCKER_GID = String(process.getgid?.() ?? 1000); + +const env = { ...process.env, ...overrides }; + +const compose = spawnSync( + "docker", + [ + "compose", + "-f", + "compose.yaml", + "-f", + "compose.dev.yaml", + "up", + "-d", + "postgres", + "docker-gateway", + ], + { cwd: root, env, stdio: "inherit" }, +); +if (compose.status !== 0) { + console.error("启动 postgres/docker-gateway 容器失败,请确认 docker 可用。"); + process.exit(compose.status ?? 1); +} + +const air = spawn("air", ["-c", ".air.toml", ...process.argv.slice(2)], { + cwd: root, + env, + stdio: "inherit", +}); +air.on("exit", (code) => process.exit(code ?? 0)); diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 0000000..bdd40bd --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// dev: 同时跑 dev:backend(air 热加载 control-plane)与 dev:frontend(vite HMR)。 +// Ctrl-C 结束两个子进程。 +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const pnpm = process.env.npm_execpath || "pnpm"; + +const procs = [ + spawn(pnpm, ["run", "dev:backend"], { cwd: root, stdio: "inherit" }), + spawn(pnpm, ["run", "dev:frontend"], { cwd: root, stdio: "inherit" }), +]; + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + for (const p of procs) p.kill(signal); + }); +} + +let remaining = procs.length; +for (const p of procs) { + p.on("exit", (code) => { + if (--remaining === 0) process.exit(code ?? 0); + }); +} diff --git a/web/vite.config.js b/web/vite.config.js index 84a33d7..036869b 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -1,21 +1,23 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [react(), tailwindcss()], server: { - proxy: { '/api': 'http://127.0.0.1:8080' }, + port: 5173, + strictPort: true, + proxy: { "/api": "http://127.0.0.1:8082" }, }, test: { - environment: 'jsdom', - pool: 'threads', + environment: "jsdom", + pool: "threads", maxWorkers: 1, isolate: false, // ponytail: one worker avoids 90s startup stalls; restore isolation if tests leak state. coverage: { - provider: 'v8', - reporter: ['text'], + provider: "v8", + reporter: ["text"], thresholds: { lines: 65 }, }, }, -}) +});