@antigenic-oss/paint 0.2.0 → 0.2.2

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 (65) hide show
  1. package/README.md +32 -17
  2. package/bin/bridge-server.js +38 -0
  3. package/bin/paint.js +559 -104
  4. package/bin/terminal-server.js +105 -0
  5. package/package.json +7 -8
  6. package/public/dev-editor-inspector.js +92 -104
  7. package/src/app/api/claude/apply/route.ts +2 -2
  8. package/src/app/api/project/scan/route.ts +1 -1
  9. package/src/app/api/proxy/[[...path]]/route.ts +4 -4
  10. package/src/app/docs/DocsClient.tsx +1 -1
  11. package/src/app/docs/page.tsx +0 -1
  12. package/src/app/page.tsx +1 -1
  13. package/src/bridge/api-handlers.ts +1 -1
  14. package/src/bridge/proxy-handler.ts +4 -4
  15. package/src/bridge/server.ts +135 -39
  16. package/src/components/ConnectModal.tsx +1 -2
  17. package/src/components/PreviewFrame.tsx +2 -2
  18. package/src/components/ResponsiveToolbar.tsx +1 -2
  19. package/src/components/common/ColorPicker.tsx +7 -9
  20. package/src/components/common/UnitInput.tsx +1 -1
  21. package/src/components/common/VariableColorPicker.tsx +0 -1
  22. package/src/components/left-panel/ComponentsPanel.tsx +3 -3
  23. package/src/components/left-panel/LayerNode.tsx +1 -1
  24. package/src/components/left-panel/icons.tsx +1 -1
  25. package/src/components/left-panel/terminal/TerminalPanel.tsx +2 -2
  26. package/src/components/right-panel/ElementLogBox.tsx +1 -3
  27. package/src/components/right-panel/changes/ChangesPanel.tsx +12 -12
  28. package/src/components/right-panel/claude/ClaudeIntegrationPanel.tsx +2 -2
  29. package/src/components/right-panel/claude/DiffViewer.tsx +1 -1
  30. package/src/components/right-panel/claude/ProjectRootSelector.tsx +7 -7
  31. package/src/components/right-panel/claude/SetupFlow.tsx +1 -1
  32. package/src/components/right-panel/console/ConsolePanel.tsx +4 -4
  33. package/src/components/right-panel/design/BackgroundSection.tsx +2 -2
  34. package/src/components/right-panel/design/GradientEditor.tsx +6 -6
  35. package/src/components/right-panel/design/LayoutSection.tsx +4 -4
  36. package/src/components/right-panel/design/PositionSection.tsx +2 -2
  37. package/src/components/right-panel/design/SVGSection.tsx +2 -3
  38. package/src/components/right-panel/design/ShadowBlurSection.tsx +5 -5
  39. package/src/components/right-panel/design/TextSection.tsx +5 -5
  40. package/src/components/right-panel/design/icons.tsx +1 -1
  41. package/src/components/right-panel/design/inputs/BoxModelPreview.tsx +2 -2
  42. package/src/components/right-panel/design/inputs/CompactInput.tsx +2 -2
  43. package/src/components/right-panel/design/inputs/DraggableLabel.tsx +2 -1
  44. package/src/components/right-panel/design/inputs/IconToggleGroup.tsx +1 -1
  45. package/src/components/right-panel/design/inputs/LinkedInputPair.tsx +3 -3
  46. package/src/components/right-panel/design/inputs/SectionHeader.tsx +2 -1
  47. package/src/hooks/useDOMTree.ts +0 -1
  48. package/src/hooks/usePostMessage.ts +4 -3
  49. package/src/hooks/useTargetUrl.ts +1 -1
  50. package/src/inspector/DOMTraverser.ts +2 -2
  51. package/src/inspector/HoverHighlighter.ts +6 -6
  52. package/src/inspector/SelectionHighlighter.ts +4 -4
  53. package/src/lib/classifyElement.ts +1 -2
  54. package/src/lib/claude-bin.ts +1 -1
  55. package/src/lib/clientProjectScanner.ts +13 -13
  56. package/src/lib/cssVariableUtils.ts +1 -1
  57. package/src/lib/folderPicker.ts +4 -1
  58. package/src/lib/projectScanner.ts +15 -15
  59. package/src/lib/tailwindClassParser.ts +1 -1
  60. package/src/lib/textShadowUtils.ts +1 -1
  61. package/src/lib/utils.ts +4 -4
  62. package/src/proxy.ts +1 -1
  63. package/src/store/treeSlice.ts +2 -2
  64. package/src/server/terminal-server.ts +0 -104
  65. package/tsconfig.server.json +0 -12
