create-next-imagicma 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-next-imagicma",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "create-next-imagicma": "./bin/create-next-imagicma.mjs"
@@ -1,5 +1,5 @@
1
1
  version: "0.5"
2
- log_location: ".opencode/logs/process-compose.log"
2
+ log_location: ".imagicma/process-compose.log"
3
3
 
4
4
  processes:
5
5
  web:
@@ -1,8 +1,5 @@
1
- # 复制为 .env.local 并填入真实值(.env.local 不会被提交)
1
+ # 复制为 .env.local 后按需覆盖(.env.local 不会被提交)
2
2
  #
3
- # Postgres 连接串(Drizzle + pg 使用)
4
- # 示例:
5
- # DATABASE_URL="postgres://user:pass@localhost:5432/mydb"
6
- DATABASE_URL=""
7
-
3
+ # 可选:自定义 SQLite 文件位置;默认使用 ./.data/app.db
4
+ # DATABASE_FILE="./.data/app.db"
8
5
 
@@ -12,7 +12,7 @@
12
12
  - 前端:React 19 + React Router + Tailwind v4 + shadcn/ui
13
13
  - 请求层:React Query + fetch
14
14
  - 契约:Zod(`shared/routes.ts`)
15
- - 数据库:优先 Postgres + Drizzle;若 `DATABASE_URL` 缺失,默认走 SQLite(零配置)并保持 API 契约不变
15
+ - 数据库:SQLite + Drizzle(默认 `./.data/app.db`,无需 `DATABASE_URL`;可用 `DATABASE_FILE` 覆盖)
16
16
 
17
17
  ## 目录约定
18
18
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  - 前端:React 19、React Router、Tailwind CSS v4、shadcn/ui、React Query
8
8
  - 后端:Hono(Node runtime)
9
- - 数据层:Drizzle ORM + PostgreSQL (`pg`)
9
+ - 数据层:Drizzle ORM + SQLite(默认 `./.data/app.db`)
10
10
  - 校验契约:Zod(`shared/routes.ts`)
11
11
 
12
12
  ## 目录结构(重点)
@@ -44,15 +44,36 @@ pnpm start
44
44
 
45
45
  ## 数据库
46
46
 
47
- 1. 复制 `.env.example` 为 `.env.local`
48
- 2. 填写 `DATABASE_URL`
49
- 3. 初始化结构:
47
+ 首次运行前初始化结构:
50
48
 
51
49
  ```bash
52
50
  pnpm db:push
53
51
  ```
54
52
 
53
+ - 默认数据库文件:`./.data/app.db`
54
+ - 如需自定义位置,可复制 `.env.example` 为 `.env.local` 后设置 `DATABASE_FILE`
55
+
55
56
  ## 验证路径
56
57
 
57
58
  - API:`GET /api/greeting`
58
59
  - 页面:`/hello`
60
+
61
+ ## E2E 与 `run_test`
62
+
63
+ - Playwright 配置位于 [`playwright.config.ts`](/Users/alexliu/Project/imagicma-all/imagicma-template/hono-app/playwright.config.ts)
64
+ - 测试契约位于 [`hono-app/.imagicma/testing-manifest.json`](/Users/alexliu/Project/imagicma-all/imagicma-template/hono-app/.imagicma/testing-manifest.json)
65
+ - 本地回归命令:
66
+
67
+ ```bash
68
+ pnpm test:e2e
69
+ ```
70
+
71
+ - `run_test` 驱动 Playwright 时会注入:
72
+ - `IMAGICMA_RUN_ID`
73
+ - `IMAGICMA_RUN_TEST_RUNTIME_ROOT`
74
+ - `IMAGICMA_RUN_TEST_SKIP_WEBSERVER`
75
+ - `BASE_URL`
76
+
77
+ fixtures 约定:
78
+ - `tests/e2e/fixtures/imagicma.ts` 必须把浏览器 warning/error、page errors、失败网络请求与 current URL 写入 `IMAGICMA_RUN_TEST_RUNTIME_ROOT`
79
+ - `testing-manifest.json` 负责声明稳定页面、selectors、fixtures 与 auth profile,供 `run_test` 编译阶段使用
@@ -1,35 +1,102 @@
1
1
  import React from "react";
