slipway-cli 0.0.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/README.md +68 -0
- package/package.json +29 -0
- package/src/commands/db-create.js +55 -0
- package/src/commands/db-url.js +44 -0
- package/src/commands/deployments.js +72 -0
- package/src/commands/env-list.js +53 -0
- package/src/commands/env-set.js +63 -0
- package/src/commands/env-unset.js +62 -0
- package/src/commands/env.js +2 -0
- package/src/commands/environment-create.js +38 -0
- package/src/commands/environment-update.js +43 -0
- package/src/commands/environments.js +47 -0
- package/src/commands/init.js +94 -0
- package/src/commands/link.js +43 -0
- package/src/commands/login.js +205 -0
- package/src/commands/logout.js +12 -0
- package/src/commands/logs.js +88 -0
- package/src/commands/project-update.js +40 -0
- package/src/commands/projects.js +42 -0
- package/src/commands/run.js +46 -0
- package/src/commands/services.js +68 -0
- package/src/commands/slide.js +310 -0
- package/src/commands/terminal.js +46 -0
- package/src/commands/whoami.js +25 -0
- package/src/index.js +313 -0
- package/src/lib/api.js +136 -0
- package/src/lib/colors.js +30 -0
- package/src/lib/config.js +72 -0
- package/src/lib/prompt.js +102 -0
- package/src/lib/sse.js +102 -0
- package/src/lib/utils.js +122 -0
package/src/lib/api.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { getCredentials, isLoggedIn } from './config.js'
|
|
2
|
+
|
|
3
|
+
export class APIError extends Error {
|
|
4
|
+
constructor(message, statusCode, body) {
|
|
5
|
+
super(message)
|
|
6
|
+
this.name = 'APIError'
|
|
7
|
+
this.statusCode = statusCode
|
|
8
|
+
this.body = body
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function apiRequest(method, path, options = {}) {
|
|
13
|
+
if (!isLoggedIn()) {
|
|
14
|
+
throw new Error('Not logged in. Run `slipway login` first.')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { server, token } = getCredentials()
|
|
18
|
+
const url = `${server}/api/v1${path}`
|
|
19
|
+
|
|
20
|
+
const headers = {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
Authorization: `Bearer ${token}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const fetchOptions = {
|
|
26
|
+
method,
|
|
27
|
+
headers
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (options.body) {
|
|
31
|
+
fetchOptions.body = JSON.stringify(options.body)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(url, fetchOptions)
|
|
36
|
+
const body = await response.json()
|
|
37
|
+
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
const message = body.message || body.error || `Request failed with status ${response.status}`
|
|
40
|
+
throw new APIError(message, response.status, body)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return body
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error instanceof APIError) {
|
|
46
|
+
throw error
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Failed to connect to Slipway server: ${error.message}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Upload a file as multipart form data.
|
|
54
|
+
* Used by `slipway slide` to push source tarballs.
|
|
55
|
+
*/
|
|
56
|
+
async function apiUpload(path, fieldName, buffer, filename) {
|
|
57
|
+
if (!isLoggedIn()) {
|
|
58
|
+
throw new Error('Not logged in. Run `slipway login` first.')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { server, token } = getCredentials()
|
|
62
|
+
const url = `${server}/api/v1${path}`
|
|
63
|
+
|
|
64
|
+
const formData = new FormData()
|
|
65
|
+
formData.append(fieldName, new Blob([buffer]), filename)
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch(url, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: `Bearer ${token}`
|
|
72
|
+
},
|
|
73
|
+
body: formData
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const body = await response.json()
|
|
77
|
+
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
const message = body.message || body.error || `Upload failed with status ${response.status}`
|
|
80
|
+
throw new APIError(message, response.status, body)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return body
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof APIError) {
|
|
86
|
+
throw error
|
|
87
|
+
}
|
|
88
|
+
throw new Error(`Failed to upload to Slipway server: ${error.message}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Convenience methods
|
|
93
|
+
export const api = {
|
|
94
|
+
get: (path) => apiRequest('GET', path),
|
|
95
|
+
post: (path, body) => apiRequest('POST', path, { body }),
|
|
96
|
+
patch: (path, body) => apiRequest('PATCH', path, { body }),
|
|
97
|
+
delete: (path) => apiRequest('DELETE', path),
|
|
98
|
+
upload: apiUpload
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Project endpoints
|
|
102
|
+
api.projects = {
|
|
103
|
+
list: () => api.get('/projects'),
|
|
104
|
+
create: (data) => api.post('/projects', data),
|
|
105
|
+
get: (id) => api.get(`/projects/${id}`),
|
|
106
|
+
update: (id, data) => api.patch(`/projects/${id}`, data),
|
|
107
|
+
delete: (id) => api.delete(`/projects/${id}`),
|
|
108
|
+
push: (id, tarballBuffer) => api.upload(`/projects/${id}/push`, 'source', tarballBuffer, 'source.tar.gz')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Environment endpoints
|
|
112
|
+
api.environments = {
|
|
113
|
+
list: (projectId) => api.get(`/projects/${projectId}/environments`),
|
|
114
|
+
create: (projectId, data) => api.post(`/projects/${projectId}/environments`, data),
|
|
115
|
+
get: (projectId, id) => api.get(`/projects/${projectId}/environments/${id}`),
|
|
116
|
+
update: (projectId, id, data) => api.patch(`/projects/${projectId}/environments/${id}`, data),
|
|
117
|
+
delete: (projectId, id) => api.delete(`/projects/${projectId}/environments/${id}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Deployment endpoints
|
|
121
|
+
api.deployments = {
|
|
122
|
+
trigger: (projectId, environmentId, data) =>
|
|
123
|
+
api.post(`/projects/${projectId}/environments/${environmentId}/deploy`, data),
|
|
124
|
+
status: (id) => api.get(`/deployments/${id}`),
|
|
125
|
+
logs: (id, type = 'all') => api.get(`/deployments/${id}/logs?type=${type}`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Service endpoints
|
|
129
|
+
api.services = {
|
|
130
|
+
list: (projectId, environmentId) =>
|
|
131
|
+
api.get(`/projects/${projectId}/environments/${environmentId}/services`),
|
|
132
|
+
create: (projectId, environmentId, data) =>
|
|
133
|
+
api.post(`/projects/${projectId}/environments/${environmentId}/services`, data),
|
|
134
|
+
get: (id) => api.get(`/services/${id}`),
|
|
135
|
+
delete: (id) => api.delete(`/services/${id}`)
|
|
136
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// ANSI color codes
|
|
2
|
+
const codes = {
|
|
3
|
+
reset: '\x1b[0m',
|
|
4
|
+
bold: '\x1b[1m',
|
|
5
|
+
dim: '\x1b[2m',
|
|
6
|
+
red: '\x1b[31m',
|
|
7
|
+
green: '\x1b[32m',
|
|
8
|
+
yellow: '\x1b[33m',
|
|
9
|
+
blue: '\x1b[34m',
|
|
10
|
+
cyan: '\x1b[36m',
|
|
11
|
+
white: '\x1b[37m',
|
|
12
|
+
gray: '\x1b[90m',
|
|
13
|
+
// Slipway brand color (teal-ish)
|
|
14
|
+
slipway: '\x1b[38;2;20;184;166m',
|
|
15
|
+
bgSlipway: '\x1b[48;2;20;184;166m'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const c = {
|
|
19
|
+
error: (s) => `${codes.red}${s}${codes.reset}`,
|
|
20
|
+
success: (s) => `${codes.green}${s}${codes.reset}`,
|
|
21
|
+
warn: (s) => `${codes.yellow}${s}${codes.reset}`,
|
|
22
|
+
info: (s) => `${codes.cyan}${s}${codes.reset}`,
|
|
23
|
+
dim: (s) => `${codes.dim}${s}${codes.reset}`,
|
|
24
|
+
bold: (s) => `${codes.bold}${s}${codes.reset}`,
|
|
25
|
+
gray: (s) => `${codes.gray}${s}${codes.reset}`,
|
|
26
|
+
highlight: (s) => `${codes.slipway}${s}${codes.reset}`,
|
|
27
|
+
brand: (s) => `${codes.bgSlipway}${codes.white}${codes.bold} ${s} ${codes.reset}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default c
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
// Global config stored in ~/.slipway/config.json
|
|
6
|
+
const CONFIG_DIR = join(homedir(), '.slipway')
|
|
7
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
|
|
8
|
+
|
|
9
|
+
// Project config stored in .slipway.json in the project directory
|
|
10
|
+
export const PROJECT_CONFIG_FILE = '.slipway.json'
|
|
11
|
+
|
|
12
|
+
function ensureConfigDir() {
|
|
13
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
14
|
+
mkdirSync(CONFIG_DIR, { recursive: true })
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readConfig() {
|
|
19
|
+
ensureConfigDir()
|
|
20
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
21
|
+
return { server: '', token: '', user: null, team: null }
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
|
|
25
|
+
} catch {
|
|
26
|
+
return { server: '', token: '', user: null, team: null }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeConfig(config) {
|
|
31
|
+
ensureConfigDir()
|
|
32
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getProjectConfig() {
|
|
36
|
+
const configPath = join(process.cwd(), PROJECT_CONFIG_FILE)
|
|
37
|
+
if (!existsSync(configPath)) {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readFileSync(configPath, 'utf8'))
|
|
42
|
+
} catch {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function saveProjectConfig(projectConfig) {
|
|
48
|
+
const configPath = join(process.cwd(), PROJECT_CONFIG_FILE)
|
|
49
|
+
writeFileSync(configPath, JSON.stringify(projectConfig, null, 2) + '\n')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isLoggedIn() {
|
|
53
|
+
const config = readConfig()
|
|
54
|
+
return Boolean(config.token && config.server)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getCredentials() {
|
|
58
|
+
return readConfig()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function setCredentials({ server, token, user, team }) {
|
|
62
|
+
const config = readConfig()
|
|
63
|
+
if (server) config.server = server
|
|
64
|
+
if (token) config.token = token
|
|
65
|
+
if (user) config.user = user
|
|
66
|
+
if (team) config.team = team
|
|
67
|
+
writeConfig(config)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function clearCredentials() {
|
|
71
|
+
writeConfig({ server: '', token: '', user: null, team: null })
|
|
72
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline'
|
|
2
|
+
|
|
3
|
+
export function prompt(question, defaultValue = '') {
|
|
4
|
+
const rl = createInterface({
|
|
5
|
+
input: process.stdin,
|
|
6
|
+
output: process.stdout
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
const suffix = defaultValue ? ` (${defaultValue})` : ''
|
|
10
|
+
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
rl.question(`${question}${suffix}: `, (answer) => {
|
|
13
|
+
rl.close()
|
|
14
|
+
resolve(answer.trim() || defaultValue)
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function promptPassword(question) {
|
|
20
|
+
const rl = createInterface({
|
|
21
|
+
input: process.stdin,
|
|
22
|
+
output: process.stdout
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
// Hide input for password
|
|
27
|
+
process.stdout.write(`${question}: `)
|
|
28
|
+
|
|
29
|
+
const stdin = process.stdin
|
|
30
|
+
const wasRaw = stdin.isRaw
|
|
31
|
+
|
|
32
|
+
if (stdin.isTTY) {
|
|
33
|
+
stdin.setRawMode(true)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let password = ''
|
|
37
|
+
|
|
38
|
+
const onData = (char) => {
|
|
39
|
+
char = char.toString()
|
|
40
|
+
|
|
41
|
+
switch (char) {
|
|
42
|
+
case '\n':
|
|
43
|
+
case '\r':
|
|
44
|
+
case '\u0004': // Ctrl+D
|
|
45
|
+
if (stdin.isTTY) {
|
|
46
|
+
stdin.setRawMode(wasRaw)
|
|
47
|
+
}
|
|
48
|
+
stdin.removeListener('data', onData)
|
|
49
|
+
rl.close()
|
|
50
|
+
process.stdout.write('\n')
|
|
51
|
+
resolve(password)
|
|
52
|
+
break
|
|
53
|
+
case '\u0003': // Ctrl+C
|
|
54
|
+
process.exit(1)
|
|
55
|
+
break
|
|
56
|
+
case '\u007F': // Backspace
|
|
57
|
+
password = password.slice(0, -1)
|
|
58
|
+
break
|
|
59
|
+
default:
|
|
60
|
+
password += char
|
|
61
|
+
break
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
stdin.on('data', onData)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function confirm(question, defaultValue = true) {
|
|
70
|
+
const rl = createInterface({
|
|
71
|
+
input: process.stdin,
|
|
72
|
+
output: process.stdout
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const suffix = defaultValue ? ' (Y/n)' : ' (y/N)'
|
|
76
|
+
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
rl.question(`${question}${suffix}: `, (answer) => {
|
|
79
|
+
rl.close()
|
|
80
|
+
const normalized = answer.trim().toLowerCase()
|
|
81
|
+
if (normalized === '') {
|
|
82
|
+
resolve(defaultValue)
|
|
83
|
+
} else {
|
|
84
|
+
resolve(normalized === 'y' || normalized === 'yes')
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function waitForEnter(message = 'Press ENTER to continue...') {
|
|
91
|
+
const rl = createInterface({
|
|
92
|
+
input: process.stdin,
|
|
93
|
+
output: process.stdout
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
rl.question(message, () => {
|
|
98
|
+
rl.close()
|
|
99
|
+
resolve()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
}
|
package/src/lib/sse.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { getCredentials } from './config.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Connect to a Server-Sent Events endpoint
|
|
5
|
+
*
|
|
6
|
+
* @param {string} path - API path (e.g., '/cli/auth/stream')
|
|
7
|
+
* @param {object} options - Options
|
|
8
|
+
* @param {function} options.onMessage - Called for each message
|
|
9
|
+
* @param {function} options.onError - Called on error
|
|
10
|
+
* @param {function} options.onClose - Called when stream closes
|
|
11
|
+
* @returns {function} - Call to abort the connection
|
|
12
|
+
*/
|
|
13
|
+
export function connectSSE(path, { onMessage, onError, onClose }) {
|
|
14
|
+
const { server, token } = getCredentials()
|
|
15
|
+
const url = `${server}/api/v1${path}`
|
|
16
|
+
|
|
17
|
+
const controller = new AbortController()
|
|
18
|
+
|
|
19
|
+
;(async () => {
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(url, {
|
|
22
|
+
headers: {
|
|
23
|
+
Accept: 'text/event-stream',
|
|
24
|
+
Authorization: token ? `Bearer ${token}` : undefined
|
|
25
|
+
},
|
|
26
|
+
signal: controller.signal
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
throw new Error(`SSE connection failed: ${response.status}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const reader = response.body.getReader()
|
|
34
|
+
const decoder = new TextDecoder()
|
|
35
|
+
let buffer = ''
|
|
36
|
+
|
|
37
|
+
while (true) {
|
|
38
|
+
const { done, value } = await reader.read()
|
|
39
|
+
|
|
40
|
+
if (done) {
|
|
41
|
+
if (onClose) onClose()
|
|
42
|
+
break
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
buffer += decoder.decode(value, { stream: true })
|
|
46
|
+
|
|
47
|
+
// Parse SSE format: "event: type\ndata: json\n\n"
|
|
48
|
+
const lines = buffer.split('\n')
|
|
49
|
+
buffer = ''
|
|
50
|
+
|
|
51
|
+
let eventType = 'message'
|
|
52
|
+
let eventData = null
|
|
53
|
+
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
if (line.startsWith('event:')) {
|
|
56
|
+
eventType = line.slice(6).trim()
|
|
57
|
+
} else if (line.startsWith('data:')) {
|
|
58
|
+
const dataStr = line.slice(5).trim()
|
|
59
|
+
try {
|
|
60
|
+
eventData = JSON.parse(dataStr)
|
|
61
|
+
} catch {
|
|
62
|
+
eventData = dataStr
|
|
63
|
+
}
|
|
64
|
+
} else if (line === '' && eventData !== null) {
|
|
65
|
+
// Empty line means end of event
|
|
66
|
+
if (onMessage) {
|
|
67
|
+
onMessage(eventType, eventData)
|
|
68
|
+
}
|
|
69
|
+
eventType = 'message'
|
|
70
|
+
eventData = null
|
|
71
|
+
} else if (line !== '') {
|
|
72
|
+
// Incomplete event, save for next chunk
|
|
73
|
+
buffer = line
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err.name === 'AbortError') {
|
|
79
|
+
if (onClose) onClose()
|
|
80
|
+
} else {
|
|
81
|
+
if (onError) onError(err)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
})()
|
|
85
|
+
|
|
86
|
+
// Return abort function
|
|
87
|
+
return () => controller.abort()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Subscribe to deployment status updates
|
|
92
|
+
*/
|
|
93
|
+
export function subscribeToDeployment(deploymentId, callbacks) {
|
|
94
|
+
return connectSSE(`/deployments/${deploymentId}/stream`, callbacks)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Subscribe to CLI auth confirmation
|
|
99
|
+
*/
|
|
100
|
+
export function subscribeToAuthConfirmation(code, callbacks) {
|
|
101
|
+
return connectSSE(`/cli/auth/stream?code=${code}`, callbacks)
|
|
102
|
+
}
|
package/src/lib/utils.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { c } from './colors.js'
|
|
2
|
+
import { getProjectConfig } from './config.js'
|
|
3
|
+
|
|
4
|
+
export function error(message) {
|
|
5
|
+
console.error(`${c.error('Error:')} ${message}`)
|
|
6
|
+
process.exit(1)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function warn(message) {
|
|
10
|
+
console.warn(`${c.warn('Warning:')} ${message}`)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function success(message) {
|
|
14
|
+
console.log(`${c.success('✓')} ${message}`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function info(message) {
|
|
18
|
+
console.log(`${c.info('ℹ')} ${message}`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function requireProject() {
|
|
22
|
+
const project = getProjectConfig()
|
|
23
|
+
if (!project) {
|
|
24
|
+
error('No Slipway project found. Run `slipway init` or `slipway link <project>` first.')
|
|
25
|
+
}
|
|
26
|
+
return project
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatDate(timestamp) {
|
|
30
|
+
if (!timestamp) return 'N/A'
|
|
31
|
+
const date = new Date(timestamp)
|
|
32
|
+
return date.toLocaleString()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatDuration(seconds) {
|
|
36
|
+
if (!seconds) return 'N/A'
|
|
37
|
+
if (seconds < 60) return `${seconds}s`
|
|
38
|
+
const minutes = Math.floor(seconds / 60)
|
|
39
|
+
const secs = seconds % 60
|
|
40
|
+
return `${minutes}m ${secs}s`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function statusColor(status) {
|
|
44
|
+
const colorMap = {
|
|
45
|
+
running: c.success,
|
|
46
|
+
pending: c.warn,
|
|
47
|
+
building: c.info,
|
|
48
|
+
deploying: c.info,
|
|
49
|
+
stopped: c.gray,
|
|
50
|
+
failed: c.error,
|
|
51
|
+
cancelled: c.gray
|
|
52
|
+
}
|
|
53
|
+
const colorFn = colorMap[status] || ((s) => s)
|
|
54
|
+
return colorFn(status)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Simple spinner using stdout
|
|
58
|
+
export function createSpinner(text) {
|
|
59
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
60
|
+
let i = 0
|
|
61
|
+
let interval = null
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
start() {
|
|
65
|
+
process.stdout.write(`\r${c.info(frames[0])} ${text}`)
|
|
66
|
+
interval = setInterval(() => {
|
|
67
|
+
i = (i + 1) % frames.length
|
|
68
|
+
process.stdout.write(`\r${c.info(frames[i])} ${text}`)
|
|
69
|
+
}, 80)
|
|
70
|
+
return this
|
|
71
|
+
},
|
|
72
|
+
stop(finalText) {
|
|
73
|
+
if (interval) {
|
|
74
|
+
clearInterval(interval)
|
|
75
|
+
interval = null
|
|
76
|
+
}
|
|
77
|
+
process.stdout.write('\r' + ' '.repeat(text.length + 4) + '\r')
|
|
78
|
+
if (finalText) console.log(finalText)
|
|
79
|
+
return this
|
|
80
|
+
},
|
|
81
|
+
succeed(msg) {
|
|
82
|
+
return this.stop(`${c.success('✓')} ${msg}`)
|
|
83
|
+
},
|
|
84
|
+
fail(msg) {
|
|
85
|
+
return this.stop(`${c.error('✗')} ${msg}`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Alias for backwards compatibility
|
|
91
|
+
export function spinner(text) {
|
|
92
|
+
return createSpinner(text).start()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Simple table display
|
|
96
|
+
export function table(headers, rows) {
|
|
97
|
+
// Calculate column widths
|
|
98
|
+
const widths = headers.map((h, i) => {
|
|
99
|
+
const colValues = [h, ...rows.map(r => String(r[i] || ''))]
|
|
100
|
+
return Math.max(...colValues.map(v => stripAnsi(v).length))
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Print header
|
|
104
|
+
const headerRow = headers.map((h, i) => h.padEnd(widths[i])).join(' ')
|
|
105
|
+
console.log(` ${c.dim(headerRow)}`)
|
|
106
|
+
console.log(` ${c.dim('─'.repeat(headerRow.length))}`)
|
|
107
|
+
|
|
108
|
+
// Print rows
|
|
109
|
+
for (const row of rows) {
|
|
110
|
+
const rowStr = row.map((cell, i) => {
|
|
111
|
+
const str = String(cell || '')
|
|
112
|
+
const padding = widths[i] - stripAnsi(str).length
|
|
113
|
+
return str + ' '.repeat(Math.max(0, padding))
|
|
114
|
+
}).join(' ')
|
|
115
|
+
console.log(` ${rowStr}`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Strip ANSI codes for length calculation
|
|
120
|
+
function stripAnsi(str) {
|
|
121
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '')
|
|
122
|
+
}
|