From 13b9e480257bfe9a61c2c65a1e13e136d7d015f9 Mon Sep 17 00:00:00 2001 From: KKKK Date: Fri, 16 Oct 2026 19:01:57 +0800 Subject: [PATCH 1/6] Add isolated MVP regression and acceptance plan --- docs/beat-geo/acceptance-2026-10-16.md | 137 ++++++++++++++++++ docs/roadmap.md | 2 + e2e/run-local.sh | 55 +++---- package.json | 1 + .../content/docs/developer-guide/commands.mdx | 32 +++- scripts/test-mvp.mjs | 90 ++++++++++++ 6 files changed, 273 insertions(+), 44 deletions(-) create mode 100644 docs/beat-geo/acceptance-2026-10-16.md create mode 100644 scripts/test-mvp.mjs diff --git a/docs/beat-geo/acceptance-2026-10-16.md b/docs/beat-geo/acceptance-2026-10-16.md new file mode 100644 index 00000000..25e75f0a --- /dev/null +++ b/docs/beat-geo/acceptance-2026-10-16.md @@ -0,0 +1,137 @@ +# BeatGEO:实测、Profound 对标与下一轮验收 + +核对日期:2026-10-16(上海)。基于 `c1e0a5a9`,本轮新增可重复的 MVP 回归入口。这里的“通过”仅覆盖明确列出的测试,不代表全部 Profound 功能已实现。 + +## 本轮实际跑了什么 + +| 范围 | 结果 | 证据边界 | +| --- | --- | --- | +| 环境预检 | 首次失败:终端 Node 22、pnpm 不匹配、Docker 未运行 | 本轮选用现有 Node 24.19.0、Corepack 的项目固定 pnpm,并启动 Docker;没有修改全局 Node | +| 单元测试 | 1,449 通过 | config 119、CLI 35、lib 675、deployment 52、web 568 | +| 浏览器采集契约 | 20 通过 | 使用模拟传输;包含超时不计负面曝光、提交不确定时不重放、人工暂停 | +| 安装与脚本 | 16 通过 | 包含缺少依赖、独立数据库和安装配置 | +| PostgreSQL 集成 | 41 通过 | 独立 PostgreSQL 17;真实迁移、队列和事务;提供方回答为合成数据 | +| 类型检查 / lint | 通过 | lint 检查 846 个文件 | +| 新账号注册 | 通过 | 独立库、独立本地网页服务,真正提交注册表单 | +| 设置页 | 通过指定检查 | 中英切换、Key 输入入口、浏览器离线提示;桌面与 390px 手机宽度无水平溢出 | +| 图片业务题库 | 通过 | 创建测试品牌,选择图片业务和中文,生成并保存 12 题;没有手写问题 | +| 未启动 worker 的表现 | 通过 | 明确显示问题已保存、尚未开始采集,没有显示成功或零曝光 | +| 新的一轮真实模型采集 | 本轮未执行 | 历史 ChatGPT 实测见 validation.md;不能借历史结果宣称本轮多平台通过 | +| BeatAPI 真实 Key / 消费 | 本轮未验证 | 设置、研究和错误契约有测试;真实搜索、阅读、模型权限与账单仍需验收 | +| 跨天自动运行、睡眠恢复 | 本轮未验证 | 排队成功不能代替下一周期实际执行 | + +UI 使用隔离的无头 Chromium,服务使用测试提供方,没有导入用户的浏览器 Cookie。首次自动化脚本过早提交未完成 hydration 的表单;修正脚本为等待语言控件可用后,注册成功。品牌创建在本地模式直接进入题库,脚本原先错误等待额外的“Create brand”步骤,已据实际页面继续检查。不能把这些脚本等待错误计成产品测试通过。 + +开发服务器在调整视口时记录了 ResizeObserver 通知警告;注册、保存和最终页面检查没有捕获到未处理的 pageerror。生产构建下仍应再做一次视觉验收。 + +## 团队如何重复测试 + +准备 Node.js 24、项目指定 pnpm 12.4.1、已安装依赖、已启动 Docker: + +```bash +pnpm local:doctor +pnpm test:mvp +pnpm -r --if-present check-types +pnpm lint +``` + +`test:mvp` 新建随机名称、随机端口、仅本机可达的临时 PostgreSQL 容器;运行采集契约、脚本、单元和数据库集成测试,结束后删除容器。数据库迁移只针对该临时库。它不证明真实网页登录、付费 API、完整 UI 或无人值守运行。 + +本轮执行该命令全程通过,并确认临时容器已清理。旧 `test:e2e-local` 原来调用本仓库不存在的工作流,现改为检查显式的本地测试 URL 和 `_test` 数据库后运行 Playwright。完整 UI 测试仍须预先准备对应 fixtures,不会对现有实例自动播种。见开发者命令文档。 + +## 用户应走的闭环 + +```mermaid +flowchart TD + A[安装与环境自检] --> B[注册本地账号] + B --> C[设置 BeatAPI Key 与研究模型] + C --> D[连接 BeatBrowser 和已登录的平台] + D --> E[输入网站与品牌] + E --> F[研究业务 / 确认竞品与别名] + F --> G[生成题库 / 确认固定问题版本] + G --> H[首次采集基线] + H --> I{采集完成?} + I -->|否| J[超时 / 离线 / 人工处理 / 待执行] + J --> H + I -->|是| K[答案、引用、提及率、竞品差距] + K --> L[带证据的优化任务] + L --> M[内容或技术修改 / 人工确认实施] + M --> N[同组问题复测 / 标明缺失覆盖] + N --> K + O[SEO 数据:需求、SERP、竞品页面] -.后续接入.-> F + O -.后续接入.-> L +``` + +没有 Key 时允许行业模板题库。模板不是对网站的真实研究,也不是用户实际提问量。后续试用优先用 BeatAPI 和一个非 API 行业品牌,避免整套流程只适合我们自己。 + +## 下一轮真实验收:7 天小样本 + +第一天用 BeatAPI 建一个项目;用团队自有图片产品再建一个项目。每个项目先固定 10 个中性购买意图问题,另放 2 个品牌认知问题,报告分开显示。先跑 ChatGPT;稳定后再逐个加 Gemini、Perplexity、Claude。没有通过的平台标“实验性”,不把 API 接入数量当成网页平台覆盖。 + +1. **Key 与费用**:填写 Key、验证、选择模型、保存;执行一次网站研究,核对搜索、页面阅读、模型输出与真实用量。无效 Key、余额不足和限流分别可理解地失败,不重复扣费重试。 +2. **业务理解**:网站识别出的产品、受众、竞品可修改;图片业务不会生成 API 中转问题;未知业务先询问或降级。 +3. **采集与证据**:每个结果有问题版本、时间、平台、账号环境说明、原回答及来源。人工抽查 10 条:引用链接不是导航链接,品牌出现不是用户问题回显。 +4. **失败与恢复**:主动断开扩展、关闭任务页、暂停任务;验证不会重复提交,不把失败当没曝光。重载扩展和彻底重启 Chrome 分别验收,后者可能需要重新关联页面。 +5. **指标核算**:用 10 条确定结果手算分母;超时不进入负面样本。品牌词与非品牌词分列,mention、recommendation、citation、搜索排名各自独立。 +6. **优化与复测**:保存一项有来源的建议和基线,实际修改一个自有页面,记录实施,再复测同一组问题。修改过的问题、失效样本和改变的采集条件不得伪装成可比提升。 +7. **持续运行**:跨 3 个实际调度周期观察执行;故意睡眠/唤醒一次,核查漏跑与恢复策略;七天后看重复任务、遗留窗口、失败率、延迟和成本。模型答案变化本身不算软件失败,也不证明优化因果。 + +建议发布门槛:团队两名成员在独立环境各完成首次使用;上述状态正确且无跨品牌混数据/密钥外露/重复提交;每个平台有独立验收记录。持续采集成功率与耗时先报告实测分子分母,再设 SLO,不凭一次成功宣布稳定。 + +## Profound 对标:按模块补,不按菜单数量补 + +Profound 官方功能页包含 Answer Engine Insights、Prompt Volumes、Agent Analytics、Agents、Shopping;导航还包含 Context Manager、AI Marketer、Projects、Documents 和 Pages。2026-10-09 的更新加入 Moz SEO 数据以及基于看板的 AI 分析。下表是产品补齐建议,非 Profound 私有实现说明。 + +| 能力 | BeatGEO 当前边界 | 下一步 | +| --- | --- | --- | +| 网站初始化、品牌、问题、竞品 | 已有模型研究路径和行业题库;真实 Key 研究待验收 | 明确网站证据与可编辑业务档案,增加首次采集诊断 | +| AI 回答监控 | 有 Browser 采集及数据库闭环;平台稳定性不齐 | ChatGPT 稳定性优先,随后 Gemini / Perplexity / Claude;每家有错误状态与完整来源验收 | +| 可见度、SoV、引用与历史 | 有基础指标;完整 SoV 工作台、统一口径和新鲜度仍需补齐 | 展示有效样本数、失败覆盖、品牌/非品牌问题拆分;避免旧样本前向填充冒充新观察 | +| Pages / 技术站点检查 | 独立技术审计未落地 | 有限范围读取 robots、noindex、canonical、结构化数据、关键正文,给出原始证据和修复任务 | +| 优化与复测 | 已实现基线、实施记录、排队复测与匹配比较 | 增加带引用的 brief / FAQ / 比较页草稿、人工审核和导出;再做 CMS 发布回执 | +| Context / AI Marketer / Agents | 没有完整品牌知识库和通用工作流编辑器 | 先做 3 个可用模板:竞品差距、引用机会、周报;共用现有账号、权限、pg-boss 与证据 | +| Prompt Volumes | 没有等价真实需求数据资产 | SEO 量作为搜索需求信号;真实 AI 对话数据需单独授权、覆盖说明和估计方法 | +| Agent Analytics | 没有完整 CDN/GSC/GA4 接入 | 爬虫访问、人类来源访问、转化分别建模;不把 UA 字符串单独当已验证爬虫身份 | +| Shopping | 未接入 | 产品/SKU/商家/价格/商品卡独立模型,后续阶段验收 | +| 企业协作与运维 | 有基础组织权限;不是成熟托管 SaaS | 备份恢复演练、设备在线状态、跨 worker 租约、任务预算、通知交付 | + +P0 是首次使用、数据可信度和持续执行;P1 是站点检查、SEO 数据、来源机会与内容草稿;P2 才是通用 Agent 编辑器、复杂集成、Shopping 和企业能力。功能广度可以逐步对标,数据规模与有效性必须单独证明。 + +## 给 SEO 接入同学的边界 + +不要为供应商再增加账号、任务库、钱包或独立 UI。上游接入和计费归 BeatAPI 网关;BeatGEO 通过同一 Key 消费客户可见契约,并把结果关联现有品牌、问题、页面和优化任务。 + +| 首批能力 | 产品用处 | 最低验收 | +| --- | --- | --- | +| 关键词建议与搜索量 | 题库主题发现和优先级 | 国家、语言、时间窗口、估算标识;不能写成 AI 提问量 | +| SERP / AIO | 看引用候选页和竞品出现位置 | 地区、语言、设备、结果类型;没触发 AIO 与请求失败分开 | +| 竞品排名词与 Top Pages | 找内容缺口和承接页 | 域名/页面范围、来源时间、分页/采样覆盖 | +| 引用域/外链 | 寻找第三方来源机会 | 外链与 AI 引用分开;供应商评分不能混成同一个权威分 | + +建议契约包含 `schemaVersion`、`capability`、`requestId`、`observedAt`、`sourceType`、`country`、`language`、`device`、`coverage`、`status`、`data`,以及网关确实提供时才记录的 `usage`。状态至少区分成功、部分成功、不可用、限流、失败;没有购买或没有连接时为空/未连接,不返回假 0。字段是待评审建议,不是已上线 API 端点。 + +流程:先确定客户契约 → 接供应商 → 用固定小样本核对真实返回与计费 → 网关投影 → BeatGEO 适配 → 题库/机会页使用。不要让前端接触供应商 Key、成本或内部错误。站点技术检查可独立于商业 SEO 数据先做,不必等采购完成。 + +## 本轮开源候选复核 + +GitHub 仓库元数据于本轮读取;README 功能声明不是部署验收。尚未导入代码或部署候选项目。 + +| 项目 | 许可证 / 最近推送 | 值得进一步阅读 | 集成方式 | +| --- | --- | --- | --- | +| oneglanse/oneglanse | MIT / 2026-10-04 | 多网页平台回答、引用提取和回答分析 | 只移植适配思想到 BeatBrowser;不再引入它的 Camoufox、账户和任务栈 | +| Albert-Weasker/niubigeo | Apache-2.0 / 2026-10-10 | 域名认知、关键词独立历史和原始证据呈现 | 借鉴研究流程;API 观测继续单独标记,复用代码须保留相应许可/NOTICE | +| Pupok462/open-geo | MIT / 2026-10-07 | 按引擎的操作手册、来源与引用分层 | 可用于人工校验清单,不以自然语言 playbook 代替稳定性测试 | +| qq136692547-cmyk/geo-score | MIT / 2026-10-13 | 技术站点审计与可解释修复项 | 仅作候选;同名项目多,需锁定仓库和代码验证,准备度分不能冒充曝光预测 | + +后续由用户主动发起增量研究,不设定时任务。本轮曾创建的研究自动化已按用户要求删除。研究时查 GitHub 与 Profound 官方更新,附许可证、来源、对应模块与验收方法;不自动合入或发布。 + +## 来源 + +- [Profound 功能页](https://www.tryprofound.com/features) +- [Profound 更新日志](https://product.tryprofound.com/changelog) +- [Answer Engine Insights 指标介绍](https://help.tryprofound.com/articles/5194011335-interpret-answer-engine-insights-v2) +- [OneGlanse](https://github.com/oneglanse/oneglanse) +- [NiubiGEO](https://github.com/Albert-Weasker/niubigeo) +- [open-geo](https://github.com/Pupok462/open-geo) +- [geo-score 候选仓库](https://github.com/qq136692547-cmyk/geo-score) +- 本项目:`docs/metrics.md`、`docs/beat-geo/validation.md`、`docs/beat-geo/beatapi-product-loop.md` 与本轮测试输出。历史 feature CSV 中的状态早于后续实现,应结合本次实测阅读。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 6d2db724..842cbed9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,6 +2,8 @@ [Interactive blueprint](product/flow.html) · [67-item feature inventory](product/features.csv). These describe the target product, not completed functionality. +[2026-10-16 acceptance review](beat-geo/acceptance-2026-10-16.md) records the current test results, Profound gaps, SEO integration boundary and the seven-day live acceptance plan. Run `pnpm test:mvp` for repeatable isolated core regression. + ## Available MVP Independent BeatBrowser tasks, manual brand/questions, ChatGPT/Gemini adapters with stated limits, original answers and visible links, brand/engine/status filters, timeout evidence, export and local agent drafts. The full foundation retains organization, brand, prompt, competitor and report implementations. PostgreSQL and pg-boss now persist collection attempts, answers and citations, frozen action baselines, implementation notes and comparable retests. BeatAPI research supports bounded search/read/model calls; real-key acceptance is still pending. diff --git a/e2e/run-local.sh b/e2e/run-local.sh index 8d896c42..68745ea3 100644 --- a/e2e/run-local.sh +++ b/e2e/run-local.sh @@ -1,39 +1,20 @@ #!/usr/bin/env bash -# -# Run E2E tests locally using act (https://github.com/nektos/act) -# -# The GitHub workflow (.github/workflows/e2e.yml) is the single source -# of truth. This script just invokes it locally via act. -# -# Install act: -# brew install act -# # or: gh extension install https://github.com/nektos/gh-act -# -# Usage: -# bash e2e/run-local.sh # Run the full E2E job -# pnpm test:e2e-local # Same thing via package.json -# +# Run UI tests against an explicitly prepared, isolated local test stack. set -euo pipefail - -if ! command -v act &>/dev/null; then - echo "Error: 'act' is not installed." - echo "" - echo "Install via one of:" - echo " brew install act" - echo " gh extension install https://github.com/nektos/gh-act" - echo "" - echo "See https://github.com/nektos/act for more options." - exit 1 -fi - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$REPO_ROOT" - -mkdir -p "$REPO_ROOT/e2e/artifacts" - -exec act -j e2e \ - -W .github/workflows/e2e.yaml \ - --artifact-server-path "$REPO_ROOT/e2e/artifacts" \ - --container-daemon-socket /var/run/docker.sock \ - --container-architecture linux/amd64 \ - "$@" +cd "$(dirname "$0")/.." +node --input-type=module <<'JS' +const local = new Set(['localhost', '127.0.0.1', '[::1]']); +try { + const app = new URL(process.env.BASE_URL || ''); + const database = new URL(process.env.DATABASE_URL || ''); + if (!local.has(app.hostname) || !['http:', 'https:'].includes(app.protocol) || + !local.has(database.hostname) || !['postgres:', 'postgresql:'].includes(database.protocol) || + !decodeURIComponent(database.pathname).endsWith('_test')) throw new Error(); + const response = await fetch(app.origin, { signal: AbortSignal.timeout(5000), redirect: 'manual' }); + if (response.status >= 500) throw new Error(); +} catch { + console.error('Set BASE_URL to a running local test app and DATABASE_URL to its disposable local *_test database. Prepare E2E fixtures first. For isolated core regression, use pnpm test:mvp.'); + process.exit(1); +} +JS +exec pnpm --filter e2e test:e2e "$@" diff --git a/package.json b/package.json index 09863966..0b53d199 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test:api:demo": "pnpm --filter e2e test:api:demo", "validate:spec": "pnpm --filter e2e validate:spec", "test:scripts": "node --test 'scripts/*.test.mjs'", + "test:mvp": "node scripts/test-mvp.mjs", "license-check": "node scripts/check-licenses.mjs", "browser:lab": "node apps/browser-lab/server.mjs", "test:browser": "node --test apps/browser-lab/*.test.mjs", diff --git a/packages/docs/content/docs/developer-guide/commands.mdx b/packages/docs/content/docs/developer-guide/commands.mdx index c7267e30..1ce2c4e3 100644 --- a/packages/docs/content/docs/developer-guide/commands.mdx +++ b/packages/docs/content/docs/developer-guide/commands.mdx @@ -59,17 +59,35 @@ TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/beatgeo_test \ ### End-to-End Tests -`.github/workflows/e2e.yaml` is the source of truth for E2E orchestration — it -stands up one Dockerized container per deployment mode, seeds them, and runs the -suites in order. Replay the whole thing locally with -[act](https://github.com/nektos/act): +The default CI validates unit tests and PostgreSQL integration. It does not run +all Playwright deployment-mode suites. The inherited E2E orchestration workflow +is not part of this fork. + +For repeatable MVP regression, select Node.js 24, install the pinned pnpm version +and dependencies, and start Docker: + +```bash +pnpm test:mvp +``` + +This creates and migrates a disposable PostgreSQL 17 database, runs browser +collector contracts, script tests, workspace unit tests and database/queue/retest +integration, then removes the test container. Provider responses are synthetic; +this is not live browser, paid API, UI or overnight acceptance. + +UI suites require a separately running **test** app with the E2E fixture data. +The app and test process must use the same disposable local database. Do not +seed an existing instance: the E2E seeder deletes data. The local wrapper checks +explicit loopback URLs and a database name ending in `_test` before running: ```bash -pnpm test:e2e-local +BASE_URL=http://localhost:3156 \ +DATABASE_URL=postgres://postgres:postgres@localhost:55446/beatgeo_test \ + pnpm test:e2e-local ``` -The scripts below each run one mode's suite against a stack that workflow has -already brought up, which is what you want when iterating on a single spec: +Other mode-specific commands below also assume their respective test stacks and +fixtures already exist; they do not provision a server: ```bash # Playwright specs diff --git a/scripts/test-mvp.mjs b/scripts/test-mvp.mjs new file mode 100644 index 00000000..74cc3499 --- /dev/null +++ b/scripts/test-mvp.mjs @@ -0,0 +1,90 @@ +import { spawn, spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +// A fresh database makes this command safe to repeat beside a real local instance. +const root = fileURLToPath(new URL("../", import.meta.url)); +const name = `beatgeo-mvp-${randomBytes(6).toString("hex")}`; +const password = randomBytes(24).toString("hex"); +const pnpm = process.env.npm_execpath || "pnpm"; +let child; +let created = false; + +function docker(args) { + const result = spawnSync("docker", args, { encoding: "utf8", timeout: 60000 }); + if (result.error || result.status !== 0) throw new Error(`Docker ${args[0]} failed. Check that Docker is running.`); + return result.stdout.trim(); +} + +function cleanup() { + if (created) { + const result = spawnSync("docker", ["rm", "-f", name], { stdio: "pipe", timeout: 15000 }); + if (result.error || result.status !== 0) { + console.error(`Could not remove test container ${name}. Run: docker rm -f ${name}`); + process.exitCode = 1; + } + created = false; + } +} + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, () => { + if (child?.pid) { + try { + if (process.platform === "win32") child.kill(signal); + else process.kill(-child.pid, signal); + } catch { /* The test process already exited. */ } + } + cleanup(); + process.exit(signal === "SIGINT" ? 130 : 143); + }); +} + +async function run(label, args, env = {}) { + console.log(`\n${label}`); + const command = /\.[cm]?js$/.test(pnpm) ? process.execPath : pnpm; + const commandArgs = command === process.execPath ? [pnpm, ...args] : args; + await new Promise((resolve, reject) => { + child = spawn(command, commandArgs, { + cwd: root, stdio: "inherit", detached: process.platform !== "win32", + env: { ...process.env, ...env }, + }); + child.once("error", reject); + child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`${label} failed (${code}).`))); + }); + child = undefined; +} + +try { + if (process.versions.node.split(".")[0] !== "24") throw new Error("Select Node.js 24 before running pnpm test:mvp."); + docker(["info", "--format", "{{.ServerVersion}}"]); + console.log("MVP regression: synthetic provider replies, disposable PostgreSQL; no live AI collection."); + docker([ + "run", "--detach", "--rm", "--name", name, + "--publish", "127.0.0.1::5432", "--tmpfs", "/var/lib/postgresql/data", + "--env", "POSTGRES_USER=beatgeo_test", "--env", `POSTGRES_PASSWORD=${password}`, + "--env", "POSTGRES_DB=beatgeo_mvp_test", "postgres:17", + ]); + created = true; + let ready = false; + for (let attempt = 0; attempt < 30; attempt++) { + const result = spawnSync("docker", ["exec", name, "pg_isready", "-U", "beatgeo_test"], { stdio: "pipe", timeout: 5000 }); + if (result.status === 0) { ready = true; break; } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + if (!ready) throw new Error("Test PostgreSQL did not become ready."); + const address = docker(["port", name, "5432/tcp"]); + if (!/^127\.0\.0\.1:\d+$/.test(address)) throw new Error("Test database must bind only to loopback."); + await run("Browser collector contracts", ["test:browser"]); + await run("Installer and script tests", ["test:scripts"]); + await run("Workspace unit tests", ["-r", "--if-present", "test"]); + await run("Database, queue and retest integration", ["--filter", "@workspace/web", "test:integration"], { + TEST_DATABASE_URL: `postgres://beatgeo_test:${password}@${address}/beatgeo_mvp_test`, + }); + console.log("\nMVP regression passed. Live keys, platform sessions, UI and overnight scheduling need separate acceptance."); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} finally { + cleanup(); +} From 9fe06f1f5bf1c5806cd3940cf19334d52e838b43 Mon Sep 17 00:00:00 2001 From: KKKK Date: Fri, 16 Oct 2026 19:53:50 +0800 Subject: [PATCH 2/6] Bundle PostgreSQL for database-free local setup --- AGENTS.md | 1 + package.json | 3 +- pnpm-lock.yaml | 109 +++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 18 ++++++ scripts/local-database.mjs | 88 +++++++++++++++++++++++++ scripts/local-doctor.mjs | 13 ++-- scripts/local-runtime.mjs | 3 +- scripts/local-runtime.test.mjs | 13 ++++ scripts/setup-local.mjs | 36 +++++------ scripts/start-local.mjs | 115 ++++++++++++++++----------------- scripts/test-mvp.mjs | 83 ++++++++++++------------ 11 files changed, 355 insertions(+), 127 deletions(-) create mode 100644 scripts/local-database.mjs diff --git a/AGENTS.md b/AGENTS.md index 60ddec41..5ae61b0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,7 @@ Before handing work back or opening a PR, run `pnpm lint` and get it passing; CI - **Always use pnpm.** Never install or run dependencies with npm, yarn, or `npx` — that sidesteps the workspace's protections. - This repo enforces [pnpm supply-chain security](https://pnpm.io/supply-chain-security) via `pnpm-workspace.yaml`: `minimumReleaseAge` (a multi-day cooldown on new releases), `trustPolicy: no-downgrade`, `blockExoticSubdeps`, and an `allowBuilds` allowlist for install scripts. +- Owner-approved exception (2026-10-16): the exact `@embedded-postgres/*@17.10.0-beta.17` platform builds may restore their package-local native library symlinks for bundled local storage. Version upgrades need a fresh review; all other protections below still apply. - **Never weaken or bypass these controls**: don't add `minimumReleaseAgeExclude` entries, don't flip packages to `true` in `allowBuilds`, don't suppress `pnpm audit` advisories, and don't remove `overrides` (many are scoped security patches or dedup anchors). If an install fails because of these controls, that is the system working — report it instead of working around it. ## Environment diff --git a/package.json b/package.json index 0b53d199..59eaf368 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "typescript": "^7.0.2" }, "dependencies": { - "@beatapi/beat-browser": "github:BeatAPI/BeatBrowser#9aefd38bbd4e3fc7762751bcff57599fe8405588" + "@beatapi/beat-browser": "github:BeatAPI/BeatBrowser#9aefd38bbd4e3fc7762751bcff57599fe8405588", + "embedded-postgres": "17.10.0-beta.17" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2737a79b..93b9a939 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,6 +163,14 @@ settings: excludeLinksFromLockfile: false overrides: + '@embedded-postgres/darwin-arm64': 17.10.0-beta.17 + '@embedded-postgres/darwin-x64': 17.10.0-beta.17 + '@embedded-postgres/linux-arm': 17.10.0-beta.17 + '@embedded-postgres/linux-arm64': 17.10.0-beta.17 + '@embedded-postgres/linux-ia32': 17.10.0-beta.17 + '@embedded-postgres/linux-ppc64': 17.10.0-beta.17 + '@embedded-postgres/linux-x64': 17.10.0-beta.17 + '@embedded-postgres/windows-x64': 17.10.0-beta.17 esbuild: ^0.28.1 seroval: ^1.5.3 '@xmldom/xmldom': ~0.8.15 @@ -184,6 +192,9 @@ importers: '@beatapi/beat-browser': specifier: github:BeatAPI/BeatBrowser#9aefd38bbd4e3fc7762751bcff57599fe8405588 version: https://codeload.github.com/BeatAPI/BeatBrowser/tar.gz/9aefd38bbd4e3fc7762751bcff57599fe8405588(supports-color@8.1.1)(zod@4.6.5) + embedded-postgres: + specifier: 17.10.0-beta.17 + version: 17.10.0-beta.17 devDependencies: '@biomejs/biome': specifier: 2.5.15 @@ -1792,6 +1803,54 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@embedded-postgres/darwin-arm64@17.10.0-beta.17': + resolution: {integrity: sha512-E2dxhSllrcyAJBHuvgTrBTx2BaPlX2vvjXClPKHxfwBezk+nTZ01uAEraKiRJMpzHSCyN8J1dTjKh4q/uTsBLQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@embedded-postgres/darwin-x64@17.10.0-beta.17': + resolution: {integrity: sha512-gXk65MIyttD/PWo6NDSmu/n4vzPcqZQmxVWjEKuecwlyGmWO984AZ3Ljb7F0bwPvXhqcovH4FGN4ydr0fGeJxg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@embedded-postgres/linux-arm64@17.10.0-beta.17': + resolution: {integrity: sha512-mC+bQvaBEf0WDD0nPWW3eGUXDUVzoyCzMtgl+Fw6Jy9DkQFK+jcPtzkzqhhrS/fGzn5telNpKInl8xPqDh71Ig==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@embedded-postgres/linux-arm@17.10.0-beta.17': + resolution: {integrity: sha512-XQrRydqjgPrvvCKF1PrLvMljMbeXXT4GbB70+z7viqUWg3bgq31nGMkupa2TXHLqRnfmEhz1u3gn3xgQIphLZQ==} + engines: {node: '>=16'} + cpu: [arm] + os: [linux] + + '@embedded-postgres/linux-ia32@17.10.0-beta.17': + resolution: {integrity: sha512-ck2QlYCPmmNRJotXp0cJnnB5Yn/bk89qT3cjVCxUfkS1b/56Odu9ID6zvAYFebvYeHU69UwOEbplikV4saRYWw==} + engines: {node: '>=16'} + cpu: [ia32] + os: [linux] + + '@embedded-postgres/linux-ppc64@17.10.0-beta.17': + resolution: {integrity: sha512-+Mb0E41rvlwBhAV11eZf51rlxSHA8ancPHPFxhyljg+ZJ62QB1YryBf+rIkphpG2bnt4aJwxd1JOUk3PonlrLA==} + engines: {node: '>=16'} + cpu: [ppc64] + os: [linux] + + '@embedded-postgres/linux-x64@17.10.0-beta.17': + resolution: {integrity: sha512-hU4Sgna19ixDP/l3P/zkHhQ9B0JDV39qA97RNz55QcngeRjFC5/1G36LZjODVJ84kVJHUTQMmly7nzWO8js8kg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@embedded-postgres/windows-x64@17.10.0-beta.17': + resolution: {integrity: sha512-q6xIETTkv67i1QlTWz1LAKb3Z+vG2V3qrp+BjLEWhCQ9jE4hbbsQ/J/3wiOPFLJbuwak7CfHC9EUJG7txAbGKw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@emnapi/core@1.11.0': resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==} @@ -5524,6 +5583,10 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} @@ -6424,6 +6487,10 @@ packages: electron-to-chromium@1.5.422: resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} + embedded-postgres@17.10.0-beta.17: + resolution: {integrity: sha512-s9ITJA41OTIQmn91RLqQ5X2MVP2BCZ8pb4tQX3MY8VOd8xNCzzlgfHGcXuitDzK800FhbG/3YufFk21KTTvpzA==} + engines: {node: '>=16'} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -11292,6 +11359,30 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} + '@embedded-postgres/darwin-arm64@17.10.0-beta.17': + optional: true + + '@embedded-postgres/darwin-x64@17.10.0-beta.17': + optional: true + + '@embedded-postgres/linux-arm64@17.10.0-beta.17': + optional: true + + '@embedded-postgres/linux-arm@17.10.0-beta.17': + optional: true + + '@embedded-postgres/linux-ia32@17.10.0-beta.17': + optional: true + + '@embedded-postgres/linux-ppc64@17.10.0-beta.17': + optional: true + + '@embedded-postgres/linux-x64@17.10.0-beta.17': + optional: true + + '@embedded-postgres/windows-x64@17.10.0-beta.17': + optional: true + '@emnapi/core@1.11.0': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -15307,6 +15398,8 @@ snapshots: astring@1.9.0: {} + async-exit-hook@2.0.1: {} + async-retry@1.3.3: dependencies: retry: 0.13.1 @@ -16124,6 +16217,22 @@ snapshots: electron-to-chromium@1.5.422: {} + embedded-postgres@17.10.0-beta.17: + dependencies: + async-exit-hook: 2.0.1 + pg: 8.23.1 + optionalDependencies: + '@embedded-postgres/darwin-arm64': 17.10.0-beta.17 + '@embedded-postgres/darwin-x64': 17.10.0-beta.17 + '@embedded-postgres/linux-arm': 17.10.0-beta.17 + '@embedded-postgres/linux-arm64': 17.10.0-beta.17 + '@embedded-postgres/linux-ia32': 17.10.0-beta.17 + '@embedded-postgres/linux-ppc64': 17.10.0-beta.17 + '@embedded-postgres/linux-x64': 17.10.0-beta.17 + '@embedded-postgres/windows-x64': 17.10.0-beta.17 + transitivePeerDependencies: + - pg-native + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3a3d1d51..7f6edae9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,15 @@ publicHoistPattern: - "@takumi-rs/core-*" overrides: + # Pin the bundled database binaries to the reviewed wrapper version. + "@embedded-postgres/darwin-arm64": "17.10.0-beta.17" + "@embedded-postgres/darwin-x64": "17.10.0-beta.17" + "@embedded-postgres/linux-arm": "17.10.0-beta.17" + "@embedded-postgres/linux-arm64": "17.10.0-beta.17" + "@embedded-postgres/linux-ia32": "17.10.0-beta.17" + "@embedded-postgres/linux-ppc64": "17.10.0-beta.17" + "@embedded-postgres/linux-x64": "17.10.0-beta.17" + "@embedded-postgres/windows-x64": "17.10.0-beta.17" # GHSA-67mh-4wv8-2f99 (drizzle-kit's @esbuild-kit loader asks for the 0.18 line) and # GHSA-g7r4-m6w7-qqqr (dev-server arbitrary file read on Windows). esbuild: "^0.28.1" @@ -28,6 +37,15 @@ overrides: "@scalar/json-magic>undici": "^7.29.1" # GHSA-4cwx-7wf7-3272, GHSA-rfgv-xxqx-mfg5, and four more allowBuilds: + # Owner-approved fixed-version exception: restore package-local native library links. + "@embedded-postgres/darwin-arm64@17.10.0-beta.17": true + "@embedded-postgres/darwin-x64@17.10.0-beta.17": true + "@embedded-postgres/linux-arm@17.10.0-beta.17": true + "@embedded-postgres/linux-arm64@17.10.0-beta.17": true + "@embedded-postgres/linux-ia32@17.10.0-beta.17": true + "@embedded-postgres/linux-ppc64@17.10.0-beta.17": true + "@embedded-postgres/linux-x64@17.10.0-beta.17": true + "@embedded-postgres/windows-x64@17.10.0-beta.17": true "@tailwindcss/oxide": true esbuild: true msgpackr-extract: true diff --git a/scripts/local-database.mjs b/scripts/local-database.mjs new file mode 100644 index 00000000..9dd94f08 --- /dev/null +++ b/scripts/local-database.mjs @@ -0,0 +1,88 @@ +import { existsSync } from "node:fs"; +import { chmod, lstat, mkdir, readFile, writeFile } from "node:fs/promises"; +import net from "node:net"; +import path from "node:path"; + +export const DATABASE_ENGINE_VERSION = "17.10.0-beta.17"; + +export async function availablePort(preferred) { + for (let port = preferred; port < preferred + 100; port++) { + if (await portAvailable(port)) return port; + } + throw new Error("No free local port found. Close an unused BeatGEO instance and try again."); +} + +export async function portAvailable(port) { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => server.close(() => resolve(true))); + }); +} + +export function embeddedSettings(root, env) { + const url = new URL(env.DATABASE_URL); + if (env.BEAT_GEO_DATABASE_MODE !== "embedded" || env.DEPLOYMENT_MODE !== "local" || + url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/beatgeo" || + url.username !== "beatgeo" || !url.password || url.search || url.hash) + throw new Error("Embedded storage requires a dedicated local BeatGEO configuration."); + return { + databaseDir: path.join(root, ".beat-geo/postgres"), + port: Number(url.port), user: "beatgeo", password: decodeURIComponent(url.password), + persistent: true, authMethod: "scram-sha-256", createPostgresUser: false, + initdbFlags: ["--encoding=UTF8", "--locale=C"], + postgresFlags: ["-h", "127.0.0.1", "-c", "unix_socket_directories=", "-c", "max_connections=100"], + onLog: () => {}, onError: () => {}, + }; +} + +export async function startEmbeddedDatabase(root, env, { initialize = false } = {}) { + if (process.getuid?.() === 0) throw new Error("Start BeatGEO as your normal user, without sudo."); + const settings = embeddedSettings(root, env); + const local = path.dirname(settings.databaseDir); + await mkdir(local, { recursive: true, mode: 0o700 }); + if ((await lstat(local)).isSymbolicLink()) throw new Error("The private data directory must not be a symlink."); + await chmod(local, 0o700); + const marker = path.join(local, "database.json"); + const identity = { engine: "postgres", major: 17, directory: "postgres", port: settings.port }; + if (!existsSync(marker)) { + if (!initialize || existsSync(settings.databaseDir)) + throw new Error("Local database ownership is unknown. Restore your configuration or complete setup; data was not changed."); + await writeFile(marker, JSON.stringify(identity), { mode: 0o600, flag: "wx" }); + } + if (JSON.stringify(JSON.parse(await readFile(marker, "utf8"))) !== JSON.stringify(identity)) + throw new Error("The database configuration does not match this workspace. Restore its original configuration."); + if (existsSync(settings.databaseDir) && (await lstat(settings.databaseDir)).isSymbolicLink()) + throw new Error("The database directory must not be a symlink."); + if (!(await portAvailable(settings.port))) + throw new Error("The local database port is in use. Stop the other BeatGEO instance before starting again."); + const { default: EmbeddedPostgres } = await import("embedded-postgres"); + const database = new EmbeddedPostgres(settings); + const versionFile = path.join(settings.databaseDir, "PG_VERSION"); + try { + if (!existsSync(versionFile)) { + if (!initialize) throw new Error("Local storage is not initialized. Run pnpm local:setup --resume."); + // The dependency writes a temporary password file during initdb. + const previousMask = process.umask(0o077); + try { await database.initialise(); } finally { process.umask(previousMask); } + } else if ((await readFile(versionFile, "utf8")).trim() !== "17") { + throw new Error("This database needs a version migration. Back it up before upgrading."); + } + await database.start(); + const client = database.getPgClient("postgres", "127.0.0.1"); + try { + await client.connect(); + const found = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", ["beatgeo"]); + if (!found.rowCount) { + if (!initialize) throw new Error("The BeatGEO database is missing. Run pnpm local:setup --resume."); + await client.query('CREATE DATABASE "beatgeo"'); + } + } finally { await client.end(); } + let stopping; + return { getClient: (name = "beatgeo") => database.getPgClient(name, "127.0.0.1"), stop: () => { stopping ??= database.stop(); return stopping; } }; + } catch (error) { + if (database.process?.exitCode === null && database.process?.signalCode === null) await database.stop(); + throw new Error(error instanceof Error ? error.message.replaceAll(settings.password, "[redacted]") : + "The bundled database could not start. Check available disk space and your operating system compatibility."); + } +} diff --git a/scripts/local-doctor.mjs b/scripts/local-doctor.mjs index 262162a4..2b28a45c 100644 --- a/scripts/local-doctor.mjs +++ b/scripts/local-doctor.mjs @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; import { pathToFileURL } from "node:url"; const REQUIREMENTS = [ @@ -11,25 +12,29 @@ const REQUIREMENTS = [ ["docker", ["compose", "version", "--short"], "Docker Compose", "Install Docker Compose v2 or update Docker Desktop."], ]; -export function inspectLocalRequirements({ nodeVersion = process.versions.node, run = spawnSync } = {}) { +export function inspectLocalRequirements({ nodeVersion = process.versions.node, run = spawnSync, databaseMode = "embedded" } = {}) { const checks = [{ name: "Node.js 24", ok: nodeVersion.split(".")[0] === "24", fix: "Install and select Node.js 24." }]; for (const [command, args, name, fix, accepts] of REQUIREMENTS) { + if (command === "docker" && databaseMode !== "docker") continue; const result = run(command, args, { encoding: "utf8", timeout: 5000, stdio: "pipe" }); checks.push({ name, ok: !result.error && result.status === 0 && (!accepts || accepts(result.stdout)), fix }); } return checks; } -export function requireLocalEnvironment() { - const checks = inspectLocalRequirements(); +export function requireLocalEnvironment(databaseMode = "embedded") { + const checks = inspectLocalRequirements({ databaseMode }); const failed = checks.filter((check) => !check.ok); if (failed.length) throw new Error(failed.map((check) => `${check.name}: ${check.fix}`).join("\n")); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const checks = inspectLocalRequirements(); + if (existsSync(".env")) process.loadEnvFile(".env"); + const databaseMode = process.env.BEAT_GEO_DATABASE_MODE || (existsSync(".beat-geo/compose.yaml") ? "docker" : "embedded"); + const checks = inspectLocalRequirements({ databaseMode }); for (const check of checks) console.log(`${check.ok ? "OK" : "NEEDS ACTION"} · ${check.name}${check.ok ? "" : ` — ${check.fix}`}`); + console.log(`Storage mode: ${databaseMode}. Embedded mode needs no Docker or separate database installation.`); console.log("Chrome: install it on this computer, then load the bundled BeatBrowser extension in Setup."); console.log("This check does not create a database, install extensions or call paid APIs."); process.exitCode = checks.every((check) => check.ok) ? 0 : 1; diff --git a/scripts/local-runtime.mjs b/scripts/local-runtime.mjs index 7f90d510..de44f18b 100644 --- a/scripts/local-runtime.mjs +++ b/scripts/local-runtime.mjs @@ -2,11 +2,12 @@ import { randomBytes } from "node:crypto"; import { cp, mkdir, readFile } from "node:fs/promises"; import path from "node:path"; -export function localEnvironment({ databasePort = 55440, appPort = 3140, browserPort = 4319 } = {}) { +export function localEnvironment({ databasePort = 55440, appPort = 3140, browserPort = 4319, databaseMode = "embedded" } = {}) { const password = randomBytes(24).toString("hex"); return { DATABASE_URL: `postgres://beatgeo:${password}@127.0.0.1:${databasePort}/beatgeo`, DEPLOYMENT_MODE: "local", + BEAT_GEO_DATABASE_MODE: databaseMode, BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), BEAT_GEO_ENCRYPTION_KEY: randomBytes(32).toString("base64"), DISABLE_TELEMETRY: "1", diff --git a/scripts/local-runtime.test.mjs b/scripts/local-runtime.test.mjs index dfdf6079..f1e9dfbd 100644 --- a/scripts/local-runtime.test.mjs +++ b/scripts/local-runtime.test.mjs @@ -25,6 +25,7 @@ test("installation preflight distinguishes missing tools, wrong versions and a s const { inspectLocalRequirements } = await import("./local-doctor.mjs"); const checks = inspectLocalRequirements({ nodeVersion: "22.0.0", + databaseMode: "docker", run: (command, args) => { if (command === "pnpm") return { status: 0, stdout: "11.0.0" }; if (command === "git") return { status: null, error: new Error("not found") }; @@ -44,3 +45,15 @@ test("installation preflight accepts the supported source-install environment", }); assert(checks.every((check) => check.ok)); }); + +test("default install does not require Docker or a database command", async () => { + const { inspectLocalRequirements } = await import("./local-doctor.mjs"); + const commands = []; + const checks = inspectLocalRequirements({ nodeVersion: "24.19.0", run: (command) => { + commands.push(command); + return { status: 0, stdout: "12.4.1" }; + } }); + assert(checks.every((check) => check.ok)); + assert.deepEqual(commands, ["pnpm", "git"]); + assert.equal(localEnvironment().BEAT_GEO_DATABASE_MODE, "embedded"); +}); diff --git a/scripts/setup-local.mjs b/scripts/setup-local.mjs index 57ad0c27..f9de804d 100644 --- a/scripts/setup-local.mjs +++ b/scripts/setup-local.mjs @@ -1,26 +1,18 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; -import net from "node:net"; +import { availablePort, startEmbeddedDatabase } from "./local-database.mjs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { requireLocalEnvironment } from "./local-doctor.mjs"; import { localCompose, localEnvironment, prepareExtension } from "./local-runtime.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); -function run(command, args, cwd = root) { - const result = spawnSync(command, args, { cwd, stdio: "inherit" }); +function run(command, args, cwd = root, environment = process.env) { + const result = spawnSync(command, args, { cwd, stdio: "inherit", env: environment }); if (result.error || result.status !== 0) throw new Error(`${command} did not complete. Check the message above.`); } -async function available(port) { - await new Promise((resolve, reject) => { - const server = net.createServer(); - server.on("error", () => - reject(new Error(`Port ${port} is occupied. Stop that service or configure BeatGEO manually.`)), - ); - server.listen(port, "127.0.0.1", () => server.close(resolve)); - }); -} +let database; try { if (Number(process.versions.node.split(".")[0]) !== 24) throw new Error("Use Node.js 24 and pnpm 12.4.1."); @@ -31,7 +23,6 @@ try { throw new Error( "Existing configuration found; it has not been changed. Use pnpm local:start. For an interrupted automatic setup, run pnpm local:setup --resume.", ); - requireLocalEnvironment(); let env; if (resume) { const rootText = await readFile(path.join(root, ".env"), "utf8"); @@ -43,17 +34,19 @@ try { rootText !== webText || env.DEPLOYMENT_MODE !== "local" || url.hostname !== "127.0.0.1" || - url.port !== "55440" || + !url.port || url.pathname !== "/beatgeo" || - (await readFile(path.join(local, "compose.yaml"), "utf8")) !== localCompose(env) + (env.BEAT_GEO_DATABASE_MODE !== "embedded" && + (await readFile(path.join(local, "compose.yaml"), "utf8")) !== localCompose(env)) ) throw new Error( "This is not an unchanged automatic local setup. Follow the self-hosting guide; no database was modified.", ); } else { - await Promise.all([55440, 3140, 4319].map(available)); - env = localEnvironment(); + const [databasePort, appPort, browserPort] = await Promise.all([55440, 3140, 4319].map(availablePort)); + env = localEnvironment({ databasePort, appPort, browserPort, databaseMode: process.argv.includes("--docker") ? "docker" : "embedded" }); } + requireLocalEnvironment(env.BEAT_GEO_DATABASE_MODE || "docker"); run("pnpm", ["install", "--frozen-lockfile"]); if (!resume) { const text = @@ -61,15 +54,18 @@ try { .map(([key, value]) => `${key}=${value}`) .join("\n") + "\n"; await mkdir(local, { recursive: true, mode: 0o700 }); - await writeFile(path.join(local, "compose.yaml"), localCompose(env), { mode: 0o600, flag: "wx" }); + if (env.BEAT_GEO_DATABASE_MODE === "docker") + await writeFile(path.join(local, "compose.yaml"), localCompose(env), { mode: 0o600, flag: "wx" }); await writeFile(path.join(root, ".env"), text, { mode: 0o600, flag: "wx" }); await writeFile(path.join(root, "apps/web/.env"), text, { mode: 0o600, flag: "wx" }); } - run("docker", ["compose", "-p", "beatgeo-local", "-f", path.join(local, "compose.yaml"), "up", "-d", "--wait"]); + if (env.BEAT_GEO_DATABASE_MODE === "embedded") database = await startEmbeddedDatabase(root, env, { initialize: true }); + else run("docker", ["compose", "-p", "beatgeo-local", "-f", path.join(local, "compose.yaml"), "up", "-d", "--wait"]); run( process.execPath, ["--env-file=../../.env", "node_modules/drizzle-kit/bin.cjs", "migrate"], path.join(root, "packages/lib"), + { ...process.env, ...env }, ); const extension = await prepareExtension(root); console.log( @@ -78,4 +74,6 @@ try { } catch (error) { console.error(error.message); process.exitCode = 1; +} finally { + await database?.stop(); } diff --git a/scripts/start-local.mjs b/scripts/start-local.mjs index e3485a5c..47336b4f 100644 --- a/scripts/start-local.mjs +++ b/scripts/start-local.mjs @@ -1,79 +1,76 @@ import { spawn, execFileSync } from "node:child_process"; +import { parseEnv } from "node:util"; +import { readFileSync } from "node:fs"; import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { startEmbeddedDatabase } from "./local-database.mjs"; import { prepareExtension } from "./local-runtime.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); const envPath = process.argv[2] || path.join(root, ".env"); if (!existsSync(envPath)) throw new Error("Run pnpm local:setup first, or supply your BeatGEO environment file."); -process.loadEnvFile(envPath); +Object.assign(process.env, parseEnv(readFileSync(envPath, "utf8"))); if (process.env.DEPLOYMENT_MODE !== "local") throw new Error("This launcher only starts a local BeatGEO instance."); -await prepareExtension(root); -const compose = path.join(root, ".beat-geo/compose.yaml"); -if (!process.argv[2] && existsSync(compose)) - execFileSync("docker", ["compose", "-p", "beatgeo-local", "-f", compose, "up", "-d", "--wait"], { stdio: "inherit" }); +const app = new URL(process.env.APP_URL || "http://localhost:3140"); +if (!["localhost", "127.0.0.1"].includes(app.hostname)) + throw new Error("Use a loopback APP_URL for the local launcher."); const children = []; +let database; let stopping = false; -function stop(code = 0) { +function signal(child, signalName) { + if (child.exitCode !== null || child.signalCode !== null) return; + try { + if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signalName); + else child.kill(signalName); + } catch { /* The service already exited. */ } +} +async function stop(code = 0) { if (stopping) return; stopping = true; - for (const child of children) { - try { - if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGTERM"); - else child.kill("SIGTERM"); - } catch { - /* This service already exited. */ - } - } - process.exitCode = code; - setTimeout(() => process.exit(code), 2000).unref(); + for (const child of children) signal(child, "SIGTERM"); + await Promise.all(children.map((child) => { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return new Promise((resolve) => { + const timeout = setTimeout(() => { signal(child, "SIGKILL"); resolve(); }, 35_000); + child.once("exit", () => { clearTimeout(timeout); resolve(); }); + }); + })); + try { await database?.stop(); } catch { console.error("Local storage did not stop cleanly. Restart BeatGEO before backing up."); code = 1; } + process.exit(code); } function start(command, args) { - const child = spawn(command, args, { - cwd: root, - stdio: "inherit", - env: process.env, - detached: process.platform !== "win32", - }); + const child = spawn(command, args, { cwd: root, stdio: "inherit", env: process.env, detached: process.platform !== "win32" }); children.push(child); - child.on("error", () => { - console.error("A local service could not start."); - stop(1); - }); - child.on("exit", (code) => { - if (!stopping) stop(code || 1); - }); + child.on("error", () => { console.error("A local service could not start."); void stop(1); }); + child.on("exit", (code) => { if (!stopping) void stop(code || 1); }); } -const app = new URL(process.env.APP_URL || "http://localhost:3140"); -if (!["localhost", "127.0.0.1"].includes(app.hostname)) - throw new Error("Use a loopback APP_URL for the local launcher."); -start(process.execPath, ["apps/browser-lab/server.mjs"]); -// Doctor reuses the existing shared bridge, or starts it once. Do not stop it -// with BeatGEO: other agents may still be using that connection. +process.on("SIGINT", () => { void stop(); }); +process.on("SIGTERM", () => { void stop(); }); try { - execFileSync(process.execPath, ["node_modules/@beatapi/beat-browser/src/cli.js", "doctor", "--json"], { - cwd: root, - timeout: 15000, - stdio: "pipe", - }); -} catch { - console.log("Chrome is not connected yet. Complete the extension step in Setup."); + await prepareExtension(root); + if (process.env.BEAT_GEO_DATABASE_MODE === "embedded") { + database = await startEmbeddedDatabase(root, process.env); + // Migrations are applied before either application process starts. + execFileSync(process.execPath, ["node_modules/drizzle-kit/bin.cjs", "migrate"], { + cwd: path.join(root, "packages/lib"), stdio: "inherit", env: process.env, + }); + } else { + const compose = path.join(root, ".beat-geo/compose.yaml"); + if (!process.argv[2] && existsSync(compose)) + execFileSync("docker", ["compose", "-p", "beatgeo-local", "-f", compose, "up", "-d", "--wait"], { stdio: "inherit" }); + } + start(process.execPath, ["apps/browser-lab/server.mjs"]); + // The shared bridge may serve other agents, so it is not stopped with BeatGEO. + try { + execFileSync(process.execPath, ["node_modules/@beatapi/beat-browser/src/cli.js", "doctor", "--json"], { + cwd: root, timeout: 15000, stdio: "pipe", + }); + } catch { console.log("Chrome is not connected yet. Complete the extension step in Setup."); } + start("pnpm", ["--filter", "@workspace/web", "exec", "vite", "--host", "127.0.0.1", "--port", app.port || "3140", "--strictPort"]); + start("pnpm", ["--filter", "@workspace/worker", "start"]); + console.log(`BeatGEO setup: ${app.origin}/setup\nKeep this terminal running for scheduled monitoring. Ctrl+C stops these services and saves local data.`); +} catch (error) { + console.error(error instanceof Error ? error.message : "BeatGEO could not start."); + await stop(1); } -start("pnpm", [ - "--filter", - "@workspace/web", - "exec", - "vite", - "--host", - "127.0.0.1", - "--port", - app.port || "3140", - "--strictPort", -]); -start("pnpm", ["--filter", "@workspace/worker", "start"]); -process.on("SIGINT", () => stop()); -process.on("SIGTERM", () => stop()); -console.log( - `BeatGEO setup: ${app.origin}/setup\nKeep this terminal running for scheduled monitoring. Ctrl+C stops these services.`, -); diff --git a/scripts/test-mvp.mjs b/scripts/test-mvp.mjs index 74cc3499..b1948532 100644 --- a/scripts/test-mvp.mjs +++ b/scripts/test-mvp.mjs @@ -1,41 +1,32 @@ -import { spawn, spawnSync } from "node:child_process"; -import { randomBytes } from "node:crypto"; +import { spawn } from "node:child_process"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { availablePort, startEmbeddedDatabase } from "./local-database.mjs"; +import { localEnvironment } from "./local-runtime.mjs"; import { fileURLToPath } from "node:url"; // A fresh database makes this command safe to repeat beside a real local instance. const root = fileURLToPath(new URL("../", import.meta.url)); -const name = `beatgeo-mvp-${randomBytes(6).toString("hex")}`; -const password = randomBytes(24).toString("hex"); const pnpm = process.env.npm_execpath || "pnpm"; let child; -let created = false; - -function docker(args) { - const result = spawnSync("docker", args, { encoding: "utf8", timeout: 60000 }); - if (result.error || result.status !== 0) throw new Error(`Docker ${args[0]} failed. Check that Docker is running.`); - return result.stdout.trim(); -} - -function cleanup() { - if (created) { - const result = spawnSync("docker", ["rm", "-f", name], { stdio: "pipe", timeout: 15000 }); - if (result.error || result.status !== 0) { - console.error(`Could not remove test container ${name}. Run: docker rm -f ${name}`); - process.exitCode = 1; - } - created = false; - } +let database; +let temporaryRoot; +async function cleanup() { + await database?.stop(); + if (temporaryRoot) await rm(temporaryRoot, { recursive: true, force: true }); } for (const signal of ["SIGINT", "SIGTERM"]) { - process.once(signal, () => { + process.once(signal, async () => { if (child?.pid) { try { if (process.platform === "win32") child.kill(signal); else process.kill(-child.pid, signal); } catch { /* The test process already exited. */ } } - cleanup(); + await cleanup(); process.exit(signal === "SIGINT" ? 130 : 143); }); } @@ -57,34 +48,40 @@ async function run(label, args, env = {}) { try { if (process.versions.node.split(".")[0] !== "24") throw new Error("Select Node.js 24 before running pnpm test:mvp."); - docker(["info", "--format", "{{.ServerVersion}}"]); - console.log("MVP regression: synthetic provider replies, disposable PostgreSQL; no live AI collection."); - docker([ - "run", "--detach", "--rm", "--name", name, - "--publish", "127.0.0.1::5432", "--tmpfs", "/var/lib/postgresql/data", - "--env", "POSTGRES_USER=beatgeo_test", "--env", `POSTGRES_PASSWORD=${password}`, - "--env", "POSTGRES_DB=beatgeo_mvp_test", "postgres:17", - ]); - created = true; - let ready = false; - for (let attempt = 0; attempt < 30; attempt++) { - const result = spawnSync("docker", ["exec", name, "pg_isready", "-U", "beatgeo_test"], { stdio: "pipe", timeout: 5000 }); - if (result.status === 0) { ready = true; break; } - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - if (!ready) throw new Error("Test PostgreSQL did not become ready."); - const address = docker(["port", name, "5432/tcp"]); - if (!/^127\.0\.0\.1:\d+$/.test(address)) throw new Error("Test database must bind only to loopback."); + console.log("MVP regression: synthetic provider replies, disposable bundled PostgreSQL; no Docker or live AI calls."); + temporaryRoot = await mkdtemp(path.join(tmpdir(), "beatgeo-mvp-")); + const env = localEnvironment({ databasePort: await availablePort(55550) }); + database = await startEmbeddedDatabase(temporaryRoot, env, { initialize: true }); + const first = database.getClient(); + await first.connect(); + await first.query("CREATE TABLE restart_probe (id integer primary key)"); + await first.query("INSERT INTO restart_probe VALUES (1)"); + await first.query("BEGIN"); + await first.query("INSERT INTO restart_probe VALUES (2)"); + await first.query("ROLLBACK"); + await first.query("CREATE DATABASE beatgeo_mvp_test"); + await first.end(); + await assert.rejects(() => startEmbeddedDatabase(temporaryRoot, env), /port is in use/); + await database.stop(); + database = await startEmbeddedDatabase(temporaryRoot, env); + await Promise.all(Array.from({ length: 8 }, async () => { + const client = database.getClient(); + try { + await client.connect(); + assert.deepEqual((await client.query("SELECT id FROM restart_probe")).rows, [{ id: 1 }]); + } finally { await client.end(); } + })); + console.log("Bundled storage: restart, rollback, concurrent connections and duplicate-instance protection passed."); await run("Browser collector contracts", ["test:browser"]); await run("Installer and script tests", ["test:scripts"]); await run("Workspace unit tests", ["-r", "--if-present", "test"]); await run("Database, queue and retest integration", ["--filter", "@workspace/web", "test:integration"], { - TEST_DATABASE_URL: `postgres://beatgeo_test:${password}@${address}/beatgeo_mvp_test`, + TEST_DATABASE_URL: env.DATABASE_URL.replace(/\/beatgeo$/, "/beatgeo_mvp_test"), }); console.log("\nMVP regression passed. Live keys, platform sessions, UI and overnight scheduling need separate acceptance."); } catch (error) { console.error(error.message); process.exitCode = 1; } finally { - cleanup(); + await cleanup(); } From 59bebddd2c8b75dcd63afaa9a56c2a1267d98204 Mon Sep 17 00:00:00 2001 From: KKKK Date: Fri, 16 Oct 2026 19:54:28 +0800 Subject: [PATCH 3/6] Complete local diagnostics and reviewed optimization workflow --- .changeset/bright-local-workspace.md | 6 + README.de.md | 7 +- README.ja.md | 7 +- README.ko.md | 7 +- README.md | 7 +- README.zh-CN.md | 7 +- THIRD_PARTY_NOTICES.md | 4 +- apps/web/src/components/collection-check.tsx | 71 + apps/web/src/components/content-draft.tsx | 136 + apps/web/src/components/evidence-summary.tsx | 65 + .../components/first-collection-status.tsx | 4 + apps/web/src/components/monitoring-loop.tsx | 10 +- apps/web/src/components/site-audit.tsx | 75 + apps/web/src/lib/i18n/zh.json | 94 +- .../app/org/$org/brand/$brand/index.tsx | 7 +- .../org/$org/brand/$brand/opportunities.tsx | 4 + apps/web/src/routes/_authed/setup.tsx | 37 +- .../src/server/__tests__/connections.test.ts | 1 + .../monitoring-loop.integration.test.ts | 20 +- .../runtime-health.integration.test.ts | 30 + .../site-and-draft.integration.test.ts | 55 + apps/web/src/server/connections.ts | 7 +- apps/web/src/server/content-draft-core.ts | 120 + apps/web/src/server/content-draft-fn.ts | 24 + apps/web/src/server/evidence-summary.ts | 35 + apps/web/src/server/monitoring-core.ts | 49 +- apps/web/src/server/monitoring-fn.ts | 8 + apps/web/src/server/site-audit-core.ts | 35 + apps/web/src/server/site-audit-fn.ts | 18 + apps/worker/src/index.ts | 30 +- docs/beat-geo/acceptance-2026-10-16.md | 49 +- docs/beat-geo/beatapi-product-loop.md | 2 +- docs/beat-geo/getting-started.md | 14 +- docs/metrics.md | 4 +- docs/roadmap.md | 6 +- docs/self-hosting.md | 4 +- .../content/docs/developer-guide/commands.mdx | 4 +- packages/lib/package.json | 5 +- packages/lib/src/db/db.ts | 5 +- .../src/db/migrations/0025_runtime_health.sql | 7 + .../0026_site_checks_and_drafts.sql | 13 + .../src/db/migrations/meta/0025_snapshot.json | 3815 ++++++++++++++++ .../src/db/migrations/meta/0026_snapshot.json | 3909 +++++++++++++++++ .../lib/src/db/migrations/meta/_journal.json | 14 + packages/lib/src/db/schema.ts | 23 + packages/lib/src/observed-metrics.test.ts | 38 + packages/lib/src/observed-metrics.ts | 60 + packages/lib/src/public-page.test.ts | 49 + packages/lib/src/public-page.ts | 131 + packages/lib/src/runtime-health.ts | 65 + packages/lib/src/site-audit.test.ts | 75 + packages/lib/src/site-audit.ts | 155 + 52 files changed, 9349 insertions(+), 78 deletions(-) create mode 100644 .changeset/bright-local-workspace.md create mode 100644 apps/web/src/components/collection-check.tsx create mode 100644 apps/web/src/components/content-draft.tsx create mode 100644 apps/web/src/components/evidence-summary.tsx create mode 100644 apps/web/src/components/site-audit.tsx create mode 100644 apps/web/src/server/__tests__/runtime-health.integration.test.ts create mode 100644 apps/web/src/server/__tests__/site-and-draft.integration.test.ts create mode 100644 apps/web/src/server/content-draft-core.ts create mode 100644 apps/web/src/server/content-draft-fn.ts create mode 100644 apps/web/src/server/evidence-summary.ts create mode 100644 apps/web/src/server/site-audit-core.ts create mode 100644 apps/web/src/server/site-audit-fn.ts create mode 100644 packages/lib/src/db/migrations/0025_runtime_health.sql create mode 100644 packages/lib/src/db/migrations/0026_site_checks_and_drafts.sql create mode 100644 packages/lib/src/db/migrations/meta/0025_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/0026_snapshot.json create mode 100644 packages/lib/src/observed-metrics.test.ts create mode 100644 packages/lib/src/observed-metrics.ts create mode 100644 packages/lib/src/public-page.test.ts create mode 100644 packages/lib/src/public-page.ts create mode 100644 packages/lib/src/runtime-health.ts create mode 100644 packages/lib/src/site-audit.test.ts create mode 100644 packages/lib/src/site-audit.ts diff --git a/.changeset/bright-local-workspace.md b/.changeset/bright-local-workspace.md new file mode 100644 index 00000000..46ad89e3 --- /dev/null +++ b/.changeset/bright-local-workspace.md @@ -0,0 +1,6 @@ +--- +"@workspace/web": patch +"@workspace/worker": patch +"@workspace/lib": patch +--- +Simplify local setup with bundled PostgreSQL and add worker diagnostics, website checks, reviewed content drafts and single-question collection controls. diff --git a/README.de.md b/README.de.md index 8d35b7bd..2330cdc3 100644 --- a/README.de.md +++ b/README.de.md @@ -23,7 +23,7 @@ Gib deine Website und dein Geschäftsfeld an, bestätige die vorgeschlagenen Fra ## Schnellstart -Benötigt werden **Git, Node.js 24, pnpm 12.4.1, Docker mit Compose und Chrome**. Starte Docker vor der Einrichtung. +Benötigt werden **Git, Node.js 24, pnpm 12.4.1 und Chrome**. BeatGEO bringt PostgreSQL mit und verwaltet es automatisch. Docker und eine separate Datenbankinstallation sind nicht erforderlich. ```sh git clone https://github.com/BeatAPI/BeatGEO.git @@ -41,7 +41,7 @@ pnpm local:start Das Setup richtet eine eigene lokale PostgreSQL-Datenbank und die Erweiterung ein. Kein zweiter Repository-Klon, kein zusätzliches BeatBrowser-Konto und keine globale CLI-Installation nötig. Eine vorhandene BeatBrowser-Verbindung kann verwendet werden; pro Chrome-Profil nur eine Erweiterungskopie aktivieren. -**Für laufendes Monitoring muss die Umgebung laufen:** Computer wach und online halten; Docker/PostgreSQL, Chrome und `pnpm local:start` müssen aktiv bleiben. Der Dashboard-Tab darf geschlossen werden. Zum Neustart reicht `pnpm local:start`; die Datenbank nicht neu anlegen. Bei ausgeschaltetem oder offline befindlichem Computer findet keine Erfassung statt. +**Für laufendes Monitoring muss die Umgebung laufen:** Computer wach und online halten; Chrome und `pnpm local:start` müssen aktiv bleiben. Der Dashboard-Tab darf geschlossen werden. Zum Neustart reicht `pnpm local:start`; die Datenbank nicht neu anlegen. Bei ausgeschaltetem oder offline befindlichem Computer findet keine Erfassung statt. [Ausführliche Einrichtung auf Chinesisch](docs/beat-geo/getting-started.md) · [Vorhandenes PostgreSQL einrichten, Chinesisch](docs/self-hosting.md) @@ -79,7 +79,8 @@ Den [interaktiven Entwurf](docs/product/flow.html) lokal öffnen. Er zeigt das * | Fragen und Verbesserungen | Fragen auf Englisch/Chinesisch, Branchenvorlagen, gespeicherte Ausgangswerte und vergleichbare Wiederholungstests. | | Planung und Speicherung | PostgreSQL + pg-boss. Der nächste Lauf wurde im Live-Test eingereiht; unbeaufsichtigter Dauerbetrieb bleibt zu prüfen. Kein SQLite. | | BeatAPI-Recherche | Suche, Seitenabruf und Modellintegration implementiert; Abnahme mit echtem Key und kostenpflichtigen Aufrufen ausstehend. | -| Technisches Website-Audit und SEO-Daten | Geplant oder nicht angeschlossen. Keine behaupteten Keyword-Volumina, Backlink-Indizes oder echten KI-Fragevolumina. | +| Website-Prüfung und Entwürfe | Begrenzte HTML-/robots-/Sitemap-Prüfung, kostenlose Gliederungen, Bearbeitung und Freigabe je Version. Grenzen der Netzwerk- und kostenpflichtigen KI-Tests stehen im Prüfbericht. | +| SEO-Daten | Nicht angeschlossen. Keine Keyword-Volumina, Backlink-Indizes oder echten KI-Fragevolumina. | Die README gibt es auf Englisch, Chinesisch, Japanisch, Deutsch und Koreanisch. **Die Produktoberfläche lässt sich über EN / 中文 zwischen Englisch und Chinesisch umschalten. Die Auswahl wird im Browser gespeichert.** Fragen, Markennamen und gesammelte Antworten bleiben in ihrer ursprünglichen Sprache. Die README-Übersetzungen bedeuten keine fünfsprachige Oberfläche. diff --git a/README.ja.md b/README.ja.md index 10ccb3f4..928b4f64 100644 --- a/README.ja.md +++ b/README.ja.md @@ -23,7 +23,7 @@ ## クイックスタート -**Git、Node.js 24、pnpm 12.4.1、Docker(Compose を含む)、Chrome** を用意し、Docker を起動してください。 +**Git、Node.js 24、pnpm 12.4.1、Chrome** を用意してください。PostgreSQL は BeatGEO に同梱され、自動管理されます。Docker やデータベースの別途インストールは不要です。 ```sh git clone https://github.com/BeatAPI/BeatGEO.git @@ -41,7 +41,7 @@ pnpm local:start セットアップが専用のローカル PostgreSQL と拡張機能を準備します。BeatBrowser を別にクローンしたり、別アカウントやグローバル CLI を用意したりする必要はありません。既存の接続も利用できますが、同じ Chrome プロファイルで拡張機能を二重に有効化しないでください。 -**継続監視には稼働中の環境が必要です。** パソコンをスリープさせず、ネット接続、Docker/PostgreSQL、Chrome、`pnpm local:start` を維持します。ダッシュボードのタブは閉じても構いません。再開時は `pnpm local:start` を実行し、データベースは作り直さないでください。オフライン中の収集はできません。 +**継続監視には稼働中の環境が必要です。** パソコンをスリープさせず、ネット接続、Chrome、`pnpm local:start` を維持します。ダッシュボードのタブは閉じても構いません。再開時は `pnpm local:start` を実行し、データベースは作り直さないでください。オフライン中の収集はできません。 [詳しい導入手順(中国語)](docs/beat-geo/getting-started.md) · [既存 PostgreSQL の設定(中国語)](docs/self-hosting.md) @@ -79,7 +79,8 @@ BeatGEO が製品データとスケジュールを管理し、**BeatBrowser** | 質問と改善 | 英語・中国語の質問生成、業種別テンプレート、改善の基準値保存、同一質問での再測定。 | | スケジュールと保存 | PostgreSQL + pg-boss。実測では次回処理の予約まで確認。長期間の無人運用と SQLite には未対応です。 | | BeatAPI リサーチ | 検索・読み取り・モデル連携は実装済み。実キーによる有料呼び出しの受け入れ検証は未完了です。 | -| サイト技術監査・SEO データ | 計画中または未接続。検索量、被リンク索引、実際の AI 質問量を保有しているとは表明しません。 | +| サイト確認・コンテンツ下書き | 範囲を限定した HTML/robots/sitemap 確認、無料アウトライン、編集と版ごとの承認。公開ネットワークと有料生成の検証範囲はレポートを参照。 | +| SEO データ | 未接続。検索量、被リンク索引、実際の AI 質問量は提供しません。 | README は英語、中国語、日本語、ドイツ語、韓国語で提供します。**製品 UI は EN / 中文で英語と中国語を切り替えられ、選択はブラウザーに保存されます。** 質問、ブランド名、収集した回答の言語は変更しません。README の翻訳は UI の五言語対応を意味しません。 diff --git a/README.ko.md b/README.ko.md index bba40ef7..ca6e294e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -23,7 +23,7 @@ ## 빠른 시작 -**Git, Node.js 24, pnpm 12.4.1, Compose가 포함된 Docker, Chrome**을 준비하고 Docker를 먼저 실행하세요. +**Git, Node.js 24, pnpm 12.4.1, Chrome**을 준비하세요. PostgreSQL은 BeatGEO에 포함되어 자동으로 관리됩니다. Docker나 별도의 데이터베이스를 설치할 필요가 없습니다. ```sh git clone https://github.com/BeatAPI/BeatGEO.git @@ -41,7 +41,7 @@ pnpm local:start 설치 과정에서 전용 로컬 PostgreSQL과 확장 프로그램을 준비합니다. 별도의 저장소 복제, BeatBrowser 계정, 전역 CLI 설치는 필요하지 않습니다. 기존 BeatBrowser 연결을 재사용할 수 있지만, Chrome 프로필 하나에는 확장 프로그램 한 개만 활성화하세요. -**지속적인 모니터링에는 실행 중인 환경이 필요합니다.** 컴퓨터가 절전 모드에 들어가지 않도록 하고 인터넷 연결, Docker/PostgreSQL, Chrome, `pnpm local:start`를 유지하세요. 대시보드 탭은 닫아도 됩니다. 다음에는 `pnpm local:start`로 다시 시작하며, 데이터베이스를 새로 만들지 않습니다. 컴퓨터가 오프라인이면 수집도 중단됩니다. +**지속적인 모니터링에는 실행 중인 환경이 필요합니다.** 컴퓨터가 절전 모드에 들어가지 않도록 하고 인터넷 연결, Chrome, `pnpm local:start`를 유지하세요. 대시보드 탭은 닫아도 됩니다. 다음에는 `pnpm local:start`로 다시 시작하며, 데이터베이스를 새로 만들지 않습니다. 컴퓨터가 오프라인이면 수집도 중단됩니다. [상세 설치 안내 — 중국어](docs/beat-geo/getting-started.md) · [기존 PostgreSQL 설정 — 중국어](docs/self-hosting.md) @@ -79,7 +79,8 @@ BeatGEO는 제품 데이터와 일정을 관리합니다. **BeatBrowser**는 로 | 질문 및 개선 | 영어·중국어 질문 생성, 업종별 템플릿, 개선 기준값 저장, 같은 질문으로 재측정. | | 일정 및 저장 | PostgreSQL + pg-boss. 실제 검증에서 다음 작업이 예약됨을 확인했으며 장기 무인 운영은 미검증입니다. SQLite는 지원하지 않습니다. | | BeatAPI 리서치 | 검색·읽기·모델 연동 구현. 실제 키를 사용한 유료 호출 검증은 아직 완료되지 않았습니다. | -| 사이트 기술 진단 및 SEO 데이터 | 계획 중이거나 미연동입니다. 키워드 검색량, 백링크 색인, 실제 AI 질문량을 제공한다고 주장하지 않습니다. | +| 사이트 확인 및 콘텐츠 초안 | 제한된 HTML/robots/sitemap 확인, 무료 개요, 편집과 버전별 승인. 공개 네트워크 및 유료 AI 검증 범위는 보고서를 참고하세요. | +| SEO 데이터 | 미연동입니다. 키워드 검색량, 백링크 색인, 실제 AI 질문량을 제공하지 않습니다. | README는 영어, 중국어, 일본어, 독일어, 한국어로 제공합니다. **제품 UI는 EN / 中文으로 영어와 중국어를 전환할 수 있으며 선택은 브라우저에 저장됩니다.** 질문, 브랜드명, 수집한 답변은 원래 언어를 유지합니다. README 번역이 UI의 5개 언어 지원을 뜻하지는 않습니다. diff --git a/README.md b/README.md index b96f22db..84db1d5a 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Start with your website and business. Review suggested monitoring questions, col ## Quick start -Prepare **Git, Node.js 24, pnpm 12.4.1, Docker with Compose, and Chrome**. Start Docker before setup. +Prepare **Git, Node.js 24, pnpm 12.4.1, and Chrome**. PostgreSQL is bundled and managed by BeatGEO; no Docker or separate database installation is required. ```sh git clone https://github.com/BeatAPI/BeatGEO.git @@ -41,7 +41,7 @@ Open **http://localhost:3140/setup** and register your local administrator: The installer prepares a dedicated local PostgreSQL database and the included extension. No second repository clone, BeatBrowser account or global CLI installation is needed. Existing BeatBrowser connections can be reused; enable only one extension copy per profile. -**To keep monitoring:** leave the computer awake and online, with Docker/PostgreSQL, Chrome and `pnpm local:start` running. You can close the dashboard tab. Restart with `pnpm local:start`; do not recreate the database. This local edition does not provide monitoring while your computer is offline. +**To keep monitoring:** leave the computer awake and online, with Chrome and `pnpm local:start` running. You can close the dashboard tab. Restart with `pnpm local:start`; do not recreate the database. This local edition does not provide monitoring while your computer is offline. [Detailed onboarding — Chinese](docs/beat-geo/getting-started.md) · [Manual PostgreSQL setup — Chinese](docs/self-hosting.md) @@ -79,7 +79,8 @@ Open [the interactive blueprint](docs/product/flow.html) locally to explore its | Questions and optimization | English/Chinese question generation, industry templates, saved action baselines and comparable retests. | | Scheduling and storage | PostgreSQL + pg-boss. The next cycle was queued in live validation; long unattended operation has not been established. SQLite is not supported. | | BeatAPI research | Search/read/model integration is implemented; acceptance with a real key and billed calls remains pending. | -| Technical site audit and SEO datasets | Planned or not connected. No claimed keyword volumes, backlink index or real AI prompt-volume dataset. | +| Website checks and drafts | Bounded HTML/robots/sitemap checks, free outlines, editable drafts and revision approval. Public-network and paid AI acceptance have stated limits. | +| SEO datasets | Not connected. No claimed keyword volumes, backlink index or real AI prompt-volume dataset. | README translations: English, Chinese, Japanese, German and Korean. **Switch the dashboard between English and Chinese using EN / 中文. Your preference is saved in this browser.** Questions, brand names and collected answers retain their original language. The translated READMEs do not imply five-language UI support. diff --git a/README.zh-CN.md b/README.zh-CN.md index f63e1e19..7b73d414 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,7 +23,7 @@ ## 快速开始 -准备 **Git、Node.js 24、pnpm 12.4.1、Docker(含 Compose)和 Chrome**,先启动 Docker。 +准备 **Git、Node.js 24、pnpm 12.4.1 和 Chrome**。BeatGEO 自带并管理独立 PostgreSQL,无需安装 Docker 或数据库。 ```sh git clone https://github.com/BeatAPI/BeatGEO.git @@ -41,7 +41,7 @@ pnpm local:start 安装脚本会准备独立 PostgreSQL 和内置扩展,不必再克隆 BeatBrowser、注册另一套账号或安装全局 CLI。已有 BeatBrowser 可以复用,同一 Chrome 配置只启用一份扩展。 -**持续监测需要持续运行:** 电脑保持唤醒和联网,Docker/PostgreSQL、Chrome、`pnpm local:start` 保持运行。可以关闭工作台网页;下次执行 `pnpm local:start` 即可,不要重建数据库。本地版不会在电脑离线时继续采集。 +**持续监测需要持续运行:** 电脑保持唤醒和联网,Chrome、`pnpm local:start` 保持运行。可以关闭工作台网页;下次执行 `pnpm local:start` 即可,不要重建数据库。本地版不会在电脑离线时继续采集。 [详细使用引导](docs/beat-geo/getting-started.md) · [已有 PostgreSQL 的手动配置](docs/self-hosting.md) @@ -79,7 +79,8 @@ BeatGEO 管理产品数据和调度,**BeatBrowser** 执行已登录浏览器 | 题库与优化 | 中英文问题生成、行业模板、优化行动基线与同组复测。 | | 调度与存储 | PostgreSQL + pg-boss;实测已排入下一轮任务,长期无人值守仍待验收。不支持 SQLite。 | | BeatAPI 研究 | 搜索、阅读、模型分析已接入代码,真实 Key 的付费调用仍待验收。 | -| 网站技术审计与 SEO 数据 | 待开发或接入,不提供虚构的关键词量、外链库或真实 AI 提问量。 | +| 网站检查与内容草稿 | 有限 HTML/robots/sitemap 检查、免费大纲、可编辑草稿及版本审核;公网检查与真实付费生成的验收边界见报告。 | +| SEO 数据 | 尚未接入,不提供虚构的关键词量、外链库或真实 AI 提问量。 | README 提供英文、中文、日文、德文和韩文。**产品界面通过 EN / 中文切换,选择会保存在当前浏览器。** 问题、品牌名和采集原文保留原语言。文档五语言不代表界面五语言。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4245d4ff..bd815922 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -4,7 +4,7 @@ BeatGEO is maintained by BeatAPI and incorporates the MIT-licensed Elmo codebase Inherited statistics include share-of-voice and citation-stability calculations. The local metrics module reuses the same answer-level share-of-voice ratio. -[BeatBrowser](https://github.com/BeatAPI/BeatBrowser) is included as a pinned MIT dependency (`e39d1ee488fd5d069afa385b1cbcc326c8850018`); its original license and notices remain in the package. Its Chrome extension is copied locally during setup, with no separate product account or database. +[BeatBrowser](https://github.com/BeatAPI/BeatBrowser) is included as a pinned MIT dependency (`9aefd38bbd4e3fc7762751bcff57599fe8405588`); its original license and notices remain in the package. Its Chrome extension is copied locally during setup, with no separate product account or database. Research references, not bundled complete products: @@ -14,3 +14,5 @@ Research references, not bundled complete products: - [Ansvisor](https://github.com/ansvisor/ansvisor): opportunity-to-action workflow. Geist and Lucide notices remain in `apps/browser-lab/public/assets/`. Dependency notices remain with packages. Historical changelogs and source documentation retain original attribution. Compatibility identifiers for existing ciphertext are not product branding. + +The default local installer uses [embedded-postgres](https://github.com/leinelissen/embedded-postgres), pinned to `17.10.0-beta.17` (MIT wrapper; platform packages declare Apache-2.0). PostgreSQL and bundled native libraries retain their own notices in the dependency packages. The fixed-version installation scripts restore native library symlinks inside those packages; BeatGEO does not create operating-system users or install a system-wide database service. diff --git a/apps/web/src/components/collection-check.tsx b/apps/web/src/components/collection-check.tsx new file mode 100644 index 00000000..4f03ba5a --- /dev/null +++ b/apps/web/src/components/collection-check.tsx @@ -0,0 +1,71 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useT } from "@/lib/i18n"; +import { collectOneQuestionFn } from "@/server/monitoring-fn"; +export function CollectionCheck({ + brandId, + ready, + questions, +}: { + brandId: string; + ready: boolean; + questions: { id: string; value: string }[]; +}) { + const t = useT(); + const client = useQueryClient(); + const [selected, setSelected] = useState(""); + const promptId = questions.some((q) => q.id === selected) ? selected : questions[0]?.id; + const mutation = useMutation({ + mutationFn: () => collectOneQuestionFn({ data: { brandId, promptId: promptId! } }), + onSuccess: () => client.invalidateQueries({ queryKey: ["monitoring", brandId] }), + }); + return ( +
+

