@tothalex/nulljs 0.0.40
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/package.json +45 -0
- package/scripts/install-server.js +132 -0
- package/src/commands/api.ts +16 -0
- package/src/commands/auth.ts +54 -0
- package/src/commands/create.ts +43 -0
- package/src/commands/deploy.ts +160 -0
- package/src/commands/dev/function/index.ts +221 -0
- package/src/commands/dev/function/utils.ts +99 -0
- package/src/commands/dev/index.tsx +126 -0
- package/src/commands/dev/logging-manager.ts +87 -0
- package/src/commands/dev/server/index.ts +48 -0
- package/src/commands/dev/server/utils.ts +37 -0
- package/src/commands/dev/ui/components/scroll-area.tsx +141 -0
- package/src/commands/dev/ui/components/tab-bar.tsx +67 -0
- package/src/commands/dev/ui/index.tsx +71 -0
- package/src/commands/dev/ui/logging-context.tsx +76 -0
- package/src/commands/dev/ui/tabs/functions-tab.tsx +35 -0
- package/src/commands/dev/ui/tabs/server-tab.tsx +36 -0
- package/src/commands/dev/ui/tabs/vite-tab.tsx +35 -0
- package/src/commands/dev/ui/use-logging.tsx +34 -0
- package/src/commands/dev/vite/index.ts +54 -0
- package/src/commands/dev/vite/utils.ts +71 -0
- package/src/commands/host.ts +339 -0
- package/src/commands/index.ts +8 -0
- package/src/commands/profile.ts +189 -0
- package/src/commands/secret.ts +79 -0
- package/src/index.ts +346 -0
- package/src/lib/api.ts +189 -0
- package/src/lib/bundle/external.ts +23 -0
- package/src/lib/bundle/function/index.ts +46 -0
- package/src/lib/bundle/index.ts +2 -0
- package/src/lib/bundle/react/index.ts +2 -0
- package/src/lib/bundle/react/spa.ts +77 -0
- package/src/lib/bundle/react/ssr/client.ts +93 -0
- package/src/lib/bundle/react/ssr/config.ts +77 -0
- package/src/lib/bundle/react/ssr/index.ts +4 -0
- package/src/lib/bundle/react/ssr/props.ts +71 -0
- package/src/lib/bundle/react/ssr/server.ts +83 -0
- package/src/lib/bundle/types.ts +4 -0
- package/src/lib/config.ts +347 -0
- package/src/lib/deployment.ts +244 -0
- package/src/lib/update-server.ts +180 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'fs'
|
|
2
|
+
import { resolve, basename } from 'path'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { deployFunction, isTypescript, type DeploymentResult } from '../../../lib/deployment'
|
|
5
|
+
import { createSecretsFromFile } from '../../secret'
|
|
6
|
+
|
|
7
|
+
export const deployFunctionAndLog = async (
|
|
8
|
+
filePath: string,
|
|
9
|
+
log: (data: string) => void,
|
|
10
|
+
force: boolean = false
|
|
11
|
+
): Promise<void> => {
|
|
12
|
+
if (!isTypescript(filePath)) {
|
|
13
|
+
log(chalk.gray(`Skipping non-TypeScript file: ${basename(filePath)}`))
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
// Silent logger for watch mode
|
|
19
|
+
const logger = {
|
|
20
|
+
log: () => {},
|
|
21
|
+
error: (error: any) => log(chalk.red(`Error: ${error}`))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const result: DeploymentResult = await deployFunction(filePath, logger, force)
|
|
25
|
+
|
|
26
|
+
// Show appropriate status based on actual result
|
|
27
|
+
if (result.error) {
|
|
28
|
+
log(chalk.red(` ${basename(filePath, '.ts')} deployment failed`))
|
|
29
|
+
if (result.error.message.includes('Unable to connect')) {
|
|
30
|
+
log(chalk.yellow(' Is your server running?'))
|
|
31
|
+
} else {
|
|
32
|
+
log(chalk.gray(` ${result.error.message}`))
|
|
33
|
+
}
|
|
34
|
+
} else if (result.cached) {
|
|
35
|
+
log(chalk.gray(` ${basename(filePath, '.ts')} skipped (no changes)`))
|
|
36
|
+
} else if (result.deployed) {
|
|
37
|
+
log(chalk.green(` ${basename(filePath, '.ts')} deployed successfully`))
|
|
38
|
+
}
|
|
39
|
+
} catch (error: any) {
|
|
40
|
+
log(chalk.red(` ✗ ${basename(filePath, '.ts')} failed`))
|
|
41
|
+
log(chalk.gray(` ${error.message || error}`))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const deployAllFunctions = async (
|
|
46
|
+
srcDir: string,
|
|
47
|
+
log: (data: string) => void
|
|
48
|
+
): Promise<void> => {
|
|
49
|
+
const functionDir = resolve(srcDir, 'function')
|
|
50
|
+
|
|
51
|
+
if (!existsSync(functionDir)) {
|
|
52
|
+
log(chalk.yellow('⚠️ No function directory found'))
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const files = readdirSync(functionDir, { recursive: true })
|
|
58
|
+
const tsFiles = files
|
|
59
|
+
.filter((file): file is string => typeof file === 'string')
|
|
60
|
+
.filter((file) => file.endsWith('.ts'))
|
|
61
|
+
|
|
62
|
+
if (tsFiles.length === 0) {
|
|
63
|
+
log(chalk.yellow('⚠️ No TypeScript functions found to deploy'))
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
log(chalk.blue(`🚀 Manually deploying ${tsFiles.length} functions...`))
|
|
68
|
+
|
|
69
|
+
for (const file of tsFiles) {
|
|
70
|
+
const absolutePath = resolve(functionDir, file)
|
|
71
|
+
await deployFunctionAndLog(absolutePath, log, true)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
log(chalk.green('✅ Manual function deployment complete'))
|
|
75
|
+
} catch (error) {
|
|
76
|
+
log(chalk.red('❌ Error during manual function deployment: ' + error))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const deploySecrets = async (srcDir: string, log: (data: string) => void): Promise<void> => {
|
|
81
|
+
const secretsPath = resolve(srcDir, '../.secrets')
|
|
82
|
+
|
|
83
|
+
log(chalk.cyan(`🔍 Checking for .secrets file at ${secretsPath}`))
|
|
84
|
+
|
|
85
|
+
if (!existsSync(secretsPath)) {
|
|
86
|
+
log(chalk.yellow('⚠️ No .secrets file found'))
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
log(chalk.blue('🔐 Deploying secrets from .secrets file...' + ' ' + chalk.gray(secretsPath)))
|
|
92
|
+
|
|
93
|
+
await createSecretsFromFile({ filePath: secretsPath, log })
|
|
94
|
+
|
|
95
|
+
log(chalk.green('✅ Secret deployment complete'))
|
|
96
|
+
} catch (error) {
|
|
97
|
+
log(chalk.red('❌ Error during secret deployment: ' + error))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { resolve } from 'path'
|
|
2
|
+
import { exists } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
import { createServer } from './server'
|
|
5
|
+
import { createVite } from './vite'
|
|
6
|
+
import { UI } from './ui/'
|
|
7
|
+
import { render } from 'ink'
|
|
8
|
+
import { LoggingManager } from './logging-manager'
|
|
9
|
+
import type { Tab } from './ui/components/tab-bar'
|
|
10
|
+
import { createFunctionWatcher } from './function'
|
|
11
|
+
|
|
12
|
+
const analyzeProject = async (srcDir: string) => {
|
|
13
|
+
const indexTsxPath = resolve(srcDir, 'index.tsx')
|
|
14
|
+
const functionDirPath = resolve(srcDir, 'function')
|
|
15
|
+
|
|
16
|
+
const hasReactApp = await exists(indexTsxPath)
|
|
17
|
+
const hasFunctions = await exists(functionDirPath)
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
hasReactApp,
|
|
21
|
+
hasFunctions,
|
|
22
|
+
indexTsxPath: hasReactApp ? indexTsxPath : null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const dev = async (srcPath: string) => {
|
|
27
|
+
const srcDir = resolve(srcPath)
|
|
28
|
+
const loggingManager = new LoggingManager()
|
|
29
|
+
|
|
30
|
+
const { hasFunctions, indexTsxPath } = await analyzeProject(srcDir)
|
|
31
|
+
|
|
32
|
+
const tabs: Tab[] = [
|
|
33
|
+
{ name: 'Server', status: 'loading' },
|
|
34
|
+
{ name: 'Functions', status: 'loading' },
|
|
35
|
+
{ name: 'Vite', status: 'loading' }
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
let viteServer: any = null
|
|
39
|
+
let serverProcess: any = null
|
|
40
|
+
let functionWatcher: any = null
|
|
41
|
+
|
|
42
|
+
if (indexTsxPath) {
|
|
43
|
+
viteServer = await createVite({
|
|
44
|
+
indexTsxPath,
|
|
45
|
+
srcDir,
|
|
46
|
+
log: loggingManager.getLogFunction('vite')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
tabs.find((tab) => tab.name === 'Vite')!.status = 'running'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
serverProcess = createServer({
|
|
53
|
+
log: loggingManager.getLogFunction('server')
|
|
54
|
+
})
|
|
55
|
+
tabs.find((tab) => tab.name === 'Server')!.status = 'running'
|
|
56
|
+
|
|
57
|
+
if (hasFunctions) {
|
|
58
|
+
functionWatcher = createFunctionWatcher({
|
|
59
|
+
srcDir,
|
|
60
|
+
log: loggingManager.getLogFunction('functions')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
tabs.find((tab) => tab.name === 'Functions')!.status = 'running'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const shutdown = async () => {
|
|
67
|
+
loggingManager.addLog('server', 'info', 'Shutting down services...')
|
|
68
|
+
|
|
69
|
+
const shutdownTasks = []
|
|
70
|
+
|
|
71
|
+
if (viteServer) {
|
|
72
|
+
shutdownTasks.push(
|
|
73
|
+
viteServer
|
|
74
|
+
.close()
|
|
75
|
+
.then(() => {
|
|
76
|
+
loggingManager.addLog('vite', 'info', 'Vite server stopped')
|
|
77
|
+
tabs.find((tab) => tab.name === 'Vite')!.status = 'stopped'
|
|
78
|
+
})
|
|
79
|
+
.catch((error: any) =>
|
|
80
|
+
loggingManager.addLog('vite', 'error', `Error stopping Vite: ${error}`)
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (serverProcess) {
|
|
86
|
+
shutdownTasks.push(
|
|
87
|
+
Promise.resolve()
|
|
88
|
+
.then(() => {
|
|
89
|
+
serverProcess.kill()
|
|
90
|
+
loggingManager.addLog('server', 'info', 'Server process stopped')
|
|
91
|
+
tabs.find((tab) => tab.name === 'Server')!.status = 'stopped'
|
|
92
|
+
})
|
|
93
|
+
.catch((error: any) =>
|
|
94
|
+
loggingManager.addLog('server', 'error', `Error stopping server: ${error}`)
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (functionWatcher) {
|
|
100
|
+
shutdownTasks.push(
|
|
101
|
+
functionWatcher
|
|
102
|
+
.close()
|
|
103
|
+
.then(() => {
|
|
104
|
+
loggingManager.addLog('functions', 'info', 'Function watcher stopped')
|
|
105
|
+
tabs.find((tab) => tab.name === 'Functions')!.status = 'stopped'
|
|
106
|
+
})
|
|
107
|
+
.catch((error: any) =>
|
|
108
|
+
loggingManager.addLog('functions', 'error', `Error stopping function watcher: ${error}`)
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
await Promise.race([
|
|
115
|
+
Promise.all(shutdownTasks),
|
|
116
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Shutdown timeout')), 3000))
|
|
117
|
+
])
|
|
118
|
+
} catch (error) {
|
|
119
|
+
loggingManager.addLog('server', 'warn', `Shutdown timeout or error: ${error}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
process.exit(0)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
render(<UI tabs={tabs} loggingManager={loggingManager} onShutdown={shutdown} srcDir={srcDir} />)
|
|
126
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export type LogEntry = {
|
|
2
|
+
id: string
|
|
3
|
+
timestamp: Date
|
|
4
|
+
service: 'server' | 'functions' | 'vite'
|
|
5
|
+
level: 'info' | 'warn' | 'error'
|
|
6
|
+
message: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class LoggingManager {
|
|
10
|
+
private logs: Record<string, LogEntry[]> = {
|
|
11
|
+
server: [],
|
|
12
|
+
functions: [],
|
|
13
|
+
vite: []
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
private listeners: Set<() => void> = new Set()
|
|
17
|
+
|
|
18
|
+
addLog(service: LogEntry['service'], level: LogEntry['level'], message: string) {
|
|
19
|
+
const newLog: LogEntry = {
|
|
20
|
+
id: `${service}-${Date.now()}-${Math.random()}`,
|
|
21
|
+
timestamp: new Date(),
|
|
22
|
+
service,
|
|
23
|
+
level,
|
|
24
|
+
message
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.logs[service].push(newLog)
|
|
28
|
+
this.notifyListeners()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getLogs(service?: LogEntry['service']): LogEntry[] {
|
|
32
|
+
if (service) {
|
|
33
|
+
return [...this.logs[service]]
|
|
34
|
+
}
|
|
35
|
+
return Object.values(this.logs).flat()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getAllLogs(): Record<string, LogEntry[]> {
|
|
39
|
+
return {
|
|
40
|
+
server: [...this.logs.server],
|
|
41
|
+
functions: [...this.logs.functions],
|
|
42
|
+
vite: [...this.logs.vite]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
clearLogs(service?: LogEntry['service']) {
|
|
47
|
+
if (service) {
|
|
48
|
+
this.logs[service] = []
|
|
49
|
+
} else {
|
|
50
|
+
this.logs = {
|
|
51
|
+
server: [],
|
|
52
|
+
functions: [],
|
|
53
|
+
vite: []
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
this.notifyListeners()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
subscribe(listener: () => void) {
|
|
60
|
+
this.listeners.add(listener)
|
|
61
|
+
|
|
62
|
+
return () => this.listeners.delete(listener)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private notifyListeners() {
|
|
66
|
+
this.listeners.forEach((listener) => listener())
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getLogFunction(service: LogEntry['service']) {
|
|
70
|
+
return (data: any) => {
|
|
71
|
+
let message: string
|
|
72
|
+
let level: LogEntry['level'] = 'info'
|
|
73
|
+
|
|
74
|
+
if (typeof data === 'string') {
|
|
75
|
+
message = data
|
|
76
|
+
} else if (data && typeof data === 'object') {
|
|
77
|
+
message = data.message || JSON.stringify(data)
|
|
78
|
+
level = data.level || level
|
|
79
|
+
} else {
|
|
80
|
+
message = String(data)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.addLog(service, level, message)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { spawn } from 'bun'
|
|
3
|
+
import { existsSync } from 'fs'
|
|
4
|
+
|
|
5
|
+
import { buildServerArgs, getServerBinPath } from './utils'
|
|
6
|
+
import { getDevConfig } from '../../../lib/config'
|
|
7
|
+
|
|
8
|
+
export const createServer = (props: { log?: (data: string) => void }) => {
|
|
9
|
+
const serverBinPath = getServerBinPath()
|
|
10
|
+
|
|
11
|
+
if (!existsSync(serverBinPath)) {
|
|
12
|
+
console.log(chalk.yellow('⚠️ Server binary not found ' + serverBinPath))
|
|
13
|
+
console.log(chalk.gray(' Run: bun run postinstall to install the server'))
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const devConfig = getDevConfig()
|
|
18
|
+
|
|
19
|
+
const args = buildServerArgs(devConfig)
|
|
20
|
+
|
|
21
|
+
const process = spawn([serverBinPath, ...args], {
|
|
22
|
+
stdout: props.log ? 'pipe' : 'inherit',
|
|
23
|
+
stderr: props.log ? 'pipe' : 'inherit'
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
if (props.log && process.stdout && process.stderr) {
|
|
27
|
+
const decoder = new TextDecoder()
|
|
28
|
+
|
|
29
|
+
const readStream = async (stream: ReadableStream<Uint8Array>) => {
|
|
30
|
+
const reader = stream.getReader()
|
|
31
|
+
while (true) {
|
|
32
|
+
const { done, value } = await reader.read()
|
|
33
|
+
if (done) {
|
|
34
|
+
break
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (props.log) {
|
|
38
|
+
props.log(decoder.decode(value))
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
readStream(process.stdout)
|
|
44
|
+
readStream(process.stderr)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return process
|
|
48
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { resolve, dirname, join } from 'path'
|
|
2
|
+
import { existsSync } from 'fs'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
|
|
5
|
+
import { getCloudPath, type DevConfig } from '../../../lib/config'
|
|
6
|
+
|
|
7
|
+
export const getServerBinPath = (): string => {
|
|
8
|
+
// Try to find the server binary relative to this module
|
|
9
|
+
// This works both in development and when installed globally
|
|
10
|
+
const currentFile = fileURLToPath(import.meta.url)
|
|
11
|
+
const moduleRoot = resolve(dirname(currentFile), '../../..')
|
|
12
|
+
const path = join(moduleRoot, 'bin', 'server')
|
|
13
|
+
|
|
14
|
+
if (!existsSync(path)) {
|
|
15
|
+
const currentFile = fileURLToPath(import.meta.url)
|
|
16
|
+
const moduleRoot = resolve(dirname(currentFile), '../../../..')
|
|
17
|
+
return join(moduleRoot, 'bin', 'server')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return path
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const buildServerArgs = (config: DevConfig): string[] => {
|
|
24
|
+
const args = ['--dev']
|
|
25
|
+
|
|
26
|
+
args.push('--api-port', config.apiPort.toString())
|
|
27
|
+
args.push('--gateway-port', config.gatewayPort.toString())
|
|
28
|
+
|
|
29
|
+
const cloudPath = getCloudPath()
|
|
30
|
+
args.push('--cloud-path', cloudPath)
|
|
31
|
+
|
|
32
|
+
if (config.publicKey) {
|
|
33
|
+
args.push('--public-key', config.publicKey)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return args
|
|
37
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Box, type DOMElement, measureElement, useInput } from 'ink'
|
|
2
|
+
import { useEffect, useReducer, useRef } from 'react'
|
|
3
|
+
|
|
4
|
+
interface ScrollAreaState {
|
|
5
|
+
innerHeight: number
|
|
6
|
+
height: number
|
|
7
|
+
scrollTop: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type ScrollAreaAction =
|
|
11
|
+
| { type: 'SET_INNER_HEIGHT'; innerHeight: number }
|
|
12
|
+
| { type: 'SET_HEIGHT'; height: number }
|
|
13
|
+
| { type: 'SCROLL_DOWN' }
|
|
14
|
+
| { type: 'SCROLL_UP' }
|
|
15
|
+
| { type: 'SCROLL_TO_BOTTOM' }
|
|
16
|
+
| { type: 'SCROLL_TO_TOP' }
|
|
17
|
+
|
|
18
|
+
const reducer = (state: ScrollAreaState, action: ScrollAreaAction) => {
|
|
19
|
+
switch (action.type) {
|
|
20
|
+
case 'SET_INNER_HEIGHT':
|
|
21
|
+
return {
|
|
22
|
+
...state,
|
|
23
|
+
innerHeight: action.innerHeight
|
|
24
|
+
}
|
|
25
|
+
case 'SET_HEIGHT':
|
|
26
|
+
return {
|
|
27
|
+
...state,
|
|
28
|
+
height: action.height
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
case 'SCROLL_DOWN':
|
|
32
|
+
return {
|
|
33
|
+
...state,
|
|
34
|
+
scrollTop: Math.min(
|
|
35
|
+
state.innerHeight <= state.height ? 0 : state.innerHeight - state.height,
|
|
36
|
+
state.scrollTop + 1
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
case 'SCROLL_UP':
|
|
41
|
+
return {
|
|
42
|
+
...state,
|
|
43
|
+
scrollTop: Math.max(0, state.scrollTop - 1)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
case 'SCROLL_TO_BOTTOM':
|
|
47
|
+
return {
|
|
48
|
+
...state,
|
|
49
|
+
scrollTop: Math.max(0, state.innerHeight - state.height)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
case 'SCROLL_TO_TOP':
|
|
53
|
+
return {
|
|
54
|
+
...state,
|
|
55
|
+
scrollTop: 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
default:
|
|
59
|
+
return state
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ScrollAreaProps extends React.PropsWithChildren {
|
|
64
|
+
height: number
|
|
65
|
+
autoScroll?: boolean
|
|
66
|
+
onRef?: (scrollToBottom: () => void) => void
|
|
67
|
+
onShutdown?: () => Promise<void>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function ScrollArea({ height, children, autoScroll = false, onRef }: ScrollAreaProps) {
|
|
71
|
+
const [state, dispatch] = useReducer(reducer, {
|
|
72
|
+
height: height,
|
|
73
|
+
scrollTop: 0,
|
|
74
|
+
innerHeight: 0
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const innerRef = useRef<DOMElement>(null)
|
|
78
|
+
|
|
79
|
+
const scrollToBottom = () => {
|
|
80
|
+
dispatch({ type: 'SCROLL_TO_BOTTOM' })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (onRef) {
|
|
85
|
+
onRef(scrollToBottom)
|
|
86
|
+
}
|
|
87
|
+
}, [onRef])
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
dispatch({ type: 'SET_HEIGHT', height })
|
|
91
|
+
}, [height])
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!innerRef.current) return
|
|
95
|
+
|
|
96
|
+
const dimensions = measureElement(innerRef.current)
|
|
97
|
+
|
|
98
|
+
dispatch({
|
|
99
|
+
type: 'SET_INNER_HEIGHT',
|
|
100
|
+
innerHeight: dimensions.height
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
if (autoScroll) {
|
|
104
|
+
scrollToBottom()
|
|
105
|
+
}
|
|
106
|
+
}, [children, autoScroll])
|
|
107
|
+
|
|
108
|
+
useInput((input) => {
|
|
109
|
+
if (input === 'j') {
|
|
110
|
+
dispatch({
|
|
111
|
+
type: 'SCROLL_DOWN'
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (input === 'k') {
|
|
116
|
+
dispatch({
|
|
117
|
+
type: 'SCROLL_UP'
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (input === 'G') {
|
|
122
|
+
dispatch({
|
|
123
|
+
type: 'SCROLL_TO_BOTTOM'
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (input === 'g') {
|
|
128
|
+
dispatch({
|
|
129
|
+
type: 'SCROLL_TO_TOP'
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<Box height={height} flexDirection="column" flexGrow={1} overflow="hidden">
|
|
136
|
+
<Box ref={innerRef} flexShrink={0} flexDirection="column" marginTop={-state.scrollTop}>
|
|
137
|
+
{children}
|
|
138
|
+
</Box>
|
|
139
|
+
</Box>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
|
|
4
|
+
export type Tab = {
|
|
5
|
+
name: string
|
|
6
|
+
status?: 'running' | 'stopped' | 'error' | 'loading'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type TabBarProps = {
|
|
10
|
+
tabs: Tab[]
|
|
11
|
+
activeTabIndex: number
|
|
12
|
+
showKeyBindings?: boolean
|
|
13
|
+
isAutoScrolling?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const getStatusSymbol = (status?: Tab['status']): string => {
|
|
17
|
+
switch (status) {
|
|
18
|
+
case 'running':
|
|
19
|
+
return '●'
|
|
20
|
+
case 'stopped':
|
|
21
|
+
return '○'
|
|
22
|
+
case 'error':
|
|
23
|
+
return '✗'
|
|
24
|
+
case 'loading':
|
|
25
|
+
return '⋯'
|
|
26
|
+
default:
|
|
27
|
+
return '○'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const TabBar: React.FC<TabBarProps> = ({ tabs, activeTabIndex, showKeyBindings = true }) => {
|
|
32
|
+
const tabsText =
|
|
33
|
+
tabs.length === 0
|
|
34
|
+
? 'No services'
|
|
35
|
+
: tabs
|
|
36
|
+
.map((tab, index) => {
|
|
37
|
+
const statusSymbol = getStatusSymbol(tab.status)
|
|
38
|
+
const isActive = index === activeTabIndex
|
|
39
|
+
const activeIndicator = isActive ? '►' : ' '
|
|
40
|
+
|
|
41
|
+
return `${activeIndicator} ${index + 1}. ${statusSymbol} ${tab.name} `
|
|
42
|
+
})
|
|
43
|
+
.join('')
|
|
44
|
+
|
|
45
|
+
const currentTab = tabs[activeTabIndex]
|
|
46
|
+
const functionKeybinding =
|
|
47
|
+
currentTab?.name === 'Functions' ? ' | d: deploy all functions | s: deploy secrets' : ''
|
|
48
|
+
const keyBindings = showKeyBindings
|
|
49
|
+
? `h/l: switch panels | j/k: scroll | g/G: top/bottom | q: quit${functionKeybinding}`
|
|
50
|
+
: ''
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Box
|
|
54
|
+
borderStyle="single"
|
|
55
|
+
borderColor="white"
|
|
56
|
+
paddingX={1}
|
|
57
|
+
justifyContent="space-between"
|
|
58
|
+
flexDirection="row">
|
|
59
|
+
<Text>{tabsText}</Text>
|
|
60
|
+
{showKeyBindings && (
|
|
61
|
+
<Text color="gray" dimColor>
|
|
62
|
+
{keyBindings}
|
|
63
|
+
</Text>
|
|
64
|
+
)}
|
|
65
|
+
</Box>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
import { Box, useStdout, useInput } from 'ink'
|
|
3
|
+
|
|
4
|
+
import { TabBar, type Tab } from './components/tab-bar'
|
|
5
|
+
import { ServerTab } from './tabs/server-tab'
|
|
6
|
+
import { FunctionsTab } from './tabs/functions-tab'
|
|
7
|
+
import { ViteTab } from './tabs/vite-tab'
|
|
8
|
+
import { LoggingManager } from '../logging-manager'
|
|
9
|
+
import { deployAllFunctions, deploySecrets } from '../function/utils'
|
|
10
|
+
|
|
11
|
+
type UIProps = {
|
|
12
|
+
loggingManager: LoggingManager
|
|
13
|
+
onShutdown: () => Promise<void>
|
|
14
|
+
tabs: Tab[]
|
|
15
|
+
srcDir: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const UI: React.FC<UIProps> = ({ loggingManager, onShutdown, tabs, srcDir }) => {
|
|
19
|
+
const { stdout } = useStdout()
|
|
20
|
+
const [activeTabIndex, setActiveTabIndex] = useState(0)
|
|
21
|
+
|
|
22
|
+
const switchToNextService = useCallback(() => {
|
|
23
|
+
setActiveTabIndex((prev) => (prev + 1) % tabs.length)
|
|
24
|
+
}, [tabs.length])
|
|
25
|
+
|
|
26
|
+
const switchToPrevService = useCallback(() => {
|
|
27
|
+
setActiveTabIndex((prev) => (prev - 1 + tabs.length) % tabs.length)
|
|
28
|
+
}, [tabs.length])
|
|
29
|
+
|
|
30
|
+
useInput(async (input, key) => {
|
|
31
|
+
if (input === 'l') {
|
|
32
|
+
switchToNextService()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (input === 'h') {
|
|
36
|
+
switchToPrevService()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (input === 'd') {
|
|
40
|
+
await deployAllFunctions(srcDir, loggingManager.getLogFunction('functions'))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (input === 's') {
|
|
44
|
+
await deploySecrets(srcDir, loggingManager.getLogFunction('functions'))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (input === 'q' || (key.ctrl && input === 'c')) {
|
|
48
|
+
await onShutdown()
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const renderActiveTab = () => {
|
|
53
|
+
switch (activeTabIndex) {
|
|
54
|
+
case 0:
|
|
55
|
+
return <ServerTab loggingManager={loggingManager} />
|
|
56
|
+
case 1:
|
|
57
|
+
return <FunctionsTab loggingManager={loggingManager} />
|
|
58
|
+
case 2:
|
|
59
|
+
return <ViteTab loggingManager={loggingManager} />
|
|
60
|
+
default:
|
|
61
|
+
return <ServerTab loggingManager={loggingManager} />
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Box flexDirection="column" width="100%" height={stdout?.rows - 1 || 24}>
|
|
67
|
+
<TabBar tabs={tabs} activeTabIndex={activeTabIndex} />
|
|
68
|
+
{renderActiveTab()}
|
|
69
|
+
</Box>
|
|
70
|
+
)
|
|
71
|
+
}
|