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
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { c } from '../lib/colors.js'
|
|
3
|
+
import { api } from '../lib/api.js'
|
|
4
|
+
import { isLoggedIn, getCredentials } from '../lib/config.js'
|
|
5
|
+
import { error, requireProject, createSpinner } from '../lib/utils.js'
|
|
6
|
+
|
|
7
|
+
export default async function slide(options) {
|
|
8
|
+
if (!isLoggedIn()) {
|
|
9
|
+
error('Not logged in. Run `slipway login` first.')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const project = requireProject()
|
|
13
|
+
const environment = options.env || 'production'
|
|
14
|
+
|
|
15
|
+
console.log()
|
|
16
|
+
console.log(` ${c.bold(c.highlight('Sliding'))} ${project.project} ${c.dim('into')} ${environment}`)
|
|
17
|
+
console.log()
|
|
18
|
+
|
|
19
|
+
// 1. Package source code
|
|
20
|
+
const spin = createSpinner('Packaging source...').start()
|
|
21
|
+
|
|
22
|
+
let tarballBuffer
|
|
23
|
+
try {
|
|
24
|
+
tarballBuffer = createTarball()
|
|
25
|
+
const sizeMB = (tarballBuffer.length / 1024 / 1024).toFixed(1)
|
|
26
|
+
spin.succeed(`Source packaged (${sizeMB} MB)`)
|
|
27
|
+
} catch (err) {
|
|
28
|
+
spin.fail('Failed to package source')
|
|
29
|
+
error(err.message)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 2. Upload source to server
|
|
33
|
+
const pushSpin = createSpinner('Pushing source...').start()
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
await api.projects.push(project.project, tarballBuffer)
|
|
37
|
+
pushSpin.succeed('Source pushed')
|
|
38
|
+
} catch (err) {
|
|
39
|
+
pushSpin.fail('Failed to push source')
|
|
40
|
+
error(err.message)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 3. Trigger deployment
|
|
44
|
+
const deploySpin = createSpinner('Starting deployment...').start()
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const { deployment } = await api.deployments.trigger(
|
|
48
|
+
project.project,
|
|
49
|
+
environment,
|
|
50
|
+
{
|
|
51
|
+
message: options.message,
|
|
52
|
+
...getGitInfo()
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
deploySpin.succeed('Deployment started')
|
|
57
|
+
console.log()
|
|
58
|
+
console.log(` ${c.dim('Deployment ID:')} ${deployment.id.substring(0, 8)}`)
|
|
59
|
+
console.log()
|
|
60
|
+
|
|
61
|
+
// 4. Watch deployment status via SSE (with polling fallback)
|
|
62
|
+
const result = await watchDeployment(deployment.id)
|
|
63
|
+
|
|
64
|
+
if (result.status === 'running') {
|
|
65
|
+
console.log(` ${c.success('✓')} Deployment successful`)
|
|
66
|
+
console.log()
|
|
67
|
+
|
|
68
|
+
// Get environment info to show URL
|
|
69
|
+
try {
|
|
70
|
+
const { environment: env } = await api.environments.get(project.project, environment)
|
|
71
|
+
if (env.url) {
|
|
72
|
+
console.log(` ${c.dim('URL:')} ${c.highlight(env.url)}`)
|
|
73
|
+
console.log()
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Ignore errors fetching URL
|
|
77
|
+
}
|
|
78
|
+
} else if (result.status === 'failed') {
|
|
79
|
+
console.log(` ${c.error('✗')} Deployment failed`)
|
|
80
|
+
console.log()
|
|
81
|
+
console.log(` ${c.dim('Run')} ${c.highlight(`slipway logs -d ${deployment.id.substring(0, 8)}`)} ${c.dim('to view logs.')}`)
|
|
82
|
+
console.log()
|
|
83
|
+
process.exit(1)
|
|
84
|
+
} else {
|
|
85
|
+
console.log(` ${c.warn('!')} Deployment ended with status: ${result.status}`)
|
|
86
|
+
console.log()
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
deploySpin.fail('Deployment failed')
|
|
90
|
+
error(err.message)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a tarball of the current directory.
|
|
96
|
+
* Uses `git archive` if in a git repo (respects .gitignore automatically),
|
|
97
|
+
* otherwise falls back to `tar` with sensible exclusions.
|
|
98
|
+
*/
|
|
99
|
+
function createTarball() {
|
|
100
|
+
const cwd = process.cwd()
|
|
101
|
+
|
|
102
|
+
// Check if we're in a git repo
|
|
103
|
+
if (isGitRepo(cwd)) {
|
|
104
|
+
// git archive creates a clean tar from the current HEAD,
|
|
105
|
+
// excluding .git/ and respecting .gitignore
|
|
106
|
+
return execFileSync('git', ['archive', '--format=tar.gz', 'HEAD'], {
|
|
107
|
+
cwd,
|
|
108
|
+
maxBuffer: 500 * 1024 * 1024 // 500MB
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Fallback: tar with exclusions
|
|
113
|
+
const excludes = [
|
|
114
|
+
'node_modules',
|
|
115
|
+
'.git',
|
|
116
|
+
'.env',
|
|
117
|
+
'.DS_Store',
|
|
118
|
+
'*.log'
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
const args = ['czf', '-', ...excludes.flatMap(e => ['--exclude', e]), '.']
|
|
122
|
+
|
|
123
|
+
return execFileSync('tar', args, {
|
|
124
|
+
cwd,
|
|
125
|
+
maxBuffer: 500 * 1024 * 1024
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check if a directory is inside a git repository.
|
|
131
|
+
*/
|
|
132
|
+
function isGitRepo(dir) {
|
|
133
|
+
try {
|
|
134
|
+
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
135
|
+
cwd: dir,
|
|
136
|
+
stdio: 'pipe'
|
|
137
|
+
})
|
|
138
|
+
return true
|
|
139
|
+
} catch {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Extract git metadata (commit, branch, message) if available.
|
|
146
|
+
*/
|
|
147
|
+
function getGitInfo() {
|
|
148
|
+
try {
|
|
149
|
+
const cwd = process.cwd()
|
|
150
|
+
const gitCommit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, stdio: 'pipe' })
|
|
151
|
+
.toString().trim()
|
|
152
|
+
const gitBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, stdio: 'pipe' })
|
|
153
|
+
.toString().trim()
|
|
154
|
+
const gitMessage = execFileSync('git', ['log', '-1', '--pretty=%s'], { cwd, stdio: 'pipe' })
|
|
155
|
+
.toString().trim()
|
|
156
|
+
return { gitCommit, gitBranch, gitMessage }
|
|
157
|
+
} catch {
|
|
158
|
+
return {}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Watch deployment status using SSE with polling fallback
|
|
164
|
+
*/
|
|
165
|
+
async function watchDeployment(deploymentId) {
|
|
166
|
+
// Try SSE first
|
|
167
|
+
try {
|
|
168
|
+
return await watchDeploymentSSE(deploymentId)
|
|
169
|
+
} catch {
|
|
170
|
+
// Fall back to polling
|
|
171
|
+
return await watchDeploymentPolling(deploymentId)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Watch deployment via Server-Sent Events
|
|
177
|
+
*/
|
|
178
|
+
function watchDeploymentSSE(deploymentId) {
|
|
179
|
+
const { server, token } = getCredentials()
|
|
180
|
+
|
|
181
|
+
return new Promise((resolve, reject) => {
|
|
182
|
+
const controller = new AbortController()
|
|
183
|
+
let currentSpin = null
|
|
184
|
+
let lastStatus = ''
|
|
185
|
+
|
|
186
|
+
const timeout = setTimeout(() => {
|
|
187
|
+
controller.abort()
|
|
188
|
+
if (currentSpin) currentSpin.stop()
|
|
189
|
+
reject(new Error('timeout'))
|
|
190
|
+
}, 10 * 60 * 1000) // 10 minute timeout
|
|
191
|
+
|
|
192
|
+
fetch(`${server}/api/v1/deployments/${deploymentId}/stream`, {
|
|
193
|
+
headers: {
|
|
194
|
+
Accept: 'text/event-stream',
|
|
195
|
+
Authorization: `Bearer ${token}`
|
|
196
|
+
},
|
|
197
|
+
signal: controller.signal
|
|
198
|
+
})
|
|
199
|
+
.then(async (response) => {
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
throw new Error('SSE not available')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const reader = response.body.getReader()
|
|
205
|
+
const decoder = new TextDecoder()
|
|
206
|
+
let buffer = ''
|
|
207
|
+
|
|
208
|
+
currentSpin = createSpinner('Building...').start()
|
|
209
|
+
|
|
210
|
+
while (true) {
|
|
211
|
+
const { done, value } = await reader.read()
|
|
212
|
+
if (done) break
|
|
213
|
+
|
|
214
|
+
buffer += decoder.decode(value, { stream: true })
|
|
215
|
+
|
|
216
|
+
// Parse SSE events
|
|
217
|
+
const events = buffer.split('\n\n')
|
|
218
|
+
buffer = events.pop() || ''
|
|
219
|
+
|
|
220
|
+
for (const event of events) {
|
|
221
|
+
const dataMatch = event.match(/data:\s*(.+)/)
|
|
222
|
+
if (dataMatch) {
|
|
223
|
+
try {
|
|
224
|
+
const data = JSON.parse(dataMatch[1])
|
|
225
|
+
|
|
226
|
+
// Update spinner based on status
|
|
227
|
+
if (data.status !== lastStatus) {
|
|
228
|
+
lastStatus = data.status
|
|
229
|
+
if (currentSpin) currentSpin.stop()
|
|
230
|
+
|
|
231
|
+
if (data.status === 'building') {
|
|
232
|
+
currentSpin = createSpinner('Building...').start()
|
|
233
|
+
} else if (data.status === 'deploying') {
|
|
234
|
+
currentSpin = createSpinner('Deploying...').start()
|
|
235
|
+
} else if (data.status === 'running' || data.status === 'failed' || data.status === 'cancelled') {
|
|
236
|
+
clearTimeout(timeout)
|
|
237
|
+
controller.abort()
|
|
238
|
+
resolve({ status: data.status })
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Show build output if available
|
|
244
|
+
if (data.output) {
|
|
245
|
+
if (currentSpin) currentSpin.stop()
|
|
246
|
+
console.log(` ${c.dim(data.output)}`)
|
|
247
|
+
if (lastStatus === 'building' || lastStatus === 'deploying') {
|
|
248
|
+
currentSpin = createSpinner(lastStatus === 'building' ? 'Building...' : 'Deploying...').start()
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
// Invalid JSON, ignore
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
.catch((err) => {
|
|
259
|
+
clearTimeout(timeout)
|
|
260
|
+
if (currentSpin) currentSpin.stop()
|
|
261
|
+
if (err.name !== 'AbortError') {
|
|
262
|
+
reject(err)
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Watch deployment via polling (fallback)
|
|
270
|
+
*/
|
|
271
|
+
async function watchDeploymentPolling(deploymentId) {
|
|
272
|
+
const spin = createSpinner('Building...').start()
|
|
273
|
+
let lastStatus = ''
|
|
274
|
+
|
|
275
|
+
const maxAttempts = 300 // 10 minutes at 2s intervals
|
|
276
|
+
let attempts = 0
|
|
277
|
+
|
|
278
|
+
while (attempts < maxAttempts) {
|
|
279
|
+
await sleep(2000)
|
|
280
|
+
attempts++
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const result = await api.deployments.status(deploymentId)
|
|
284
|
+
const status = result.deployment.status
|
|
285
|
+
|
|
286
|
+
// Update spinner text based on status
|
|
287
|
+
if (status !== lastStatus) {
|
|
288
|
+
lastStatus = status
|
|
289
|
+
spin.stop()
|
|
290
|
+
|
|
291
|
+
if (status === 'building') {
|
|
292
|
+
createSpinner('Building...').start()
|
|
293
|
+
} else if (status === 'deploying') {
|
|
294
|
+
createSpinner('Deploying...').start()
|
|
295
|
+
} else if (status === 'running' || status === 'failed' || status === 'cancelled') {
|
|
296
|
+
return { status }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
// Continue polling on error
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
spin.stop()
|
|
305
|
+
return { status: 'timeout' }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function sleep(ms) {
|
|
309
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
310
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { c } from '../lib/colors.js'
|
|
2
|
+
import { api } from '../lib/api.js'
|
|
3
|
+
import { isLoggedIn } from '../lib/config.js'
|
|
4
|
+
import { error, requireProject } from '../lib/utils.js'
|
|
5
|
+
|
|
6
|
+
export default async function terminal(options) {
|
|
7
|
+
if (!isLoggedIn()) {
|
|
8
|
+
error('Not logged in. Run `slipway login` first.')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const project = requireProject()
|
|
12
|
+
const environment = options.env || 'production'
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
// Get environment to find container name
|
|
16
|
+
const { environment: env } = await api.environments.get(project.project, environment)
|
|
17
|
+
|
|
18
|
+
if (!env.app || env.app.length === 0) {
|
|
19
|
+
error('No app deployed in this environment. Run `slipway deploy` first.')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const app = env.app[0]
|
|
23
|
+
if (app.status !== 'running') {
|
|
24
|
+
error(`App is not running (status: ${app.status})`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log()
|
|
28
|
+
console.log(` ${c.bold(c.highlight('Terminal Access'))}`)
|
|
29
|
+
console.log(` ${c.dim(`${project.project} / ${environment}`)}`)
|
|
30
|
+
console.log()
|
|
31
|
+
console.log(` ${c.dim('Container:')} ${app.containerName}`)
|
|
32
|
+
console.log()
|
|
33
|
+
console.log(` ${c.dim('To open a shell, run:')}`)
|
|
34
|
+
console.log(` docker exec -it ${app.containerName} /bin/sh`)
|
|
35
|
+
console.log()
|
|
36
|
+
console.log(` ${c.dim('Or for bash (if available):')}`)
|
|
37
|
+
console.log(` docker exec -it ${app.containerName} /bin/bash`)
|
|
38
|
+
console.log()
|
|
39
|
+
|
|
40
|
+
// TODO: In a production implementation, we could:
|
|
41
|
+
// 1. Use a WebSocket to proxy the terminal session
|
|
42
|
+
// 2. Or spawn docker exec directly with inherited stdio
|
|
43
|
+
} catch (err) {
|
|
44
|
+
error(err.message)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { c } from '../lib/colors.js'
|
|
2
|
+
import { getCredentials, isLoggedIn } from '../lib/config.js'
|
|
3
|
+
import { error } from '../lib/utils.js'
|
|
4
|
+
|
|
5
|
+
export default async function whoami() {
|
|
6
|
+
if (!isLoggedIn()) {
|
|
7
|
+
error('Not logged in. Run `slipway login` first.')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { server, user, team } = getCredentials()
|
|
11
|
+
|
|
12
|
+
console.log()
|
|
13
|
+
if (user) {
|
|
14
|
+
console.log(` ${c.dim('Email:')} ${user.email}`)
|
|
15
|
+
console.log(` ${c.dim('Name:')} ${user.fullName}`)
|
|
16
|
+
if (team) {
|
|
17
|
+
console.log(` ${c.dim('Team:')} ${team.name}`)
|
|
18
|
+
}
|
|
19
|
+
if (user.teamRole) {
|
|
20
|
+
console.log(` ${c.dim('Role:')} ${user.teamRole}`)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
console.log(` ${c.dim('Server:')} ${server}`)
|
|
24
|
+
console.log()
|
|
25
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
import { c } from './lib/colors.js'
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'))
|
|
11
|
+
|
|
12
|
+
// Command aliases (alias → primary command)
|
|
13
|
+
const aliases = {
|
|
14
|
+
deploy: 'slide',
|
|
15
|
+
launch: 'slide'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const commands = {
|
|
19
|
+
// Auth commands
|
|
20
|
+
login: {
|
|
21
|
+
description: 'Authenticate with your Slipway server',
|
|
22
|
+
options: { server: { type: 'string', short: 's' } }
|
|
23
|
+
},
|
|
24
|
+
logout: {
|
|
25
|
+
description: 'Clear stored credentials',
|
|
26
|
+
options: {}
|
|
27
|
+
},
|
|
28
|
+
whoami: {
|
|
29
|
+
description: 'Show current authenticated user',
|
|
30
|
+
options: {}
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// Project commands
|
|
34
|
+
projects: {
|
|
35
|
+
description: 'List all projects',
|
|
36
|
+
options: {}
|
|
37
|
+
},
|
|
38
|
+
'project:update': {
|
|
39
|
+
description: 'Update a project',
|
|
40
|
+
args: '<slug>',
|
|
41
|
+
options: {
|
|
42
|
+
name: { type: 'string', short: 'n' },
|
|
43
|
+
description: { type: 'string', short: 'd' },
|
|
44
|
+
repo: { type: 'string', short: 'r' }
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
init: {
|
|
48
|
+
description: 'Initialize a new Slipway project',
|
|
49
|
+
options: { name: { type: 'string', short: 'n' } }
|
|
50
|
+
},
|
|
51
|
+
link: {
|
|
52
|
+
description: 'Link current directory to an existing project',
|
|
53
|
+
args: '<project>',
|
|
54
|
+
options: {}
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
// Environment commands
|
|
58
|
+
environments: {
|
|
59
|
+
description: 'List environments for the current project',
|
|
60
|
+
options: {}
|
|
61
|
+
},
|
|
62
|
+
'environment:create': {
|
|
63
|
+
description: 'Create a new environment',
|
|
64
|
+
args: '<name>',
|
|
65
|
+
options: {
|
|
66
|
+
production: { type: 'boolean', short: 'p' },
|
|
67
|
+
domain: { type: 'string', short: 'd' }
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
'environment:update': {
|
|
71
|
+
description: 'Update an environment',
|
|
72
|
+
args: '<slug>',
|
|
73
|
+
options: {
|
|
74
|
+
name: { type: 'string', short: 'n' },
|
|
75
|
+
domain: { type: 'string', short: 'd' },
|
|
76
|
+
production: { type: 'boolean', short: 'p' }
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// Deployment commands
|
|
81
|
+
slide: {
|
|
82
|
+
description: 'Deploy the current project',
|
|
83
|
+
aliases: ['deploy', 'launch'],
|
|
84
|
+
options: {
|
|
85
|
+
env: { type: 'string', short: 'e', default: 'production' },
|
|
86
|
+
message: { type: 'string', short: 'm' }
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
deployments: {
|
|
90
|
+
description: 'List recent deployments',
|
|
91
|
+
options: {
|
|
92
|
+
env: { type: 'string', short: 'e' },
|
|
93
|
+
limit: { type: 'string', short: 'n', default: '10' }
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
logs: {
|
|
97
|
+
description: 'View application logs',
|
|
98
|
+
options: {
|
|
99
|
+
env: { type: 'string', short: 'e', default: 'production' },
|
|
100
|
+
follow: { type: 'boolean', short: 'f' },
|
|
101
|
+
tail: { type: 'string', short: 'n', default: '100' },
|
|
102
|
+
deployment: { type: 'string', short: 'd' }
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
// Database commands
|
|
107
|
+
'db:create': {
|
|
108
|
+
description: 'Create a new database service',
|
|
109
|
+
args: '<name>',
|
|
110
|
+
options: {
|
|
111
|
+
type: { type: 'string', short: 't', default: 'postgresql' },
|
|
112
|
+
version: { type: 'string', short: 'v', default: 'latest' },
|
|
113
|
+
env: { type: 'string', short: 'e', default: 'production' }
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
'db:url': {
|
|
117
|
+
description: 'Get database connection URL',
|
|
118
|
+
args: '<name>',
|
|
119
|
+
options: {
|
|
120
|
+
env: { type: 'string', short: 'e', default: 'production' }
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// Service commands
|
|
125
|
+
services: {
|
|
126
|
+
description: 'List all services',
|
|
127
|
+
options: {
|
|
128
|
+
env: { type: 'string', short: 'e' }
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
// Environment variable commands
|
|
133
|
+
env: {
|
|
134
|
+
description: 'List environment variables',
|
|
135
|
+
options: {
|
|
136
|
+
env: { type: 'string', short: 'e', default: 'production' }
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
'env:set': {
|
|
140
|
+
description: 'Set environment variables (KEY=value)',
|
|
141
|
+
args: '<pairs...>',
|
|
142
|
+
options: {
|
|
143
|
+
env: { type: 'string', short: 'e', default: 'production' }
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
'env:unset': {
|
|
147
|
+
description: 'Remove environment variables',
|
|
148
|
+
args: '<keys...>',
|
|
149
|
+
options: {
|
|
150
|
+
env: { type: 'string', short: 'e', default: 'production' }
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
// Container access
|
|
155
|
+
terminal: {
|
|
156
|
+
description: 'Open a terminal session in the running container',
|
|
157
|
+
options: {
|
|
158
|
+
env: { type: 'string', short: 'e', default: 'production' }
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
run: {
|
|
162
|
+
description: 'Run a command in the container',
|
|
163
|
+
args: '<command...>',
|
|
164
|
+
options: {
|
|
165
|
+
env: { type: 'string', short: 'e', default: 'production' }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function showHelp() {
|
|
171
|
+
console.log()
|
|
172
|
+
console.log(` ${c.bold(c.highlight('Slipway'))} ${c.dim(`v${pkg.version}`)}`)
|
|
173
|
+
console.log(` ${c.dim('Deploy Sails apps with ease')}`)
|
|
174
|
+
console.log()
|
|
175
|
+
console.log(' Usage: slipway <command> [options]')
|
|
176
|
+
console.log()
|
|
177
|
+
console.log(' Commands:')
|
|
178
|
+
console.log()
|
|
179
|
+
|
|
180
|
+
// Group commands
|
|
181
|
+
const groups = {
|
|
182
|
+
'Authentication': ['login', 'logout', 'whoami'],
|
|
183
|
+
'Project': ['projects', 'project:update', 'init', 'link'],
|
|
184
|
+
'Environments': ['environments', 'environment:create', 'environment:update'],
|
|
185
|
+
'Deployment': ['slide', 'deployments', 'logs'],
|
|
186
|
+
'Database': ['db:create', 'db:url'],
|
|
187
|
+
'Services': ['services'],
|
|
188
|
+
'Env Variables': ['env', 'env:set', 'env:unset'],
|
|
189
|
+
'Container': ['terminal', 'run']
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const [groupName, cmds] of Object.entries(groups)) {
|
|
193
|
+
console.log(` ${c.dim(groupName)}`)
|
|
194
|
+
for (const cmd of cmds) {
|
|
195
|
+
const def = commands[cmd]
|
|
196
|
+
const args = def.args ? ` ${def.args}` : ''
|
|
197
|
+
const aliasText = def.aliases ? ` ${c.dim(`(or: ${def.aliases.join(', ')})`)}` : ''
|
|
198
|
+
console.log(` ${c.highlight(cmd)}${c.dim(args)}${aliasText}`)
|
|
199
|
+
console.log(` ${def.description}`)
|
|
200
|
+
}
|
|
201
|
+
console.log()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log(' Options:')
|
|
205
|
+
console.log(` ${c.dim('-h, --help')} Show help`)
|
|
206
|
+
console.log(` ${c.dim('-v, --version')} Show version`)
|
|
207
|
+
console.log()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function showVersion() {
|
|
211
|
+
console.log(`slipway v${pkg.version}`)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function main() {
|
|
215
|
+
// Parse global options first
|
|
216
|
+
const { values: globalValues, positionals } = parseArgs({
|
|
217
|
+
allowPositionals: true,
|
|
218
|
+
strict: false,
|
|
219
|
+
options: {
|
|
220
|
+
help: { type: 'boolean', short: 'h' },
|
|
221
|
+
version: { type: 'boolean', short: 'v' }
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
if (globalValues.version) {
|
|
226
|
+
showVersion()
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (globalValues.help || positionals.length === 0) {
|
|
231
|
+
showHelp()
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Resolve aliases
|
|
236
|
+
let command = positionals[0]
|
|
237
|
+
if (aliases[command]) {
|
|
238
|
+
command = aliases[command]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const commandDef = commands[command]
|
|
242
|
+
|
|
243
|
+
if (!commandDef) {
|
|
244
|
+
console.error(`${c.error('Error:')} Unknown command: ${positionals[0]}`)
|
|
245
|
+
console.error(`Run ${c.highlight('slipway --help')} for available commands.`)
|
|
246
|
+
process.exit(1)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Parse command-specific options
|
|
250
|
+
const commandArgs = process.argv.slice(3) // Skip node, script, command
|
|
251
|
+
let parsed
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
parsed = parseArgs({
|
|
255
|
+
args: commandArgs,
|
|
256
|
+
allowPositionals: true,
|
|
257
|
+
options: {
|
|
258
|
+
...commandDef.options,
|
|
259
|
+
help: { type: 'boolean', short: 'h' }
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
} catch (err) {
|
|
263
|
+
console.error(`${c.error('Error:')} ${err.message}`)
|
|
264
|
+
process.exit(1)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (parsed.values.help) {
|
|
268
|
+
console.log()
|
|
269
|
+
console.log(` ${c.bold(command)}${commandDef.args ? ` ${c.dim(commandDef.args)}` : ''}`)
|
|
270
|
+
if (commandDef.aliases) {
|
|
271
|
+
console.log(` ${c.dim(`Aliases: ${commandDef.aliases.join(', ')}`)}`)
|
|
272
|
+
}
|
|
273
|
+
console.log(` ${commandDef.description}`)
|
|
274
|
+
console.log()
|
|
275
|
+
if (Object.keys(commandDef.options).length > 0) {
|
|
276
|
+
console.log(' Options:')
|
|
277
|
+
for (const [name, opt] of Object.entries(commandDef.options)) {
|
|
278
|
+
const short = opt.short ? `-${opt.short}, ` : ' '
|
|
279
|
+
const def = opt.default ? ` (default: ${opt.default})` : ''
|
|
280
|
+
console.log(` ${short}--${name}${def}`)
|
|
281
|
+
}
|
|
282
|
+
console.log()
|
|
283
|
+
}
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Apply defaults
|
|
288
|
+
const options = { ...parsed.values }
|
|
289
|
+
for (const [name, opt] of Object.entries(commandDef.options)) {
|
|
290
|
+
if (options[name] === undefined && opt.default !== undefined) {
|
|
291
|
+
options[name] = opt.default
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Map command to file name (handle colons)
|
|
296
|
+
const commandFile = command.replace(':', '-')
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const module = await import(`./commands/${commandFile}.js`)
|
|
300
|
+
await module.default(options, parsed.positionals)
|
|
301
|
+
} catch (err) {
|
|
302
|
+
if (err.code === 'ERR_MODULE_NOT_FOUND') {
|
|
303
|
+
console.error(`${c.error('Error:')} Command '${command}' is not yet implemented.`)
|
|
304
|
+
process.exit(1)
|
|
305
|
+
}
|
|
306
|
+
throw err
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
main().catch((err) => {
|
|
311
|
+
console.error(`${c.error('Error:')} ${err.message}`)
|
|
312
|
+
process.exit(1)
|
|
313
|
+
})
|