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/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
+ }
@@ -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
+ }