@tothalex/nulljs 0.0.47 → 0.0.53

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 (58) hide show
  1. package/package.json +22 -32
  2. package/src/cli.ts +24 -0
  3. package/src/commands/config.ts +130 -0
  4. package/src/commands/deploy.ts +182 -123
  5. package/src/commands/dev.ts +10 -0
  6. package/src/commands/host.ts +130 -139
  7. package/src/commands/index.ts +6 -8
  8. package/src/commands/secret.ts +364 -56
  9. package/src/commands/status.ts +41 -0
  10. package/src/components/DeployAnimation.tsx +92 -0
  11. package/src/components/DeploymentLogsPane.tsx +79 -0
  12. package/src/components/Header.tsx +57 -0
  13. package/src/components/HelpModal.tsx +64 -0
  14. package/src/components/SystemLogsPane.tsx +78 -0
  15. package/src/config/index.ts +181 -0
  16. package/src/lib/bundle/function.ts +125 -0
  17. package/src/lib/bundle/index.ts +3 -0
  18. package/src/lib/bundle/react.ts +149 -0
  19. package/src/lib/deploy.ts +103 -0
  20. package/src/lib/server.ts +160 -0
  21. package/src/lib/vite.ts +120 -0
  22. package/src/lib/watcher.ts +274 -0
  23. package/src/ui.tsx +363 -0
  24. package/tsconfig.json +30 -0
  25. package/scripts/install-server.js +0 -199
  26. package/src/commands/api.ts +0 -16
  27. package/src/commands/auth.ts +0 -54
  28. package/src/commands/create.ts +0 -43
  29. package/src/commands/dev/function/index.ts +0 -221
  30. package/src/commands/dev/function/utils.ts +0 -99
  31. package/src/commands/dev/index.tsx +0 -126
  32. package/src/commands/dev/logging-manager.ts +0 -87
  33. package/src/commands/dev/server/index.ts +0 -48
  34. package/src/commands/dev/server/utils.ts +0 -37
  35. package/src/commands/dev/ui/components/scroll-area.tsx +0 -141
  36. package/src/commands/dev/ui/components/tab-bar.tsx +0 -67
  37. package/src/commands/dev/ui/index.tsx +0 -71
  38. package/src/commands/dev/ui/logging-context.tsx +0 -76
  39. package/src/commands/dev/ui/tabs/functions-tab.tsx +0 -35
  40. package/src/commands/dev/ui/tabs/server-tab.tsx +0 -36
  41. package/src/commands/dev/ui/tabs/vite-tab.tsx +0 -35
  42. package/src/commands/dev/ui/use-logging.tsx +0 -34
  43. package/src/commands/dev/vite/index.ts +0 -54
  44. package/src/commands/dev/vite/utils.ts +0 -71
  45. package/src/commands/profile.ts +0 -189
  46. package/src/index.ts +0 -346
  47. package/src/lib/api.ts +0 -189
  48. package/src/lib/bundle/function/index.ts +0 -46
  49. package/src/lib/bundle/react/index.ts +0 -2
  50. package/src/lib/bundle/react/spa.ts +0 -77
  51. package/src/lib/bundle/react/ssr/client.ts +0 -93
  52. package/src/lib/bundle/react/ssr/config.ts +0 -77
  53. package/src/lib/bundle/react/ssr/index.ts +0 -4
  54. package/src/lib/bundle/react/ssr/props.ts +0 -71
  55. package/src/lib/bundle/react/ssr/server.ts +0 -83
  56. package/src/lib/config.ts +0 -347
  57. package/src/lib/deployment.ts +0 -244
  58. package/src/lib/update-server.ts +0 -262
@@ -1,79 +1,387 @@
1
+ import { Command } from 'commander'
1
2
  import chalk from 'chalk'
2
- import fs from 'fs/promises'
3
- import path, { resolve } from 'path'
3
+ import { resolve } from 'path'
4
+ import { readFile, readdir } from 'fs/promises'
5
+ import { existsSync } from 'fs'
6
+ import * as p from '@clack/prompts'
4
7
 
