@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,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
+ }