@open-dy/vibecoding-cli 0.0.1-beta.1

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.
Files changed (54) hide show
  1. package/README.md +47 -0
  2. package/assets/schemas/custom-card-schema.json +102 -0
  3. package/bin/vibecoding +89 -0
  4. package/bin/vibecoding-dev +308 -0
  5. package/bin/vibecoding-interest-card.mjs +35 -0
  6. package/bin/vibecoding.mjs +9 -0
  7. package/package.json +58 -0
  8. package/src/adapters/interest-card/opencard/commands.mjs +62 -0
  9. package/src/adapters/interest-card/opencard/dev-watch.mjs +203 -0
  10. package/src/adapters/interest-card/opencard/dev_run.sh +126 -0
  11. package/src/adapters/interest-card/opencard/eslint-plugin-opencard-taro.mjs +278 -0
  12. package/src/adapters/interest-card/opencard/lint-lynx-css.mjs +676 -0
  13. package/src/adapters/interest-card/opencard/lint.sh +131 -0
  14. package/src/adapters/interest-card/opencard/lynx-css-rules.mjs +508 -0
  15. package/src/adapters/interest-card/opencard/pack.sh +27 -0
  16. package/src/adapters/interest-card/opencard/validate-opencard-structure.mjs +65 -0
  17. package/src/adapters/interest-card/opencard/validate-widget-shell.mjs +902 -0
  18. package/src/adapters/interest-card/opencard/validate.mjs +11 -0
  19. package/src/adapters/interest-card/opencard/write-web-config.mjs +17 -0
  20. package/src/build/opencard-environment.mjs +49 -0
  21. package/src/build/turbo-config.mjs +140 -0
  22. package/src/cli.mjs +104 -0
  23. package/src/commands/build.mjs +138 -0
  24. package/src/commands/db.mjs +14 -0
  25. package/src/commands/dev.mjs +319 -0
  26. package/src/commands/lint.mjs +176 -0
  27. package/src/commands/pack.mjs +169 -0
  28. package/src/commands/turbo.mjs +36 -0
  29. package/src/commands/validate.mjs +26 -0
  30. package/src/commands/verify.mjs +602 -0
  31. package/src/core/args.mjs +101 -0
  32. package/src/core/context.mjs +101 -0
  33. package/src/core/environment.mjs +55 -0
  34. package/src/core/errors.mjs +14 -0
  35. package/src/core/ports.mjs +76 -0
  36. package/src/core/process-output.mjs +25 -0
  37. package/src/core/runner.mjs +90 -0
  38. package/src/core/service-log-context.mjs +27 -0
  39. package/src/core/web-config.mjs +13 -0
  40. package/src/lint/lint-lynx-css.mjs +244 -0
  41. package/src/lint/lynx-css-rules.mjs +138 -0
  42. package/src/preview/dev-watch.mjs +38 -0
  43. package/src/preview/start-preview.mjs +213 -0
  44. package/src/preview/watch-card-routing.mjs +89 -0
  45. package/src/scripts/pack.mjs +15 -0
  46. package/src/validators/dependencies.mjs +34 -0
  47. package/src/validators/empty-project.mjs +29 -0
  48. package/src/validators/feed-card.cjs +156 -0
  49. package/src/validators/feed-card.mjs +32 -0
  50. package/src/validators/opencard-structure.mjs +58 -0
  51. package/src/validators/routes.cjs +470 -0
  52. package/src/validators/routes.mjs +17 -0
  53. package/src/validators/sql.mjs +57 -0
  54. package/src/validators/widget-shell.mjs +107 -0