5
- import { fetchSecretKeys, postSecret } from '../lib/api'
8
+ import { listSecrets, createSecret } from '@nulljs/api'
9
+ import { loadPrivateKey, listConfigs, getConfig, type Config } from '../config'
6
10
 
7
- export const createSecret = async (props: { key: string; value: string }) => {
8
- try {
9
- await postSecret(props)
10
- console.log(chalk.green('Secret stored: ') + chalk.bgGreen.black(props.key))
11
- } catch (error) {
12
- console.log(error)
13
- }
14
- }
11
+ const selectConfig = async (): Promise<Config | null> => {
12
+ const configList = listConfigs()
15
13
 
16
- export const listSecretKeys = async () => {
17
- const keys = await fetchSecretKeys()
14
+ if (!configList || configList.configs.length === 0) {
15
+ return null
16
+ }
18
17
 
19
- if (keys.length === 0) {
20
- console.log(chalk.yellow('No secrets found'))
21
- return
18
+ if (configList.configs.length === 1) {
19
+ return configList.configs[0] ?? null
22
20
  }
23
21
 
24
- console.log(chalk.bold.blue('Available secret keys:'))
25
- keys.forEach((key, index) => {
26
- console.log(chalk.green(`${index + 1}.`), chalk.cyan(key))
22
+ const selected = await p.select({
23
+ message: 'Select config',
24
+ options: configList.configs.map((c) => ({
25
+ value: c.name,
26
+ label: c.name,
27
+ hint: c.name === configList.current ? 'current' : c.api
28
+ })),
29
+ initialValue: configList.current
27
30
  })
31
+
32
+ if (p.isCancel(selected)) {
33
+ p.cancel('Cancelled')
34
+ process.exit(0)
35
+ }
36
+
37
+ return getConfig(selected as string)
28
38
  }
29
39
 
30
- export const createSecretsFromFile = async (props: {
31
- filePath: string
32
- log?: (message: string) => void
33
- }) => {
34
- const logger = props.log || console.log
40
+ const selectFile = async (cwd: string): Promise<string | null> => {
41
+ const files: string[] = []
35
42
 
43
+ // Find .secret files in current directory
36
44
  try {
37
- const file = resolve(props.filePath)
38
- const fileContent = await fs.readFile(file, 'utf-8')
39
- const lines = fileContent.split('\n')
45
+ const items = await readdir(cwd)
46
+ for (const item of items) {
47
+ if (item === '.secret' || item.startsWith('.secret.')) {
48
+ files.push(item)
49
+ }
50
+ }
51
+ } catch {
52
+ // Ignore errors
53
+ }
40
54
 
41
- let createdCount = 0
55
+ if (files.length === 0) {
56
+ console.log(chalk.yellow('No .secret files found in current directory'))
57
+ return null
58
+ }
42
59
 
43
- for (const line of lines) {
44
- const trimmedLine = line.trim()
60
+ const selected = await p.select({
61
+ message: 'Select file to import secrets from',
62
+ options: files.map((f) => ({
63
+ value: f,
64
+ label: f
65
+ }))
66
+ })
45
67
 
46
- if (!trimmedLine || trimmedLine.startsWith('#')) {
47
- continue
48
- }
68
+ if (p.isCancel(selected)) {
69
+ p.cancel('Cancelled')
70
+ process.exit(0)
71
+ }
49
72
 
50
- const equalIndex = trimmedLine.indexOf('=')
51
- if (equalIndex === -1) {
52
- logger(chalk.yellow(`Skipping invalid line: ${trimmedLine}`))
53
- continue
54
- }
73
+ return resolve(cwd, selected as string)
74
+ }
55
75
 
56
- const key = trimmedLine.substring(0, equalIndex).trim()
57
- const value = trimmedLine.substring(equalIndex + 1).trim()
76
+ const parseEnvFile = async (filePath: string): Promise<Array<{ key: string; value: string }>> => {
77
+ const content = await readFile(filePath, 'utf-8')
78
+ const lines = content.split('\n')
79
+ const secrets: Array<{ key: string; value: string }> = []
58
80
 
59
- if (!key) {
60
- logger(chalk.yellow(`Skipping line with empty key: ${trimmedLine}`))
61
- continue
62
- }
81
+ for (const line of lines) {
82
+ const trimmed = line.trim()
63
83
 
64
- try {
65
- await postSecret({ key, value })
66
- logger(chalk.green(`✓ Created secret: ${key}`))
67
- createdCount++
68
- } catch (error) {
69
- logger(chalk.red(`✗ Failed to create secret ${key}:`, error))
70
- }
84
+ // Skip empty lines and comments
85
+ if (!trimmed || trimmed.startsWith('#')) {
86
+ continue
71
87
  }
72
88
 
73
- logger(
74
- chalk.bold.blue(`\nCreated ${createdCount} secrets from ${path.basename(props.filePath)}`)
75
- )
76
- } catch (error) {
77
- logger(chalk.red(`Error reading file ${props.filePath}:`, error))
89
+ const equalIndex = trimmed.indexOf('=')
90
+ if (equalIndex === -1) {
91
+ continue
92
+ }
93
+
94
+ const key = trimmed.substring(0, equalIndex).trim()
95
+ let value = trimmed.substring(equalIndex + 1).trim()
96
+
97
+ // Remove quotes if present
98
+ if (
99
+ (value.startsWith('"') && value.endsWith('"')) ||
100
+ (value.startsWith("'") && value.endsWith("'"))
101
+ ) {
102
+ value = value.slice(1, -1)
103
+ }
104
+
105
+ if (key) {
106
+ secrets.push({ key, value })
107
+ }
78
108
  }
109
+
110
+ return secrets
111
+ }
112
+
113
+ export const registerSecretCommand = (program: Command) => {
114
+ program
115
+ .command('secret')
116
+ .description('Secret management')
117
+ .addCommand(
118
+ new Command('list')
119
+ .description('List all secret keys')
120
+ .option('-e, --env <name>', 'Use a specific config environment')
121
+ .action(async (options: { env?: string }) => {
122
+ let config: Awaited<ReturnType<typeof selectConfig>>
123
+
124
+ try {
125
+ config = options.env ? getConfig(options.env) : await selectConfig()
126
+ } catch (err) {
127
+ console.error(chalk.red('✗ Failed to get config:'), err)
128
+ process.exit(1)
129
+ }
130
+
131
+ // Allow event loop to clear after p.select
132
+ await new Promise((resolve) => setImmediate(resolve))
133
+
134
+ if (!config) {
135
+ console.error(chalk.red('✗ No configuration found.'))
136
+ console.error(chalk.gray(' Run "nulljs dev" first to initialize the project'))
137
+ process.exit(1)
138
+ }
139
+
140
+ try {
141
+ console.log(chalk.gray(`Fetching secrets from ${config.api}...`))
142
+
143
+ const privateKey = await loadPrivateKey(config)
144
+ const keys = await listSecrets({ privateKey, url: config.api })
145
+
146
+ if (keys.length === 0) {
147
+ console.log(chalk.yellow('No secrets found'))
148
+ return
149
+ }
150
+
151
+ console.log(chalk.bold('\nSecret keys:'))
152
+ keys.forEach((key, index) => {
153
+ console.log(chalk.green(` ${index + 1}.`) + ` ${chalk.cyan(key)}`)
154
+ })
155
+ } catch (error) {
156
+ console.error(chalk.red('✗ Failed to list secrets:'), error instanceof Error ? error.message : String(error))
157
+ process.exit(1)
158
+ }
159
+ })
160
+ )
161
+ .addCommand(
162
+ new Command('create')
163
+ .description('Create a new secret')
164
+ .argument('[key]', 'Secret key')
165
+ .argument('[value]', 'Secret value')
166
+ .option('-e, --env <name>', 'Use a specific config environment')
167
+ .action(async (key?: string, value?: string, options?: { env?: string }) => {
168
+ const config = options?.env ? getConfig(options.env) : await selectConfig()
169
+
170
+ if (!config) {
171
+ console.error(chalk.red('✗ No configuration found.'))
172
+ console.error(chalk.gray(' Run "nulljs dev" first to initialize the project'))
173
+ process.exit(1)
174
+ }
175
+
176
+ let secretKey = key
177
+ let secretValue = value
178
+
179
+ // Interactive mode
180
+ if (!secretKey || !secretValue) {
181
+ if (!secretKey) {
182
+ const keyInput = await p.text({
183
+ message: 'Secret key',
184
+ placeholder: 'e.g., API_KEY, DATABASE_URL',
185
+ validate: (v) => {
186
+ if (!v.trim()) return 'Key is required'
187
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(v)) {
188
+ return 'Key must start with a letter or underscore and contain only letters, numbers, and underscores'
189
+ }
190
+ }
191
+ })
192
+
193
+ if (p.isCancel(keyInput)) {
194
+ p.cancel('Cancelled')
195
+ process.exit(0)
196
+ }
197
+
198
+ secretKey = keyInput as string
199
+ }
200
+
201
+ if (!secretValue) {
202
+ const valueInput = await p.password({
203
+ message: 'Secret value',
204
+ validate: (v) => {
205
+ if (!v) return 'Value is required'
206
+ }
207
+ })
208
+
209
+ if (p.isCancel(valueInput)) {
210
+ p.cancel('Cancelled')
211
+ process.exit(0)
212
+ }
213
+
214
+ secretValue = valueInput as string
215
+ }
216
+ }
217
+
218
+ // Allow event loop to clear after prompts
219
+ await new Promise((resolve) => setImmediate(resolve))
220
+
221
+ try {
222
+ const privateKey = await loadPrivateKey(config)
223
+ await createSecret(
224
+ { key: secretKey, value: secretValue },
225
+ { privateKey, url: config.api }
226
+ )
227
+ console.log(chalk.green('✓ Secret created:') + ` ${chalk.cyan(secretKey)}`)
228
+ } catch (error) {
229
+ console.error(
230
+ chalk.red('✗ Failed to create secret:'),
231
+ error instanceof Error ? error.message : error
232
+ )
233
+ process.exit(1)
234
+ }
235
+ })
236
+ )
237
+ .addCommand(
238
+ new Command('deploy')
239
+ .description('Deploy secrets from a .secret file')
240
+ .argument('[file]', 'Path to .secret file')
241
+ .option('-e, --env <name>', 'Use a specific config environment')
242
+ .action(async (file?: string, options?: { env?: string }) => {
243
+ const config = options?.env ? getConfig(options.env) : await selectConfig()
244
+
245
+ if (!config) {
246
+ console.error(chalk.red('✗ No configuration found.'))
247
+ console.error(chalk.gray(' Run "nulljs dev" first to initialize the project'))
248
+ process.exit(1)
249
+ }
250
+
251
+ let filePath = file ? resolve(file) : null
252
+
253
+ // Interactive file picker if no file provided
254
+ if (!filePath) {
255
+ filePath = await selectFile(process.cwd())
256
+ if (!filePath) {
257
+ process.exit(1)
258
+ }
259
+ }
260
+
261
+ if (!existsSync(filePath)) {
262
+ console.error(chalk.red(`✗ File not found: ${filePath}`))
263
+ process.exit(1)
264
+ }
265
+
266
+ // Allow event loop to clear after prompts
267
+ await new Promise((resolve) => setImmediate(resolve))
268
+
269
+ try {
270
+ const secrets = await parseEnvFile(filePath)
271
+
272
+ if (secrets.length === 0) {
273
+ console.log(chalk.yellow('No secrets found in file'))
274
+ return
275
+ }
276
+
277
+ const privateKey = await loadPrivateKey(config)
278
+ let created = 0
279
+ let failed = 0
280
+
281
+ console.log(chalk.cyan(`Deploying ${secrets.length} secret(s) to ${config.name}...\n`))
282
+
283
+ for (const secret of secrets) {
284
+ try {
285
+ await createSecret(secret, { privateKey, url: config.api })
286
+ console.log(chalk.green('✓') + ` ${secret.key}`)
287
+ created++
288
+ } catch (error) {
289
+ console.log(
290
+ chalk.red('✗') +
291
+ ` ${secret.key}: ${error instanceof Error ? error.message : error}`
292
+ )
293
+ failed++
294
+ }
295
+ }
296
+
297
+ console.log('')
298
+ if (failed > 0) {
299
+ console.log(chalk.yellow(`Deploy completed: ${created} created, ${failed} failed`))
300
+ } else {
301
+ console.log(chalk.green(`Deploy completed: ${created} secret(s) created`))
302
+ }
303
+ } catch (error) {
304
+ console.error(
305
+ chalk.red('✗ Failed to deploy secrets:'),
306
+ error instanceof Error ? error.message : error
307
+ )
308
+ process.exit(1)
309
+ }
310
+ })
311
+ )
312
+ .addCommand(
313
+ new Command('import')
314
+ .description('Import secrets from a file (alias for deploy)')
315
+ .argument('[file]', 'Path to .secret file')
316
+ .option('-e, --env <name>', 'Use a specific config environment')
317
+ .action(async (file?: string, options?: { env?: string }) => {
318
+ const config = options?.env ? getConfig(options.env) : await selectConfig()
319
+
320
+ if (!config) {
321
+ console.error(chalk.red('✗ No configuration found.'))
322
+ console.error(chalk.gray(' Run "nulljs dev" first to initialize the project'))
323
+ process.exit(1)
324
+ }
325
+
326
+ let filePath = file ? resolve(file) : null
327
+
328
+ // Interactive file picker if no file provided
329
+ if (!filePath) {
330
+ filePath = await selectFile(process.cwd())
331
+ if (!filePath) {
332
+ process.exit(1)
333
+ }
334
+ }
335
+
336
+ if (!existsSync(filePath)) {
337
+ console.error(chalk.red(`✗ File not found: ${filePath}`))
338
+ process.exit(1)
339
+ }
340
+
341
+ // Allow event loop to clear after prompts
342
+ await new Promise((resolve) => setImmediate(resolve))
343
+
344
+ try {
345
+ const secrets = await parseEnvFile(filePath)
346
+
347
+ if (secrets.length === 0) {
348
+ console.log(chalk.yellow('No secrets found in file'))
349
+ return
350
+ }
351
+
352
+ const privateKey = await loadPrivateKey(config)
353
+ let created = 0
354
+ let failed = 0
355
+
356
+ console.log(chalk.cyan(`Importing ${secrets.length} secret(s) to ${config.name}...\n`))
357
+
358
+ for (const secret of secrets) {
359
+ try {
360
+ await createSecret(secret, { privateKey, url: config.api })
361
+ console.log(chalk.green('✓') + ` ${secret.key}`)
362
+ created++
363
+ } catch (error) {
364
+ console.log(
365
+ chalk.red('✗') +
366
+ ` ${secret.key}: ${error instanceof Error ? error.message : error}`
367
+ )
368
+ failed++
369
+ }
370
+ }
371
+
372
+ console.log('')
373
+ if (failed > 0) {
374
+ console.log(chalk.yellow(`Import completed: ${created} created, ${failed} failed`))
375
+ } else {
376
+ console.log(chalk.green(`Import completed: ${created} secret(s) created`))
377
+ }
378
+ } catch (error) {
379
+ console.error(
380
+ chalk.red('✗ Failed to import secrets:'),
381
+ error instanceof Error ? error.message : error
382
+ )
383
+ process.exit(1)
384
+ }
385
+ })
386
+ )
79
387
  }
@@ -0,0 +1,41 @@
1
+ import type { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+
4
+ import { listConfigs } from '../config'
5
+
6
+ export const registerStatusCommand = (program: Command) => {
7
+ program.action(() => {
8
+ const result = listConfigs()
9
+
10
+ if (result && result.configs.length > 0) {
11
+ const currentConfig = result.configs.find((c) => c.name === result.current)
12
+ if (currentConfig) {
13
+ console.log(chalk.green('●') + ' ' + chalk.bold(`Active: ${currentConfig.name}`))
14
+ console.log(chalk.blue(' API:') + ` ${currentConfig.api}`)
15
+ console.log(chalk.blue(' Public Key:') + ` ${currentConfig.key.public.substring(0, 20)}...`)
16
+
17
+ if (result.configs.length > 1) {
18
+ console.log(chalk.gray(`\n ${result.configs.length - 1} other config(s) available`))
19
+ }
20
+ }
21
+ } else {
22
+ console.log(chalk.yellow('○ No local configuration found.'))
23
+ console.log(chalk.gray(' Run "nulljs dev" to initialize the project'))
24
+ }
25
+
26
+ printAvailableCommands()
27
+ })
28
+ }
29
+
30
+ const printAvailableCommands = () => {
31
+ console.log('\n' + chalk.bold('Commands:'))
32
+ console.log(chalk.cyan(' nulljs dev') + chalk.gray(' - Launch development server'))
33
+ console.log(chalk.cyan(' nulljs deploy') + chalk.gray(' - Deploy all functions and React apps'))
34
+ console.log(chalk.cyan(' nulljs config list') + chalk.gray(' - List all configurations'))
35
+ console.log(chalk.cyan(' nulljs config new') + chalk.gray(' - Create new configuration'))
36
+ console.log(chalk.cyan(' nulljs config use') + chalk.gray(' - Switch active configuration'))
37
+ console.log(chalk.cyan(' nulljs secret list') + chalk.gray(' - List all secret keys'))
38
+ console.log(chalk.cyan(' nulljs secret create') + chalk.gray(' - Create a new secret'))
39
+ console.log(chalk.cyan(' nulljs secret deploy') + chalk.gray(' - Deploy secrets from .secret file'))
40
+ console.log(chalk.cyan(' nulljs host') + chalk.gray(' - Set up production hosting with systemd'))
41
+ }
@@ -0,0 +1,92 @@
1
+ import { useEffect, useState, useRef } from 'react'
2
+
3
+ export type DeployedFunction = {
4
+ name: string
5
+ success: boolean
6
+ error?: string
7
+ }
8
+
9
+ export const DeployAnimation = (props: {
10
+ deployed: DeployedFunction[]
11
+ onComplete: () => void
12
+ }) => {
13
+ const [currentIndex, setCurrentIndex] = useState(0)
14
+ const [progress, setProgress] = useState(0)
15
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
16
+ const completedRef = useRef(false)
17
+
18
+ const duration = 400 // ms per item
19
+ const frameRate = 16 // ~60fps
20
+
21
+ useEffect(() => {
22
+ if (props.deployed.length === 0) {
23
+ props.onComplete()
24
+ return
25
+ }
26
+
27
+ if (completedRef.current) {
28
+ return
29
+ }
30
+
31
+ // Reset progress for new item
32
+ setProgress(0)
33
+ const startTime = Date.now()
34
+
35
+ intervalRef.current = setInterval(() => {
36
+ const elapsed = Date.now() - startTime
37
+ const newProgress = Math.min(100, (elapsed / duration) * 100)
38
+ setProgress(newProgress)
39
+
40
+ if (newProgress >= 100) {
41
+ if (intervalRef.current) {
42
+ clearInterval(intervalRef.current)
43
+ intervalRef.current = null
44
+ }
45
+
46
+ // Move to next or complete
47
+ setTimeout(() => {
48
+ if (currentIndex >= props.deployed.length - 1) {
49
+ completedRef.current = true
50
+ props.onComplete()
51
+ } else {
52
+ setCurrentIndex((prev) => prev + 1)
53
+ }
54
+ }, 150)
55
+ }
56
+ }, frameRate)
57
+
58
+ return () => {
59
+ if (intervalRef.current) {
60
+ clearInterval(intervalRef.current)
61
+ }
62
+ }
63
+ }, [currentIndex, props.deployed.length, props.onComplete])
64
+
65
+ const current = props.deployed[currentIndex]
66
+
67
+ if (props.deployed.length === 0 || !current || completedRef.current) {
68
+ return null
69
+ }
70
+
71
+ const barWidth = 8
72
+ const filled = Math.round((progress / 100) * barWidth)
73
+ const empty = barWidth - filled
74
+ const bar = '━'.repeat(filled) + '─'.repeat(empty)
75
+
76
+ return (
77
+ <box backgroundColor="black" flexDirection="row" paddingLeft={1} paddingRight={1}>
78
+ <text>
79
+ <span fg={current.success ? '#22c55e' : '#ef4444'}>
80
+ {bar} {current.success ? '✓' : '✗'}
81
+ </span>
82
+ <span> {current.name}</span>
83
+ {props.deployed.length > 1 && (
84
+ <span fg="#666">
85
+ {' '}
86
+ ({currentIndex + 1}/{props.deployed.length})
87
+ </span>
88
+ )}
89
+ </text>
90
+ </box>
91
+ )
92
+ }
@@ -0,0 +1,79 @@
1
+ import type { LogEntry } from '@nulljs/api'
2
+
3
+ interface DeploymentLogsPaneProps {
4
+ logs: LogEntry[]
5
+ loading: boolean
6
+ autoScroll: boolean
7
+ jumpTrigger: number
8
+ }
9
+
10
+ const getLevelColor = (level: string): string => {
11
+ switch (level.toLowerCase()) {
12
+ case 'error':
13
+ return 'red'
14
+ case 'warn':
15
+ case 'warning':
16
+ return 'yellow'
17
+ case 'info':
18
+ return 'cyan'
19
+ case 'debug':
20
+ return 'gray'
21
+ default:
22
+ return 'white'
23
+ }
24
+ }
25
+
26
+ const sanitizeMessage = (message: string): string => {
27
+ return message
28
+ .replace(/\x1b\[[0-9;]*m/g, '')
29
+ .replace(/[\r\n]+/g, ' ')
30
+ .replace(/\s+/g, ' ')
31
+ .trim()
32
+ }
33
+
34
+ export const DeploymentLogsPane = (props: DeploymentLogsPaneProps) => {
35
+ if (props.loading && props.logs.length === 0) {
36
+ return (
37
+ <box flexDirection="column" padding={1}>
38
+ <text>Loading deployment logs...</text>
39
+ </box>
40
+ )
41
+ }
42
+
43
+ if (!props.loading && props.logs.length === 0) {
44
+ return (
45
+ <box flexDirection="column" padding={1}>
46
+ <text>No deployment logs found</text>
47
+ </box>
48
+ )
49
+ }
50
+
51
+ // Reverse logs so oldest are at top, newest at bottom
52
+ const logsInOrder = [...props.logs].reverse()
53
+
54
+ return (
55
+ <scrollbox
56
+ focused
57
+ stickyStart={props.autoScroll ? 'bottom' : 'top'}
58
+ stickyScroll={props.autoScroll}
59
+ key={`${props.autoScroll}-${props.jumpTrigger}`}>
60
+ {logsInOrder.map((log) => {
61
+ const time = new Date(log.time).toLocaleTimeString()
62
+ const timestamp = `[${time}]`
63
+ const level = log.level.toUpperCase()
64
+
65
+ return (
66
+ <box key={log.id} flexDirection="row">
67
+ <text>
68
+ <span fg="gray">{timestamp}</span>
69
+ <span fg={getLevelColor(log.level)}>{` ${level} `}</span>
70
+ <span fg="blue">{`(${log.deployment_name})`}</span>
71
+ <span fg="cyan">{` ${log.trigger} `}</span>
72
+ <span>{sanitizeMessage(log.message)}</span>
73
+ </text>
74
+ </box>
75
+ )
76
+ })}
77
+ </scrollbox>
78
+ )
79
+ }