sealos-cli 0.1.0
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/LICENSE +191 -0
- package/README.md +234 -0
- package/dist/bin/cli.cjs +2066 -0
- package/dist/bin/cli.d.cts +1 -0
- package/dist/bin/cli.d.ts +1 -0
- package/dist/bin/cli.mjs +2044 -0
- package/dist/main.cjs +2079 -0
- package/dist/main.d.cts +7 -0
- package/dist/main.d.ts +7 -0
- package/dist/main.mjs +2045 -0
- package/package.json +112 -0
- package/src/bin/cli.ts +4 -0
- package/src/commands/app/index.ts +22 -0
- package/src/commands/auth/index.ts +124 -0
- package/src/commands/auth/login.ts +35 -0
- package/src/commands/auth/logout.ts +23 -0
- package/src/commands/auth/whoami.ts +38 -0
- package/src/commands/config/index.ts +54 -0
- package/src/commands/database/index.ts +881 -0
- package/src/commands/devbox/index.ts +224 -0
- package/src/commands/quota/index.ts +22 -0
- package/src/commands/s3/index.ts +35 -0
- package/src/commands/template/index.ts +314 -0
- package/src/commands/workspace/index.ts +84 -0
- package/src/docs/database_openapi.json +8297 -0
- package/src/docs/template_openapi.json +1 -0
- package/src/generated/database.ts +3969 -0
- package/src/generated/template.ts +1007 -0
- package/src/lib/api-client.ts +64 -0
- package/src/lib/api.ts +83 -0
- package/src/lib/auth.ts +570 -0
- package/src/lib/config.ts +134 -0
- package/src/lib/constants.ts +1 -0
- package/src/lib/errors.ts +105 -0
- package/src/lib/oauth.ts +197 -0
- package/src/lib/output.ts +93 -0
- package/src/lib/with-auth.ts +56 -0
- package/src/main.ts +51 -0
- package/src/types/index.ts +56 -0
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { createDatabaseClient } from '../../lib/api-client.ts'
|
|
4
|
+
import { type ApiErrorBody, mapApiError } from '../../lib/errors.ts'
|
|
5
|
+
import { outputJson, outputTable } from '../../lib/output.ts'
|
|
6
|
+
import { withAuth, withErrorHandling } from '../../lib/with-auth.ts'
|
|
7
|
+
|
|
8
|
+
const SUPPORTED_DATABASE_TYPES = [
|
|
9
|
+
'postgresql',
|
|
10
|
+
'mongodb',
|
|
11
|
+
'apecloud-mysql',
|
|
12
|
+
'mysql',
|
|
13
|
+
'redis',
|
|
14
|
+
'kafka',
|
|
15
|
+
'qdrant',
|
|
16
|
+
'nebula',
|
|
17
|
+
'weaviate',
|
|
18
|
+
'milvus',
|
|
19
|
+
'pulsar',
|
|
20
|
+
'clickhouse'
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
const SUPPORTED_LOG_DB_TYPES = [
|
|
24
|
+
'postgresql',
|
|
25
|
+
'mongodb',
|
|
26
|
+
'mysql',
|
|
27
|
+
'redis'
|
|
28
|
+
] as const
|
|
29
|
+
|
|
30
|
+
const SUPPORTED_LOG_TYPES = [
|
|
31
|
+
'runtimeLog',
|
|
32
|
+
'slowQuery',
|
|
33
|
+
'errorLog'
|
|
34
|
+
] as const
|
|
35
|
+
|
|
36
|
+
type DatabaseType = typeof SUPPORTED_DATABASE_TYPES[number]
|
|
37
|
+
type LogDbType = typeof SUPPORTED_LOG_DB_TYPES[number]
|
|
38
|
+
type LogType = typeof SUPPORTED_LOG_TYPES[number]
|
|
39
|
+
|
|
40
|
+
function collectOption (value: string, previous: string[]): string[] {
|
|
41
|
+
return [...previous, value]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseKeyValueArgs (pairs: string[]): Record<string, string> {
|
|
45
|
+
const values: Record<string, string> = {}
|
|
46
|
+
for (const pair of pairs) {
|
|
47
|
+
const index = pair.indexOf('=')
|
|
48
|
+
if (index === -1) {
|
|
49
|
+
throw new Error(`Invalid KEY=VALUE format: "${pair}"`)
|
|
50
|
+
}
|
|
51
|
+
values[pair.slice(0, index)] = pair.slice(index + 1)
|
|
52
|
+
}
|
|
53
|
+
return values
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeDatabaseType (type: string): DatabaseType {
|
|
57
|
+
const normalized = type.trim().toLowerCase()
|
|
58
|
+
const aliases: Record<string, DatabaseType> = {
|
|
59
|
+
postgres: 'postgresql',
|
|
60
|
+
postgresql: 'postgresql',
|
|
61
|
+
mongo: 'mongodb',
|
|
62
|
+
mongodb: 'mongodb'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const resolved = aliases[normalized] || (normalized as DatabaseType)
|
|
66
|
+
if (!SUPPORTED_DATABASE_TYPES.includes(resolved)) {
|
|
67
|
+
throw new Error(`Unsupported database type "${type}"`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return resolved
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeLogDbType (type: string): LogDbType {
|
|
74
|
+
const normalized = normalizeDatabaseType(type)
|
|
75
|
+
if (!SUPPORTED_LOG_DB_TYPES.includes(normalized as LogDbType)) {
|
|
76
|
+
throw new Error(`Logs API only supports db types: ${SUPPORTED_LOG_DB_TYPES.join(', ')}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return normalized as LogDbType
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeLogType (type: string): LogType {
|
|
83
|
+
const normalized = type.trim() as LogType
|
|
84
|
+
if (!SUPPORTED_LOG_TYPES.includes(normalized)) {
|
|
85
|
+
throw new Error(`Unsupported log type "${type}". Use one of: ${SUPPORTED_LOG_TYPES.join(', ')}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return normalized
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseNumericValue (value: string, field: string): number {
|
|
92
|
+
const normalized = value.trim().toLowerCase()
|
|
93
|
+
let raw = normalized
|
|
94
|
+
|
|
95
|
+
if (field === 'cpu' && normalized.endsWith('c')) {
|
|
96
|
+
raw = normalized.slice(0, -1)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if ((field === 'memory' || field === 'storage') && /gi?$|gb$|g$/i.test(normalized)) {
|
|
100
|
+
raw = normalized.replace(/gi?$|gb$|g$/i, '')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const parsed = Number(raw)
|
|
104
|
+
if (!Number.isFinite(parsed)) {
|
|
105
|
+
throw new Error(`Invalid ${field} value "${value}"`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return parsed
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseIntegerValue (value: string, field: string): number {
|
|
112
|
+
const parsed = parseNumericValue(value, field)
|
|
113
|
+
if (!Number.isInteger(parsed)) {
|
|
114
|
+
throw new Error(`${field} must be an integer`)
|
|
115
|
+
}
|
|
116
|
+
return parsed
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildQuota (options: { cpu?: string; memory?: string; storage?: string; replicas?: string }): Record<string, number> {
|
|
120
|
+
const quota: Record<string, number> = {}
|
|
121
|
+
|
|
122
|
+
if (options.cpu !== undefined) quota.cpu = parseNumericValue(options.cpu, 'cpu')
|
|
123
|
+
if (options.memory !== undefined) quota.memory = parseNumericValue(options.memory, 'memory')
|
|
124
|
+
if (options.storage !== undefined) quota.storage = parseNumericValue(options.storage, 'storage')
|
|
125
|
+
if (options.replicas !== undefined) quota.replicas = parseIntegerValue(options.replicas, 'replicas')
|
|
126
|
+
|
|
127
|
+
return quota
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildAutoBackup (options: {
|
|
131
|
+
backupStart?: boolean
|
|
132
|
+
backupType?: string
|
|
133
|
+
backupWeek: string[]
|
|
134
|
+
backupHour?: string
|
|
135
|
+
backupMinute?: string
|
|
136
|
+
backupSaveTime?: string
|
|
137
|
+
backupSaveType?: string
|
|
138
|
+
}): Record<string, unknown> | undefined {
|
|
139
|
+
const autoBackup: Record<string, unknown> = {}
|
|
140
|
+
|
|
141
|
+
if (options.backupStart) autoBackup.start = true
|
|
142
|
+
if (options.backupType) autoBackup.type = options.backupType
|
|
143
|
+
if (options.backupWeek.length > 0) autoBackup.week = options.backupWeek
|
|
144
|
+
if (options.backupHour) autoBackup.hour = options.backupHour
|
|
145
|
+
if (options.backupMinute) autoBackup.minute = options.backupMinute
|
|
146
|
+
if (options.backupSaveTime) autoBackup.saveTime = parseIntegerValue(options.backupSaveTime, 'backup-save-time')
|
|
147
|
+
if (options.backupSaveType) autoBackup.saveType = options.backupSaveType
|
|
148
|
+
|
|
149
|
+
return Object.keys(autoBackup).length > 0 ? autoBackup : undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatValue (value: unknown): string {
|
|
153
|
+
if (value === undefined || value === null || value === '') return '-'
|
|
154
|
+
return String(value)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function summarizeVersions (versions: string[]): string {
|
|
158
|
+
if (versions.length <= 3) return versions.join(', ')
|
|
159
|
+
return `${versions.slice(0, 3).join(', ')} ... (${versions.length} total)`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function extractVersionsMap (payload: any): Record<string, string[]> {
|
|
163
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
164
|
+
if (payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
|
|
165
|
+
return payload.data as Record<string, string[]>
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return payload as Record<string, string[]>
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
throw new Error('Unexpected versions response shape')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function printDatabaseDetail (database: any): void {
|
|
175
|
+
console.log(chalk.bold(`\n ${database.name}\n`))
|
|
176
|
+
console.log(` ${chalk.dim('Type:')} ${formatValue(database.type)}`)
|
|
177
|
+
console.log(` ${chalk.dim('Version:')} ${formatValue(database.version)}`)
|
|
178
|
+
console.log(` ${chalk.dim('Status:')} ${formatValue(database.status)}`)
|
|
179
|
+
console.log(` ${chalk.dim('Created:')} ${formatValue(database.createdAt)}`)
|
|
180
|
+
console.log(` ${chalk.dim('Termination policy:')} ${formatValue(database.terminationPolicy)}`)
|
|
181
|
+
console.log(` ${chalk.dim('UID:')} ${formatValue(database.uid)}`)
|
|
182
|
+
console.log(` ${chalk.dim('Resource type:')} ${formatValue(database.resourceType)}`)
|
|
183
|
+
|
|
184
|
+
if (database.quota) {
|
|
185
|
+
console.log(`\n ${chalk.dim('Resources:')}`)
|
|
186
|
+
console.log(` CPU: ${formatValue(database.quota.cpu)} core(s)`)
|
|
187
|
+
console.log(` Memory: ${formatValue(database.quota.memory)} GB`)
|
|
188
|
+
console.log(` Storage: ${formatValue(database.quota.storage)} GB`)
|
|
189
|
+
console.log(` Replicas: ${formatValue(database.quota.replicas)}`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (database.connection) {
|
|
193
|
+
const privateReady = database.connection.privateConnection != null
|
|
194
|
+
const publicReady = database.connection.publicConnection != null
|
|
195
|
+
console.log(`\n ${chalk.dim('Connectivity:')}`)
|
|
196
|
+
console.log(` Private: ${privateReady ? 'available' : 'not ready'}`)
|
|
197
|
+
console.log(` Public: ${publicReady ? 'enabled' : 'disabled'}`)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (database.autoBackup) {
|
|
201
|
+
console.log(`\n ${chalk.dim('Auto backup:')}`)
|
|
202
|
+
console.log(` Enabled: ${database.autoBackup.start ? 'yes' : 'no'}`)
|
|
203
|
+
if (database.autoBackup.type) {
|
|
204
|
+
console.log(` Schedule: ${database.autoBackup.type}`)
|
|
205
|
+
}
|
|
206
|
+
if (database.autoBackup.week?.length) {
|
|
207
|
+
console.log(` Weekdays: ${database.autoBackup.week.join(', ')}`)
|
|
208
|
+
}
|
|
209
|
+
if (database.autoBackup.hour || database.autoBackup.minute) {
|
|
210
|
+
console.log(` Time: ${formatValue(database.autoBackup.hour)}:${formatValue(database.autoBackup.minute)}`)
|
|
211
|
+
}
|
|
212
|
+
if (database.autoBackup.saveTime || database.autoBackup.saveType) {
|
|
213
|
+
console.log(` Retention: ${formatValue(database.autoBackup.saveTime)} ${formatValue(database.autoBackup.saveType)}`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const params = database.parameterConfig ? Object.entries(database.parameterConfig).filter(([, value]) => value !== undefined && value !== null && value !== '') : []
|
|
218
|
+
if (params.length > 0) {
|
|
219
|
+
console.log(`\n ${chalk.dim('Parameters:')}`)
|
|
220
|
+
const rows: string[][] = [[chalk.bold('Key'), chalk.bold('Value')]]
|
|
221
|
+
for (const [key, value] of params) {
|
|
222
|
+
rows.push([key, formatValue(value)])
|
|
223
|
+
}
|
|
224
|
+
outputTable(rows)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (database.pods?.length > 0) {
|
|
228
|
+
console.log(`\n ${chalk.dim('Pods:')}`)
|
|
229
|
+
const rows: string[][] = [[chalk.bold('Name'), chalk.bold('Status')]]
|
|
230
|
+
for (const pod of database.pods) {
|
|
231
|
+
rows.push([formatValue(pod.name), formatValue(pod.status)])
|
|
232
|
+
}
|
|
233
|
+
outputTable(rows)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function printConnectionDetail (connection: any): void {
|
|
238
|
+
const rows: string[][] = [[chalk.bold('Field'), chalk.bold('Value')]]
|
|
239
|
+
const privateConnection = connection?.privateConnection
|
|
240
|
+
const publicConnection = connection?.publicConnection
|
|
241
|
+
|
|
242
|
+
if (privateConnection && typeof privateConnection === 'object') {
|
|
243
|
+
rows.push(['Private endpoint', formatValue(privateConnection.endpoint)])
|
|
244
|
+
rows.push(['Private host', formatValue(privateConnection.host)])
|
|
245
|
+
rows.push(['Private port', formatValue(privateConnection.port)])
|
|
246
|
+
rows.push(['Username', formatValue(privateConnection.username)])
|
|
247
|
+
rows.push(['Password', formatValue(privateConnection.password)])
|
|
248
|
+
rows.push(['Connection string', formatValue(privateConnection.connectionString)])
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (publicConnection !== undefined && publicConnection !== null) {
|
|
252
|
+
rows.push(['Public connection', formatValue(publicConnection)])
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (rows.length === 1) {
|
|
256
|
+
console.log('Connection information is not available yet.')
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
outputTable(rows)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function createDatabaseCommand (): Command {
|
|
264
|
+
const dbCmd = new Command('database')
|
|
265
|
+
.alias('db')
|
|
266
|
+
.description('Manage databases')
|
|
267
|
+
|
|
268
|
+
dbCmd
|
|
269
|
+
.command('list')
|
|
270
|
+
.description('List databases')
|
|
271
|
+
.option('-o, --output <format>', 'Output format (json|table)', 'table')
|
|
272
|
+
.action(withAuth({ spinnerText: 'Loading databases...' }, async (ctx, options: { output: string }) => {
|
|
273
|
+
const client = createDatabaseClient()
|
|
274
|
+
const { data, error, response } = await client.GET('/databases', {
|
|
275
|
+
headers: ctx.auth
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
279
|
+
|
|
280
|
+
ctx.spinner.stop()
|
|
281
|
+
|
|
282
|
+
if (options.output === 'json') {
|
|
283
|
+
outputJson(data)
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (data.length === 0) {
|
|
288
|
+
console.log('No databases found.')
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const rows: string[][] = [[
|
|
293
|
+
chalk.bold('Name'),
|
|
294
|
+
chalk.bold('Type'),
|
|
295
|
+
chalk.bold('Version'),
|
|
296
|
+
chalk.bold('Status'),
|
|
297
|
+
chalk.bold('CPU'),
|
|
298
|
+
chalk.bold('Memory'),
|
|
299
|
+
chalk.bold('Storage'),
|
|
300
|
+
chalk.bold('Replicas')
|
|
301
|
+
]]
|
|
302
|
+
|
|
303
|
+
for (const database of data) {
|
|
304
|
+
rows.push([
|
|
305
|
+
formatValue(database.name),
|
|
306
|
+
formatValue(database.type),
|
|
307
|
+
formatValue(database.version),
|
|
308
|
+
formatValue(database.status),
|
|
309
|
+
formatValue(database.quota?.cpu),
|
|
310
|
+
formatValue(database.quota?.memory),
|
|
311
|
+
formatValue(database.quota?.storage),
|
|
312
|
+
formatValue(database.quota?.replicas)
|
|
313
|
+
])
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
outputTable(rows)
|
|
317
|
+
}))
|
|
318
|
+
|
|
319
|
+
dbCmd
|
|
320
|
+
.command('versions')
|
|
321
|
+
.description('List supported database versions (public endpoint)')
|
|
322
|
+
.option('-o, --output <format>', 'Output format (json|table)', 'table')
|
|
323
|
+
.option('--host <host>', 'Sealos region host for public version lookup, e.g. https://gzg.sealos.run')
|
|
324
|
+
.option('--type <type>', 'Filter versions by database type')
|
|
325
|
+
.action(withErrorHandling({ spinnerText: 'Loading versions...' }, async (ctx, options: { output: string; host?: string; type?: string }) => {
|
|
326
|
+
const client = createDatabaseClient({ baseUrl: options.host })
|
|
327
|
+
const { data, error, response } = await client.GET('/databases/versions')
|
|
328
|
+
|
|
329
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
330
|
+
|
|
331
|
+
ctx.spinner.stop()
|
|
332
|
+
|
|
333
|
+
if (options.output === 'json') {
|
|
334
|
+
if (!options.type) {
|
|
335
|
+
outputJson(data)
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const versionsMap = extractVersionsMap(data)
|
|
340
|
+
const databaseType = normalizeDatabaseType(options.type)
|
|
341
|
+
outputJson({ [databaseType]: versionsMap[databaseType] ?? [] })
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const versionsMap = extractVersionsMap(data)
|
|
346
|
+
if (options.type) {
|
|
347
|
+
const databaseType = normalizeDatabaseType(options.type)
|
|
348
|
+
const versions = versionsMap[databaseType] ?? []
|
|
349
|
+
if (versions.length === 0) {
|
|
350
|
+
console.log(`No versions found for database type "${databaseType}".`)
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const rows: string[][] = [[chalk.bold('Type'), chalk.bold('Version')]]
|
|
355
|
+
for (const version of versions) {
|
|
356
|
+
rows.push([databaseType, version])
|
|
357
|
+
}
|
|
358
|
+
outputTable(rows)
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const rows: string[][] = [[chalk.bold('Type'), chalk.bold('Versions')]]
|
|
363
|
+
for (const [type, versions] of Object.entries(versionsMap)) {
|
|
364
|
+
rows.push([type, summarizeVersions(versions)])
|
|
365
|
+
}
|
|
366
|
+
outputTable(rows)
|
|
367
|
+
}))
|
|
368
|
+
|
|
369
|
+
dbCmd
|
|
370
|
+
.command('create <type>')
|
|
371
|
+
.description('Create a database')
|
|
372
|
+
.requiredOption('--name <name>', 'Database name')
|
|
373
|
+
.option('--version <version>', 'Database version')
|
|
374
|
+
.option('--cpu <cpu>', 'CPU cores per replica', '1')
|
|
375
|
+
.option('--memory <memory>', 'Memory in GB per replica', '1')
|
|
376
|
+
.option('--storage <storage>', 'Storage in GB per replica', '3')
|
|
377
|
+
.option('--replicas <replicas>', 'Replica count', '1')
|
|
378
|
+
.option('--termination-policy <policy>', 'Termination policy (delete|wipeout)')
|
|
379
|
+
.option('--backup-start', 'Enable automatic backups')
|
|
380
|
+
.option('--backup-type <type>', 'Automatic backup frequency (day|hour|week)')
|
|
381
|
+
.option('--backup-week <day>', 'Weekday for weekly backups', collectOption, [] as string[])
|
|
382
|
+
.option('--backup-hour <hour>', 'Backup hour (00-23)')
|
|
383
|
+
.option('--backup-minute <minute>', 'Backup minute (00-59)')
|
|
384
|
+
.option('--backup-save-time <count>', 'Retention count')
|
|
385
|
+
.option('--backup-save-type <type>', 'Retention unit (days|hours|weeks|months)')
|
|
386
|
+
.option('--param <KEY=VALUE>', 'Database parameter override', collectOption, [] as string[])
|
|
387
|
+
.option('-o, --output <format>', 'Output format (json|table)', 'table')
|
|
388
|
+
.action(withAuth({
|
|
389
|
+
spinnerText: 'Creating database...'
|
|
390
|
+
}, async (
|
|
391
|
+
ctx,
|
|
392
|
+
type: string,
|
|
393
|
+
options: {
|
|
394
|
+
name: string
|
|
395
|
+
version?: string
|
|
396
|
+
cpu: string
|
|
397
|
+
memory: string
|
|
398
|
+
storage: string
|
|
399
|
+
replicas: string
|
|
400
|
+
terminationPolicy?: string
|
|
401
|
+
backupStart?: boolean
|
|
402
|
+
backupType?: string
|
|
403
|
+
backupWeek: string[]
|
|
404
|
+
backupHour?: string
|
|
405
|
+
backupMinute?: string
|
|
406
|
+
backupSaveTime?: string
|
|
407
|
+
backupSaveType?: string
|
|
408
|
+
param: string[]
|
|
409
|
+
output: string
|
|
410
|
+
}
|
|
411
|
+
) => {
|
|
412
|
+
const client = createDatabaseClient()
|
|
413
|
+
const quota = {
|
|
414
|
+
cpu: parseNumericValue(options.cpu, 'cpu'),
|
|
415
|
+
memory: parseNumericValue(options.memory, 'memory'),
|
|
416
|
+
storage: parseNumericValue(options.storage, 'storage'),
|
|
417
|
+
replicas: parseIntegerValue(options.replicas, 'replicas')
|
|
418
|
+
}
|
|
419
|
+
const autoBackup = buildAutoBackup(options)
|
|
420
|
+
const parameterConfig = options.param.length > 0 ? parseKeyValueArgs(options.param) : undefined
|
|
421
|
+
|
|
422
|
+
const body = {
|
|
423
|
+
name: options.name,
|
|
424
|
+
type: normalizeDatabaseType(type),
|
|
425
|
+
quota
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (options.version) Object.assign(body, { version: options.version })
|
|
429
|
+
if (options.terminationPolicy) Object.assign(body, { terminationPolicy: options.terminationPolicy })
|
|
430
|
+
if (autoBackup) Object.assign(body, { autoBackup })
|
|
431
|
+
if (parameterConfig) Object.assign(body, { parameterConfig })
|
|
432
|
+
|
|
433
|
+
const { data, error, response } = await client.POST('/databases', {
|
|
434
|
+
headers: ctx.auth,
|
|
435
|
+
body: body as any
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
439
|
+
|
|
440
|
+
if (options.output === 'json') {
|
|
441
|
+
ctx.spinner.stop()
|
|
442
|
+
outputJson(data)
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
ctx.spinner.succeed(`Database "${data.name}" created successfully`)
|
|
447
|
+
console.log(chalk.dim(` Provisioning status: ${data.status}`))
|
|
448
|
+
console.log(chalk.dim(` Next: sealos database get ${data.name}`))
|
|
449
|
+
}))
|
|
450
|
+
|
|
451
|
+
dbCmd
|
|
452
|
+
.command('get <name>')
|
|
453
|
+
.alias('describe')
|
|
454
|
+
.description('Get database details')
|
|
455
|
+
.option('-o, --output <format>', 'Output format (json|table)', 'table')
|
|
456
|
+
.action(withAuth({ spinnerText: 'Loading database...' }, async (ctx, name: string, options: { output: string }) => {
|
|
457
|
+
const client = createDatabaseClient()
|
|
458
|
+
const { data, error, response } = await client.GET('/databases/{databaseName}', {
|
|
459
|
+
headers: ctx.auth,
|
|
460
|
+
params: {
|
|
461
|
+
path: { databaseName: name }
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
466
|
+
|
|
467
|
+
ctx.spinner.stop()
|
|
468
|
+
|
|
469
|
+
if (options.output === 'json') {
|
|
470
|
+
outputJson(data)
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
printDatabaseDetail(data)
|
|
475
|
+
}))
|
|
476
|
+
|
|
477
|
+
dbCmd
|
|
478
|
+
.command('connection <name>')
|
|
479
|
+
.description('Show database connection details')
|
|
480
|
+
.option('-o, --output <format>', 'Output format (json|table)', 'table')
|
|
481
|
+
.action(withAuth({ spinnerText: 'Loading connection details...' }, async (ctx, name: string, options: { output: string }) => {
|
|
482
|
+
const client = createDatabaseClient()
|
|
483
|
+
const { data, error, response } = await client.GET('/databases/{databaseName}', {
|
|
484
|
+
headers: ctx.auth,
|
|
485
|
+
params: {
|
|
486
|
+
path: { databaseName: name }
|
|
487
|
+
}
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
491
|
+
|
|
492
|
+
ctx.spinner.stop()
|
|
493
|
+
|
|
494
|
+
if (options.output === 'json') {
|
|
495
|
+
outputJson(data.connection ?? null)
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
printConnectionDetail(data.connection)
|
|
500
|
+
}))
|
|
501
|
+
|
|
502
|
+
dbCmd
|
|
503
|
+
.command('update <name>')
|
|
504
|
+
.description('Update database resources')
|
|
505
|
+
.option('--cpu <cpu>', 'CPU cores per replica')
|
|
506
|
+
.option('--memory <memory>', 'Memory in GB per replica')
|
|
507
|
+
.option('--storage <storage>', 'Storage in GB per replica')
|
|
508
|
+
.option('--replicas <replicas>', 'Replica count')
|
|
509
|
+
.action(withAuth({
|
|
510
|
+
spinnerText: 'Updating database...'
|
|
511
|
+
}, async (
|
|
512
|
+
ctx,
|
|
513
|
+
name: string,
|
|
514
|
+
options: { cpu?: string; memory?: string; storage?: string; replicas?: string }
|
|
515
|
+
) => {
|
|
516
|
+
const quota = buildQuota(options)
|
|
517
|
+
if (Object.keys(quota).length === 0) {
|
|
518
|
+
throw new Error('Provide at least one of --cpu, --memory, --storage, or --replicas.')
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const client = createDatabaseClient()
|
|
522
|
+
const { error, response } = await client.PATCH('/databases/{databaseName}', {
|
|
523
|
+
headers: ctx.auth,
|
|
524
|
+
params: {
|
|
525
|
+
path: { databaseName: name }
|
|
526
|
+
},
|
|
527
|
+
body: { quota }
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
531
|
+
|
|
532
|
+
ctx.spinner.succeed(`Database "${name}" update requested`)
|
|
533
|
+
}))
|
|
534
|
+
|
|
535
|
+
dbCmd
|
|
536
|
+
.command('start <name>')
|
|
537
|
+
.description('Start a database')
|
|
538
|
+
.action(withAuth({ spinnerText: 'Starting database...' }, async (ctx, name: string) => {
|
|
539
|
+
const client = createDatabaseClient()
|
|
540
|
+
const { error, response } = await client.POST('/databases/{databaseName}/start', {
|
|
541
|
+
headers: ctx.auth,
|
|
542
|
+
params: {
|
|
543
|
+
path: { databaseName: name }
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
548
|
+
ctx.spinner.succeed(`Database "${name}" start requested`)
|
|
549
|
+
}))
|
|
550
|
+
|
|
551
|
+
dbCmd
|
|
552
|
+
.command('pause <name>')
|
|
553
|
+
.alias('stop')
|
|
554
|
+
.description('Pause a database')
|
|
555
|
+
.action(withAuth({ spinnerText: 'Pausing database...' }, async (ctx, name: string) => {
|
|
556
|
+
const client = createDatabaseClient()
|
|
557
|
+
const { error, response } = await client.POST('/databases/{databaseName}/pause', {
|
|
558
|
+
headers: ctx.auth,
|
|
559
|
+
params: {
|
|
560
|
+
path: { databaseName: name }
|
|
561
|
+
}
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
565
|
+
ctx.spinner.succeed(`Database "${name}" pause requested`)
|
|
566
|
+
}))
|
|
567
|
+
|
|
568
|
+
dbCmd
|
|
569
|
+
.command('restart <name>')
|
|
570
|
+
.description('Restart a database')
|
|
571
|
+
.action(withAuth({ spinnerText: 'Restarting database...' }, async (ctx, name: string) => {
|
|
572
|
+
const client = createDatabaseClient()
|
|
573
|
+
const { error, response } = await client.POST('/databases/{databaseName}/restart', {
|
|
574
|
+
headers: ctx.auth,
|
|
575
|
+
params: {
|
|
576
|
+
path: { databaseName: name }
|
|
577
|
+
}
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
581
|
+
ctx.spinner.succeed(`Database "${name}" restart requested`)
|
|
582
|
+
}))
|
|
583
|
+
|
|
584
|
+
dbCmd
|
|
585
|
+
.command('delete <name>')
|
|
586
|
+
.description('Delete a database')
|
|
587
|
+
.option('-f, --force', 'Delete without confirmation')
|
|
588
|
+
.action(withAuth({ spinnerText: 'Deleting database...' }, async (ctx, name: string) => {
|
|
589
|
+
const client = createDatabaseClient()
|
|
590
|
+
const { error, response } = await client.DELETE('/databases/{databaseName}', {
|
|
591
|
+
headers: ctx.auth,
|
|
592
|
+
params: {
|
|
593
|
+
path: { databaseName: name }
|
|
594
|
+
}
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
598
|
+
ctx.spinner.succeed(`Database "${name}" delete requested`)
|
|
599
|
+
}))
|
|
600
|
+
|
|
601
|
+
dbCmd
|
|
602
|
+
.command('backups <name>')
|
|
603
|
+
.description('List backups for a database')
|
|
604
|
+
.option('-o, --output <format>', 'Output format (json|table)', 'table')
|
|
605
|
+
.action(withAuth({ spinnerText: 'Loading backups...' }, async (ctx, name: string, options: { output: string }) => {
|
|
606
|
+
const client = createDatabaseClient()
|
|
607
|
+
const { data, error, response } = await client.GET('/databases/{databaseName}/backups', {
|
|
608
|
+
headers: ctx.auth,
|
|
609
|
+
params: {
|
|
610
|
+
path: { databaseName: name }
|
|
611
|
+
}
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
615
|
+
|
|
616
|
+
ctx.spinner.stop()
|
|
617
|
+
|
|
618
|
+
if (options.output === 'json') {
|
|
619
|
+
outputJson(data)
|
|
620
|
+
return
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (data.length === 0) {
|
|
624
|
+
console.log('No backups found.')
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const rows: string[][] = [[
|
|
629
|
+
chalk.bold('Name'),
|
|
630
|
+
chalk.bold('Status'),
|
|
631
|
+
chalk.bold('Created'),
|
|
632
|
+
chalk.bold('Description')
|
|
633
|
+
]]
|
|
634
|
+
|
|
635
|
+
for (const backup of data) {
|
|
636
|
+
rows.push([
|
|
637
|
+
formatValue(backup.name),
|
|
638
|
+
formatValue(backup.status),
|
|
639
|
+
formatValue(backup.createdAt),
|
|
640
|
+
formatValue(backup.description)
|
|
641
|
+
])
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
outputTable(rows)
|
|
645
|
+
}))
|
|
646
|
+
|
|
647
|
+
dbCmd
|
|
648
|
+
.command('backup <name>')
|
|
649
|
+
.description('Create a database backup')
|
|
650
|
+
.option('--name <backupName>', 'Backup name')
|
|
651
|
+
.option('--description <description>', 'Backup description')
|
|
652
|
+
.action(withAuth({
|
|
653
|
+
spinnerText: 'Creating backup...'
|
|
654
|
+
}, async (
|
|
655
|
+
ctx,
|
|
656
|
+
name: string,
|
|
657
|
+
options: { name?: string; description?: string }
|
|
658
|
+
) => {
|
|
659
|
+
const client = createDatabaseClient()
|
|
660
|
+
const body: Record<string, string> = {}
|
|
661
|
+
if (options.name) body.name = options.name
|
|
662
|
+
if (options.description) body.description = options.description
|
|
663
|
+
|
|
664
|
+
const { error, response } = await client.POST('/databases/{databaseName}/backups', {
|
|
665
|
+
headers: ctx.auth,
|
|
666
|
+
params: {
|
|
667
|
+
path: { databaseName: name }
|
|
668
|
+
},
|
|
669
|
+
body
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
673
|
+
ctx.spinner.succeed(`Backup requested for database "${name}"`)
|
|
674
|
+
}))
|
|
675
|
+
|
|
676
|
+
dbCmd
|
|
677
|
+
.command('backup-delete <databaseName> <backupName>')
|
|
678
|
+
.description('Delete a database backup')
|
|
679
|
+
.action(withAuth({
|
|
680
|
+
spinnerText: 'Deleting backup...'
|
|
681
|
+
}, async (ctx, databaseName: string, backupName: string) => {
|
|
682
|
+
const client = createDatabaseClient()
|
|
683
|
+
const { error, response } = await client.DELETE('/databases/{databaseName}/backups/{backupName}', {
|
|
684
|
+
headers: ctx.auth,
|
|
685
|
+
params: {
|
|
686
|
+
path: { databaseName, backupName }
|
|
687
|
+
}
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
691
|
+
ctx.spinner.succeed(`Backup "${backupName}" deleted`)
|
|
692
|
+
}))
|
|
693
|
+
|
|
694
|
+
dbCmd
|
|
695
|
+
.command('restore <databaseName>')
|
|
696
|
+
.description('Restore a database from a backup')
|
|
697
|
+
.requiredOption('--from <backupName>', 'Backup name to restore from')
|
|
698
|
+
.option('--name <name>', 'Name for the restored database')
|
|
699
|
+
.option('--replicas <replicas>', 'Replica count for the restored database')
|
|
700
|
+
.action(withAuth({
|
|
701
|
+
spinnerText: 'Restoring database...'
|
|
702
|
+
}, async (
|
|
703
|
+
ctx,
|
|
704
|
+
databaseName: string,
|
|
705
|
+
options: { from: string; name?: string; replicas?: string }
|
|
706
|
+
) => {
|
|
707
|
+
const client = createDatabaseClient()
|
|
708
|
+
const body: Record<string, unknown> = {}
|
|
709
|
+
if (options.name) body.name = options.name
|
|
710
|
+
if (options.replicas) body.replicas = parseIntegerValue(options.replicas, 'replicas')
|
|
711
|
+
|
|
712
|
+
const { error, response } = await client.POST('/databases/{databaseName}/backups/{backupName}/restore', {
|
|
713
|
+
headers: ctx.auth,
|
|
714
|
+
params: {
|
|
715
|
+
path: { databaseName, backupName: options.from }
|
|
716
|
+
},
|
|
717
|
+
body
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
721
|
+
ctx.spinner.succeed(`Restore requested from backup "${options.from}"`)
|
|
722
|
+
}))
|
|
723
|
+
|
|
724
|
+
dbCmd
|
|
725
|
+
.command('enable-public <name>')
|
|
726
|
+
.description('Enable public access for a database')
|
|
727
|
+
.action(withAuth({ spinnerText: 'Enabling public access...' }, async (ctx, name: string) => {
|
|
728
|
+
const client = createDatabaseClient()
|
|
729
|
+
const { error, response } = await client.POST('/databases/{databaseName}/enable-public', {
|
|
730
|
+
headers: ctx.auth,
|
|
731
|
+
params: {
|
|
732
|
+
path: { databaseName: name }
|
|
733
|
+
}
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
737
|
+
ctx.spinner.succeed(`Public access enabled for "${name}"`)
|
|
738
|
+
}))
|
|
739
|
+
|
|
740
|
+
dbCmd
|
|
741
|
+
.command('disable-public <name>')
|
|
742
|
+
.description('Disable public access for a database')
|
|
743
|
+
.action(withAuth({ spinnerText: 'Disabling public access...' }, async (ctx, name: string) => {
|
|
744
|
+
const client = createDatabaseClient()
|
|
745
|
+
const { error, response } = await client.POST('/databases/{databaseName}/disable-public', {
|
|
746
|
+
headers: ctx.auth,
|
|
747
|
+
params: {
|
|
748
|
+
path: { databaseName: name }
|
|
749
|
+
}
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
753
|
+
ctx.spinner.succeed(`Public access disabled for "${name}"`)
|
|
754
|
+
}))
|
|
755
|
+
|
|
756
|
+
dbCmd
|
|
757
|
+
.command('logs <podName>')
|
|
758
|
+
.description('Get parsed database logs for a pod')
|
|
759
|
+
.requiredOption('--db-type <type>', 'Database type used by the log service')
|
|
760
|
+
.requiredOption('--log-type <type>', 'Log type used by the log service')
|
|
761
|
+
.requiredOption('--log-path <path>', 'Log path to read. Use "log-files" first to discover valid paths')
|
|
762
|
+
.option('--page <page>', 'Page number', '1')
|
|
763
|
+
.option('--page-size <pageSize>', 'Page size', '200')
|
|
764
|
+
.option('-o, --output <format>', 'Output format (plain|json|table)', 'plain')
|
|
765
|
+
.action(withAuth({
|
|
766
|
+
spinnerText: 'Loading logs...'
|
|
767
|
+
}, async (
|
|
768
|
+
ctx,
|
|
769
|
+
podName: string,
|
|
770
|
+
options: {
|
|
771
|
+
dbType: string
|
|
772
|
+
logType: string
|
|
773
|
+
logPath: string
|
|
774
|
+
page: string
|
|
775
|
+
pageSize: string
|
|
776
|
+
output: string
|
|
777
|
+
}
|
|
778
|
+
) => {
|
|
779
|
+
const client = createDatabaseClient()
|
|
780
|
+
const { data, error, response } = await client.GET('/logs', {
|
|
781
|
+
headers: ctx.auth,
|
|
782
|
+
params: {
|
|
783
|
+
query: {
|
|
784
|
+
podName,
|
|
785
|
+
dbType: normalizeLogDbType(options.dbType),
|
|
786
|
+
logType: normalizeLogType(options.logType),
|
|
787
|
+
logPath: options.logPath,
|
|
788
|
+
page: parseIntegerValue(options.page, 'page'),
|
|
789
|
+
pageSize: parseIntegerValue(options.pageSize, 'page-size')
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
795
|
+
|
|
796
|
+
ctx.spinner.stop()
|
|
797
|
+
|
|
798
|
+
if (options.output === 'json') {
|
|
799
|
+
outputJson(data)
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (options.output === 'table') {
|
|
804
|
+
const rows: string[][] = [[chalk.bold('Timestamp'), chalk.bold('Level'), chalk.bold('Content')]]
|
|
805
|
+
for (const log of data.data.logs) {
|
|
806
|
+
rows.push([formatValue(log.timestamp), formatValue(log.level), formatValue(log.content)])
|
|
807
|
+
}
|
|
808
|
+
outputTable(rows)
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
for (const log of data.data.logs) {
|
|
813
|
+
console.log(`${log.timestamp} [${log.level}] ${log.content}`)
|
|
814
|
+
}
|
|
815
|
+
console.log(chalk.dim(`\npage=${data.data.metadata.page} total=${data.data.metadata.total} hasMore=${String(data.data.metadata.hasMore)}`))
|
|
816
|
+
}))
|
|
817
|
+
|
|
818
|
+
dbCmd
|
|
819
|
+
.command('log-files <podName>')
|
|
820
|
+
.description('List database log files for a pod')
|
|
821
|
+
.requiredOption('--db-type <type>', 'Database type used by the log service')
|
|
822
|
+
.requiredOption('--log-type <type>', 'Log type used by the log service')
|
|
823
|
+
.option('-o, --output <format>', 'Output format (json|table)', 'table')
|
|
824
|
+
.action(withAuth({
|
|
825
|
+
spinnerText: 'Loading log files...'
|
|
826
|
+
}, async (
|
|
827
|
+
ctx,
|
|
828
|
+
podName: string,
|
|
829
|
+
options: { dbType: string; logType: string; output: string }
|
|
830
|
+
) => {
|
|
831
|
+
const client = createDatabaseClient()
|
|
832
|
+
const { data, error, response } = await client.GET('/logs/files', {
|
|
833
|
+
headers: ctx.auth,
|
|
834
|
+
params: {
|
|
835
|
+
query: {
|
|
836
|
+
podName,
|
|
837
|
+
dbType: normalizeLogDbType(options.dbType),
|
|
838
|
+
logType: normalizeLogType(options.logType)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
if (error) throw mapApiError(response.status, error as ApiErrorBody)
|
|
844
|
+
|
|
845
|
+
ctx.spinner.stop()
|
|
846
|
+
|
|
847
|
+
if (options.output === 'json') {
|
|
848
|
+
outputJson(data)
|
|
849
|
+
return
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (data.data.length === 0) {
|
|
853
|
+
console.log('No log files found.')
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const rows: string[][] = [[
|
|
858
|
+
chalk.bold('Name'),
|
|
859
|
+
chalk.bold('Path'),
|
|
860
|
+
chalk.bold('Kind'),
|
|
861
|
+
chalk.bold('Size'),
|
|
862
|
+
chalk.bold('Updated'),
|
|
863
|
+
chalk.bold('Processed')
|
|
864
|
+
]]
|
|
865
|
+
|
|
866
|
+
for (const file of data.data) {
|
|
867
|
+
rows.push([
|
|
868
|
+
formatValue(file.name),
|
|
869
|
+
formatValue(file.path),
|
|
870
|
+
formatValue(file.kind),
|
|
871
|
+
formatValue(file.size),
|
|
872
|
+
formatValue(file.updateTime),
|
|
873
|
+
formatValue(file.processed)
|
|
874
|
+
])
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
outputTable(rows)
|
|
878
|
+
}))
|
|
879
|
+
|
|
880
|
+
return dbCmd
|
|
881
|
+
}
|