package/bin/paint.js CHANGED
@@ -3,13 +3,50 @@
3
3
  const fs = require('node:fs')
4
4
  const os = require('node:os')
5
5
  const path = require('node:path')
6
+ const http = require('node:http')
7
+ const https = require('node:https')
6
8
  const { spawn, spawnSync } = require('node:child_process')
7
9
 
8
- const APP_ROOT = path.resolve(__dirname, '..')
10
+ const ENTRY_REAL_PATH = fs.realpathSync(__filename)
11
+ const APP_ROOT = path.resolve(path.dirname(ENTRY_REAL_PATH), '..')
9
12
  const STATE_DIR = path.join(os.homedir(), '.paint')
10
- const STATE_FILE = path.join(STATE_DIR, 'server.json')
11
- const LOG_FILE = path.join(STATE_DIR, 'server.log')
12
- const NEXT_BIN = path.join(APP_ROOT, 'node_modules', 'next', 'dist', 'bin', 'next')
13
+
14
+ const APP_STATE_FILE = path.join(STATE_DIR, 'server.json')
15
+ const TERMINAL_STATE_FILE = path.join(STATE_DIR, 'terminal.json')
16
+ const BRIDGE_STATE_FILE = path.join(STATE_DIR, 'bridge.json')
17
+
18
+ const WEB_LOG_FILE = path.join(STATE_DIR, 'web.log')
19
+ const TERMINAL_LOG_FILE = path.join(STATE_DIR, 'terminal.log')
20
+ const BRIDGE_LOG_FILE = path.join(STATE_DIR, 'bridge.log')
21
+
22
+ function resolveNextBin() {
23
+ const directPath = path.join(
24
+ APP_ROOT,
25
+ 'node_modules',
26
+ 'next',
27
+ 'dist',
28
+ 'bin',
29
+ 'next',
30
+ )
31
+ if (fs.existsSync(directPath)) {
32
+ return directPath
33
+ }
34
+
35
+ try {
36
+ return require.resolve('next/dist/bin/next', { paths: [APP_ROOT] })
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ const NEXT_BIN = resolveNextBin()
43
+ const TERMINAL_SERVER_BIN = path.join(APP_ROOT, 'bin', 'terminal-server.js')
44
+ const BRIDGE_SERVER_BIN = path.join(APP_ROOT, 'bin', 'bridge-server.js')
45
+
46
+ const DEFAULT_HOST = '127.0.0.1'
47
+ const DEFAULT_WEB_PORT = 4000
48
+ const DEFAULT_TERMINAL_PORT = 4001
49
+ const DEFAULT_BRIDGE_PORT = 4002
13
50
 
14
51
  function ensureStateDir() {
15
52
  fs.mkdirSync(STATE_DIR, { recursive: true })
@@ -20,17 +57,35 @@ function now() {
20
57
  }
21
58
 
22
59
  function parseArgs(argv) {
60
+ const mode = ['bridge', 'terminal'].includes(argv[0]) ? argv[0] : 'app'
61
+ const command = mode === 'app' ? argv[0] || 'help' : argv[1] || 'help'
62
+ const args = mode === 'app' ? argv.slice(1) : argv.slice(2)
63
+
23
64
  const parsed = {
24
- command: argv[0] || 'help',
25
- port: 4000,
26
- host: '127.0.0.1',
65
+ mode,
66
+ command,
67
+ host: DEFAULT_HOST,
68
+ port: DEFAULT_WEB_PORT,
69
+ terminalPort: DEFAULT_TERMINAL_PORT,
70
+ bridgePort: DEFAULT_BRIDGE_PORT,
27
71
  rebuild: false,
28
72
  }
29
73
 
30
- for (let i = 1; i < argv.length; i += 1) {
31
- const arg = argv[i]
32
- if (arg === '--port' && argv[i + 1]) {
33
- parsed.port = Number(argv[i + 1])
74
+ for (let i = 0; i < args.length; i += 1) {
75
+ const arg = args[i]
76
+
77
+ if (arg === '--host' && args[i + 1]) {
78
+ parsed.host = args[i + 1]
79
+ i += 1
80
+ continue
81
+ }
82
+ if (arg.startsWith('--host=')) {
83
+ parsed.host = arg.slice('--host='.length)
84
+ continue
85
+ }
86
+
87
+ if (arg === '--port' && args[i + 1]) {
88
+ parsed.port = Number(args[i + 1])
34
89
  i += 1
35
90
  continue
36
91
  }
@@ -38,15 +93,27 @@ function parseArgs(argv) {
38
93
  parsed.port = Number(arg.slice('--port='.length))
39
94
  continue
40
95
  }
41
- if (arg === '--host' && argv[i + 1]) {
42
- parsed.host = argv[i + 1]
96
+
97
+ if (arg === '--terminal-port' && args[i + 1]) {
98
+ parsed.terminalPort = Number(args[i + 1])
43
99
  i += 1
44
100
  continue
45
101
  }
46
- if (arg.startsWith('--host=')) {
47
- parsed.host = arg.slice('--host='.length)
102
+ if (arg.startsWith('--terminal-port=')) {
103
+ parsed.terminalPort = Number(arg.slice('--terminal-port='.length))
104
+ continue
105
+ }
106
+
107
+ if (arg === '--bridge-port' && args[i + 1]) {
108
+ parsed.bridgePort = Number(args[i + 1])
109
+ i += 1
110
+ continue
111
+ }
112
+ if (arg.startsWith('--bridge-port=')) {
113
+ parsed.bridgePort = Number(arg.slice('--bridge-port='.length))
48
114
  continue
49
115
  }
116
+
50
117
  if (arg === '--rebuild') {
51
118
  parsed.rebuild = true
52
119
  }
@@ -55,21 +122,21 @@ function parseArgs(argv) {
55
122
  return parsed
56
123
  }
57
124
 
58
- function readState() {
125
+ function readState(filePath) {
59
126
  try {
60
- return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'))
127
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
61
128
  } catch {
62
129
  return null
63
130
  }
64
131
  }
65
132
 
66
- function writeState(state) {
67
- fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
133
+ function writeState(filePath, state) {
134
+ fs.writeFileSync(filePath, JSON.stringify(state, null, 2))
68
135
  }
69
136
 
70
- function removeState() {
71
- if (fs.existsSync(STATE_FILE)) {
72
- fs.unlinkSync(STATE_FILE)
137
+ function removeState(filePath) {
138
+ if (fs.existsSync(filePath)) {
139
+ fs.unlinkSync(filePath)
73
140
  }
74
141
  }
75
142
 
@@ -83,9 +150,111 @@ function isProcessAlive(pid) {
83
150
  }
84
151
  }
85
152
 
153
+ function stopProcess(pid) {
154
+ if (!Number.isInteger(pid) || pid <= 0) return true
155
+
156
+ if (process.platform === 'win32') {
157
+ const result = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
158
+ stdio: 'ignore',
159
+ })
160
+ return result.status === 0
161
+ }
162
+
163
+ try {
164
+ process.kill(-pid, 'SIGTERM')
165
+ return true
166
+ } catch {
167
+ try {
168
+ process.kill(pid, 'SIGTERM')
169
+ return true
170
+ } catch {
171
+ return false
172
+ }
173
+ }
174
+ }
175
+
176
+ function validatePort(port, flagName) {
177
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
178
+ console.error(`Invalid ${flagName} value: ${port}`)
179
+ process.exit(1)
180
+ }
181
+ }
182
+
183
+ function spawnDetached(command, args, opts) {
184
+ const { env, logFile } = opts
185
+ const fd = fs.openSync(logFile, 'a')
186
+ fs.writeSync(fd, `\n[${now()}] spawn: ${command} ${args.join(' ')}\n`)
187
+
188
+ const child = spawn(command, args, {
189
+ cwd: APP_ROOT,
190
+ env,
191
+ detached: true,
192
+ stdio: ['ignore', fd, fd],
193
+ })
194
+
195
+ child.unref()
196
+ return child
197
+ }
198
+
199
+ function delay(ms) {
200
+ return new Promise((resolve) => setTimeout(resolve, ms))
201
+ }
202
+
203
+ function probeHttp(url, validator) {
204
+ return new Promise((resolve) => {
205
+ const parsed = new URL(url)
206
+ const client = parsed.protocol === 'https:' ? https : http
207
+ const req = client.request(
208
+ {
209
+ protocol: parsed.protocol,
210
+ hostname: parsed.hostname,
211
+ port: parsed.port,
212
+ path: `${parsed.pathname}${parsed.search}`,
213
+ method: 'GET',
214
+ timeout: 1200,
215
+ },
216
+ (res) => {
217
+ const chunks = []
218
+ res.on('data', (chunk) => chunks.push(chunk))
219
+ res.on('end', () => {
220
+ const body = Buffer.concat(chunks).toString('utf8')
221
+ if (typeof validator === 'function') {
222
+ resolve(Boolean(validator(res, body)))
223
+ return
224
+ }
225
+ resolve(res.statusCode >= 200 && res.statusCode < 400)
226
+ })
227
+ },
228
+ )
229
+
230
+ req.on('timeout', () => {
231
+ req.destroy()
232
+ resolve(false)
233
+ })
234
+
235
+ req.on('error', () => resolve(false))
236
+ req.end()
237
+ })
238
+ }
239
+
240
+ async function waitForHttp(url, timeoutMs = 30000, validator) {
241
+ const deadline = Date.now() + timeoutMs
242
+ while (Date.now() < deadline) {
243
+ // eslint-disable-next-line no-await-in-loop
244
+ const ok = await probeHttp(url, validator)
245
+ if (ok) return true
246
+ // eslint-disable-next-line no-await-in-loop
247
+ await delay(300)
248
+ }
249
+ return false
250
+ }
251
+
86
252
  function ensureNextInstalled() {
87
- if (!fs.existsSync(NEXT_BIN)) {
88
- console.error('pAInt runtime is missing Next.js binaries. Reinstall the package.')
253
+ if (!NEXT_BIN || !fs.existsSync(NEXT_BIN)) {
254
+ console.error(
255
+ 'pAInt runtime is missing Next.js binaries. Reinstall the package.',
256
+ )
257
+ console.error(`Resolved app root: ${APP_ROOT}`)
89
258
  process.exit(1)
90
259
  }
91
260
  }
@@ -99,7 +268,9 @@ function ensureBuilt(forceRebuild) {
99
268
  return
100
269
  }
101
270
 
102
- console.log(forceRebuild ? 'Rebuilding pAInt…' : 'Building pAInt for first run…')
271
+ console.log(
272
+ forceRebuild ? 'Rebuilding pAInt…' : 'Building pAInt for first run…',
273
+ )
103
274
  const result = spawnSync(process.execPath, [NEXT_BIN, 'build'], {
104
275
  cwd: APP_ROOT,
105
276
  env: process.env,
@@ -111,156 +282,440 @@ function ensureBuilt(forceRebuild) {
111
282
  }
112
283
  }
113
284
 
114
- function stopProcess(pid) {
115
- if (process.platform === 'win32') {
116
- const result = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
117
- stdio: 'ignore',
118
- })
119
- return result.status === 0
120
- }
121
-
122
- try {
123
- process.kill(-pid, 'SIGTERM')
124
- return true
125
- } catch {
126
- try {
127
- process.kill(pid, 'SIGTERM')
128
- return true
129
- } catch {
130
- return false
131
- }
132
- }
133
- }
285
+ async function startApp(options) {
286
+ validatePort(options.port, '--port')
134
287
 
135
- function startServer({ port, host, rebuild }) {
136
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
137
- console.error(`Invalid --port value: ${port}`)
138
- process.exit(1)
288
+ const existing = readState(APP_STATE_FILE)
289
+ if (existing && isProcessAlive(existing.webPid)) {
290
+ console.log(
291
+ `pAInt is already running (web pid ${existing.webPid}) at http://${existing.host}:${existing.port}`,
292
+ )
293
+ return
139
294
  }
140
295
 
141
- const existing = readState()
142
- if (existing && isProcessAlive(existing.pid)) {
143
- console.log(`pAInt is already running (pid ${existing.pid}) at http://${existing.host}:${existing.port}`)
144
- process.exit(0)
296
+ if (existing) {
297
+ stopProcess(existing.webPid)
298
+ removeState(APP_STATE_FILE)
145
299
  }
146
300
 
147
- ensureBuilt(rebuild)
301
+ ensureBuilt(options.rebuild)
148
302
  ensureStateDir()
149
303
 
150
- const logFd = fs.openSync(LOG_FILE, 'a')
151
- fs.writeSync(logFd, `\n[${now()}] starting pAInt server\n`)
152
-
153
- const child = spawn(
304
+ const webChild = spawnDetached(
154
305
  process.execPath,
155
- [NEXT_BIN, 'start', '--port', String(port), '--hostname', host],
306
+ [
307
+ NEXT_BIN,
308
+ 'start',
309
+ '--port',
310
+ String(options.port),
311
+ '--hostname',
312
+ options.host,
313
+ ],
156
314
  {
157
- cwd: APP_ROOT,
158
315
  env: process.env,
159
- detached: true,
160
- stdio: ['ignore', logFd, logFd],
316
+ logFile: WEB_LOG_FILE,
161
317
  },
162
318
  )
163
319
 
164
- child.unref()
320
+ const webReady = await waitForHttp(
321
+ `http://${options.host}:${options.port}/api/claude/status`,
322
+ 25000,
323
+ (res, body) => {
324
+ const ct = String(res.headers['content-type'] || '')
325
+ return (
326
+ res.statusCode >= 200 &&
327
+ res.statusCode < 400 &&
328
+ ct.includes('application/json') &&
329
+ body.includes('"available"')
330
+ )
331
+ },
332
+ )
333
+
334
+ if (!webReady) {
335
+ stopProcess(webChild.pid)
336
+ removeState(APP_STATE_FILE)
337
+ console.error('Failed to start pAInt web server cleanly.')
338
+ console.error(
339
+ `- web server did not become ready at http://${options.host}:${options.port}/`,
340
+ )
341
+ console.error(`Web logs: ${WEB_LOG_FILE}`)
342
+ process.exit(1)
343
+ }
165
344
 
166
- writeState({
167
- pid: child.pid,
168
- host,
169
- port,
345
+ writeState(APP_STATE_FILE, {
346
+ webPid: webChild.pid,
347
+ host: options.host,
348
+ port: options.port,
170
349
  startedAt: now(),
171
- logFile: LOG_FILE,
350
+ logs: {
351
+ web: WEB_LOG_FILE,
352
+ },
172
353
  })
173
354
 
174
- console.log(`pAInt started (pid ${child.pid}) at http://${host}:${port}`)
175
- console.log(`Logs: ${LOG_FILE}`)
355
+ console.log(`pAInt started at http://${options.host}:${options.port}`)
356
+ console.log(`Web pid: ${webChild.pid}`)
357
+ console.log(`Web logs: ${WEB_LOG_FILE}`)
176
358
  }
177
359
 
178
- function stopServer() {
179
- const existing = readState()
360
+ function stopApp() {
361
+ const existing = readState(APP_STATE_FILE)
180
362
  if (!existing) {
181
363
  console.log('pAInt is not running.')
182
364
  return
183
365
  }
184
366
 
185
- if (!isProcessAlive(existing.pid)) {
186
- removeState()
367
+ const alive = isProcessAlive(existing.webPid)
368
+ const ok = alive ? stopProcess(existing.webPid) : true
369
+ removeState(APP_STATE_FILE)
370
+
371
+ if (!alive) {
187
372
  console.log('pAInt was not running. Cleared stale state.')
188
373
  return
189
374
  }
190
375
 
191
- const ok = stopProcess(existing.pid)
192
- removeState()
193
-
194
376
  if (!ok) {
195
- console.error(`Failed to stop process ${existing.pid}`)
377
+ console.error(`Failed to stop web process ${existing.webPid}`)
196
378
  process.exit(1)
197
379
  }
198
380
 
199
- console.log(`Stopped pAInt (pid ${existing.pid}).`)
381
+ console.log(`Stopped pAInt (web ${existing.webPid}).`)
200
382
  }
201
383
 
202
- function serverStatus() {
203
- const existing = readState()
204
- if (!existing || !isProcessAlive(existing.pid)) {
384
+ function appStatus() {
385
+ const existing = readState(APP_STATE_FILE)
386
+ if (!existing || !isProcessAlive(existing.webPid)) {
205
387
  console.log('pAInt is not running.')
206
388
  return
207
389
  }
208
390
 
209
- console.log(`pAInt is running (pid ${existing.pid})`)
391
+ console.log('pAInt is running')
392
+ console.log(`Web: up (pid ${existing.webPid})`)
210
393
  console.log(`URL: http://${existing.host}:${existing.port}`)
211
394
  console.log(`Started: ${existing.startedAt}`)
212
- console.log(`Logs: ${existing.logFile}`)
395
+ if (existing.logs?.web) console.log(`Web logs: ${existing.logs.web}`)
396
+ }
397
+
398
+ function appLogs() {
399
+ if (!fs.existsSync(WEB_LOG_FILE)) {
400
+ console.log('No web logs found yet.')
401
+ return
402
+ }
403
+ process.stdout.write('===== web.log =====\n')
404
+ process.stdout.write(fs.readFileSync(WEB_LOG_FILE, 'utf8'))
405
+ }
406
+
407
+ async function startTerminal(options) {
408
+ validatePort(options.terminalPort, '--terminal-port')
409
+
410
+ const existing = readState(TERMINAL_STATE_FILE)
411
+ if (existing && isProcessAlive(existing.terminalPid)) {
412
+ console.log(
413
+ `Terminal is already running (pid ${existing.terminalPid}) at ws://localhost:${existing.terminalPort}/ws`,
414
+ )
415
+ return
416
+ }
417
+
418
+ if (existing) {
419
+ stopProcess(existing.terminalPid)
420
+ removeState(TERMINAL_STATE_FILE)
421
+ }
422
+
423
+ ensureStateDir()
424
+
425
+ const terminalChild = spawnDetached(process.execPath, [TERMINAL_SERVER_BIN], {
426
+ env: {
427
+ ...process.env,
428
+ TERMINAL_PORT: String(options.terminalPort),
429
+ },
430
+ logFile: TERMINAL_LOG_FILE,
431
+ })
432
+
433
+ const terminalReady = await waitForHttp(
434
+ `http://127.0.0.1:${options.terminalPort}/health`,
435
+ 25000,
436
+ (res, body) =>
437
+ res.statusCode >= 200 && res.statusCode < 400 && body.trim() === 'ok',
438
+ )
439
+
440
+ if (!terminalReady) {
441
+ stopProcess(terminalChild.pid)
442
+ removeState(TERMINAL_STATE_FILE)
443
+ console.error(
444
+ `Terminal failed to become ready at http://127.0.0.1:${options.terminalPort}/health`,
445
+ )
446
+ console.error(`Terminal logs: ${TERMINAL_LOG_FILE}`)
447
+ process.exit(1)
448
+ }
449
+
450
+ writeState(TERMINAL_STATE_FILE, {
451
+ terminalPid: terminalChild.pid,
452
+ terminalPort: options.terminalPort,
453
+ startedAt: now(),
454
+ logs: {
455
+ terminal: TERMINAL_LOG_FILE,
456
+ },
457
+ })
458
+
459
+ console.log(`Terminal started at ws://localhost:${options.terminalPort}/ws`)
460
+ console.log(`Terminal pid: ${terminalChild.pid}`)
461
+ console.log(`Terminal logs: ${TERMINAL_LOG_FILE}`)
462
+ }
463
+
464
+ function stopTerminal() {
465
+ const existing = readState(TERMINAL_STATE_FILE)
466
+ if (!existing) {
467
+ console.log('Terminal is not running.')
468
+ return
469
+ }
470
+
471
+ const alive = isProcessAlive(existing.terminalPid)
472
+ const ok = alive ? stopProcess(existing.terminalPid) : true
473
+ removeState(TERMINAL_STATE_FILE)
474
+
475
+ if (!alive) {
476
+ console.log('Terminal was not running. Cleared stale state.')
477
+ return
478
+ }
479
+
480
+ if (!ok) {
481
+ console.error(`Failed to stop terminal process ${existing.terminalPid}`)
482
+ process.exit(1)
483
+ }
484
+
485
+ console.log(`Stopped terminal (pid ${existing.terminalPid}).`)
486
+ }
487
+
488
+ function terminalStatus() {
489
+ const existing = readState(TERMINAL_STATE_FILE)
490
+ if (!existing || !isProcessAlive(existing.terminalPid)) {
491
+ console.log('Terminal is not running.')
492
+ return
493
+ }
494
+
495
+ console.log('Terminal is running')
496
+ console.log(`PID: ${existing.terminalPid}`)
497
+ console.log(`WS: ws://localhost:${existing.terminalPort}/ws`)
498
+ console.log(`Started: ${existing.startedAt}`)
499
+ if (existing.logs?.terminal)
500
+ console.log(`Terminal logs: ${existing.logs.terminal}`)
501
+ }
502
+
503
+ function terminalLogs() {
504
+ if (!fs.existsSync(TERMINAL_LOG_FILE)) {
505
+ console.log('No terminal logs found yet.')
506
+ return
507
+ }
508
+ process.stdout.write('===== terminal.log =====\n')
509
+ process.stdout.write(fs.readFileSync(TERMINAL_LOG_FILE, 'utf8'))
510
+ }
511
+
512
+ async function startBridge(options) {
513
+ validatePort(options.bridgePort, '--bridge-port')
514
+
515
+ const existing = readState(BRIDGE_STATE_FILE)
516
+ if (existing && isProcessAlive(existing.bridgePid)) {
517
+ console.log(
518
+ `Bridge is already running (pid ${existing.bridgePid}) at http://127.0.0.1:${existing.bridgePort}`,
519
+ )
520
+ return
521
+ }
522
+
523
+ if (existing) {
524
+ stopProcess(existing.bridgePid)
525
+ removeState(BRIDGE_STATE_FILE)
526
+ }
527
+
528
+ ensureStateDir()
529
+
530
+ const child = spawnDetached(process.execPath, [BRIDGE_SERVER_BIN], {
531
+ env: {
532
+ ...process.env,
533
+ BRIDGE_PORT: String(options.bridgePort),
534
+ },
535
+ logFile: BRIDGE_LOG_FILE,
536
+ })
537
+
538
+ const ready = await waitForHttp(
539
+ `http://127.0.0.1:${options.bridgePort}/health`,
540
+ 25000,
541
+ )
542
+
543
+ if (!ready) {
544
+ stopProcess(child.pid)
545
+ removeState(BRIDGE_STATE_FILE)
546
+ console.error(
547
+ `Bridge failed to become ready at http://127.0.0.1:${options.bridgePort}/health`,
548
+ )
549
+ console.error(`Bridge logs: ${BRIDGE_LOG_FILE}`)
550
+ process.exit(1)
551
+ }
552
+
553
+ writeState(BRIDGE_STATE_FILE, {
554
+ bridgePid: child.pid,
555
+ bridgePort: options.bridgePort,
556
+ startedAt: now(),
557
+ logs: {
558
+ bridge: BRIDGE_LOG_FILE,
559
+ },
560
+ })
561
+
562
+ console.log(`Bridge started at http://127.0.0.1:${options.bridgePort}`)
563
+ console.log(`Bridge pid: ${child.pid}`)
564
+ console.log(`Bridge logs: ${BRIDGE_LOG_FILE}`)
565
+ }
566
+
567
+ function stopBridge() {
568
+ const existing = readState(BRIDGE_STATE_FILE)
569
+ if (!existing) {
570
+ console.log('Bridge is not running.')
571
+ return
572
+ }
573
+
574
+ const alive = isProcessAlive(existing.bridgePid)
575
+ const ok = alive ? stopProcess(existing.bridgePid) : true
576
+ removeState(BRIDGE_STATE_FILE)
577
+
578
+ if (!alive) {
579
+ console.log('Bridge was not running. Cleared stale state.')
580
+ return
581
+ }
582
+
583
+ if (!ok) {
584
+ console.error(`Failed to stop bridge process ${existing.bridgePid}`)
585
+ process.exit(1)
586
+ }
587
+
588
+ console.log(`Stopped bridge (pid ${existing.bridgePid}).`)
589
+ }
590
+
591
+ function bridgeStatus() {
592
+ const existing = readState(BRIDGE_STATE_FILE)
593
+ if (!existing || !isProcessAlive(existing.bridgePid)) {
594
+ console.log('Bridge is not running.')
595
+ return
596
+ }
597
+
598
+ console.log('Bridge is running')
599
+ console.log(`PID: ${existing.bridgePid}`)
600
+ console.log(`URL: http://127.0.0.1:${existing.bridgePort}`)
601
+ console.log(`Started: ${existing.startedAt}`)
602
+ if (existing.logs?.bridge) console.log(`Bridge logs: ${existing.logs.bridge}`)
603
+ }
604
+
605
+ function bridgeLogs() {
606
+ if (!fs.existsSync(BRIDGE_LOG_FILE)) {
607
+ console.log('No bridge logs found yet.')
608
+ return
609
+ }
610
+ process.stdout.write('===== bridge.log =====\n')
611
+ process.stdout.write(fs.readFileSync(BRIDGE_LOG_FILE, 'utf8'))
213
612
  }
214
613
 
215
614
  function showHelp() {
216
615
  console.log(`paint - pAInt server manager
217
616
 
218
- Usage:
617
+ App usage:
219
618
  paint start [--port 4000] [--host 127.0.0.1] [--rebuild]
220
619
  paint stop
221
620
  paint restart [--port 4000] [--host 127.0.0.1] [--rebuild]
222
621
  paint status
223
622
  paint logs
623
+
624
+ Terminal usage:
625
+ paint terminal start [--terminal-port 4001]
626
+ paint terminal stop
627
+ paint terminal restart [--terminal-port 4001]
628
+ paint terminal status
629
+ paint terminal logs
630
+
631
+ Bridge usage:
632
+ paint bridge start [--bridge-port 4002]
633
+ paint bridge stop
634
+ paint bridge restart [--bridge-port 4002]
635
+ paint bridge status
636
+ paint bridge logs
637
+
638
+ General:
224
639
  paint help
225
640
  `)
226
641
  }
227
642
 
228
- function showLogs() {
229
- if (!fs.existsSync(LOG_FILE)) {
230
- console.log('No logs found yet.')
231
- return
643
+ async function main() {
644
+ const options = parseArgs(process.argv.slice(2))
645
+
646
+ if (options.mode === 'bridge') {
647
+ switch (options.command) {
648
+ case 'start':
649
+ await startBridge(options)
650
+ return
651
+ case 'stop':
652
+ stopBridge()
653
+ return
654
+ case 'restart':
655
+ stopBridge()
656
+ await startBridge(options)
657
+ return
658
+ case 'status':
659
+ bridgeStatus()
660
+ return
661
+ case 'logs':
662
+ bridgeLogs()
663
+ return
664
+ default:
665
+ showHelp()
666
+ return
667
+ }
232
668
  }
233
- const content = fs.readFileSync(LOG_FILE, 'utf8')
234
- process.stdout.write(content)
235
- }
236
669
 
237
- function main() {
238
- const options = parseArgs(process.argv.slice(2))
670
+ if (options.mode === 'terminal') {
671
+ switch (options.command) {
672
+ case 'start':
673
+ await startTerminal(options)
674
+ return
675
+ case 'stop':
676
+ stopTerminal()
677
+ return
678
+ case 'restart':
679
+ stopTerminal()
680
+ await startTerminal(options)
681
+ return
682
+ case 'status':
683
+ terminalStatus()
684
+ return
685
+ case 'logs':
686
+ terminalLogs()
687
+ return
688
+ default:
689
+ showHelp()
690
+ return
691
+ }
692
+ }
239
693
 
240
694
  switch (options.command) {
241
695
  case 'start':
242
- startServer(options)
696
+ await startApp(options)
243
697
  break
244
698
  case 'stop':
245
- stopServer()
699
+ stopApp()
246
700
  break
247
701
  case 'restart':
248
- stopServer()
249
- startServer(options)
702
+ stopApp()
703
+ await startApp(options)
250
704
  break
251
705
  case 'status':
252
- serverStatus()
706
+ appStatus()
253
707
  break
254
708
  case 'logs':
255
- showLogs()
709
+ appLogs()
256
710
  break
257
- case 'help':
258
- case '--help':
259
- case '-h':
260
711
  default:
261
712
  showHelp()
262
713
  break
263
714
  }
264
715
  }
265
716
 
266
- main()
717
+ main().catch((err) => {
718
+ const msg = err instanceof Error ? err.message : String(err)
719
+ console.error(`paint failed: ${msg}`)
720
+ process.exit(1)
721
+ })