{t(ready ? "Worker ready" : "Worker not ready")}

+ {!ready && ( +

+ {t("Collection is not ready. Start pnpm local:start and refresh; queued questions are preserved.")} +

+ )} +
+ + +
+

+ {t( + "Runs this question on configured targets. Existing scheduled jobs remain queued. API targets may incur charges; duplicate clicks are limited for 5 minutes.", + )} +

+ {mutation.data && ( +

+ {t( + mutation.data.queued + ? "Question queued. Follow the collection history below." + : "This question is already queued or was requested recently.", + )} +

+ )} + {mutation.error &&

{t(mutation.error.message)}

} +
+ ); +} diff --git a/apps/web/src/components/content-draft.tsx b/apps/web/src/components/content-draft.tsx new file mode 100644 index 00000000..8c44aaab --- /dev/null +++ b/apps/web/src/components/content-draft.tsx @@ -0,0 +1,136 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useLocale, useT } from "@/lib/i18n"; +import { createContentDraftFn, reviewContentDraftFn, saveContentDraftFn } from "@/server/content-draft-fn"; + +type DraftAction = { + id: string; + contentDraft: string | null; + draftRevision: number; + reviewedRevision: number | null; + status: string; +}; +const button = "rounded-md border px-3 py-1.5 text-sm hover:bg-muted disabled:opacity-50"; +function isReviewed(action: DraftAction) { + return Boolean(action.contentDraft) && action.draftRevision === action.reviewedRevision; +} +function draftText(edit: { content: string } | null, action: DraftAction) { + return edit?.content ?? action.contentDraft ?? ""; +} +export function ContentDraft({ brandId, action }: { brandId: string; action: DraftAction }) { + const t = useT(); + const { locale } = useLocale(); + const client = useQueryClient(); + const [edit, setEdit] = useState<{ content: string; revision: number } | null>(null); + const [notice, setNotice] = useState(""); + const content = draftText(edit, action); + const dirty = edit !== null && edit.content !== action.contentDraft; + const reviewed = isReviewed(action); + const mutation = useMutation({ + mutationFn: async (operation: "outline" | "ai" | "save" | "review") => { + setNotice(""); + const data = { brandId, id: action.id, revision: edit?.revision ?? action.draftRevision }; + if (operation === "save") return saveContentDraftFn({ data: { ...data, content } }); + if (operation === "review") return reviewContentDraftFn({ data }); + return createContentDraftFn({ data: { ...data, language: locale, useAi: operation === "ai" } }); + }, + onSuccess: async () => { + setEdit(null); + await client.invalidateQueries({ queryKey: ["monitoring", brandId] }); + }, + }); + const readOnly = action.status !== "planned"; + return ( +
+ + {t("Content draft")} · {t(reviewed ? "Reviewed" : "Needs review")} + +
+

+ {t( + "Drafts stay here until you publish them yourself. Review facts and references before marking an improvement implemented.", + )} +

+ {!readOnly && ( +
+ + +
+ )} + {content && ( + <> +