@@ -0,0 +1,36 @@
1
+ import path from 'node:path'
2
+ import { openCardBuildEnvironment } from '../build/opencard-environment.mjs'
3
+ import { prepareTurboConfig } from '../build/turbo-config.mjs'
4
+ import { UsageError } from '../core/errors.mjs'
5
+ import { resolveProjectBinary, runProcess } from '../core/runner.mjs'
6
+
7
+ function hasOption(args, option) {
8
+ return args.some((arg) => arg === option || arg.startsWith(`${option}=`))
9
+ }
10
+
11
+ /**
12
+ * @desc 使用 CLI 合并后的工程配置执行 Turbo,并注入 OpenCard 域模式。
13
+ * @params {{root: string, target: string, taroRoot?: string}} context 工程上下文。
14
+ * @params {{args?: string[]}} [options] Turbo 参数;`--` 后的参数原样透传。
15
+ * @return {Promise<unknown>} Turbo 子进程完成后的结果。
16
+ * @example runTurbo(context, { args: ['run', 'build:raw'] })
17
+ */
18
+ export async function runTurbo(context, { args = [] } = {}) {
19
+ if (args.length === 0) {
20
+ throw new UsageError(
21
+ 'vibecoding turbo requires a Turbo command, for example: run build:raw',
22
+ )
23
+ }
24
+ if (hasOption(args, '--root-turbo-json')) {
25
+ throw new UsageError('--root-turbo-json is managed by vibecoding turbo and cannot be overridden')
26
+ }
27
+
28
+ const config = prepareTurboConfig({ projectRoot: context.root, target: context.target })
29
+ const turboArgs = ['--root-turbo-json', path.relative(context.root, config), ...args]
30
+ const environment = openCardBuildEnvironment(context)
31
+ return runProcess(resolveProjectBinary(context.root, 'turbo'), turboArgs, {
32
+ cwd: context.root,
33
+ env: environment,
34
+ label: `${context.target} turbo`,
35
+ })
36
+ }
@@ -0,0 +1,26 @@
1
+ import { UsageError } from '../core/errors.mjs'
2
+ import { validateFeedCard } from '../validators/feed-card.mjs'
3
+ import { validateNonEmptyTaro } from '../validators/empty-project.mjs'
4
+ import { validateOpenCardStructure } from '../validators/opencard-structure.mjs'
5
+ import { validateRoutes } from '../validators/routes.mjs'
6
+
7
+ export async function runValidate(context, validator) {
8
+ switch (validator) {
9
+ case 'feed-card':
10
+ validateFeedCard(context)
11
+ return
12
+ case 'routes':
13
+ validateRoutes(context)
14
+ return
15
+ case 'empty':
16
+ validateNonEmptyTaro(context)
17
+ return
18
+ case 'opencard-structure':
19
+ validateOpenCardStructure(context)
20
+ return
21
+ default:
22
+ throw new UsageError(
23
+ `Unknown validator: ${validator || '<missing>'}\nAvailable validators: feed-card, routes, empty, opencard-structure`,
24
+ )
25
+ }
26
+ }
@@ -0,0 +1,602 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { CliError } from '../core/errors.mjs'
6
+ import { readRuntimeEnvironment } from '../core/environment.mjs'
7
+ import { waitForPorts } from '../core/ports.mjs'
8
+ import { runCaptured, runTasks } from '../core/runner.mjs'
9
+ import { packageUses, validateDependencies } from '../validators/dependencies.mjs'
10
+ import { validateFeedCard } from '../validators/feed-card.mjs'
11
+ import { runBuild } from './build.mjs'
12
+ import { runDatabaseInit } from './db.mjs'
13
+ import { runLint } from './lint.mjs'
14
+
15
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
16
+ const devLauncher = fileURLToPath(new URL('../../bin/vibecoding', import.meta.url))
17
+
18
+ export function targetPorts(context, frontendUrl, full = true) {
19
+ if (context.target === 'opencard') {
20
+ return [{ label: 'opencard-h5', port: Number(new URL(frontendUrl).port || 10087) }]
21
+ }
22
+ return [
23
+ { label: 'backend', port: 3000 },
24
+ ...(context.hasClosedDomainServer ? [{ label: 'closed-domain-backend', port: 3001 }] : []),
25
+ { label: 'main-h5', port: full ? Number(new URL(frontendUrl).port || 10086) : 10086 },
26
+ ...(full && context.hasOpenCard ? [{ label: 'opencard-h5', port: 10087 }] : []),
27
+ ]
28
+ }
29
+
30
+ async function runStatic(context) {
31
+ validateDependencies(context)
32
+ const env = await readRuntimeEnvironment(context.root)
33
+ const tasks = [
34
+ { name: 'lint', run: () => runLint(context) },
35
+ { name: 'build', run: () => runBuild(context, { frontendTarget: 'tt' }) },
36
+ ]
37
+ if (context.target === 'taro') {
38
+ tasks.push(
39
+ { name: 'database', run: () => runDatabaseInit(context, { env }) },
40
+ { name: 'feed-card', run: async () => validateFeedCard(context) },
41
+ )
42
+ }
43
+ await runTasks(tasks, { label: 'static verification' })
44
+ }
45
+
46
+ export function startDevDetached(context, spawnProcess = spawn) {
47
+ return new Promise((resolve, reject) => {
48
+ const child = spawnProcess('bash', [
49
+ devLauncher, 'dev', '--target', context.target, '--cwd', context.root,
50
+ ], { cwd: context.root, env: process.env, detached: true, stdio: 'ignore' })
51
+
52
+ child.once('error', (error) => {
53
+ reject(new CliError(`dev supervisor failed to start: ${error.message}`))
54
+ })
55
+ child.once('spawn', () => {
56
+ child.unref()
57
+ resolve(child.pid)
58
+ })
59
+ })
60
+ }
61
+
62
+ export function resolveDevLogPath(context, env = process.env) {
63
+ if (env.LOG_FILE) return env.LOG_FILE
64
+ const runtimeRoot = env.VIBECODING_RUNTIME_ROOT || (
65
+ process.platform === 'linux'
66
+ ? '/workspace/tmp'
67
+ : path.join(context.root, '.tmp/microapp-dev')
68
+ )
69
+ return path.join(runtimeRoot, 'microapp-logs/dev.log')
70
+ }
71
+
72
+ function printLastRunErrors(logFile, log) {
73
+ if (log === undefined) {
74
+ try { log = readFileSync(logFile, 'utf8') } catch { log = '' }
75
+ }
76
+ const section = log.split('--- DEV_RUN_START ---').at(-1) || ''
77
+ const errors = section.split('\n').filter((line) => line.includes('[error]'))
78
+ if (errors.length > 0) {
79
+ console.log(`[vibecoding:verify] Last dev run errors (${logFile}):\n${errors.join('\n')}`)
80
+ }
81
+ }
82
+
83
+ export async function waitForDevInstall(context, timeoutSeconds, options = {}) {
84
+ const logFile = options.logFile ?? resolveDevLogPath(context, options.env)
85
+ const readLog = options.readLog ?? (() => {
86
+ try { return readFileSync(logFile, 'utf8') } catch { return '' }
87
+ })
88
+ const sleepFn = options.sleepFn ?? sleep
89
+ const deadline = Date.now() + timeoutSeconds * 1000
90
+ while (Date.now() < deadline) {
91
+ if (readLog().includes('Dependencies installed successfully')) return
92
+ await sleepFn(options.pollMs ?? 1_000)
93
+ }
94
+ printLastRunErrors(logFile, readLog())
95
+ throw new CliError(
96
+ `Timed out waiting for dev dependency installation after ${timeoutSeconds}s. ` +
97
+ `Full log: ${logFile}`,
98
+ )
99
+ }
100
+
101
+ async function verifyPorts(context, options, frontendUrl) {
102
+ const ports = targetPorts(context, frontendUrl, options.full)
103
+ let status = await waitForPorts(ports, 1)
104
+ if (status.every((entry) => entry.listening)) return
105
+ if (!options.autoStart) {
106
+ throw new CliError(`Ports not listening: ${status.filter((entry) => !entry.listening).map((entry) => `${entry.label}:${entry.port}`).join(', ')}`)
107
+ }
108
+ const pid = await startDevDetached(context)
109
+ console.log(`[vibecoding:verify] started dev (PID ${pid}); waiting for ports`)
110
+ await waitForDevInstall(context, options.timeoutSeconds)
111
+ status = await waitForPorts(ports, options.timeoutSeconds)
112
+ const missing = status.filter((entry) => !entry.listening)
113
+ if (missing.length > 0) {
114
+ printLastRunErrors(resolveDevLogPath(context))
115
+ throw new CliError(`Ports not ready after ${options.timeoutSeconds}s: ${missing.map((entry) => `${entry.label}:${entry.port}`).join(', ')}`)
116
+ }
117
+ }
118
+
119
+ export async function verifyFeedApi(context, options = {}) {
120
+ if (!existsSync(path.join(context.root, 'feed/feed-card.json'))) {
121
+ return { skipped: true }
122
+ }
123
+ const routingFile = path.join(context.root, 'config/card-routing.json')
124
+ let routing = null
125
+ try {
126
+ routing = existsSync(routingFile) ? JSON.parse(readFileSync(routingFile, 'utf8')) : null
127
+ } catch {
128
+ console.warn(`[verify] Cannot read or parse ${routingFile}; using backend port ${options.backendPort ?? 3000}.`)
129
+ }
130
+ const backendPort = options.backendPort ?? (routing?.cardData?.serviceDomain === 'closed' ? 3001 : 3000)
131
+ const url = `http://127.0.0.1:${backendPort}/api/dy_card/data`
132
+ let response
133
+ try {
134
+ response = await (options.fetchFn ?? fetch)(url)
135
+ } catch (error) {
136
+ throw new CliError(`Failed to probe ${url}: ${error.message}`)
137
+ }
138
+ const body = await response.text()
139
+ if (!response.ok) throw new CliError(`${url} returned ${response.status}: ${body.slice(0, 500)}`)
140
+ let data
141
+ try { data = JSON.parse(body) } catch { throw new CliError(`${url} did not return JSON`) }
142
+ if (data.success !== true) {
143
+ throw new CliError(`${url} returned success=${JSON.stringify(data.success)}: ${body.slice(0, 500)}`)
144
+ }
145
+ if (typeof data.data !== 'string' || data.data.trim() === '') {
146
+ throw new CliError(`${url} returned success=true but data is not a non-empty JSON string`)
147
+ }
148
+ let payload
149
+ try { payload = JSON.parse(data.data) } catch {
150
+ throw new CliError(`${url} data is not valid JSON: ${data.data.slice(0, 500)}`)
151
+ }
152
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
153
+ throw new CliError(`${url} data must decode to a JSON object`)
154
+ }
155
+ return { skipped: false }
156
+ }
157
+
158
+ function flatten(value) {
159
+ if (value == null) return ''
160
+ if (typeof value === 'string') return value
161
+ try { return JSON.stringify(value) } catch { return String(value) }
162
+ }
163
+
164
+ // Keep preview finding classification aligned with the master template verifier.
165
+ const textOf = (value) => (value == null ? "" : String(value));
166
+
167
+ const BENIGN_RUNTIME_URL_HOSTS = new Set(["developer.toutiao.com"]);
168
+ const BENIGN_RUNTIME_URL_SUFFIXES = [".snssdk.com", ".zijieapi.com"];
169
+
170
+ const hostnameIsBenignRuntime = (hostname) => {
171
+ if (!hostname) return false;
172
+ if (BENIGN_RUNTIME_URL_HOSTS.has(hostname)) return true;
173
+ return BENIGN_RUNTIME_URL_SUFFIXES.some(
174
+ (suffix) => hostname === suffix.slice(1) || hostname.endsWith(suffix),
175
+ );
176
+ };
177
+
178
+ const LOCAL_PREVIEW_PROXY_MARKER = "/__proxy__/";
179
+
180
+ const extractPreviewProxyTargetUrl = (url) => {
181
+ const text = textOf(url);
182
+ const markerIndex = text.indexOf(LOCAL_PREVIEW_PROXY_MARKER);
183
+ if (markerIndex === -1) return null;
184
+ try {
185
+ return new URL(
186
+ decodeURIComponent(text.slice(markerIndex + LOCAL_PREVIEW_PROXY_MARKER.length)),
187
+ );
188
+ } catch {
189
+ return null;
190
+ }
191
+ };
192
+
193
+ const isLocalPreviewProxyRequest = (url) =>
194
+ textOf(url).includes(LOCAL_PREVIEW_PROXY_MARKER);
195
+
196
+ const flattenRuntimePayload = (value, depth = 0, seen = new Set()) => {
197
+ if (value == null || depth > 8) return "";
198
+ if (
199
+ typeof value === "string" ||
200
+ typeof value === "number" ||
201
+ typeof value === "boolean"
202
+ ) {
203
+ return String(value);
204
+ }
205
+ if (typeof value !== "object") return String(value);
206
+ if (seen.has(value)) return "";
207
+ seen.add(value);
208
+
209
+ if (depth === 0) {
210
+ try {
211
+ const json = JSON.stringify(value);
212
+ if (json && json !== "{}" && json !== "[]") return json;
213
+ } catch {
214
+ // fall through
215
+ }
216
+ }
217
+
218
+ if (Array.isArray(value)) {
219
+ return value
220
+ .map((item) => flattenRuntimePayload(item, depth + 1, seen))
221
+ .filter(Boolean)
222
+ .join(" ");
223
+ }
224
+
225
+ const parts = [];
226
+ for (const key of Object.keys(value)) {
227
+ parts.push(String(key));
228
+ parts.push(flattenRuntimePayload(value[key], depth + 1, seen));
229
+ }
230
+ return parts.filter(Boolean).join(" ");
231
+ };
232
+
233
+ const BENIGN_RUNTIME_TEXT_PATTERNS = [
234
+ /request:fail.*url not in domain list/i,
235
+ /url not in domain list/i,
236
+ /errNo["']?\s*[:=]\s*21100/i,
237
+ /errorCode["']?\s*[:=]\s*100001/i,
238
+ /mcs\.zijieapi\.com/i,
239
+ /snssdk\.com/i,
240
+ /\[TMA\].*unhandledRejection/i,
241
+ // Taro H5 preview optional hot-reload config; often 404, not an app bug
242
+ /hot-reload-config\.json/i,
243
+ ];
244
+
245
+ const matchesBenignRuntimeText = (text) =>
246
+ BENIGN_RUNTIME_TEXT_PATTERNS.some((pattern) => pattern.test(textOf(text)));
247
+
248
+ const buildRuntimeSessionContext = (consoleMessages, pageErrors) =>
249
+ [...consoleMessages, ...pageErrors]
250
+ .map((item) => flattenRuntimePayload(item))
251
+ .filter(Boolean)
252
+ .join(" ");
253
+
254
+ const isLocalPreviewOrigin = (url) => {
255
+ try {
256
+ const { hostname } = new URL(url);
257
+ return (
258
+ hostname === "localhost" ||
259
+ hostname === "127.0.0.1" ||
260
+ hostname === "[::1]" ||
261
+ hostname === "0.0.0.0"
262
+ );
263
+ } catch {
264
+ return false;
265
+ }
266
+ };
267
+
268
+ const runtimeMessageText = (payload) => {
269
+ if (payload == null) return "";
270
+ if (typeof payload !== "object") return textOf(payload);
271
+
272
+ const candidate =
273
+ payload.text ?? payload.message ?? payload.description ?? payload.error;
274
+ if (candidate == null) return "";
275
+ if (typeof candidate === "object") {
276
+ const flattened = flattenRuntimePayload(candidate);
277
+ if (flattened) return flattened;
278
+ try {
279
+ return JSON.stringify(candidate);
280
+ } catch {
281
+ return "[object Object]";
282
+ }
283
+ }
284
+ return textOf(candidate);
285
+ };
286
+
287
+ const runtimeFindingText = (payload) => {
288
+ if (payload == null) return "";
289
+ if (typeof payload !== "object") return textOf(payload);
290
+
291
+ const parts = [
292
+ payload.text,
293
+ payload.message,
294
+ payload.description,
295
+ payload.error,
296
+ payload.reason,
297
+ payload.args,
298
+ payload.value,
299
+ payload.stack,
300
+ payload.detail,
301
+ ];
302
+
303
+ const texts = [];
304
+ for (const part of parts) {
305
+ if (part == null) continue;
306
+ if (typeof part === "string") {
307
+ texts.push(part);
308
+ continue;
309
+ }
310
+ const flattened = flattenRuntimePayload(part);
311
+ if (flattened) texts.push(flattened);
312
+ }
313
+
314
+ if (texts.length > 0) return texts.join(" ").trim();
315
+ return flattenRuntimePayload(payload);
316
+ };
317
+
318
+ const isOpaqueObjectLabel = (message) =>
319
+ /^(Object|\[object Object\])$/i.test(textOf(message).trim());
320
+
321
+ const isOpaquePromiseRejectionMessage = (message) => {
322
+ const text = textOf(message).trim();
323
+ if (!text) return false;
324
+ if (isOpaqueObjectLabel(text)) return true;
325
+ if (/^Uncaught \(in promise\)/i.test(text)) return true;
326
+ if (/\[TMA\].*unhandledRejection/i.test(text)) return true;
327
+ return false;
328
+ };
329
+
330
+ export const isBenignRuntimeFinding = (payload, sessionContext = "") => {
331
+ const flattened = flattenRuntimePayload(payload);
332
+ const haystack = [flattened, sessionContext].filter(Boolean).join(" ");
333
+ if (matchesBenignRuntimeText(haystack)) return true;
334
+ if (matchesBenignRuntimeText(flattened)) return true;
335
+
336
+ const message = runtimeMessageText(payload);
337
+ const url =
338
+ payload && typeof payload === "object"
339
+ ? textOf(payload.url || payload.source || payload.filename)
340
+ : "";
341
+ const detail =
342
+ payload && typeof payload === "object"
343
+ ? textOf(payload.detail || payload.stack)
344
+ : "";
345
+
346
+ if (isOpaqueObjectLabel(message) && /^blob:/i.test(url)) return true;
347
+ if (
348
+ isOpaqueObjectLabel(message) &&
349
+ (/^blob:/i.test(detail) ||
350
+ /blob:|localhost|127\.0\.0\.1/i.test(`${url} ${detail}`))
351
+ ) {
352
+ return true;
353
+ }
354
+
355
+ if (
356
+ isOpaquePromiseRejectionMessage(message) &&
357
+ matchesBenignRuntimeText(sessionContext)
358
+ ) {
359
+ return true;
360
+ }
361
+
362
+ return matchesBenignRuntimeText(message);
363
+ };
364
+
365
+ export const isBenignPreviewRuntimeFinding = (payload, sessionContext, previewUrl) => {
366
+ const findingText = runtimeFindingText(payload);
367
+ if (isBenignRuntimeFinding(payload, sessionContext)) return true;
368
+ if (matchesBenignRuntimeText(findingText)) return true;
369
+ if (!isLocalPreviewOrigin(previewUrl)) return false;
370
+
371
+ const message = runtimeMessageText(payload);
372
+ if (isOpaqueObjectLabel(message)) return true;
373
+ if (isOpaquePromiseRejectionMessage(message)) return true;
374
+ if (isOpaquePromiseRejectionMessage(findingText)) return true;
375
+
376
+ return false;
377
+ };
378
+
379
+ const isBenignRuntimeUrl = (url) => {
380
+ const text = textOf(url);
381
+ // Taro H5 optional file; missing in local preview is expected noise
382
+ if (/hot-reload-config\.json/i.test(text)) return true;
383
+ try {
384
+ if (hostnameIsBenignRuntime(new URL(text).hostname)) return true;
385
+ } catch {
386
+ // fall through
387
+ }
388
+
389
+ const proxyTarget = extractPreviewProxyTargetUrl(text);
390
+ if (proxyTarget && hostnameIsBenignRuntime(proxyTarget.hostname)) {
391
+ return true;
392
+ }
393
+
394
+ return false;
395
+ };
396
+
397
+ /**
398
+ * agent-browser 经 CDP 采集网络请求时,Fetch/XHR 常拿不到 status,
399
+ * 不能据此判失败。仅对明确 >=400,或同域 Document/Script 缺 status 报错;
400
+ * 已成功重定向的初始 Document 在浏览器检查中单独排除。
401
+ */
402
+ export const isActionableNetworkRequestFailure = (request, pageOrigin) => {
403
+ const requestUrl = textOf(request.url);
404
+ if (isBenignRuntimeUrl(requestUrl)) return false;
405
+
406
+ const status =
407
+ typeof request.status === "number" ? request.status : Number(request.status);
408
+ const hasStatus = Number.isFinite(status);
409
+ if (hasStatus) return status >= 400;
410
+
411
+ if (isLocalPreviewProxyRequest(requestUrl)) return false;
412
+ if (["Fetch", "XHR"].includes(request.resourceType)) return false;
413
+ if (!["Document", "Script"].includes(request.resourceType)) return false;
414
+
415
+ try {
416
+ return new URL(requestUrl).origin === pageOrigin;
417
+ } catch {
418
+ return false;
419
+ }
420
+ };
421
+
422
+ /** The preview runner may redirect the initial document to the same path with card_appids. */
423
+ const isCompletedSamePageRedirect = (request, requestedUrl, requests) => {
424
+ if (request.resourceType !== 'Document' || Number.isFinite(Number(request.status))) return false
425
+ let initialUrl
426
+ try {
427
+ initialUrl = new URL(request.url)
428
+ if (initialUrl.href !== new URL(requestedUrl).href) return false
429
+ } catch {
430
+ return false
431
+ }
432
+ return requests.some((candidate) => {
433
+ if (candidate.resourceType !== 'Document') return false
434
+ const status = Number(candidate.status)
435
+ if (!Number.isFinite(status) || status < 200 || status >= 300) return false
436
+ try {
437
+ const completedUrl = new URL(candidate.url)
438
+ return completedUrl.origin === initialUrl.origin &&
439
+ completedUrl.pathname === initialUrl.pathname &&
440
+ completedUrl.search !== initialUrl.search
441
+ } catch {
442
+ return false
443
+ }
444
+ })
445
+ }
446
+
447
+ const parseHttpUrl = (value, label) => {
448
+ let parsed;
449
+ try {
450
+ parsed = new URL(value);
451
+ } catch {
452
+ throw new Error(`Invalid ${label} runtime check URL: ${value}`);
453
+ }
454
+ if (!["http:", "https:"].includes(parsed.protocol)) {
455
+ throw new Error(`Unsupported ${label} URL protocol: ${parsed.protocol}`);
456
+ }
457
+ return parsed;
458
+ };
459
+
460
+ /**
461
+ * 仅为失败请求补充路径语义,不参与错误过滤。
462
+ * URL 拆分得到的是预期业务路由,不代表已观察到代理实际转发结果。
463
+ */
464
+ const printRuntimeApiPathHint = (findings, previewUrl) => {
465
+ // PROJECT_DOMAIN 是前端构建期常量;根据错误里的实际 URL 识别模板路径。
466
+ for (const finding of findings) {
467
+ const urls = runtimeFindingText(finding)
468
+ .replace(/\\+\//g, "/")
469
+ .match(/https?:\/\/[^\s"'<>\\)\]}]+/g) || [];
470
+
471
+ for (const candidate of urls) {
472
+ let url;
473
+ try {
474
+ url = extractPreviewProxyTargetUrl(candidate) || new URL(candidate);
475
+ } catch {
476
+ continue;
477
+ }
478
+
479
+ let prefix;
480
+ const cloudPrefix = url.pathname.match(
481
+ /^(\/api\/apps\/open_ai_coding_chat_server\/aio_server\/[^/]+\/api)(?=\/api\/)/,
482
+ );
483
+ if (cloudPrefix) {
484
+ prefix = cloudPrefix[1];
485
+ } else if (
486
+ isLocalPreviewOrigin(url.href) &&
487
+ url.origin === new URL(previewUrl).origin &&
488
+ url.pathname.startsWith("/api/api/")
489
+ ) {
490
+ prefix = "/api";
491
+ }
492
+ if (!prefix) continue;
493
+
494
+ const serviceRoute = url.pathname.slice(prefix.length);
495
+ console.log([
496
+ "[verify] API 路径说明(网关前缀 + 业务路由):",
497
+ `[verify] 网关前缀:${url.origin}${prefix}`,
498
+ `[verify] 预期业务路由:${serviceRoute}(按 URL 拆分,实际转发结果需核对)`,
499
+ "[verify] 本模板默认 PROJECT_DOMAIN 是以 /api 结尾的网关 baseURL;与 /api/... 业务路由拼接后,连续 /api/api/... 可以是预期格式。",
500
+ "[verify] 不要仅凭重复 /api 删除业务路由的 /api,或修改 PROJECT_DOMAIN、网络拼接函数、SDK 构建配置或 devServer 的代理推导。",
501
+ "[verify] 请求失败仍需排查:核对实际转发路径、服务端路由和 HTTP 方法,再结合状态码、响应内容及服务端日志定位。",
502
+ ].join("\n"));
503
+ return;
504
+ }
505
+ }
506
+ };
507
+
508
+ export function isIgnorableOpenCardConsoleFinding(value, options) {
509
+ if (!String(options.label ?? '').includes('opencard')) return false
510
+ let isLocalPreview = false
511
+ try {
512
+ const hostname = new URL(options.previewUrl).hostname
513
+ isLocalPreview = ['localhost', '127.0.0.1', '[::1]', '0.0.0.0'].includes(hostname)
514
+ } catch {}
515
+ if (!isLocalPreview) return false
516
+ const text = value && typeof value === 'object' && typeof value.text === 'string'
517
+ ? value.text
518
+ : flatten(value)
519
+ if (/Refused to set unsafe header ["']user-agent["']/i.test(text)) return true
520
+ return !options.hasActionableNetworkFailure &&
521
+ /Failed to load resource: the server responded with a status of 404/i.test(text)
522
+ }
523
+
524
+ async function browserCheck(root, url, label) {
525
+ if (packageUses(root, '@open-dy/threejs')) {
526
+ console.log(`[vibecoding:verify] ${label}: skipping browser runtime check for @open-dy/threejs`)
527
+ return
528
+ }
529
+ parseHttpUrl(url, label)
530
+ const session = process.env.AGENT_BROWSER_SESSION || `vibecoding-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
531
+ const call = (args, allowFailure = false) => runCaptured('agent-browser', ['--session', session, ...args], { allowFailure })
532
+ try {
533
+ await call(['network', 'requests'], true)
534
+ await call(['open', url])
535
+ await call(['wait', '--load', 'networkidle'], true)
536
+ await sleep(1500)
537
+ const [errorsResult, consoleResult, networkResult] = await Promise.all([
538
+ call(['errors', '--json']), call(['console', '--json']), call(['network', 'requests', '--json']),
539
+ ])
540
+ const parse = (result) => JSON.parse(result.stdout).data ?? {}
541
+ const pageErrors = parse(errorsResult).errors ?? []
542
+ const consoleErrors = (parse(consoleResult).messages ?? []).filter((item) => ['error', 'assert'].includes(item.type))
543
+ const origin = new URL(url).origin
544
+ const sessionContext = buildRuntimeSessionContext(parse(consoleResult).messages ?? [], pageErrors)
545
+ const requests = parse(networkResult).requests ?? []
546
+ const networkErrors = requests
547
+ .filter((item) => isActionableNetworkRequestFailure(item, origin) &&
548
+ !isCompletedSamePageRedirect(item, url, requests))
549
+ const actionableNetworkFailure = networkErrors.length > 0
550
+ const actionableConsoleErrors = consoleErrors.filter((item) =>
551
+ !isIgnorableOpenCardConsoleFinding(item, {
552
+ label,
553
+ previewUrl: url,
554
+ hasActionableNetworkFailure: actionableNetworkFailure,
555
+ }),
556
+ )
557
+ const findings = [
558
+ ...[...pageErrors, ...actionableConsoleErrors]
559
+ .filter((item) => !isBenignPreviewRuntimeFinding(item, sessionContext, url)),
560
+ ...networkErrors,
561
+ ]
562
+ if (findings.length > 0) {
563
+ printRuntimeApiPathHint(findings, url)
564
+ throw new CliError(`${label} runtime check found ${findings.length} issue(s):\n${findings.map((item) => `- ${flatten(item)}`).join('\n')}`)
565
+ }
566
+ } finally {
567
+ await call(['close'], true)
568
+ }
569
+ }
570
+
571
+ export function resolveFrontendUrl(context, options = {}, env = process.env) {
572
+ return options.frontendUrl || (context.target === 'opencard'
573
+ ? 'http://localhost:10087/'
574
+ : env.RUNTIME_CHECK_URL || 'http://localhost:10086/')
575
+ }
576
+
577
+ export async function runVerify(context, options = {}) {
578
+ if (options.staticOnly && options.portsOnly) {
579
+ throw new CliError('--static-only cannot be combined with --ports-only')
580
+ }
581
+ const full = Boolean(options.full || options.autoStart || options.staticOnly ||
582
+ (context.target === 'taro' && process.env.npm_lifecycle_event === 'verify'))
583
+ options = { timeoutSeconds: 90, ...options, full }
584
+ const frontendUrl = resolveFrontendUrl(context, options)
585
+ parseHttpUrl(frontendUrl, 'frontend')
586
+ if (full) await waitForDevInstall(context, options.timeoutSeconds)
587
+ if (full || options.staticOnly) await runStatic(context)
588
+ if (options.staticOnly) return
589
+
590
+ await verifyPorts(context, options, frontendUrl)
591
+ if (options.portsOnly) return
592
+ if (full && context.target === 'taro') await verifyFeedApi(context)
593
+ if (context.target === 'taro' &&
594
+ existsSync(path.resolve(context.root, '../sar_game/package.json'))) {
595
+ console.log('[vibecoding:verify] SAR game project detected; skipping browser runtime checks')
596
+ return
597
+ }
598
+ await browserCheck(context.root, frontendUrl, context.target)
599
+ if (context.target === 'taro' && context.hasOpenCard) {
600
+ await browserCheck(context.opencardRoot, 'http://localhost:10087/', 'opencard')
601
+ }
602
+ }