spindb 0.6.0 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +421 -294
- package/cli/commands/config.ts +7 -1
- package/cli/commands/connect.ts +1 -0
- package/cli/commands/create.ts +7 -7
- package/cli/commands/edit.ts +10 -0
- package/cli/commands/engines.ts +10 -188
- package/cli/commands/info.ts +7 -14
- package/cli/commands/list.ts +2 -9
- package/cli/commands/logs.ts +130 -0
- package/cli/commands/menu/backup-handlers.ts +798 -0
- package/cli/commands/menu/container-handlers.ts +832 -0
- package/cli/commands/menu/engine-handlers.ts +382 -0
- package/cli/commands/menu/index.ts +184 -0
- package/cli/commands/menu/shared.ts +26 -0
- package/cli/commands/menu/shell-handlers.ts +331 -0
- package/cli/commands/menu/sql-handlers.ts +197 -0
- package/cli/commands/menu/update-handlers.ts +94 -0
- package/cli/commands/run.ts +150 -0
- package/cli/commands/url.ts +19 -5
- package/cli/constants.ts +10 -0
- package/cli/helpers.ts +152 -0
- package/cli/index.ts +5 -2
- package/cli/ui/prompts.ts +3 -11
- package/config/defaults.ts +5 -29
- package/core/binary-manager.ts +2 -2
- package/core/container-manager.ts +3 -2
- package/core/dependency-manager.ts +0 -163
- package/core/error-handler.ts +0 -26
- package/core/platform-service.ts +60 -40
- package/core/start-with-retry.ts +3 -28
- package/core/transaction-manager.ts +0 -8
- package/engines/base-engine.ts +10 -0
- package/engines/mysql/binary-detection.ts +1 -1
- package/engines/mysql/index.ts +78 -2
- package/engines/postgresql/index.ts +49 -0
- package/package.json +1 -1
- package/types/index.ts +7 -4
- package/cli/commands/menu.ts +0 -2670
package/cli/commands/config.ts
CHANGED
|
@@ -45,10 +45,16 @@ export const configCommand = new Command('config')
|
|
|
45
45
|
.addCommand(
|
|
46
46
|
new Command('show')
|
|
47
47
|
.description('Show current configuration')
|
|
48
|
-
.
|
|
48
|
+
.option('--json', 'Output as JSON')
|
|
49
|
+
.action(async (options: { json?: boolean }) => {
|
|
49
50
|
try {
|
|
50
51
|
const config = await configManager.getConfig()
|
|
51
52
|
|
|
53
|
+
if (options.json) {
|
|
54
|
+
console.log(JSON.stringify(config, null, 2))
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
console.log()
|
|
53
59
|
console.log(header('SpinDB Configuration'))
|
|
54
60
|
console.log()
|
package/cli/commands/connect.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { promptContainerSelect } from '../ui/prompts'
|
|
|
21
21
|
import { error, warning, info, success } from '../ui/theme'
|
|
22
22
|
|
|
23
23
|
export const connectCommand = new Command('connect')
|
|
24
|
+
.alias('shell')
|
|
24
25
|
.description('Connect to a container with database client')
|
|
25
26
|
.argument('[name]', 'Container name')
|
|
26
27
|
.option('-d, --database <name>', 'Database name')
|
package/cli/commands/create.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { getMissingDependencies } from '../../core/dependency-manager'
|
|
|
20
20
|
import { platformService } from '../../core/platform-service'
|
|
21
21
|
import { startWithRetry } from '../../core/start-with-retry'
|
|
22
22
|
import { TransactionManager } from '../../core/transaction-manager'
|
|
23
|
-
import
|
|
23
|
+
import { Engine } from '../../types'
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Detect if a location string is a connection string or a file path
|
|
@@ -28,19 +28,19 @@ import type { EngineName } from '../../types'
|
|
|
28
28
|
*/
|
|
29
29
|
function detectLocationType(location: string): {
|
|
30
30
|
type: 'connection' | 'file' | 'not_found'
|
|
31
|
-
inferredEngine?:
|
|
31
|
+
inferredEngine?: Engine
|
|
32
32
|
} {
|
|
33
33
|
// Check for PostgreSQL connection string
|
|
34
34
|
if (
|
|
35
35
|
location.startsWith('postgresql://') ||
|
|
36
36
|
location.startsWith('postgres://')
|
|
37
37
|
) {
|
|
38
|
-
return { type: 'connection', inferredEngine:
|
|
38
|
+
return { type: 'connection', inferredEngine: Engine.PostgreSQL }
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// Check for MySQL connection string
|
|
42
42
|
if (location.startsWith('mysql://')) {
|
|
43
|
-
return { type: 'connection', inferredEngine:
|
|
43
|
+
return { type: 'connection', inferredEngine: Engine.MySQL }
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// Check if file exists
|
|
@@ -79,7 +79,7 @@ export const createCommand = new Command('create')
|
|
|
79
79
|
|
|
80
80
|
try {
|
|
81
81
|
let containerName = name
|
|
82
|
-
let engine:
|
|
82
|
+
let engine: Engine = (options.engine as Engine) || Engine.PostgreSQL
|
|
83
83
|
let version = options.version
|
|
84
84
|
let database = options.database
|
|
85
85
|
|
|
@@ -136,7 +136,7 @@ export const createCommand = new Command('create')
|
|
|
136
136
|
if (!containerName) {
|
|
137
137
|
const answers = await promptCreateOptions()
|
|
138
138
|
containerName = answers.name
|
|
139
|
-
engine = answers.engine as
|
|
139
|
+
engine = answers.engine as Engine
|
|
140
140
|
version = answers.version
|
|
141
141
|
database = answers.database
|
|
142
142
|
}
|
|
@@ -254,7 +254,7 @@ export const createCommand = new Command('create')
|
|
|
254
254
|
|
|
255
255
|
try {
|
|
256
256
|
await containerManager.create(containerName, {
|
|
257
|
-
engine: dbEngine.name as
|
|
257
|
+
engine: dbEngine.name as Engine,
|
|
258
258
|
version,
|
|
259
259
|
port,
|
|
260
260
|
database,
|
package/cli/commands/edit.ts
CHANGED
|
@@ -90,6 +90,16 @@ async function promptNewPort(currentPort: number): Promise<number | null> {
|
|
|
90
90
|
return null
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// Double-check availability and warn (user already confirmed via validation)
|
|
94
|
+
const portAvailable = await portManager.isPortAvailable(newPort)
|
|
95
|
+
if (!portAvailable) {
|
|
96
|
+
console.log(
|
|
97
|
+
warning(
|
|
98
|
+
`Note: Port ${newPort} is currently in use. It will be used when the container starts.`,
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
93
103
|
return newPort
|
|
94
104
|
}
|
|
95
105
|
|
package/cli/commands/engines.ts
CHANGED
|
@@ -1,197 +1,19 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import chalk from 'chalk'
|
|
3
|
-
import {
|
|
4
|
-
import { existsSync } from 'fs'
|
|
5
|
-
import { join } from 'path'
|
|
6
|
-
import { exec } from 'child_process'
|
|
7
|
-
import { promisify } from 'util'
|
|
3
|
+
import { rm } from 'fs/promises'
|
|
8
4
|
import inquirer from 'inquirer'
|
|
9
|
-
import { paths } from '../../config/paths'
|
|
10
5
|
import { containerManager } from '../../core/container-manager'
|
|
11
6
|
import { promptConfirm } from '../ui/prompts'
|
|
12
7
|
import { createSpinner } from '../ui/spinner'
|
|
13
8
|
import { error, warning, info, formatBytes } from '../ui/theme'
|
|
9
|
+
import { getEngineIcon, ENGINE_ICONS } from '../constants'
|
|
14
10
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
getInstalledEngines,
|
|
12
|
+
getInstalledPostgresEngines,
|
|
13
|
+
type InstalledPostgresEngine,
|
|
14
|
+
type InstalledMysqlEngine,
|
|
15
|
+
} from '../helpers'
|
|
19
16
|
|
|
20
|
-
const execAsync = promisify(exec)
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Installed engine info for PostgreSQL (downloaded binaries)
|
|
24
|
-
*/
|
|
25
|
-
type InstalledPostgresEngine = {
|
|
26
|
-
engine: 'postgresql'
|
|
27
|
-
version: string
|
|
28
|
-
platform: string
|
|
29
|
-
arch: string
|
|
30
|
-
path: string
|
|
31
|
-
sizeBytes: number
|
|
32
|
-
source: 'downloaded'
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Installed engine info for MySQL (system-installed)
|
|
37
|
-
*/
|
|
38
|
-
type InstalledMysqlEngine = {
|
|
39
|
-
engine: 'mysql'
|
|
40
|
-
version: string
|
|
41
|
-
path: string
|
|
42
|
-
source: 'system'
|
|
43
|
-
isMariaDB: boolean
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
type InstalledEngine = InstalledPostgresEngine | InstalledMysqlEngine
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Get the actual PostgreSQL version from the binary
|
|
50
|
-
*/
|
|
51
|
-
async function getPostgresVersion(binPath: string): Promise<string | null> {
|
|
52
|
-
const postgresPath = join(binPath, 'bin', 'postgres')
|
|
53
|
-
if (!existsSync(postgresPath)) {
|
|
54
|
-
return null
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
try {
|
|
58
|
-
const { stdout } = await execAsync(`"${postgresPath}" --version`)
|
|
59
|
-
// Output: postgres (PostgreSQL) 17.7
|
|
60
|
-
const match = stdout.match(/\(PostgreSQL\)\s+([\d.]+)/)
|
|
61
|
-
return match ? match[1] : null
|
|
62
|
-
} catch {
|
|
63
|
-
return null
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Get installed PostgreSQL engines from ~/.spindb/bin/
|
|
69
|
-
*/
|
|
70
|
-
async function getInstalledPostgresEngines(): Promise<
|
|
71
|
-
InstalledPostgresEngine[]
|
|
72
|
-
> {
|
|
73
|
-
const binDir = paths.bin
|
|
74
|
-
|
|
75
|
-
if (!existsSync(binDir)) {
|
|
76
|
-
return []
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const entries = await readdir(binDir, { withFileTypes: true })
|
|
80
|
-
const engines: InstalledPostgresEngine[] = []
|
|
81
|
-
|
|
82
|
-
for (const entry of entries) {
|
|
83
|
-
if (entry.isDirectory()) {
|
|
84
|
-
// Parse directory name: postgresql-17-darwin-arm64
|
|
85
|
-
const match = entry.name.match(/^(\w+)-(.+)-(\w+)-(\w+)$/)
|
|
86
|
-
if (match && match[1] === 'postgresql') {
|
|
87
|
-
const [, , majorVersion, platform, arch] = match
|
|
88
|
-
const dirPath = join(binDir, entry.name)
|
|
89
|
-
|
|
90
|
-
// Get actual version from the binary
|
|
91
|
-
const actualVersion =
|
|
92
|
-
(await getPostgresVersion(dirPath)) || majorVersion
|
|
93
|
-
|
|
94
|
-
// Get directory size
|
|
95
|
-
let sizeBytes = 0
|
|
96
|
-
try {
|
|
97
|
-
const files = await readdir(dirPath, { recursive: true })
|
|
98
|
-
for (const file of files) {
|
|
99
|
-
try {
|
|
100
|
-
const filePath = join(dirPath, file.toString())
|
|
101
|
-
const fileStat = await lstat(filePath)
|
|
102
|
-
if (fileStat.isFile()) {
|
|
103
|
-
sizeBytes += fileStat.size
|
|
104
|
-
}
|
|
105
|
-
} catch {
|
|
106
|
-
// Skip files we can't stat
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
} catch {
|
|
110
|
-
// Skip directories we can't read
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
engines.push({
|
|
114
|
-
engine: 'postgresql',
|
|
115
|
-
version: actualVersion,
|
|
116
|
-
platform,
|
|
117
|
-
arch,
|
|
118
|
-
path: dirPath,
|
|
119
|
-
sizeBytes,
|
|
120
|
-
source: 'downloaded',
|
|
121
|
-
})
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Sort by version descending
|
|
127
|
-
engines.sort((a, b) => compareVersions(b.version, a.version))
|
|
128
|
-
|
|
129
|
-
return engines
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Detect system-installed MySQL
|
|
134
|
-
*/
|
|
135
|
-
async function getInstalledMysqlEngine(): Promise<InstalledMysqlEngine | null> {
|
|
136
|
-
const mysqldPath = await getMysqldPath()
|
|
137
|
-
if (!mysqldPath) {
|
|
138
|
-
return null
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const version = await getMysqlVersion(mysqldPath)
|
|
142
|
-
if (!version) {
|
|
143
|
-
return null
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const mariadb = await isMariaDB()
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
engine: 'mysql',
|
|
150
|
-
version,
|
|
151
|
-
path: mysqldPath,
|
|
152
|
-
source: 'system',
|
|
153
|
-
isMariaDB: mariadb,
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Get all installed engines (PostgreSQL + MySQL)
|
|
159
|
-
*/
|
|
160
|
-
async function getInstalledEngines(): Promise<InstalledEngine[]> {
|
|
161
|
-
const engines: InstalledEngine[] = []
|
|
162
|
-
|
|
163
|
-
// Get PostgreSQL engines
|
|
164
|
-
const pgEngines = await getInstalledPostgresEngines()
|
|
165
|
-
engines.push(...pgEngines)
|
|
166
|
-
|
|
167
|
-
// Get MySQL engine
|
|
168
|
-
const mysqlEngine = await getInstalledMysqlEngine()
|
|
169
|
-
if (mysqlEngine) {
|
|
170
|
-
engines.push(mysqlEngine)
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return engines
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function compareVersions(a: string, b: string): number {
|
|
177
|
-
const partsA = a.split('.').map((p) => parseInt(p, 10) || 0)
|
|
178
|
-
const partsB = b.split('.').map((p) => parseInt(p, 10) || 0)
|
|
179
|
-
|
|
180
|
-
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
|
181
|
-
const numA = partsA[i] || 0
|
|
182
|
-
const numB = partsB[i] || 0
|
|
183
|
-
if (numA !== numB) return numA - numB
|
|
184
|
-
}
|
|
185
|
-
return 0
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Engine icons
|
|
190
|
-
*/
|
|
191
|
-
const engineIcons: Record<string, string> = {
|
|
192
|
-
postgresql: '🐘',
|
|
193
|
-
mysql: '🐬',
|
|
194
|
-
}
|
|
195
17
|
|
|
196
18
|
/**
|
|
197
19
|
* List subcommand action
|
|
@@ -243,7 +65,7 @@ async function listEngines(options: { json?: boolean }): Promise<void> {
|
|
|
243
65
|
|
|
244
66
|
// PostgreSQL rows
|
|
245
67
|
for (const engine of pgEngines) {
|
|
246
|
-
const icon =
|
|
68
|
+
const icon = getEngineIcon(engine.engine)
|
|
247
69
|
const platformInfo = `${engine.platform}-${engine.arch}`
|
|
248
70
|
|
|
249
71
|
console.log(
|
|
@@ -257,7 +79,7 @@ async function listEngines(options: { json?: boolean }): Promise<void> {
|
|
|
257
79
|
|
|
258
80
|
// MySQL row
|
|
259
81
|
if (mysqlEngine) {
|
|
260
|
-
const icon =
|
|
82
|
+
const icon = ENGINE_ICONS.mysql
|
|
261
83
|
const displayName = mysqlEngine.isMariaDB ? 'mariadb' : 'mysql'
|
|
262
84
|
|
|
263
85
|
console.log(
|
|
@@ -313,7 +135,7 @@ async function deleteEngine(
|
|
|
313
135
|
// Interactive selection if not provided
|
|
314
136
|
if (!engineName || !engineVersion) {
|
|
315
137
|
const choices = pgEngines.map((e) => ({
|
|
316
|
-
name: `${
|
|
138
|
+
name: `${getEngineIcon(e.engine)} ${e.engine} ${e.version} ${chalk.gray(`(${formatBytes(e.sizeBytes)})`)}`,
|
|
317
139
|
value: `${e.engine}:${e.version}:${e.path}`,
|
|
318
140
|
}))
|
|
319
141
|
|
package/cli/commands/info.ts
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import chalk from 'chalk'
|
|
3
|
+
import inquirer from 'inquirer'
|
|
3
4
|
import { containerManager } from '../../core/container-manager'
|
|
4
5
|
import { processManager } from '../../core/process-manager'
|
|
5
6
|
import { paths } from '../../config/paths'
|
|
6
7
|
import { getEngine } from '../../engines'
|
|
7
8
|
import { error, info, header } from '../ui/theme'
|
|
9
|
+
import { getEngineIcon } from '../constants'
|
|
8
10
|
import type { ContainerConfig } from '../../types'
|
|
9
11
|
|
|
10
|
-
/**
|
|
11
|
-
* Engine icons
|
|
12
|
-
*/
|
|
13
|
-
const engineIcons: Record<string, string> = {
|
|
14
|
-
postgresql: '🐘',
|
|
15
|
-
mysql: '🐬',
|
|
16
|
-
}
|
|
17
|
-
|
|
18
12
|
/**
|
|
19
13
|
* Format a date for display
|
|
20
14
|
*/
|
|
@@ -65,7 +59,7 @@ async function displayContainerInfo(
|
|
|
65
59
|
return
|
|
66
60
|
}
|
|
67
61
|
|
|
68
|
-
const icon =
|
|
62
|
+
const icon = getEngineIcon(config.engine)
|
|
69
63
|
const statusDisplay =
|
|
70
64
|
actualStatus === 'running'
|
|
71
65
|
? chalk.green('● running')
|
|
@@ -168,7 +162,7 @@ async function displayAllContainersInfo(
|
|
|
168
162
|
? chalk.green('● running')
|
|
169
163
|
: chalk.gray('○ stopped')
|
|
170
164
|
|
|
171
|
-
const icon =
|
|
165
|
+
const icon = getEngineIcon(container.engine)
|
|
172
166
|
const engineDisplay = `${icon} ${container.engine}`
|
|
173
167
|
|
|
174
168
|
console.log(
|
|
@@ -214,6 +208,7 @@ async function displayAllContainersInfo(
|
|
|
214
208
|
}
|
|
215
209
|
|
|
216
210
|
export const infoCommand = new Command('info')
|
|
211
|
+
.alias('status')
|
|
217
212
|
.description('Show container details')
|
|
218
213
|
.argument('[name]', 'Container name (omit to show all)')
|
|
219
214
|
.option('--json', 'Output as JSON')
|
|
@@ -239,9 +234,7 @@ export const infoCommand = new Command('info')
|
|
|
239
234
|
|
|
240
235
|
// If running interactively without name, ask if they want all or specific
|
|
241
236
|
if (!options.json && process.stdout.isTTY && containers.length > 1) {
|
|
242
|
-
const { choice } = await
|
|
243
|
-
await import('inquirer')
|
|
244
|
-
).default.prompt<{
|
|
237
|
+
const { choice } = await inquirer.prompt<{
|
|
245
238
|
choice: string
|
|
246
239
|
}>([
|
|
247
240
|
{
|
|
@@ -251,7 +244,7 @@ export const infoCommand = new Command('info')
|
|
|
251
244
|
choices: [
|
|
252
245
|
{ name: 'All containers', value: 'all' },
|
|
253
246
|
...containers.map((c) => ({
|
|
254
|
-
name: `${c.name} ${chalk.gray(`(${
|
|
247
|
+
name: `${c.name} ${chalk.gray(`(${getEngineIcon(c.engine)} ${c.engine})`)}`,
|
|
255
248
|
value: c.name,
|
|
256
249
|
})),
|
|
257
250
|
],
|
package/cli/commands/list.ts
CHANGED
|
@@ -3,16 +3,9 @@ import chalk from 'chalk'
|
|
|
3
3
|
import { containerManager } from '../../core/container-manager'
|
|
4
4
|
import { getEngine } from '../../engines'
|
|
5
5
|
import { info, error, formatBytes } from '../ui/theme'
|
|
6
|
+
import { getEngineIcon } from '../constants'
|
|
6
7
|
import type { ContainerConfig } from '../../types'
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
-
* Engine icons for display
|
|
10
|
-
*/
|
|
11
|
-
const engineIcons: Record<string, string> = {
|
|
12
|
-
postgresql: '🐘',
|
|
13
|
-
mysql: '🐬',
|
|
14
|
-
}
|
|
15
|
-
|
|
16
9
|
/**
|
|
17
10
|
* Get database size for a container (only if running)
|
|
18
11
|
*/
|
|
@@ -81,7 +74,7 @@ export const listCommand = new Command('list')
|
|
|
81
74
|
? chalk.green('● running')
|
|
82
75
|
: chalk.gray('○ stopped')
|
|
83
76
|
|
|
84
|
-
const engineIcon =
|
|
77
|
+
const engineIcon = getEngineIcon(container.engine)
|
|
85
78
|
const engineDisplay = `${engineIcon} ${container.engine}`
|
|
86
79
|
|
|
87
80
|
// Format size: show value if running, dash if stopped
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { spawn } from 'child_process'
|
|
3
|
+
import { existsSync } from 'fs'
|
|
4
|
+
import { readFile } from 'fs/promises'
|
|
5
|
+
import { containerManager } from '../../core/container-manager'
|
|
6
|
+
import { paths } from '../../config/paths'
|
|
7
|
+
import { promptContainerSelect } from '../ui/prompts'
|
|
8
|
+
import { error, warning, info } from '../ui/theme'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get the last N lines from a file content
|
|
12
|
+
*/
|
|
13
|
+
function getLastNLines(content: string, n: number): string {
|
|
14
|
+
const lines = content.split('\n')
|
|
15
|
+
// Filter empty trailing line if present
|
|
16
|
+
const nonEmptyLines =
|
|
17
|
+
lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines
|
|
18
|
+
return nonEmptyLines.slice(-n).join('\n')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const logsCommand = new Command('logs')
|
|
22
|
+
.description('View container logs')
|
|
23
|
+
.argument('[name]', 'Container name')
|
|
24
|
+
.option('-f, --follow', 'Follow log output (like tail -f)')
|
|
25
|
+
.option('-n, --lines <number>', 'Number of lines to show', '50')
|
|
26
|
+
.option('--editor', 'Open logs in $EDITOR')
|
|
27
|
+
.action(
|
|
28
|
+
async (
|
|
29
|
+
name: string | undefined,
|
|
30
|
+
options: { follow?: boolean; lines?: string; editor?: boolean },
|
|
31
|
+
) => {
|
|
32
|
+
try {
|
|
33
|
+
let containerName = name
|
|
34
|
+
|
|
35
|
+
// Interactive selection if no name provided
|
|
36
|
+
if (!containerName) {
|
|
37
|
+
const containers = await containerManager.list()
|
|
38
|
+
|
|
39
|
+
if (containers.length === 0) {
|
|
40
|
+
console.log(warning('No containers found'))
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const selected = await promptContainerSelect(
|
|
45
|
+
containers,
|
|
46
|
+
'Select container:',
|
|
47
|
+
)
|
|
48
|
+
if (!selected) return
|
|
49
|
+
containerName = selected
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Get container config
|
|
53
|
+
const config = await containerManager.getConfig(containerName)
|
|
54
|
+
if (!config) {
|
|
55
|
+
console.error(error(`Container "${containerName}" not found`))
|
|
56
|
+
process.exit(1)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Get log file path
|
|
60
|
+
const logPath = paths.getContainerLogPath(config.name, {
|
|
61
|
+
engine: config.engine,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// Check if log file exists
|
|
65
|
+
if (!existsSync(logPath)) {
|
|
66
|
+
console.log(
|
|
67
|
+
info(
|
|
68
|
+
`No log file found for "${containerName}". The container may not have been started yet.`,
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Open in editor if requested
|
|
75
|
+
if (options.editor) {
|
|
76
|
+
const editorCmd = process.env.EDITOR || 'vi'
|
|
77
|
+
const child = spawn(editorCmd, [logPath], {
|
|
78
|
+
stdio: 'inherit',
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
await new Promise<void>((resolve, reject) => {
|
|
82
|
+
child.on('close', (code) => {
|
|
83
|
+
if (code === 0) {
|
|
84
|
+
resolve()
|
|
85
|
+
} else {
|
|
86
|
+
reject(new Error(`Editor exited with code ${code}`))
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
child.on('error', reject)
|
|
90
|
+
})
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Follow mode using tail -f
|
|
95
|
+
if (options.follow) {
|
|
96
|
+
const lineCount = parseInt(options.lines || '50', 10)
|
|
97
|
+
const child = spawn('tail', ['-n', String(lineCount), '-f', logPath], {
|
|
98
|
+
stdio: 'inherit',
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// Handle SIGINT gracefully
|
|
102
|
+
process.on('SIGINT', () => {
|
|
103
|
+
child.kill('SIGTERM')
|
|
104
|
+
process.exit(0)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
await new Promise<void>((resolve) => {
|
|
108
|
+
child.on('close', () => resolve())
|
|
109
|
+
})
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Default: read and output last N lines
|
|
114
|
+
const lineCount = parseInt(options.lines || '50', 10)
|
|
115
|
+
const content = await readFile(logPath, 'utf-8')
|
|
116
|
+
|
|
117
|
+
if (content.trim() === '') {
|
|
118
|
+
console.log(info('Log file is empty'))
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const output = getLastNLines(content, lineCount)
|
|
123
|
+
console.log(output)
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const e = err as Error
|
|
126
|
+
console.error(error(e.message))
|
|
127
|
+
process.exit(1)
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
)
|