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.
@@ -1,5 +1,4 @@
1
1
  version: "0.5"
2
- log_location: ".opencode/logs/process-compose.log"
3
2
 
4
3
  processes:
5
4
  web:
@@ -1,29 +1,20 @@
1
- import { spawn } from "node:child_process";
2
1
  import path from "node:path";
3
2
  import { ROOT_DIR, readLockedPort } from "./imagicma-common.mjs";
3
+ import { startLoggedProcess } from "./imagicma-runtime-logs.mjs";
4
4
 
5
5
  const viteBin = path.join(ROOT_DIR, "node_modules", "vite", "bin", "vite.js");
6
6
  const port = await readLockedPort();
7
7
 
8
- const child = spawn(
9
- process.execPath,
10
- [viteBin, "--host", "0.0.0.0", "--port", String(port), "--strictPort"],
11
- {
12
- cwd: ROOT_DIR,
13
- stdio: "inherit",
14
- env: {
15
- ...process.env,
16
- IMAGICMA_SCRIPT_LAUNCH: "1",
17
- IMAGICMA_LAUNCH_MODE: "dev",
18
- },
8
+ await startLoggedProcess({
9
+ processName: "web",
10
+ mode: "dev",
11
+ command: process.execPath,
12
+ args: [viteBin, "--host", "0.0.0.0", "--port", String(port), "--strictPort"],
13
+ cwd: ROOT_DIR,
14
+ expectedPort: port,
15
+ env: {
16
+ ...process.env,
17
+ IMAGICMA_SCRIPT_LAUNCH: "1",
18
+ IMAGICMA_LAUNCH_MODE: "dev",
19
19
  },
20
- );
21
-
22
- child.on("close", (code) => {
23
- process.exit(code ?? 0);
24
- });
25
-
26
- child.on("error", (error) => {
27
- console.error(`[imagicma] 启动 dev 失败:${error.message}`);
28
- process.exit(1);
29
20
  });
@@ -0,0 +1,314 @@
1
+ import crypto from "node:crypto";
2
+ import rawFs from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { ROOT_DIR } from "./imagicma-common.mjs";
7
+
8
+ const RUNTIME_ROOT = path.join(ROOT_DIR, ".imagicma", "runtime");
9
+ const HISTORY_ROOT = path.join(RUNTIME_ROOT, ".history");
10
+ const WORKFLOW_EVENT_LOG = path.join(RUNTIME_ROOT, "events.jsonl");
11
+ const ANSI_PATTERN =
12
+ /[\u001B\u009B][[\]()#;?]*(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|(?:\d{1,4}(?:;\d{0,4})*)?)[\dA-PR-TZcf-nq-uy=><~])/g;
13
+ const WARNING_PATTERN = /\b(warn|warning|deprecated|postcss)\b/i;
14
+ const STARTUP_READY_PATTERN = /\b(VITE\b.*ready|Local:\s+http:\/\/|Server listening on port)\b/i;
15
+
16
+ function createLaunchId() {
17
+ return crypto.randomBytes(15).toString("base64url");
18
+ }
19
+
20
+ function stripAnsi(value) {
21
+ return value.replace(ANSI_PATTERN, "").replace(/\r/g, "");
22
+ }
23
+
24
+ function redactSecrets(value) {
25
+ return value
26
+ .replace(/(Authorization:\s*)(.+)$/gi, "$1[REDACTED]")
27
+ .replace(/\bBearer\s+[A-Za-z0-9._-]+\b/g, "Bearer [REDACTED]")
28
+ .replace(/([?&](?:api[_-]?key|token|access[_-]?token|session|cookie)=)([^&\s]+)/gi, "$1[REDACTED]")
29
+ .replace(/((?:api[_-]?key|token|session|cookie)\s*[:=]\s*)(["']?)[^\s"']+\2/gi, "$1[REDACTED]")
30
+ .replace(
31
+ /\b((?:postgres(?:ql)?|mysql|mariadb|redis):\/\/[^:\s/]+:)([^@\s/]+)(@)/gi,
32
+ "$1[REDACTED]$3",
33
+ );
34
+ }
35
+
36
+ function sanitizePersistedLine(value) {
37
+ return redactSecrets(stripAnsi(value));
38
+ }
39
+
40
+ function inferLevel(text) {
41
+ if (/\b(error|fatal|exception|traceback|unhandled)\b/i.test(text)) return "error";
42
+ if (WARNING_PATTERN.test(text)) return "warning";
43
+ return "info";
44
+ }
45
+
46
+ function parsePortFromText(text) {
47
+ const localMatch = text.match(/Local:\s+http:\/\/[^:]+:(\d+)/i);
48
+ if (localMatch?.[1]) return Number(localMatch[1]);
49
+ const listenMatch = text.match(/Server listening on port (\d+)/i);
50
+ if (listenMatch?.[1]) return Number(listenMatch[1]);
51
+ return null;
52
+ }
53
+
54
+ async function writeJson(filePath, value) {
55
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
56
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
57
+ }
58
+
59
+ async function appendWorkflowEvent(payload) {
60
+ await fs.mkdir(path.dirname(WORKFLOW_EVENT_LOG), { recursive: true });
61
+ await fs.appendFile(
62
+ WORKFLOW_EVENT_LOG,
63
+ `${JSON.stringify({
64
+ tsMs: Date.now(),
65
+ source: "wrapper",
66
+ ...payload,
67
+ })}\n`,
68
+ "utf8",
69
+ );
70
+ }
71
+
72
+ async function pruneOldRuns(processName, keep = 5) {
73
+ const historyDir = path.join(HISTORY_ROOT, processName);
74
+ let entries;
75
+ try {
76
+ entries = await fs.readdir(historyDir, { withFileTypes: true });
77
+ } catch {
78
+ return;
79
+ }
80
+
81
+ const jsonEntries = await Promise.all(
82
+ entries
83
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
84
+ .map(async (entry) => {
85
+ const absolutePath = path.join(historyDir, entry.name);
86
+ const stat = await fs.stat(absolutePath);
87
+ return {
88
+ absolutePath,
89
+ launchId: entry.name.slice(0, -".json".length),
90
+ mtimeMs: stat.mtimeMs,
91
+ };
92
+ }),
93
+ );
94
+
95
+ jsonEntries.sort((left, right) => right.mtimeMs - left.mtimeMs);
96
+ for (const stale of jsonEntries.slice(keep)) {
97
+ await fs.rm(stale.absolutePath, { force: true });
98
+ await fs.rm(path.join(historyDir, `${stale.launchId}.log`), { force: true });
99
+ }
100
+ }
101
+
102
+ export async function startLoggedProcess({
103
+ processName,
104
+ mode,
105
+ command,
106
+ args,
107
+ cwd = ROOT_DIR,
108
+ env,
109
+ expectedPort = null,
110
+ }) {
111
+ const launchId = createLaunchId();
112
+ const historyDir = path.join(HISTORY_ROOT, processName);
113
+ const currentLogPath = path.join(RUNTIME_ROOT, `${processName}.log`);
114
+ const currentMetadataPath = path.join(RUNTIME_ROOT, `${processName}.json`);
115
+ const historyLogPath = path.join(historyDir, `${launchId}.log`);
116
+ const historyMetadataPath = path.join(historyDir, `${launchId}.json`);
117
+
118
+ const metadata = {
119
+ schemaVersion: 2,
120
+ processName,
121
+ mode,
122
+ launchId,
123
+ command: [command, ...args],
124
+ cwd,
125
+ pid: null,
126
+ port: expectedPort,
127
+ status: "launching",
128
+ startedAtMs: Date.now(),
129
+ startupCompletedAtMs: null,
130
+ exitedAtMs: null,
131
+ exitCode: null,
132
+ };
133
+
134
+ const persistMetadata = async () => {
135
+ await writeJson(currentMetadataPath, metadata);
136
+ await writeJson(historyMetadataPath, metadata);
137
+ };
138
+
139
+ await fs.mkdir(historyDir, { recursive: true });
140
+ await fs.mkdir(RUNTIME_ROOT, { recursive: true });
141
+ await fs.writeFile(currentLogPath, "", "utf8");
142
+ await fs.writeFile(historyLogPath, "", "utf8");
143
+ await persistMetadata();
144
+ await appendWorkflowEvent({
145
+ event: "launch_requested",
146
+ processName,
147
+ launchId,
148
+ port: expectedPort,
149
+ status: "requested",
150
+ message: `${mode} launch requested`,
151
+ });
152
+
153
+ const child = spawn(command, args, {
154
+ cwd,
155
+ stdio: ["inherit", "pipe", "pipe"],
156
+ env,
157
+ });
158
+
159
+ metadata.pid = child.pid ?? null;
160
+ metadata.status = "running";
161
+ await persistMetadata();
162
+ await appendWorkflowEvent({
163
+ event: "launch_started",
164
+ processName,
165
+ launchId,
166
+ pid: metadata.pid,
167
+ port: expectedPort,
168
+ status: "running",
169
+ message: `${mode} launch started`,
170
+ });
171
+
172
+ const currentWriteStream = rawFs.createWriteStream(currentLogPath, { flags: "a" });
173
+ const historyWriteStream = rawFs.createWriteStream(historyLogPath, { flags: "a" });
174
+ const streamBuffers = {
175
+ stdout: "",
176
+ stderr: "",
177
+ };
178
+ let lineQueue = Promise.resolve();
179
+ let observedOutput = false;
180
+ let portReadyEmitted = false;
181
+ let startupCompleted = false;
182
+
183
+ const handleLine = async (stream, line) => {
184
+ const clean = sanitizePersistedLine(line);
185
+ if (!clean.trim()) return;
186
+
187
+ const record = `${new Date().toISOString()} ${stream} ${clean}\n`;
188
+ currentWriteStream.write(record);
189
+ historyWriteStream.write(record);
190
+
191
+ if (!observedOutput) {
192
+ observedOutput = true;
193
+ await appendWorkflowEvent({
194
+ event: "stdout_observed",
195
+ processName,
196
+ launchId,
197
+ pid: metadata.pid,
198
+ port: metadata.port,
199
+ status: "running",
200
+ message: `first ${stream} output observed`,
201
+ });
202
+ }
203
+
204
+ const detectedPort = parsePortFromText(clean) ?? metadata.port ?? expectedPort ?? null;
205
+ if (detectedPort && !portReadyEmitted && /Local:\s+http:\/\/|Server listening on port/i.test(clean)) {
206
+ portReadyEmitted = true;
207
+ if (!metadata.port) {
208
+ metadata.port = detectedPort;
209
+ await persistMetadata();
210
+ }
211
+ await appendWorkflowEvent({
212
+ event: "port_ready",
213
+ processName,
214
+ launchId,
215
+ pid: metadata.pid,
216
+ port: detectedPort,
217
+ status: "ready",
218
+ message: clean,
219
+ });
220
+ }
221
+
222
+ if (!startupCompleted && STARTUP_READY_PATTERN.test(clean)) {
223
+ startupCompleted = true;
224
+ metadata.startupCompletedAtMs = Date.now();
225
+ metadata.status = "ready";
226
+ await persistMetadata();
227
+ await appendWorkflowEvent({
228
+ event: "startup_completed",
229
+ processName,
230
+ launchId,
231
+ pid: metadata.pid,
232
+ port: metadata.port,
233
+ status: "ready",
234
+ message: clean,
235
+ });
236
+ }
237
+
238
+ if (inferLevel(clean) !== "info") {
239
+ await appendWorkflowEvent({
240
+ event: "warning_detected",
241
+ processName,
242
+ launchId,
243
+ pid: metadata.pid,
244
+ port: metadata.port,
245
+ status: inferLevel(clean),
246
+ message: clean,
247
+ });
248
+ }
249
+ };
250
+
251
+ const enqueueLines = (stream, chunk, target) => {
252
+ target.write(chunk);
253
+ streamBuffers[stream] += chunk.toString("utf8");
254
+ const parts = streamBuffers[stream].split(/\n/);
255
+ streamBuffers[stream] = parts.pop() ?? "";
256
+ for (const part of parts) {
257
+ const normalized = part.replace(/\r/g, "");
258
+ lineQueue = lineQueue.then(() => handleLine(stream, normalized));
259
+ }
260
+ };
261
+
262
+ child.stdout.on("data", (chunk) => enqueueLines("stdout", chunk, process.stdout));
263
+ child.stderr.on("data", (chunk) => enqueueLines("stderr", chunk, process.stderr));
264
+
265
+ const flushBufferedLines = () => {
266
+ for (const [stream, value] of Object.entries(streamBuffers)) {
267
+ const normalized = value.replace(/\r/g, "").trimEnd();
268
+ if (!normalized) continue;
269
+ lineQueue = lineQueue.then(() => handleLine(stream, normalized));
270
+ streamBuffers[stream] = "";
271
+ }
272
+ };
273
+
274
+ child.on("close", async (code) => {
275
+ flushBufferedLines();
276
+ await lineQueue;
277
+ currentWriteStream.end();
278
+ historyWriteStream.end();
279
+ metadata.exitedAtMs = Date.now();
280
+ metadata.exitCode = code ?? 0;
281
+ metadata.status = (code ?? 0) === 0 ? "exited" : "failed";
282
+ await persistMetadata();
283
+ await appendWorkflowEvent({
284
+ event: "process_exit",
285
+ processName,
286
+ launchId,
287
+ pid: metadata.pid,
288
+ port: metadata.port,
289
+ status: metadata.status,
290
+ message: `${mode} process exited with code ${code ?? 0}`,
291
+ });
292
+ await pruneOldRuns(processName);
293
+ process.exit(code ?? 0);
294
+ });
295
+
296
+ child.on("error", async (error) => {
297
+ console.error(`[imagicma] 启动 ${mode} 失败:${error.message}`);
298
+ metadata.exitedAtMs = Date.now();
299
+ metadata.status = "failed";
300
+ await persistMetadata();
301
+ await appendWorkflowEvent({
302
+ event: "process_exit",
303
+ processName,
304
+ launchId,
305
+ pid: metadata.pid,
306
+ port: metadata.port,
307
+ status: "failed",
308
+ message: error.message,
309
+ });
310
+ process.exit(1);
311
+ });
312
+
313
+ return child;
314
+ }
@@ -1,24 +1,18 @@
1
- import { spawn } from "node:child_process";
2
1
  import path from "node:path";
3
2
  import { ROOT_DIR } from "./imagicma-common.mjs";
3
+ import { startLoggedProcess } from "./imagicma-runtime-logs.mjs";
4
4
 
5
5
  const entry = path.join(ROOT_DIR, "dist", "server", "index.js");
6
6
 
7
- const child = spawn(process.execPath, [entry], {
7
+ await startLoggedProcess({
8
+ processName: "web",
9
+ mode: "start",
10
+ command: process.execPath,
11
+ args: [entry],
8
12
  cwd: ROOT_DIR,
9
- stdio: "inherit",
10
13
  env: {
11
14
  ...process.env,
12
15
  IMAGICMA_SCRIPT_LAUNCH: "1",
13
16
  IMAGICMA_LAUNCH_MODE: "start",
14
17
  },
15
18
  });
16
-
17
- child.on("close", (code) => {
18
- process.exit(code ?? 0);
19
- });
20
-
21
- child.on("error", (error) => {
22
- console.error(`[imagicma] 启动 start 失败:${error.message}`);
23
- process.exit(1);
24
- });
@@ -0,0 +1,35 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ const DEFAULT_DATABASE_FILE = "./.data/app.db";
6
+
7
+ export function resolveDatabaseFilePath() {
8
+ const configuredPath = process.env.DATABASE_FILE?.trim();
9
+ const targetPath =
10
+ configuredPath && configuredPath.length > 0 ? configuredPath : DEFAULT_DATABASE_FILE;
11
+
12
+ return path.resolve(process.cwd(), targetPath);
13
+ }
14
+
15
+ export function ensureDatabaseFilePath() {
16
+ const databaseFilePath = resolveDatabaseFilePath();
17
+
18
+ fs.mkdirSync(path.dirname(databaseFilePath), { recursive: true });
19
+
20
+ return databaseFilePath;
21
+ }
22
+
23
+ export function resolveDatabaseUrl(databaseFilePath = resolveDatabaseFilePath()) {
24
+ return pathToFileURL(databaseFilePath).href;
25
+ }
26
+
27
+ export function ensureDatabaseUrl() {
28
+ const databaseFilePath = ensureDatabaseFilePath();
29
+
30
+ return resolveDatabaseUrl(databaseFilePath);
31
+ }
32
+
33
+ export function describeDatabaseFilePath(databaseFilePath = resolveDatabaseFilePath()) {
34
+ return path.relative(process.cwd(), databaseFilePath) || path.basename(databaseFilePath);
35
+ }
@@ -1,22 +1,20 @@
1
- import { drizzle } from "drizzle-orm/node-postgres";
2
- import pg from "pg";
1
+ import { createClient } from "@libsql/client";
2
+ import { drizzle } from "drizzle-orm/libsql";
3
3
  import * as schema from "../shared/schema";
4
+ import { ensureDatabaseUrl } from "./database-path";
4
5
 
5
- const { Pool } = pg;
6
-
7
- const databaseUrl = process.env.DATABASE_URL;
6
+ const databaseUrl = ensureDatabaseUrl();
7
+ type DatabaseClient = ReturnType<typeof createClient>;
8
8
 
9
9
  declare global {
10
- var __dbPool: InstanceType<typeof Pool> | undefined;
10
+ var __dbClient: DatabaseClient | undefined;
11
11
  }
12
12
 
13
- export const pool =
14
- databaseUrl
15
- ? globalThis.__dbPool ?? new Pool({ connectionString: databaseUrl })
16
- : undefined;
13
+ const sqlite = globalThis.__dbClient ?? createClient({ url: databaseUrl });
17
14
 
18
- if (databaseUrl && process.env.NODE_ENV !== "production") {
19
- globalThis.__dbPool = pool;
15
+ if (process.env.NODE_ENV !== "production") {
16
+ globalThis.__dbClient = sqlite;
20
17
  }
21
18
 
22
- export const db = pool ? drizzle(pool, { schema }) : undefined;
19
+ export const db = drizzle(sqlite, { schema });
20
+ export { databaseUrl };
@@ -1,5 +1,6 @@
1
1
  import { desc } from "drizzle-orm";
2
2
  import { messages } from "../shared/schema";
3
+ import { describeDatabaseFilePath } from "./database-path";
3
4
  import { db } from "./db";
4
5
 
5
6
  export interface IStorage {
@@ -8,12 +9,6 @@ export interface IStorage {
8
9
 
9
10
  export class DatabaseStorage implements IStorage {
10
11
  async getMessage(): Promise<string> {
11
- if (!db) {
12
- throw new Error(
13
- "DATABASE_URL 未设置:请在 .env.local 中配置 Postgres 连接串,然后执行 `npm run db:push`。",
14
- );
15
- }
16
-
17
12
  try {
18
13
  const rows = await db
19
14
  .select({ content: messages.content })
@@ -25,12 +20,8 @@ export class DatabaseStorage implements IStorage {
25
20
  } catch (error) {
26
21
  console.error("DatabaseStorage.getMessage() failed:", error);
27
22
 
28
- if (error instanceof Error && error.message.includes("DATABASE_URL")) {
29
- throw error;
30
- }
31
-
32
23
  throw new Error(
33
- "数据库读取失败:请确认已设置 DATABASE_URL,并先执行 `npm run db:push` 创建表结构。",
24
+ `数据库读取失败:请先执行 \`pnpm db:push\` 初始化 SQLite 数据库(${describeDatabaseFilePath()})。`,
34
25
  );
35
26
  }
36
27
  }
@@ -1,11 +1,11 @@
1
- import { pgTable, text, serial, boolean } from "drizzle-orm/pg-core";
1
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
2
  import { createInsertSchema } from "drizzle-zod";
3
3
  import { z } from "zod";
4
4
 
5
- export const messages = pgTable("messages", {
6
- id: serial("id").primaryKey(),
5
+ export const messages = sqliteTable("messages", {
6
+ id: integer("id").primaryKey({ autoIncrement: true }),
7
7
  content: text("content").notNull(),
8
- isRead: boolean("is_read").default(false),
8
+ isRead: integer("is_read", { mode: "boolean" }).notNull().default(false),
9
9
  });
10
10
 
11
11
  export const insertMessageSchema = createInsertSchema(messages).pick({
@@ -0,0 +1,70 @@
1
+ import { test as base, expect } from "@playwright/test";
2
+ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ // When run_test drives Playwright, it injects these env vars so fixtures can write
6
+ // browser/network/page-error evidence directly into .imagicma/runtime/run-test/<runId>/.
7
+ const runtimeRoot = process.env.IMAGICMA_RUN_TEST_RUNTIME_ROOT;
8
+ const runId = process.env.IMAGICMA_RUN_ID || "manual";
9
+ const failureResourceTypes = new Set(["document", "xhr", "fetch"]);
10
+
11
+ function appendJsonl(name: string, row: Record<string, unknown>): void {
12
+ if (!runtimeRoot) return;
13
+ appendFileSync(join(runtimeRoot, name), `${JSON.stringify(row)}\n`, "utf-8");
14
+ }
15
+
16
+ export const test = base.extend({
17
+ page: async ({ page }, use) => {
18
+ if (runtimeRoot) {
19
+ mkdirSync(join(runtimeRoot, "screenshots"), { recursive: true });
20
+ mkdirSync(join(runtimeRoot, "traces"), { recursive: true });
21
+ mkdirSync(join(runtimeRoot, "videos"), { recursive: true });
22
+ page.on("console", (message) => {
23
+ const type = message.type();
24
+ if (type !== "warning" && type !== "warn" && type !== "error") return;
25
+ appendJsonl("console-events.jsonl", {
26
+ tsMs: Date.now(),
27
+ runId,
28
+ type,
29
+ text: message.text(),
30
+ });
31
+ });
32
+ page.on("pageerror", (error) => appendJsonl("page-errors.jsonl", {
33
+ tsMs: Date.now(),
34
+ runId,
35
+ message: error.message,
36
+ }));
37
+ page.on("response", async (response) => {
38
+ const status = response.status();
39
+ const resourceType = response.request().resourceType();
40
+ if (status < 400 || !failureResourceTypes.has(resourceType)) return;
41
+ appendJsonl("network-failures.jsonl", {
42
+ tsMs: Date.now(),
43
+ runId,
44
+ url: response.url(),
45
+ method: response.request().method(),
46
+ status,
47
+ errorText: null,
48
+ resourceType,
49
+ });
50
+ });
51
+ page.on("requestfailed", (request) => appendJsonl("network-failures.jsonl", {
52
+ tsMs: Date.now(),
53
+ runId,
54
+ url: request.url(),
55
+ method: request.method(),
56
+ status: null,
57
+ errorText: request.failure()?.errorText ?? "requestfailed",
58
+ resourceType: request.resourceType(),
59
+ }));
60
+ }
61
+
62
+ await use(page);
63
+
64
+ if (runtimeRoot) {
65
+ writeFileSync(join(runtimeRoot, "current-url.txt"), page.url(), "utf-8");
66
+ }
67
+ },
68
+ });
69
+
70
+ export { expect };
@@ -1,19 +0,0 @@
1
- declare module "pg" {
2
- export type PoolConfig = {
3
- connectionString?: string;
4
- [key: string]: unknown;
5
- };
6
-
7
- export class Pool {
8
- constructor(config?: PoolConfig);
9
- query: (...args: unknown[]) => Promise<unknown>;
10
- end: () => Promise<void>;
11
- }
12
-
13
- const pg: {
14
- Pool: typeof Pool;
15
- };
16
-
17
- export default pg;
18
- }
19
-