2
+ import {
3
+ canUsePreviewRepair,
4
+ sendPreviewRepairRequest,
5
+ } from "@/lib/imagicma-preview-repair";
2
6
 
3
7
  type AppErrorBoundaryState = {
4
8
  error: Error | null;
9
+ componentStack: string;
10
+ repairStatus: "idle" | "sending" | "synced" | "failed";
11
+ repairMessage: string;
5
12
  };
6
13
 
7
14
  export class AppErrorBoundary extends React.Component<
8
15
  { children: React.ReactNode },
9
16
  AppErrorBoundaryState
10
17
  > {
11
- state: AppErrorBoundaryState = { error: null };
18
+ state: AppErrorBoundaryState = {
19
+ error: null,
20
+ componentStack: "",
21
+ repairStatus: "idle",
22
+ repairMessage: "",
23
+ };
12
24
 
13
25
  static getDerivedStateFromError(error: Error): AppErrorBoundaryState {
14
- return { error };
26
+ return {
27
+ error,
28
+ componentStack: "",
29
+ repairStatus: "idle",
30
+ repairMessage: "",
31
+ };
15
32
  }
16
33
 
17
- componentDidCatch(error: Error) {
34
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
18
35
  console.error(error);
36
+ this.setState({
37
+ componentStack: info.componentStack || "",
38
+ repairStatus: "idle",
39
+ repairMessage: "",
40
+ });
19
41
  }
20
42
 
21
- private reset = () => {
22
- this.setState({ error: null });
43
+ private handleRepair = async () => {
44
+ const { error, componentStack } = this.state;
45
+ if (!error) return;
46
+
47
+ this.setState({
48
+ repairStatus: "sending",
49
+ repairMessage: "正在同步修复草稿到主界面…",
50
+ });
51
+
52
+ try {
53
+ const ack = await sendPreviewRepairRequest({
54
+ pageUrl: window.location.href,
55
+ errorName: (error.name || "Error").trim(),
56
+ errorMessage: (error.message || "发生未知错误").trim(),
57
+ errorStack: error.stack || undefined,
58
+ componentStack: componentStack || undefined,
59
+ timestamp: Date.now(),
60
+ });
61
+
62
+ if (ack.status === "ok") {
63
+ this.setState({
64
+ repairStatus: "synced",
65
+ repairMessage: ack.message?.trim() || "已同步修复草稿到主界面,请回到对话区确认发送。",
66
+ });
67
+ return;
68
+ }
69
+
70
+ this.setState({
71
+ repairStatus: "failed",
72
+ repairMessage: ack.message?.trim() || "同步失败,请稍后重试或手动刷新页面。",
73
+ });
74
+ } catch (error) {
75
+ this.setState({
76
+ repairStatus: "failed",
77
+ repairMessage:
78
+ error instanceof Error && error.message
79
+ ? error.message
80
+ : "同步失败,请稍后重试或手动刷新页面。",
81
+ });
82
+ }
23
83
  };
24
84
 
25
85
  render() {
26
- const { error } = this.state;
86
+ const { error, repairStatus, repairMessage } = this.state;
27
87
  if (!error) {
28
88
  return this.props.children;
29
89
  }
30
90
 
31
91
  const isDev = import.meta.env.DEV;
32
92
  const message = isDev ? (error.message || "发生未知错误").trim() : "";
93
+ const showRepairButton = canUsePreviewRepair();
94
+ const repairButtonLabel =
95
+ repairStatus === "sending"
96
+ ? "同步中..."
97
+ : repairStatus === "synced"
98
+ ? "已同步"
99
+ : "一键修复";
33
100
 
34
101
  return (
35
102
  <div
@@ -41,7 +108,7 @@ export class AppErrorBoundary extends React.Component<
41
108
  <div className="flex flex-col items-center text-center">
42
109
  <h1 className="text-3xl font-semibold tracking-tight">预览暂时不可用</h1>
43
110
  <p className="mt-4 max-w-md text-base leading-7 text-white/80">
44
- 检测到错误。修复后可点击“重试”恢复,或直接刷新页面。
111
+ 检测到错误。你可以把错误信息同步回主界面,让系统生成修复草稿,或直接刷新页面。
45
112
  </p>
46
113
 
47
114
  {isDev && message ? (
@@ -50,14 +117,21 @@ export class AppErrorBoundary extends React.Component<
50
117
  </pre>
51
118
  ) : null}
52
119
 
120
+ {repairMessage ? (
121
+ <p className="mt-4 max-w-md text-sm leading-6 text-white/80">{repairMessage}</p>
122
+ ) : null}
123
+
53
124
  <div className="mt-8 flex w-full flex-col gap-3 sm:flex-row sm:justify-center">
54
- <button
55
- type="button"
56
- className="inline-flex h-11 w-full items-center justify-center rounded-full bg-white px-5 text-sm font-medium text-black transition-colors hover:bg-white/90 sm:w-auto"
57
- onClick={this.reset}
58
- >
59
- 重试
60
- </button>
125
+ {showRepairButton ? (
126
+ <button
127
+ type="button"
128
+ className="inline-flex h-11 w-full items-center justify-center rounded-full bg-white px-5 text-sm font-medium text-black transition-colors hover:bg-white/90 disabled:cursor-not-allowed disabled:bg-white/70 sm:w-auto"
129
+ onClick={this.handleRepair}
130
+ disabled={repairStatus === "sending" || repairStatus === "synced"}
131
+ >
132
+ {repairButtonLabel}
133
+ </button>
134
+ ) : null}
61
135
  <button
62
136
  type="button"
63
137
  className="inline-flex h-11 w-full items-center justify-center rounded-full border border-white/20 bg-white/10 px-5 text-sm font-medium text-white transition-colors hover:bg-white/15 sm:w-auto"
@@ -62,7 +62,7 @@ function EnvHint() {
62
62
  return (
63
63
  <div className="text-sm text-muted-foreground">
64
64
  <p>
65
- API 报错请检查:是否已在 <code>.env.local</code> 设置 <code>DATABASE_URL</code>,并执行 <code>npm run db:push</code>。
65
+ 默认使用 <code>./.data/app.db</code>。若 API 报错,请先执行 <code>pnpm db:push</code> 初始化 SQLite;需要自定义位置时可在 <code>.env.local</code> 设置 <code>DATABASE_FILE</code>。
66
66
  </p>
67
67
  </div>
68
68
  );
@@ -0,0 +1,137 @@
1
+ export const PREVIEW_REPAIR_CHANNEL = "imagicma.preview-repair";
2
+ export const PREVIEW_REPAIR_VERSION = 1;
3
+
4
+ export type PreviewRepairAckStatus = "ok" | "error";
5
+
6
+ export interface PreviewRepairPayload {
7
+ pageUrl: string;
8
+ errorName: string;
9
+ errorMessage: string;
10
+ errorStack?: string;
11
+ componentStack?: string;
12
+ timestamp: number;
13
+ }
14
+
15
+ export interface PreviewRepairAck {
16
+ status: PreviewRepairAckStatus;
17
+ message?: string;
18
+ }
19
+
20
+ const PROD_PARENT_ORIGINS = new Set(["https://agentma.cn", "https://imagicma.cn"]);
21
+ const LOCAL_PARENT_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i;
22
+ const LOCAL_IMAGICMA_PARENT_RE = /^https?:\/\/([a-z0-9-]+\.)?local\.(agentma\.cn|imagicma\.cn)(:\d+)?$/i;
23
+ const DEFAULT_TIMEOUT_MS = 4000;
24
+
25
+ function isRecord(value: unknown): value is Record<string, unknown> {
26
+ return typeof value === "object" && value !== null;
27
+ }
28
+
29
+ function trimText(value: unknown): string {
30
+ return typeof value === "string" ? value.trim() : "";
31
+ }
32
+
33
+ function createRequestId(): string {
34
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
35
+ return crypto.randomUUID();
36
+ }
37
+ return `repair_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
38
+ }
39
+
40
+ export function isInIframe(): boolean {
41
+ return typeof window !== "undefined" && window.parent !== window;
42
+ }
43
+
44
+ export function isAllowedRepairParentOrigin(origin: string): boolean {
45
+ if (!origin) return false;
46
+ return PROD_PARENT_ORIGINS.has(origin) || LOCAL_PARENT_RE.test(origin) || LOCAL_IMAGICMA_PARENT_RE.test(origin);
47
+ }
48
+
49
+ export function resolveRepairParentOrigin(referrer: string = document.referrer): string | null {
50
+ const source = trimText(referrer);
51
+ if (!source) return null;
52
+
53
+ try {
54
+ const origin = new URL(source).origin;
55
+ return isAllowedRepairParentOrigin(origin) ? origin : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function isAckMessage(value: unknown): value is {
62
+ channel: typeof PREVIEW_REPAIR_CHANNEL;
63
+ version: typeof PREVIEW_REPAIR_VERSION;
64
+ type: "IMAGICMA_PREVIEW_REPAIR_ACK";
65
+ requestId: string;
66
+ payload: PreviewRepairAck;
67
+ } {
68
+ if (!isRecord(value)) return false;
69
+ if (value.channel !== PREVIEW_REPAIR_CHANNEL) return false;
70
+ if (value.version !== PREVIEW_REPAIR_VERSION) return false;
71
+ if (value.type !== "IMAGICMA_PREVIEW_REPAIR_ACK") return false;
72
+ if (typeof value.requestId !== "string" || value.requestId.length === 0) return false;
73
+ if (!isRecord(value.payload)) return false;
74
+ if (value.payload.status !== "ok" && value.payload.status !== "error") return false;
75
+ if (value.payload.message !== undefined && typeof value.payload.message !== "string") return false;
76
+ return true;
77
+ }
78
+
79
+ export function canUsePreviewRepair(): boolean {
80
+ return isInIframe() && !!resolveRepairParentOrigin();
81
+ }
82
+
83
+ export function sendPreviewRepairRequest(
84
+ payload: PreviewRepairPayload,
85
+ options?: { timeoutMs?: number },
86
+ ): Promise<PreviewRepairAck> {
87
+ if (!isInIframe()) {
88
+ return Promise.reject(new Error("当前页面不在 iframe 中"));
89
+ }
90
+
91
+ const parentOrigin = resolveRepairParentOrigin();
92
+ if (!parentOrigin) {
93
+ return Promise.reject(new Error("当前父页面来源不在允许名单中"));
94
+ }
95
+
96
+ const requestId = createRequestId();
97
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
98
+
99
+ return new Promise((resolve, reject) => {
100
+ const cleanup = () => {
101
+ window.removeEventListener("message", handleMessage);
102
+ window.clearTimeout(timer);
103
+ };
104
+
105
+ const handleMessage = (event: MessageEvent) => {
106
+ if (event.source !== window.parent) return;
107
+ if (event.origin !== parentOrigin) return;
108
+ if (!isAckMessage(event.data)) return;
109
+ if (event.data.requestId !== requestId) return;
110
+ cleanup();
111
+ resolve(event.data.payload);
112
+ };
113
+
114
+ const timer = window.setTimeout(() => {
115
+ cleanup();
116
+ reject(new Error("主界面响应超时,请稍后重试或手动刷新页面"));
117
+ }, timeoutMs);
118
+
119
+ window.addEventListener("message", handleMessage);
120
+
121
+ try {
122
+ window.parent.postMessage(
123
+ {
124
+ channel: PREVIEW_REPAIR_CHANNEL,
125
+ version: PREVIEW_REPAIR_VERSION,
126
+ type: "IMAGICMA_PREVIEW_REPAIR_REQUEST",
127
+ requestId,
128
+ payload,
129
+ },
130
+ parentOrigin,
131
+ );
132
+ } catch (error) {
133
+ cleanup();
134
+ reject(error instanceof Error ? error : new Error("发送修复请求失败"));
135
+ }
136
+ });
137
+ }
@@ -1,6 +1,74 @@
1
+ import { useState } from "react";
2
+
1
3
  export default function Home() {
4
+ const [query, setQuery] = useState("");
5
+ const [includeExamples, setIncludeExamples] = useState(true);
6
+ const [submitted, setSubmitted] = useState("");
7
+
2
8
  return (
3
- <div style={{ display: "none" }}>请优先修改本页</div>
9
+ <main
10
+ data-testid="page.home"
11
+ className="mx-auto flex min-h-screen max-w-3xl flex-col justify-center gap-6 px-6 py-12"
12
+ >
13
+ <div className="space-y-2">
14
+ <span
15
+ data-testid="badge.template-ready"
16
+ className="inline-flex rounded-full bg-slate-900 px-3 py-1 text-xs font-medium text-white"
17
+ >
18
+ imagicma hono template
19
+ </span>
20
+ <h1 className="text-4xl font-semibold tracking-tight text-slate-950">
21
+ Start building with a testable baseline
22
+ </h1>
23
+ <p className="max-w-2xl text-sm text-slate-600">
24
+ This page is intentionally simple and visible so generated projects start with a stable smoke-test target.
25
+ </p>
26
+ </div>
27
+
28
+ <form
29
+ data-testid="form.quick-start"
30
+ className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
31
+ onSubmit={(event) => {
32
+ event.preventDefault();
33
+ setSubmitted(`${query || "Untitled"}|examples:${includeExamples ? "on" : "off"}`);
34
+ }}
35
+ >
36
+ <div className="space-y-4">
37
+ <label className="flex flex-col gap-2 text-sm font-medium text-slate-700">
38
+ Project brief
39
+ <input
40
+ data-testid="input.project-brief"
41
+ className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
42
+ placeholder="Describe the app you want to build"
43
+ value={query}
44
+ onChange={(event) => setQuery(event.target.value)}
45
+ />
46
+ </label>
47
+
48
+ <label className="flex items-center gap-2 text-sm text-slate-700">
49
+ <input
50
+ data-testid="checkbox.include-examples"
51
+ type="checkbox"
52
+ checked={includeExamples}
53
+ onChange={(event) => setIncludeExamples(event.target.checked)}
54
+ />
55
+ Include example content
56
+ </label>
57
+
58
+ <button
59
+ data-testid="button.submit-brief"
60
+ type="submit"
61
+ className="rounded-lg bg-slate-950 px-4 py-2 text-sm font-medium text-white"
62
+ >
63
+ Save brief
64
+ </button>
65
+ </div>
66
+ </form>
67
+
68
+ <section data-testid="panel.submission" className="rounded-2xl border border-dashed border-slate-300 p-4 text-sm text-slate-600">
69
+ <div className="font-medium text-slate-900">Latest submission</div>
70
+ <div>{submitted || "No brief submitted yet."}</div>
71
+ </section>
72
+ </main>
4
73
  );
5
74
  }
6
-
@@ -1,6 +1,7 @@
1
1
  import { defineConfig } from "drizzle-kit";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { ensureDatabaseUrl } from "./server/database-path";
4
5
 
5
6
  function tryLoadEnvFile(file: string) {
6
7
  const filepath = path.resolve(process.cwd(), file);
@@ -33,18 +34,13 @@ function tryLoadEnvFile(file: string) {
33
34
 
34
35
  tryLoadEnvFile(".env.local");
35
36
  tryLoadEnvFile(".env");
36
-
37
- if (!process.env.DATABASE_URL) {
38
- throw new Error(
39
- "DATABASE_URL 未设置:请在 .env.local 中配置 Postgres 连接串(或在 shell 环境变量中导出)。",
40
- );
41
- }
37
+ const databaseUrl = ensureDatabaseUrl();
42
38
 
43
39
  export default defineConfig({
44
40
  out: "./migrations",
45
41
  schema: "./shared/schema.ts",
46
- dialect: "postgresql",
42
+ dialect: "sqlite",
47
43
  dbCredentials: {
48
- url: process.env.DATABASE_URL,
44
+ url: databaseUrl,
49
45
  },
50
46
  });
@@ -7,6 +7,7 @@
7
7
  !.yarn/plugins
8
8
  !.yarn/releases
9
9
  !.yarn/versions
10
+ .imagicma
10
11
 
11
12
  # testing
12
13
  /coverage
@@ -14,6 +15,7 @@
14
15
  # build outputs
15
16
  /dist
16
17
  /build
18
+ /.data
17
19
 
18
20
  # misc
19
21
  .DS_Store
@@ -10,9 +10,13 @@
10
10
  "start": "node ./scripts/imagicma-start.mjs",
11
11
  "check": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.server.json --noEmit",
12
12
  "db:push": "drizzle-kit push",
13
- "lint": "eslint ."
13
+ "lint": "eslint .",
14
+ "test:e2e": "playwright test",
15
+ "test:e2e:ui": "playwright test --ui",
16
+ "test:e2e:headed": "playwright test --headed"
14
17
  },
15
18
  "dependencies": {
19
+ "@libsql/client": "^0.17.0",
16
20
  "@hono/node-server": "^1.19.9",
17
21
  "@hono/zod-validator": "^0.7.6",
18
22
  "@radix-ui/react-accordion": "^1.2.4",
@@ -54,7 +58,6 @@
54
58
  "input-otp": "^1.4.2",
55
59
  "lucide-react": "^0.453.0",
56
60
  "next-themes": "^0.4.6",
57
- "pg": "^8.16.3",
58
61
  "react": "19.2.3",
59
62
  "react-day-picker": "^9.13.0",
60
63
  "react-dom": "19.2.3",
@@ -67,6 +70,7 @@
67
70
  "zod": "^4.1.5"
68
71
  },
69
72
  "devDependencies": {
73
+ "@playwright/test": "^1.57.0",
70
74
  "@hono/vite-dev-server": "^0.25.0",
71
75
  "@tailwindcss/postcss": "^4",
72
76
  "@types/node": "^20",
@@ -0,0 +1,40 @@
1
+ import { defineConfig } from "@playwright/test";
2
+ import { join } from "node:path";
3
+
4
+ const runtimeRoot = process.env.IMAGICMA_RUN_TEST_RUNTIME_ROOT;
5
+ const baseURL = process.env.BASE_URL || "http://127.0.0.1:5001";
6
+
7
+ export default defineConfig({
8
+ testDir: "./tests/e2e",
9
+ fullyParallel: false,
10
+ workers: 1,
11
+ timeout: 60_000,
12
+ retries: 0,
13
+ outputDir: runtimeRoot ? join(runtimeRoot, "test-results") : "test-results",
14
+ reporter: runtimeRoot
15
+ ? [
16
+ ["line"],
17
+ ["json", { outputFile: join(runtimeRoot, "playwright-results.json") }],
18
+ ["html", { outputFolder: join(runtimeRoot, "html-report"), open: "never" }],
19
+ ["junit", { outputFile: join(runtimeRoot, "junit.xml") }],
20
+ ]
21
+ : [
22
+ ["html", { outputFolder: "playwright-report", open: "never" }],
23
+ ["list"],
24
+ ],
25
+ use: {
26
+ baseURL,
27
+ trace: "retain-on-failure",
28
+ screenshot: "only-on-failure",
29
+ video: "retain-on-failure",
30
+ headless: true,
31
+ },
32
+ webServer: process.env.IMAGICMA_RUN_TEST_SKIP_WEBSERVER === "1"
33
+ ? undefined
34
+ : {
35
+ command: "pnpm dev",
36
+ url: baseURL,
37
+ reuseExistingServer: true,
38
+ timeout: 120_000,
39
+ },
40
+ });