diff --git a/.github/workflows/deploy-demo-on-release.yml b/.github/workflows/deploy-demo.yml similarity index 97% rename from .github/workflows/deploy-demo-on-release.yml rename to .github/workflows/deploy-demo.yml index 6be1e79d..c2d24571 100644 --- a/.github/workflows/deploy-demo-on-release.yml +++ b/.github/workflows/deploy-demo.yml @@ -1,4 +1,4 @@ -name: Deploy Demo on Release +name: Deploy Demo on: release: @@ -39,7 +39,7 @@ jobs: EDGE_EVER_DEPLOYMENT_METHOD: "github_actions" steps: - - name: Checkout release + - name: Checkout Demo source uses: actions/checkout@v5 with: ref: ${{ github.event.release.tag_name || github.ref }} diff --git a/AGENTS.md b/AGENTS.md index e7350965..47d95fb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,10 @@ bun run build:android:apk:local - 仅在用户明确指定远程实例并要求连接时,使用 `EDGE_EVER_INSTANCE=<实例名> bun run dev:remote`;私有配置以 `.env.local` 为准,不得硬编码实例名。 - 仅在用户明确要求只启动前端时使用 `bun run dev:web`。 +## Demo 示例笔记同步 + +修改 Demo 示例笔记后,使用 `bun run demo:sync` 将已推送的 `main` 部署到公开 Demo,并用最新种子重置示例数据。该命令独立于正式 Release 流程,不创建版本号、标签、Release 或 APK;执行前必须确保工作区干净且 `main` 与 `origin/main` 一致。命令会删除公开 Demo 中的临时内容。 + ## 组件复用与造轮子约束 UI 功能应尽量复用 `shadcn/ui` 等现有 UI 组件。在实现其他功能时,也应优先采用成熟、稳定的开源组件或库,绝对禁止在没有充分必要性的前提下自行从零造轮子。 diff --git a/package.json b/package.json index 17a9a808..b28fc92b 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "deploy:worker": "bun scripts/run-wrangler.mjs deploy", "deploy:cloudflare-builds": "bun run deploy:ci", "deploy:verify": "bun scripts/verify-deployment.mjs", + "demo:sync": "bun scripts/sync-demo.mjs", "deploy:builds:setup": "bun scripts/cloudflare-workers-builds.mjs setup", "deploy:site": "bun run build:site && wrangler pages deploy apps/site/dist --project-name=edgeever-official" }, diff --git a/scripts/sync-demo.mjs b/scripts/sync-demo.mjs new file mode 100644 index 00000000..f9343894 --- /dev/null +++ b/scripts/sync-demo.mjs @@ -0,0 +1,244 @@ +import { spawnSync } from "node:child_process"; + +const REPOSITORY = "tianma-if/edgeever"; +const WORKFLOW = "deploy-demo.yml"; +const DEFAULT_DEMO_URL = "https://demo.edgeever.org"; +const DEFAULT_DEMO_USERNAME = "ee-demo"; +const DEFAULT_DEMO_PASSWORD = "demo#dZ6Q29Zjfor%"; +const RUN_DISCOVERY_ATTEMPTS = 30; +const RUN_DISCOVERY_INTERVAL_MS = 2_000; + +const usage = `Usage: + bun run demo:sync + bun run demo:sync -- --dry-run + +Deploys the pushed main commit through the Demo GitHub Actions workflow, resets +the public Demo workspace to that commit's seed data, and verifies the overview +memo. It does not create a tag or Release. The command requires an authenticated +GitHub CLI. + +Optional environment overrides: + EDGE_EVER_DEMO_URL + EDGE_EVER_DEMO_USERNAME + EDGE_EVER_DEMO_PASSWORD`; + +export const parseSyncDemoArgs = (args) => { + let dryRun = false; + let help = false; + + for (const arg of args) { + if (arg === "--dry-run") { + dryRun = true; + continue; + } + if (arg === "--help" || arg === "-h") { + help = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + return { dryRun, help }; +}; + +const run = (executable, args, options = {}) => { + const result = spawnSync(executable, args, { + encoding: "utf8", + env: process.env, + stdio: options.capture ? "pipe" : "inherit", + }); + + if (result.status !== 0) { + if (options.capture) { + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + } + throw new Error(`${executable} exited with status ${result.status ?? 1}.`); + } + + return options.capture ? result.stdout.trim() : ""; +}; + +const runJson = (executable, args) => { + const output = run(executable, args, { capture: true }); + try { + return JSON.parse(output); + } catch { + throw new Error(`Unable to parse JSON output from ${executable} ${args.join(" ")}.`); + } +}; + +const sleep = (durationMs) => new Promise((resolve) => setTimeout(resolve, durationMs)); + +const assertRepositoryState = () => { + const branch = run("git", ["branch", "--show-current"], { capture: true }); + if (branch !== "main") { + throw new Error(`Demo sync must run on main; current branch is ${branch || "(detached)"}.`); + } + + const status = run("git", ["status", "--porcelain"], { capture: true }); + if (status) { + throw new Error("Demo sync requires a clean working tree. Commit or stash local changes first."); + } + + run("git", ["fetch", "origin", "main"]); + const headSha = run("git", ["rev-parse", "HEAD"], { capture: true }); + const remoteSha = run("git", ["rev-parse", "origin/main"], { capture: true }); + if (headSha !== remoteSha) { + throw new Error( + `Local main (${headSha.slice(0, 12)}) does not match origin/main (${remoteSha.slice(0, 12)}). Push or update main first.`, + ); + } + + run("gh", ["auth", "status"]); + const repository = runJson("gh", ["repo", "view", REPOSITORY, "--json", "nameWithOwner"]); + if (repository.nameWithOwner !== REPOSITORY) { + throw new Error(`GitHub repository is unavailable: ${REPOSITORY}.`); + } + + return headSha; +}; + +const listWorkflowRuns = () => + runJson("gh", [ + "run", + "list", + "--repo", + REPOSITORY, + "--workflow", + WORKFLOW, + "--event", + "workflow_dispatch", + "--branch", + "main", + "--limit", + "20", + "--json", + "databaseId,headSha,status,conclusion,url,createdAt", + ]); + +const dispatchDeployment = async (headSha) => { + const previousRunIds = new Set(listWorkflowRuns().map((workflowRun) => workflowRun.databaseId)); + + run("gh", [ + "workflow", + "run", + WORKFLOW, + "--repo", + REPOSITORY, + "--ref", + "main", + ]); + + for (let attempt = 0; attempt < RUN_DISCOVERY_ATTEMPTS; attempt += 1) { + const workflowRun = listWorkflowRuns().find( + (candidate) => candidate.headSha === headSha && !previousRunIds.has(candidate.databaseId), + ); + if (workflowRun) { + return workflowRun; + } + await sleep(RUN_DISCOVERY_INTERVAL_MS); + } + + throw new Error("Timed out while waiting for the Demo deployment workflow run to appear."); +}; + +const requestJson = async (url, options = {}) => { + const response = await fetch(url, options); + const body = await response.json().catch(() => null); + if (!response.ok) { + const message = body?.error?.message || body?.message || response.statusText; + throw new Error(`Demo API request failed (${response.status}): ${message}`); + } + return body; +}; + +const resetAndVerifyDemo = async () => { + const demoUrl = (process.env.EDGE_EVER_DEMO_URL || DEFAULT_DEMO_URL).replace(/\/+$/, ""); + const username = process.env.EDGE_EVER_DEMO_USERNAME || DEFAULT_DEMO_USERNAME; + const password = process.env.EDGE_EVER_DEMO_PASSWORD || DEFAULT_DEMO_PASSWORD; + + const session = await requestJson(`${demoUrl}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!session?.sessionToken) { + throw new Error("Demo login succeeded without returning a session token."); + } + + const authorization = { Authorization: `Bearer ${session.sessionToken}` }; + await requestJson(`${demoUrl}/api/v1/demo/reset`, { + method: "POST", + headers: authorization, + }); + + const response = await requestJson(`${demoUrl}/api/v1/memos/memo_demo_overview`, { + headers: authorization, + }); + const memo = response.memo ?? response; + if (memo.id !== "memo_demo_overview" || !memo.title || !memo.contentMarkdown) { + throw new Error("Demo overview memo verification returned incomplete data."); + } + + return { + demoUrl, + title: memo.title, + updatedAt: memo.updatedAt, + }; +}; + +const main = async () => { + let options; + try { + options = parseSyncDemoArgs(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + console.error(usage); + process.exit(1); + } + + if (options.help) { + console.log(usage); + return; + } + + if (options.dryRun) { + console.log("[dry-run] validate clean, pushed main and GitHub CLI authentication"); + console.log(`[dry-run] dispatch ${WORKFLOW} on ${REPOSITORY}@main and wait for success`); + console.log("[dry-run] reset the public Demo workspace from the deployed seed"); + console.log("[dry-run] verify memo_demo_overview through the Demo API"); + return; + } + + console.log("[1/4] Validating repository and GitHub access..."); + const headSha = assertRepositoryState(); + console.log(`[ok] main is clean and pushed at ${headSha.slice(0, 12)}`); + + console.log("[2/4] Dispatching the Demo deployment workflow..."); + const workflowRun = await dispatchDeployment(headSha); + console.log(`[ok] workflow run: ${workflowRun.url}`); + + console.log("[3/4] Waiting for the Demo deployment..."); + run("gh", [ + "run", + "watch", + String(workflowRun.databaseId), + "--repo", + REPOSITORY, + "--exit-status", + ]); + + console.log("[4/4] Resetting and verifying Demo seed data..."); + const verification = await resetAndVerifyDemo(); + console.log(`[ok] ${verification.title}`); + console.log(`[ok] updated at ${verification.updatedAt}`); + console.log(`Demo seed synchronized: ${verification.demoUrl}`); +}; + +if (import.meta.main) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +}