@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.
- package/README.md +47 -0
- package/assets/schemas/custom-card-schema.json +102 -0
- package/bin/vibecoding +89 -0
- package/bin/vibecoding-dev +308 -0
- package/bin/vibecoding-interest-card.mjs +35 -0
- package/bin/vibecoding.mjs +9 -0
- package/package.json +58 -0
- package/src/adapters/interest-card/opencard/commands.mjs +62 -0
- package/src/adapters/interest-card/opencard/dev-watch.mjs +203 -0
- package/src/adapters/interest-card/opencard/dev_run.sh +126 -0
- package/src/adapters/interest-card/opencard/eslint-plugin-opencard-taro.mjs +278 -0
- package/src/adapters/interest-card/opencard/lint-lynx-css.mjs +676 -0
- package/src/adapters/interest-card/opencard/lint.sh +131 -0
- package/src/adapters/interest-card/opencard/lynx-css-rules.mjs +508 -0
- package/src/adapters/interest-card/opencard/pack.sh +27 -0
- package/src/adapters/interest-card/opencard/validate-opencard-structure.mjs +65 -0
- package/src/adapters/interest-card/opencard/validate-widget-shell.mjs +902 -0
- package/src/adapters/interest-card/opencard/validate.mjs +11 -0
- package/src/adapters/interest-card/opencard/write-web-config.mjs +17 -0
- package/src/build/opencard-environment.mjs +49 -0
- package/src/build/turbo-config.mjs +140 -0
- package/src/cli.mjs +104 -0
- package/src/commands/build.mjs +138 -0
- package/src/commands/db.mjs +14 -0
- package/src/commands/dev.mjs +319 -0
- package/src/commands/lint.mjs +176 -0
- package/src/commands/pack.mjs +169 -0
- package/src/commands/turbo.mjs +36 -0
- package/src/commands/validate.mjs +26 -0
- package/src/commands/verify.mjs +602 -0
- package/src/core/args.mjs +101 -0
- package/src/core/context.mjs +101 -0
- package/src/core/environment.mjs +55 -0
- package/src/core/errors.mjs +14 -0
- package/src/core/ports.mjs +76 -0
- package/src/core/process-output.mjs +25 -0
- package/src/core/runner.mjs +90 -0
- package/src/core/service-log-context.mjs +27 -0
- package/src/core/web-config.mjs +13 -0
- package/src/lint/lint-lynx-css.mjs +244 -0
- package/src/lint/lynx-css-rules.mjs +138 -0
- package/src/preview/dev-watch.mjs +38 -0
- package/src/preview/start-preview.mjs +213 -0
- package/src/preview/watch-card-routing.mjs +89 -0
- package/src/scripts/pack.mjs +15 -0
- package/src/validators/dependencies.mjs +34 -0
- package/src/validators/empty-project.mjs +29 -0
- package/src/validators/feed-card.cjs +156 -0
- package/src/validators/feed-card.mjs +32 -0
- package/src/validators/opencard-structure.mjs +58 -0
- package/src/validators/routes.cjs +470 -0
- package/src/validators/routes.mjs +17 -0
- package/src/validators/sql.mjs +57 -0
- package/src/validators/widget-shell.mjs +107 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { mkdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { readRuntimeEnvironment } from '../core/environment.mjs'
|
|
5
|
+
import { CliError } from '../core/errors.mjs'
|
|
6
|
+
import { clearPort } from '../core/ports.mjs'
|
|
7
|
+
import { attachLabeledOutput } from '../core/process-output.mjs'
|
|
8
|
+
import { runProcess } from '../core/runner.mjs'
|
|
9
|
+
import { resolvePreviewPort, startPreview } from '../preview/start-preview.mjs'
|
|
10
|
+
import { runDatabaseInit } from './db.mjs'
|
|
11
|
+
|
|
12
|
+
export function assertDevSupervised(env = process.env) {
|
|
13
|
+
if (env.VIBECODING_DEV_SUPERVISED !== '1') {
|
|
14
|
+
throw new CliError(
|
|
15
|
+
'dev must be started through the vibecoding launcher so its process tree can be supervised',
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function spawnManaged(command, args, { cwd, env, name, label = name }, children, fail) {
|
|
21
|
+
const child = spawn(command, args, {
|
|
22
|
+
cwd,
|
|
23
|
+
env: { ...process.env, ...env },
|
|
24
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
25
|
+
detached: true,
|
|
26
|
+
})
|
|
27
|
+
attachLabeledOutput(child, label)
|
|
28
|
+
const managed = { child, name, expectedExit: false }
|
|
29
|
+
children.push(managed)
|
|
30
|
+
child.on('error', (error) => fail(new CliError(`${name} failed to start: ${error.message}`)))
|
|
31
|
+
child.on('exit', (code, signal) => {
|
|
32
|
+
if (managed.expectedExit) return
|
|
33
|
+
if (code !== 0) fail(new CliError(`${name} exited (${signal ?? code})`))
|
|
34
|
+
})
|
|
35
|
+
return managed
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readProjectRegistry(directory) {
|
|
39
|
+
let config
|
|
40
|
+
try {
|
|
41
|
+
config = readFileSync(path.join(directory, '.npmrc'), 'utf8')
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error?.code === 'ENOENT') return null
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const line of config.split(/\r?\n/)) {
|
|
48
|
+
const match = line.match(/^\s*registry\s*=\s*(\S+)/)
|
|
49
|
+
if (!match) continue
|
|
50
|
+
const registry = match[1]
|
|
51
|
+
if (/^https?:\/\/[^\s]+$/i.test(registry)) return registry
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function withProjectRegistry(args, directory) {
|
|
57
|
+
// Only PPE packaging injects this offline runner and explicitly pins the
|
|
58
|
+
// project registry. Other installs use pnpm's project/user configuration.
|
|
59
|
+
let manifest
|
|
60
|
+
try {
|
|
61
|
+
manifest = JSON.parse(readFileSync(path.join(directory, 'package.json'), 'utf8'))
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error?.code === 'ENOENT') return args
|
|
64
|
+
throw error
|
|
65
|
+
}
|
|
66
|
+
const runner = manifest.devDependencies?.['@byted/microapp-preview-runner']
|
|
67
|
+
if (typeof runner !== 'string' || !runner.startsWith('file:patches/')) return args
|
|
68
|
+
const registry = readProjectRegistry(directory)
|
|
69
|
+
return registry ? [...args, `--registry=${registry}`] : args
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getInstallSteps(context, options = {}) {
|
|
73
|
+
const steps = []
|
|
74
|
+
if (!options.skipRootInstall) {
|
|
75
|
+
steps.push({
|
|
76
|
+
args: withProjectRegistry(
|
|
77
|
+
['install', '--config.confirm-modules-purge=false'],
|
|
78
|
+
context.root,
|
|
79
|
+
),
|
|
80
|
+
cwd: context.root,
|
|
81
|
+
label: 'pnpm install',
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
if (context.target === 'taro' && context.hasOpenCard) {
|
|
85
|
+
steps.push({
|
|
86
|
+
args: withProjectRegistry([
|
|
87
|
+
'--dir', context.opencardRoot, 'install', '--ignore-workspace',
|
|
88
|
+
'--config.confirm-modules-purge=false',
|
|
89
|
+
], context.opencardRoot),
|
|
90
|
+
cwd: context.root,
|
|
91
|
+
label: 'opencard pnpm install',
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
return steps
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function install(context, options = {}) {
|
|
98
|
+
for (const step of getInstallSteps(context, options)) {
|
|
99
|
+
await runProcess('pnpm', step.args, {
|
|
100
|
+
cwd: step.cwd,
|
|
101
|
+
label: step.label,
|
|
102
|
+
// Match the template's registry handling for both the workspace and the
|
|
103
|
+
// independent OpenCard project. Undefined removes inherited spawn env keys.
|
|
104
|
+
env: {
|
|
105
|
+
npm_config_registry: undefined,
|
|
106
|
+
NPM_CONFIG_REGISTRY: undefined,
|
|
107
|
+
yarn_registry: undefined,
|
|
108
|
+
YARN_REGISTRY: undefined,
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
console.log('[vibecoding:dev] Dependencies installed successfully')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isTruthyFlag(value) {
|
|
116
|
+
const normalized = String(value ?? '').trim().toLowerCase()
|
|
117
|
+
return normalized === '1' || normalized === 'true' || normalized === 'yes'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getTaroDevConfiguration(context, env = process.env) {
|
|
121
|
+
const frontendPort = resolvePreviewPort(env.DEPLOY_RUN_PORT, 10086)
|
|
122
|
+
const serverPort = resolvePreviewPort(env.SERVER_PORT, 3000)
|
|
123
|
+
const openCardPort = resolvePreviewPort(env.OPENCARD_PORT, 10087)
|
|
124
|
+
const closedDomainServerPort = resolvePreviewPort(env.CLOSED_DOMAIN_SERVER_PORT, 3001)
|
|
125
|
+
const ports = [frontendPort, serverPort]
|
|
126
|
+
if (context.hasOpenCard) ports.push(openCardPort)
|
|
127
|
+
if (context.hasClosedDomainServer) ports.push(closedDomainServerPort)
|
|
128
|
+
return {
|
|
129
|
+
ports,
|
|
130
|
+
serverPort,
|
|
131
|
+
closedDomainServerPort,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createDatabaseGateWatcher(options) {
|
|
136
|
+
let previous = options.baseline
|
|
137
|
+
let running = false
|
|
138
|
+
let stopped = false
|
|
139
|
+
const tick = async () => {
|
|
140
|
+
if (stopped || running) return
|
|
141
|
+
running = true
|
|
142
|
+
try {
|
|
143
|
+
const env = await options.readEnvironment()
|
|
144
|
+
const current = env.HAS_CALLED_DATABASE_INIT_TOOL
|
|
145
|
+
if (isTruthyFlag(current) && !isTruthyFlag(previous)) {
|
|
146
|
+
previous = current
|
|
147
|
+
await options.onEnabled(env)
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
options.onError?.(error)
|
|
151
|
+
} finally {
|
|
152
|
+
running = false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const timer = (options.setIntervalFn ?? setInterval)(
|
|
156
|
+
() => { void tick() },
|
|
157
|
+
options.intervalMs ?? 5_000,
|
|
158
|
+
)
|
|
159
|
+
timer.unref?.()
|
|
160
|
+
return {
|
|
161
|
+
tick,
|
|
162
|
+
stop() {
|
|
163
|
+
stopped = true
|
|
164
|
+
;(options.clearIntervalFn ?? clearInterval)(timer)
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function initializeDatabaseThenRestart(options) {
|
|
170
|
+
try {
|
|
171
|
+
await (options.runDatabaseInitFn ?? runDatabaseInit)(
|
|
172
|
+
options.context,
|
|
173
|
+
{ env: options.env },
|
|
174
|
+
)
|
|
175
|
+
} catch (error) {
|
|
176
|
+
options.onError?.(error)
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
await options.restartBackend(options.env)
|
|
180
|
+
return true
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function runDev(context, options = {}) {
|
|
184
|
+
assertDevSupervised()
|
|
185
|
+
if (!options.skipInstall) await install(context, options)
|
|
186
|
+
let env = await readRuntimeEnvironment(context.root, { development: true })
|
|
187
|
+
const children = []
|
|
188
|
+
const runtimes = []
|
|
189
|
+
let databaseWatcher = null
|
|
190
|
+
let serverManaged = null
|
|
191
|
+
let settled = false
|
|
192
|
+
let resolveLifetime
|
|
193
|
+
let rejectLifetime
|
|
194
|
+
const lifetime = new Promise((resolve, reject) => {
|
|
195
|
+
resolveLifetime = resolve
|
|
196
|
+
rejectLifetime = reject
|
|
197
|
+
})
|
|
198
|
+
const fail = (error) => { if (!settled) rejectLifetime(error) }
|
|
199
|
+
const finish = () => { if (!settled) resolveLifetime() }
|
|
200
|
+
const handlePreviewExit = (error) => error ? fail(error) : finish()
|
|
201
|
+
|
|
202
|
+
const taroDev = context.target === 'taro'
|
|
203
|
+
? getTaroDevConfiguration(context, env)
|
|
204
|
+
: null
|
|
205
|
+
const openCardPort = resolvePreviewPort(env.OPENCARD_PORT, 10087)
|
|
206
|
+
const ports = context.target === 'opencard'
|
|
207
|
+
? [openCardPort]
|
|
208
|
+
: taroDev.ports
|
|
209
|
+
for (const port of ports) await clearPort(port)
|
|
210
|
+
|
|
211
|
+
const cleanup = async () => {
|
|
212
|
+
if (settled) return
|
|
213
|
+
settled = true
|
|
214
|
+
databaseWatcher?.stop()
|
|
215
|
+
for (const managed of children) {
|
|
216
|
+
const { child } = managed
|
|
217
|
+
managed.expectedExit = true
|
|
218
|
+
if (!child.pid || child.exitCode !== null) continue
|
|
219
|
+
try { process.kill(-child.pid, 'SIGKILL') } catch {}
|
|
220
|
+
}
|
|
221
|
+
await Promise.allSettled(runtimes.map((runtime) => runtime.close()))
|
|
222
|
+
}
|
|
223
|
+
const signalHandler = () => { void cleanup().then(() => process.exit(0)) }
|
|
224
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, signalHandler)
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
mkdirSync(path.join(context.root, '.home'), { recursive: true })
|
|
228
|
+
if (context.target === 'opencard') {
|
|
229
|
+
const preview = await startPreview({
|
|
230
|
+
root: context.root,
|
|
231
|
+
cardMode: true,
|
|
232
|
+
env,
|
|
233
|
+
onExit: handlePreviewExit,
|
|
234
|
+
})
|
|
235
|
+
children.push({ child: preview.child, name: 'opencard watch' })
|
|
236
|
+
runtimes.push(preview.runtime)
|
|
237
|
+
} else {
|
|
238
|
+
const baseline = env.HAS_CALLED_DATABASE_INIT_TOOL
|
|
239
|
+
await runDatabaseInit(context, { env })
|
|
240
|
+
env = await readRuntimeEnvironment(context.root, { development: true })
|
|
241
|
+
|
|
242
|
+
const startBackend = (runtimeEnv) => spawnManaged(
|
|
243
|
+
'pnpm',
|
|
244
|
+
['--filter', 'server', 'run', 'start:dev'],
|
|
245
|
+
{
|
|
246
|
+
cwd: context.root,
|
|
247
|
+
env: { ...runtimeEnv, NJS_SERVER_PORT: String(taroDev.serverPort) },
|
|
248
|
+
name: 'server dev',
|
|
249
|
+
label: 'server',
|
|
250
|
+
},
|
|
251
|
+
children,
|
|
252
|
+
fail,
|
|
253
|
+
)
|
|
254
|
+
const startClosedDomainServer = (runtimeEnv) => spawnManaged(
|
|
255
|
+
'pnpm',
|
|
256
|
+
['--filter', 'closed-domain-server', 'run', 'start:dev'],
|
|
257
|
+
{
|
|
258
|
+
cwd: context.root,
|
|
259
|
+
env: {
|
|
260
|
+
...runtimeEnv,
|
|
261
|
+
NJS_SERVER_PORT: String(taroDev.closedDomainServerPort),
|
|
262
|
+
},
|
|
263
|
+
name: 'closed-domain-server dev',
|
|
264
|
+
label: 'closed-domain-server',
|
|
265
|
+
},
|
|
266
|
+
children,
|
|
267
|
+
fail,
|
|
268
|
+
)
|
|
269
|
+
const startManagedPreview = async (previewOptions) => {
|
|
270
|
+
const preview = await startPreview(previewOptions)
|
|
271
|
+
children.push({ child: preview.child, name: previewOptions.cardMode ? 'opencard watch' : 'taro watch' })
|
|
272
|
+
runtimes.push(preview.runtime)
|
|
273
|
+
}
|
|
274
|
+
const previewTasks = [
|
|
275
|
+
startManagedPreview({ root: context.root, env, onExit: handlePreviewExit }),
|
|
276
|
+
]
|
|
277
|
+
if (context.hasOpenCard) {
|
|
278
|
+
previewTasks.push(startManagedPreview({
|
|
279
|
+
root: context.opencardRoot,
|
|
280
|
+
cardMode: true,
|
|
281
|
+
env,
|
|
282
|
+
onExit: handlePreviewExit,
|
|
283
|
+
}))
|
|
284
|
+
}
|
|
285
|
+
serverManaged = startBackend(env)
|
|
286
|
+
if (context.hasClosedDomainServer) startClosedDomainServer(env)
|
|
287
|
+
|
|
288
|
+
const restartBackend = async () => {
|
|
289
|
+
if (settled) return
|
|
290
|
+
if (serverManaged?.child?.pid && serverManaged.child.exitCode === null) {
|
|
291
|
+
serverManaged.expectedExit = true
|
|
292
|
+
try { process.kill(-serverManaged.child.pid, 'SIGKILL') } catch {}
|
|
293
|
+
}
|
|
294
|
+
await clearPort(taroDev.serverPort)
|
|
295
|
+
env = await readRuntimeEnvironment(context.root, { development: true })
|
|
296
|
+
serverManaged = startBackend(env)
|
|
297
|
+
}
|
|
298
|
+
databaseWatcher = createDatabaseGateWatcher({
|
|
299
|
+
baseline,
|
|
300
|
+
readEnvironment: () => readRuntimeEnvironment(context.root, { development: true }),
|
|
301
|
+
onEnabled: (runtimeEnv) => initializeDatabaseThenRestart({
|
|
302
|
+
context,
|
|
303
|
+
env: runtimeEnv,
|
|
304
|
+
restartBackend,
|
|
305
|
+
onError: (error) => console.warn(
|
|
306
|
+
`[mysql-gate] init-mysql-db failed; keeping current backend: ${error.message}`,
|
|
307
|
+
),
|
|
308
|
+
}),
|
|
309
|
+
onError: (error) => console.warn(`[mysql-gate] watcher failed: ${error.message}`),
|
|
310
|
+
})
|
|
311
|
+
await Promise.all(previewTasks)
|
|
312
|
+
}
|
|
313
|
+
console.log(`[vibecoding:dev] services started (${ports.join(', ')})`)
|
|
314
|
+
await lifetime
|
|
315
|
+
} finally {
|
|
316
|
+
await cleanup()
|
|
317
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.removeListener(signal, signalHandler)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { CliError } from '../core/errors.mjs'
|
|
4
|
+
import { resolveProjectBinary, runCaptured, runProcess, runTasks } from '../core/runner.mjs'
|
|
5
|
+
import { lintOpenCardProject } from '../lint/lint-lynx-css.mjs'
|
|
6
|
+
import { validateNonEmptyTaro } from '../validators/empty-project.mjs'
|
|
7
|
+
import { validateOpenCardStructure } from '../validators/opencard-structure.mjs'
|
|
8
|
+
import { validateSqlQueries } from '../validators/sql.mjs'
|
|
9
|
+
import { validateWidgetShell } from '../validators/widget-shell.mjs'
|
|
10
|
+
|
|
11
|
+
const globalConfigs = [
|
|
12
|
+
'eslint.config.mjs', 'tsconfig.json', 'package.json', 'pnpm-lock.yaml',
|
|
13
|
+
'opencard/eslint.config.mjs', 'opencard/tsconfig.json', 'opencard/package.json',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
export async function changedFiles(root, patterns, capture = runCaptured) {
|
|
17
|
+
try {
|
|
18
|
+
const baseCandidates = ['origin/main', 'origin/master']
|
|
19
|
+
let base = null
|
|
20
|
+
for (const candidate of baseCandidates) {
|
|
21
|
+
const check = await capture('git', ['rev-parse', '--verify', candidate], {
|
|
22
|
+
cwd: root,
|
|
23
|
+
allowFailure: true,
|
|
24
|
+
})
|
|
25
|
+
if (check.code === 0) {
|
|
26
|
+
base = candidate
|
|
27
|
+
break
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!base) {
|
|
31
|
+
const roots = await capture('git', ['rev-list', '--max-parents=0', 'HEAD'], {
|
|
32
|
+
cwd: root,
|
|
33
|
+
allowFailure: true,
|
|
34
|
+
})
|
|
35
|
+
base = roots.stdout.split('\n').map((item) => item.trim()).filter(Boolean).at(-1) ?? null
|
|
36
|
+
}
|
|
37
|
+
if (!base) return []
|
|
38
|
+
let result = await capture(
|
|
39
|
+
'git',
|
|
40
|
+
['diff', '--name-only', `${base}...HEAD`, '--', ...patterns],
|
|
41
|
+
{ cwd: root, allowFailure: true },
|
|
42
|
+
)
|
|
43
|
+
if (result.code !== 0 || !result.stdout.trim()) {
|
|
44
|
+
result = await capture('git', ['diff', '--name-only', 'HEAD', '--', ...patterns], {
|
|
45
|
+
cwd: root,
|
|
46
|
+
allowFailure: true,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
if (result.code !== 0) return []
|
|
50
|
+
return result.stdout.split('\n').map((item) => item.trim()).filter(Boolean)
|
|
51
|
+
} catch {
|
|
52
|
+
// Match the template's full-check fallback when Git cannot run.
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function incrementalLintFiles(root, capture = runCaptured) {
|
|
58
|
+
const files = await changedFiles(root, [
|
|
59
|
+
'src/**', 'server/**', 'closed-domain-server/**', 'feed/**', 'opencard/src/**', ...globalConfigs,
|
|
60
|
+
], capture)
|
|
61
|
+
return files.some((file) => globalConfigs.includes(file)) ? [] : files
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function selectOpenCardEslintFiles(changed) {
|
|
65
|
+
if (changed.length === 0) {
|
|
66
|
+
return { runEslint: true, files: ['src/**/*.{js,jsx,ts,tsx}'] }
|
|
67
|
+
}
|
|
68
|
+
const files = changed
|
|
69
|
+
.filter((file) => /\.(?:js|jsx|ts|tsx)$/.test(file))
|
|
70
|
+
.map((file) => file.replace(/^opencard\//, ''))
|
|
71
|
+
return { runEslint: files.length > 0, files }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function runLynxCss(root, lintProject = lintOpenCardProject) {
|
|
75
|
+
const findings = lintProject(root)
|
|
76
|
+
if (findings.length === 0) return
|
|
77
|
+
const details = findings.map((finding) =>
|
|
78
|
+
`${finding.file}:${finding.line}:${finding.column} ${finding.message}`,
|
|
79
|
+
)
|
|
80
|
+
throw new CliError(
|
|
81
|
+
`OpenCard Lynx CSS check failed (${findings.length}):\n${details.join('\n')}`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function lintOpenCard(context, options = {}) {
|
|
86
|
+
const root = context.target === 'opencard' ? context.root : context.opencardRoot
|
|
87
|
+
if (!root) return
|
|
88
|
+
const projectRoot = context.taroRoot && existsSync(path.join(context.taroRoot, 'node_modules/.bin/tsc'))
|
|
89
|
+
? context.taroRoot
|
|
90
|
+
: root
|
|
91
|
+
const tsc = resolveProjectBinary(projectRoot, 'tsc')
|
|
92
|
+
const eslint = resolveProjectBinary(projectRoot, 'eslint')
|
|
93
|
+
mkdirSync(path.join(root, 'node_modules/.cache/eslint'), { recursive: true })
|
|
94
|
+
const eslintArgs = ['--cache', '--cache-location', 'node_modules/.cache/eslint/opencard-src']
|
|
95
|
+
let changed = []
|
|
96
|
+
if (options.incremental) {
|
|
97
|
+
changed = await changedFiles(context.taroRoot ?? root, ['opencard/src/**'])
|
|
98
|
+
if (changed.length > 0) {
|
|
99
|
+
console.log(`[opencard lint] incremental mode (${changed.length} files)`)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const eslintSelection = selectOpenCardEslintFiles(changed)
|
|
103
|
+
eslintArgs.push(...eslintSelection.files)
|
|
104
|
+
const validationContext = { ...context, root, target: 'opencard' }
|
|
105
|
+
const tasks = [
|
|
106
|
+
{ name: 'opencard-structure', run: async () => validateOpenCardStructure(validationContext) },
|
|
107
|
+
{ name: 'opencard-widget-shell', run: async () => validateWidgetShell(validationContext) },
|
|
108
|
+
{ name: 'opencard-lynx-css', run: () => runLynxCss(root, options.lintProject) },
|
|
109
|
+
{ name: 'opencard-tsc', run: () => runProcess(tsc, ['--noEmit'], { cwd: root }) },
|
|
110
|
+
]
|
|
111
|
+
if (eslintSelection.runEslint) {
|
|
112
|
+
tasks.push({
|
|
113
|
+
name: 'opencard-eslint',
|
|
114
|
+
run: () => runProcess(eslint, eslintArgs, { cwd: root }),
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
await runTasks(tasks, { label: 'opencard lint' })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function runLint(context, options = {}) {
|
|
121
|
+
const incremental = options.incremental || process.env.LINT_INCREMENTAL === '1'
|
|
122
|
+
if (context.target === 'opencard') {
|
|
123
|
+
await lintOpenCard(context, { incremental })
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
validateNonEmptyTaro(context)
|
|
128
|
+
const tsc = resolveProjectBinary(context.root, 'tsc')
|
|
129
|
+
const eslint = resolveProjectBinary(context.root, 'eslint')
|
|
130
|
+
const eslintArgs = ['--cache', '--cache-location', 'node_modules/.cache/eslint/root-src']
|
|
131
|
+
const changed = incremental ? await incrementalLintFiles(context.root) : []
|
|
132
|
+
if (incremental) {
|
|
133
|
+
const files = changed.filter((file) => file.startsWith('src/'))
|
|
134
|
+
eslintArgs.push(...(files.length > 0 ? files : ['src/**/*.{js,jsx,ts,tsx,css,scss}']))
|
|
135
|
+
} else {
|
|
136
|
+
eslintArgs.push('src/**/*.{js,jsx,ts,tsx,css,scss}')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const full = !incremental || changed.length === 0
|
|
140
|
+
const serverChanged = changed.some((file) => file.startsWith('server/'))
|
|
141
|
+
const closedDomainChanged = changed.some((file) => file.startsWith('closed-domain-server/'))
|
|
142
|
+
const tasks = []
|
|
143
|
+
if (full || changed.some((file) => file.startsWith('src/'))) {
|
|
144
|
+
tasks.push(
|
|
145
|
+
{ name: 'taro-tsc', run: () => runProcess(tsc, ['--noEmit'], { cwd: context.root }) },
|
|
146
|
+
{ name: 'taro-eslint', run: () => runProcess(eslint, eslintArgs, { cwd: context.root }) },
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
if (full || serverChanged || closedDomainChanged) {
|
|
150
|
+
tasks.push({
|
|
151
|
+
name: 'server-sql',
|
|
152
|
+
run: () => validateSqlQueries([context.serverRoot, context.closedDomainServerRoot]),
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
if (full || serverChanged) {
|
|
156
|
+
tasks.push({
|
|
157
|
+
name: 'server-lint',
|
|
158
|
+
run: () => runTasks([
|
|
159
|
+
{ name: 'server-tsc', run: () => runProcess(tsc, ['--noEmit'], { cwd: context.serverRoot }) },
|
|
160
|
+
{
|
|
161
|
+
name: 'server-eslint',
|
|
162
|
+
run: () => runProcess(eslint, [
|
|
163
|
+
'--cache',
|
|
164
|
+
'--cache-location',
|
|
165
|
+
'node_modules/.cache/eslint/server-src',
|
|
166
|
+
'src/**/*.{js,jsx,ts,tsx}',
|
|
167
|
+
], { cwd: context.serverRoot }),
|
|
168
|
+
},
|
|
169
|
+
], { label: 'server lint' }),
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
if (context.hasOpenCard && (full || changed.some((file) => file.startsWith('opencard/')))) {
|
|
173
|
+
tasks.push({ name: 'opencard-lint', run: () => lintOpenCard(context, { incremental: !full }) })
|
|
174
|
+
}
|
|
175
|
+
await runTasks(tasks, { label: 'lint' })
|
|
176
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from 'node:fs'
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
|
+
import { CliError } from '../core/errors.mjs'
|
|
17
|
+
import { resolveProjectContext } from '../core/context.mjs'
|
|
18
|
+
import { buildOpenCard, buildPackInputs } from './build.mjs'
|
|
19
|
+
|
|
20
|
+
const DEFAULT_ARTIFACTS = '/tmp/microapp_artifacts'
|
|
21
|
+
|
|
22
|
+
// Keep build/zip exit codes from the original shell entry without changing the
|
|
23
|
+
// error handling of unrelated CLI commands.
|
|
24
|
+
function runPackProcess(command, args, options) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const child = spawn(command, args, {
|
|
27
|
+
cwd: options.cwd,
|
|
28
|
+
env: { ...process.env, ...options.env },
|
|
29
|
+
stdio: 'inherit',
|
|
30
|
+
})
|
|
31
|
+
child.once('error', error => reject(new CliError(`${command}: ${error.message}`, error.code === 'ENOENT' ? 127 : 1)))
|
|
32
|
+
child.once('exit', (code, signal) => {
|
|
33
|
+
if (code === 0) return resolve()
|
|
34
|
+
reject(new CliError(`${command} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}`,
|
|
35
|
+
code ?? (128 + (os.constants.signals[signal] ?? 0))))
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isDirectory(directory) {
|
|
41
|
+
return existsSync(directory) && statSync(directory).isDirectory()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveExistingPath(directory) {
|
|
45
|
+
if (existsSync(directory)) return realpathSync(directory)
|
|
46
|
+
const parent = path.dirname(directory)
|
|
47
|
+
return parent === directory ? directory : path.join(resolveExistingPath(parent), path.basename(directory))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveArtifactsDirectory(root, requested) {
|
|
51
|
+
if (typeof requested !== 'string' || !requested.trim()) throw new CliError('Artifact directory must not be empty')
|
|
52
|
+
const absolute = path.resolve(root, requested)
|
|
53
|
+
// Never recursively clean a project/home/temp root or an ancestor, including
|
|
54
|
+
// aliases through symlinks. Dedicated subdirectories remain supported.
|
|
55
|
+
const resolved = resolveExistingPath(absolute)
|
|
56
|
+
const protectedPaths = [root, os.homedir(), os.tmpdir(), '/tmp'].map(value => resolveExistingPath(path.resolve(value)))
|
|
57
|
+
if (protectedPaths.some(value => value === resolved || value.startsWith(`${resolved}${path.sep}`)) || resolved === path.parse(resolved).root) {
|
|
58
|
+
throw new CliError(`Unsafe artifact directory: ${absolute}`)
|
|
59
|
+
}
|
|
60
|
+
if (existsSync(absolute) && lstatSync(absolute).isSymbolicLink()) {
|
|
61
|
+
throw new CliError(`Artifact directory must not be a symlink: ${absolute}`)
|
|
62
|
+
}
|
|
63
|
+
return absolute
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function zipDirectory(directory, output, { removeExisting = true } = {}) {
|
|
67
|
+
if (!isDirectory(directory)) throw new CliError(`Pack input is missing: ${directory}`)
|
|
68
|
+
if (removeExisting) rmSync(output, { force: true })
|
|
69
|
+
await runPackProcess('zip', ['-rq', output, '.'], { cwd: directory })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createServerStage(server, stage) {
|
|
73
|
+
if (!isDirectory(server)) throw new CliError(`Server pack input is missing: ${server}`)
|
|
74
|
+
const required = ['dist', 'Dockerfile', 'package.json', 'run.sh']
|
|
75
|
+
for (const name of required) {
|
|
76
|
+
if (!existsSync(path.join(server, name))) {
|
|
77
|
+
throw new CliError(`Server pack input is missing: ${path.join(server, name)}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const name of ['dist', 'package.json', 'run.sh', 'sql', 'scripts']) {
|
|
81
|
+
const source = path.join(server, name)
|
|
82
|
+
if (existsSync(source)) cpSync(source, path.join(stage, name), { recursive: true })
|
|
83
|
+
}
|
|
84
|
+
let dockerfile = readFileSync(path.join(server, 'Dockerfile'), 'utf8')
|
|
85
|
+
const additions = []
|
|
86
|
+
if (isDirectory(path.join(server, 'scripts'))) additions.push('COPY ./scripts ./scripts')
|
|
87
|
+
if (isDirectory(path.join(server, 'sql'))) additions.push('COPY ./sql ./sql')
|
|
88
|
+
// Match awk's per-line insertion, including multiple COPY instructions and
|
|
89
|
+
// its final newline. Files named sql/scripts are copied but are not folders.
|
|
90
|
+
dockerfile = dockerfile.split('\n').filter((_, index, lines) => index < lines.length - 1 || lines[index] !== '')
|
|
91
|
+
.flatMap(line => /^COPY package\.json/.test(line) ? [line, ...additions] : [line])
|
|
92
|
+
.map(line => `${line}\n`).join('')
|
|
93
|
+
writeFileSync(path.join(stage, 'Dockerfile'), dockerfile)
|
|
94
|
+
return stage
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function packOpenCardArtifact(context, artifacts) {
|
|
98
|
+
mkdirSync(artifacts, { recursive: true })
|
|
99
|
+
await zipDirectory(path.join(context.root, 'dist'), path.join(artifacts, 'opencard.zip'))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function packServer(server, artifacts, closed = false) {
|
|
103
|
+
const stage = mkdtempSync(path.join(os.tmpdir(), 'vibecoding-server-pack-'))
|
|
104
|
+
try {
|
|
105
|
+
createServerStage(server, stage)
|
|
106
|
+
await zipDirectory(stage, path.join(artifacts, closed ? 'closed-domain-server.zip' : 'server.zip'), { removeExisting: false })
|
|
107
|
+
cpSync(path.join(stage, 'Dockerfile'), path.join(artifacts, closed ? 'closed-domain-Dockerfile' : 'Dockerfile'))
|
|
108
|
+
cpSync(path.join(server, 'run.sh'), path.join(artifacts, closed ? 'closed-domain-run.sh' : 'run.sh'))
|
|
109
|
+
} finally {
|
|
110
|
+
rmSync(stage, { recursive: true, force: true })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function packTaroArtifacts(context, artifacts) {
|
|
115
|
+
artifacts = resolveArtifactsDirectory(context.root, artifacts)
|
|
116
|
+
mkdirSync(artifacts, { recursive: true })
|
|
117
|
+
const dist = path.join(context.root, 'dist-tt')
|
|
118
|
+
console.log(`Packing ${dist} into ${artifacts}/microapp.zip`)
|
|
119
|
+
await zipDirectory(dist, path.join(artifacts, 'microapp.zip'))
|
|
120
|
+
await packServer(context.serverRoot ?? path.join(context.root, 'server'), artifacts)
|
|
121
|
+
const closedServer = path.join(context.root, 'closed-domain-server')
|
|
122
|
+
if (existsSync(path.join(closedServer, 'package.json')) && statSync(path.join(closedServer, 'package.json')).isFile()) {
|
|
123
|
+
await packServer(closedServer, artifacts, true)
|
|
124
|
+
} else {
|
|
125
|
+
console.log('No closed-domain-server/package.json; skipping closed-domain-server artifacts')
|
|
126
|
+
}
|
|
127
|
+
const routing = path.join(context.root, 'config/card-routing.json')
|
|
128
|
+
if (existsSync(routing) && statSync(routing).isFile()) {
|
|
129
|
+
cpSync(routing, path.join(artifacts, 'card-routing.json'))
|
|
130
|
+
} else {
|
|
131
|
+
console.log(`No ${routing}; skipping card-routing.json artifact`)
|
|
132
|
+
}
|
|
133
|
+
// A legacy project build hook can write an OpenCard archive into ARTIFACTS_DIR.
|
|
134
|
+
// Do not replace that archive or require opencard/dist after it succeeds.
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function runPack(context, options = {}) {
|
|
138
|
+
const artifacts = resolveArtifactsDirectory(context.root,
|
|
139
|
+
options.artifactsDir ?? process.env.ARTIFACTS_DIR ?? DEFAULT_ARTIFACTS)
|
|
140
|
+
if (context.target === 'opencard') {
|
|
141
|
+
await buildOpenCard(context)
|
|
142
|
+
await packOpenCardArtifact(context, artifacts)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log(`Cleaning ${artifacts}`)
|
|
147
|
+
rmSync(artifacts, { recursive: true, force: true })
|
|
148
|
+
mkdirSync(artifacts, { recursive: true })
|
|
149
|
+
const packageJson = context.packageJson ?? JSON.parse(readFileSync(path.join(context.root, 'package.json'), 'utf8'))
|
|
150
|
+
const projectBuild = packageJson.scripts?.['build:pack']
|
|
151
|
+
if (projectBuild) {
|
|
152
|
+
// Preserve compatibility for projects that own their OpenCard archive.
|
|
153
|
+
console.log('Running build:pack')
|
|
154
|
+
await runPackProcess('pnpm', ['build:pack'], {
|
|
155
|
+
cwd: context.root,
|
|
156
|
+
env: { ...options.env, ARTIFACTS_DIR: artifacts },
|
|
157
|
+
})
|
|
158
|
+
} else {
|
|
159
|
+
// Current templates use the shared CLI build orchestration.
|
|
160
|
+
context = resolveProjectContext({ cwd: context.root, target: 'taro' })
|
|
161
|
+
await buildPackInputs(context, options)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await packTaroArtifacts(context, artifacts)
|
|
165
|
+
if (!projectBuild && context.hasOpenCard) {
|
|
166
|
+
await packOpenCardArtifact({ root: context.opencardRoot }, artifacts)
|
|
167
|
+
}
|
|
168
|
+
console.log(`Pack completed. Artifacts located in ${artifacts}`)
|
|
169
|
+
}
|