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.
@@ -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
+ })