slipway-cli 0.0.0 → 0.0.2

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 CHANGED
@@ -17,10 +17,10 @@ This means instant startup, no supply chain risk, and zero maintenance overhead.
17
17
  ## Commands
18
18
 
19
19
  ```bash
20
- # Deploy (slide into production!)
20
+ # Slide into production!
21
21
  slipway slide # Primary command
22
- slipway launch # Alias
23
22
  slipway deploy # Alias
23
+ slipway launch # Alias
24
24
 
25
25
  # Apps
26
26
  slipway app:create myapp
@@ -53,9 +53,9 @@ slipway dev # Local dev mode
53
53
  ## Installation
54
54
 
55
55
  ```bash
56
- npm install -g slipway
56
+ npm install -g slipway-cli
57
57
  # or
58
- npx slipway
58
+ npx slipway-cli
59
59
  ```
60
60
 
61
61
  ## Part of the Slipway Suite
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slipway-cli",
3
- "version": "0.0.0",
3
+ "version": "0.0.2",
4
4
  "description": "CLI for Slipway - Deploy Sails apps with ease",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,54 @@
1
+ import { c } from '../lib/colors.js'
2
+ import { api } from '../lib/api.js'
3
+ import { isLoggedIn } from '../lib/config.js'
4
+ import { error, createSpinner, table, formatDate } from '../lib/utils.js'
5
+
6
+ export default async function auditLog(options) {
7
+ if (!isLoggedIn()) {
8
+ error('Not logged in. Run `slipway login` first.')
9
+ }
10
+
11
+ const page = parseInt(options.page, 10) || 1
12
+ const limit = parseInt(options.limit, 10) || 20
13
+
14
+ const spin = createSpinner('Fetching audit logs...').start()
15
+
16
+ try {
17
+ const { logs, pagination } = await api.auditLogs.list(page, limit)
18
+
19
+ spin.stop()
20
+
21
+ console.log()
22
+ console.log(` ${c.bold(c.highlight('Audit Log'))} ${c.dim(`— page ${pagination.page} of ${pagination.totalPages}`)}`)
23
+ console.log()
24
+
25
+ if (!logs || logs.length === 0) {
26
+ console.log(` ${c.dim('No audit log entries found.')}`)
27
+ console.log()
28
+ return
29
+ }
30
+
31
+ const rows = logs.map(log => [
32
+ formatDate(log.createdAt),
33
+ log.action,
34
+ log.resourceType,
35
+ log.userName,
36
+ log.ipAddress || 'N/A'
37
+ ])
38
+
39
+ table(
40
+ ['Date', 'Action', 'Resource', 'User', 'IP'],
41
+ rows
42
+ )
43
+
44
+ if (pagination.page < pagination.totalPages) {
45
+ console.log()
46
+ console.log(` ${c.dim('Next page:')} ${c.highlight(`slipway audit-log --page ${pagination.page + 1}`)}`)
47
+ }
48
+
49
+ console.log()
50
+ } catch (err) {
51
+ spin.fail('Failed to fetch audit logs')
52
+ error(err.message)
53
+ }
54
+ }
@@ -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, createSpinner } from '../lib/utils.js'
5
+
6
+ export default async function backupCreate(options, positionals) {
7
+ if (!isLoggedIn()) {
8
+ error('Not logged in. Run `slipway login` first.')
9
+ }
10
+
11
+ const name = positionals[0]
12
+ if (!name) {
13
+ error('Please provide a service name. Usage: slipway backup:create <service-name>')
14
+ }
15
+
16
+ const project = requireProject()
17
+ const environment = options.env || 'production'
18
+
19
+ const spin = createSpinner('Creating backup...').start()
20
+
21
+ try {
22
+ // Resolve service name to ID
23
+ const { services } = await api.services.list(project.project, environment)
24
+ const service = services.find(s => s.name === name)
25
+
26
+ if (!service) {
27
+ spin.fail('Service not found')
28
+ error(`No service named "${name}" found in ${environment} environment.`)
29
+ }
30
+
31
+ const { backup } = await api.backups.create(service.id)
32
+
33
+ spin.succeed('Backup created')
34
+
35
+ console.log()
36
+ console.log(` ${c.dim('Backup ID:')} ${backup.id}`)
37
+ console.log(` ${c.dim('Status:')} ${backup.status}`)
38
+ console.log(` ${c.dim('Type:')} ${backup.type}`)
39
+ console.log()
40
+ console.log(` ${c.dim('Run')} ${c.highlight(`slipway backup:list ${name}`)} ${c.dim('to check progress.')}`)
41
+ console.log()
42
+ } catch (err) {
43
+ spin.fail('Failed to create backup')
44
+ error(err.message)
45
+ }
46
+ }
@@ -0,0 +1,65 @@
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, createSpinner, table, formatDate, formatBytes, formatDuration, statusColor } from '../lib/utils.js'
5
+
6
+ export default async function backupList(options, positionals) {
7
+ if (!isLoggedIn()) {
8
+ error('Not logged in. Run `slipway login` first.')
9
+ }
10
+
11
+ const name = positionals[0]
12
+ if (!name) {
13
+ error('Please provide a service name. Usage: slipway backup:list <service-name>')
14
+ }
15
+
16
+ const project = requireProject()
17
+ const environment = options.env || 'production'
18
+
19
+ const spin = createSpinner('Fetching backups...').start()
20
+
21
+ try {
22
+ // Resolve service name to ID
23
+ const { services } = await api.services.list(project.project, environment)
24
+ const service = services.find(s => s.name === name)
25
+
26
+ if (!service) {
27
+ spin.fail('Service not found')
28
+ error(`No service named "${name}" found in ${environment} environment.`)
29
+ }
30
+
31
+ const { backups } = await api.backups.list(service.id)
32
+
33
+ spin.stop()
34
+
35
+ console.log()
36
+ console.log(` ${c.bold(c.highlight('Backups'))} ${c.dim(`— ${name} (${environment})`)}`)
37
+ console.log()
38
+
39
+ if (!backups || backups.length === 0) {
40
+ console.log(` ${c.dim('No backups found.')}`)
41
+ console.log(` ${c.dim('Run')} ${c.highlight(`slipway backup:create ${name}`)} ${c.dim('to create one.')}`)
42
+ console.log()
43
+ return
44
+ }
45
+
46
+ const rows = backups.map(b => [
47
+ b.id,
48
+ statusColor(b.status),
49
+ b.type,
50
+ formatBytes(b.sizeBytes),
51
+ b.durationMs ? formatDuration(Math.round(b.durationMs / 1000)) : 'N/A',
52
+ formatDate(b.createdAt)
53
+ ])
54
+
55
+ table(
56
+ ['ID', 'Status', 'Type', 'Size', 'Duration', 'Date'],
57
+ rows
58
+ )
59
+
60
+ console.log()
61
+ } catch (err) {
62
+ spin.fail('Failed to fetch backups')
63
+ error(err.message)
64
+ }
65
+ }
@@ -0,0 +1,25 @@
1
+ import { api } from '../lib/api.js'
2
+ import { isLoggedIn } from '../lib/config.js'
3
+ import { error, createSpinner } from '../lib/utils.js'
4
+
5
+ export default async function backupRestore(options, positionals) {
6
+ if (!isLoggedIn()) {
7
+ error('Not logged in. Run `slipway login` first.')
8
+ }
9
+
10
+ const backupId = positionals[0]
11
+ if (!backupId) {
12
+ error('Please provide a backup ID. Usage: slipway backup:restore <backup-id>')
13
+ }
14
+
15
+ const spin = createSpinner('Starting restore...').start()
16
+
17
+ try {
18
+ const result = await api.backups.restore(backupId)
19
+
20
+ spin.succeed(`Restore started for backup ${result.backupId}`)
21
+ } catch (err) {
22
+ spin.fail('Failed to start restore')
23
+ error(err.message)
24
+ }
25
+ }
@@ -47,12 +47,12 @@ export default async function deployments(options) {
47
47
  const limited = allDeployments.slice(0, parseInt(options.limit) || 10)
48
48
 
49
49
  if (limited.length === 0) {
50
- console.log(` ${c.dim('No deployments yet. Run `slipway deploy` to create one.')}`)
50
+ console.log(` ${c.dim('No deployments yet. Run `slipway slide` to create one.')}`)
51
51
  return
52
52
  }
53
53
 
54
54
  const rows = limited.map(d => [
55
- d.id.substring(0, 8),
55
+ String(d.id),
56
56
  statusColor(d.status),
57
57
  d.environment,
58
58
  d.gitBranch || '-',
@@ -54,7 +54,7 @@ export default async function envSet(options, positionals) {
54
54
  }
55
55
 
56
56
  console.log()
57
- console.log(` ${c.dim('Run')} ${c.highlight('slipway deploy')} ${c.dim('to apply changes.')}`)
57
+ console.log(` ${c.dim('Run')} ${c.highlight('slipway slide')} ${c.dim('to apply changes.')}`)
58
58
  console.log()
59
59
  } catch (err) {
60
60
  spin.fail('Failed to set environment variables')
@@ -53,7 +53,7 @@ export default async function envUnset(options, positionals) {
53
53
  }
54
54
 
55
55
  console.log()
56
- console.log(` ${c.dim('Run')} ${c.highlight('slipway deploy')} ${c.dim('to apply changes.')}`)
56
+ console.log(` ${c.dim('Run')} ${c.highlight('slipway slide')} ${c.dim('to apply changes.')}`)
57
57
  console.log()
58
58
  } catch (err) {
59
59
  spin.fail('Failed to remove environment variables')
@@ -31,7 +31,7 @@ export default async function link(options, positionals) {
31
31
  console.log(` ${c.dim('Project:')} ${project.name}`)
32
32
  console.log(` ${c.dim('Slug:')} ${project.slug}`)
33
33
  console.log()
34
- console.log(` ${c.dim('Run')} ${c.highlight('slipway deploy')} ${c.dim('to deploy your app.')}`)
34
+ console.log(` ${c.dim('Run')} ${c.highlight('slipway slide')} ${c.dim('to deploy your app.')}`)
35
35
  console.log()
36
36
  } catch (err) {
37
37
  spin.fail('Failed to link project')
@@ -25,11 +25,18 @@ export default async function logs(options) {
25
25
  // Get environment to find container name
26
26
  const { environment: env } = await api.environments.get(project.project, environment)
27
27
 
28
- if (!env.app || env.app.length === 0) {
29
- error('No app deployed in this environment. Run `slipway deploy` first.')
28
+ const apps = env.app || []
29
+ if (apps.length === 0) {
30
+ error('No app deployed in this environment. Run `slipway slide` first.')
30
31
  }
31
32
 
32
- const app = env.app[0]
33
+ const app = options.app
34
+ ? apps.find(a => a.slug === options.app)
35
+ : apps.find(a => a.isDefault) || apps[0]
36
+
37
+ if (!app) {
38
+ error(`App "${options.app}" not found. Available: ${apps.map(a => a.slug).join(', ')}`)
39
+ }
33
40
  if (app.status !== 'running') {
34
41
  error(`App is not running (status: ${app.status})`)
35
42
  }
@@ -0,0 +1,94 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { c } from '../lib/colors.js'
3
+ import { api } from '../lib/api.js'
4
+ import { isLoggedIn } from '../lib/config.js'
5
+ import { error, requireProject, createSpinner } from '../lib/utils.js'
6
+
7
+ export default async function push(options) {
8
+ if (!isLoggedIn()) {
9
+ error('Not logged in. Run `slipway login` first.')
10
+ }
11
+
12
+ const project = requireProject()
13
+
14
+ console.log()
15
+ console.log(` ${c.bold(c.highlight('Pushing'))} ${project.project}`)
16
+ console.log()
17
+
18
+ // 1. Package source code
19
+ const spin = createSpinner('Packaging source...').start()
20
+
21
+ let tarballBuffer
22
+ try {
23
+ tarballBuffer = createTarball()
24
+ const sizeMB = (tarballBuffer.length / 1024 / 1024).toFixed(1)
25
+ spin.succeed(`Source packaged (${sizeMB} MB)`)
26
+ } catch (err) {
27
+ spin.fail('Failed to package source')
28
+ error(err.message)
29
+ }
30
+
31
+ // 2. Upload source to server
32
+ const pushSpin = createSpinner('Pushing source...').start()
33
+
34
+ try {
35
+ await api.projects.push(project.project, tarballBuffer)
36
+ pushSpin.succeed('Source pushed')
37
+ } catch (err) {
38
+ pushSpin.fail('Failed to push source')
39
+ error(err.message)
40
+ }
41
+
42
+ console.log()
43
+ console.log(` ${c.success('✓')} Source code updated`)
44
+ console.log()
45
+ console.log(` ${c.dim('To deploy, run:')} ${c.highlight('slipway slide')}`)
46
+ console.log()
47
+ }
48
+
49
+ /**
50
+ * Create a tarball of the current directory.
51
+ * Uses `git archive` if in a git repo (respects .gitignore automatically),
52
+ * otherwise falls back to `tar` with sensible exclusions.
53
+ */
54
+ function createTarball() {
55
+ const cwd = process.cwd()
56
+
57
+ if (isGitRepo(cwd)) {
58
+ return execFileSync('git', ['archive', '--format=tar.gz', 'HEAD'], {
59
+ cwd,
60
+ maxBuffer: 500 * 1024 * 1024
61
+ })
62
+ }
63
+
64
+ // Fallback: tar with exclusions
65
+ const excludes = [
66
+ 'node_modules',
67
+ '.git',
68
+ '.env',
69
+ '.DS_Store',
70
+ '*.log'
71
+ ]
72
+
73
+ const args = ['czf', '-', ...excludes.flatMap(e => ['--exclude', e]), '.']
74
+
75
+ return execFileSync('tar', args, {
76
+ cwd,
77
+ maxBuffer: 500 * 1024 * 1024
78
+ })
79
+ }
80
+
81
+ /**
82
+ * Check if a directory is inside a git repository.
83
+ */
84
+ function isGitRepo(dir) {
85
+ try {
86
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
87
+ cwd: dir,
88
+ stdio: 'pipe'
89
+ })
90
+ return true
91
+ } catch {
92
+ return false
93
+ }
94
+ }
@@ -20,11 +20,18 @@ export default async function runCommand(options, positionals) {
20
20
  // Get environment to find container name
21
21
  const { environment: env } = await api.environments.get(project.project, environment)
22
22
 
23
- if (!env.app || env.app.length === 0) {
24
- error('No app deployed in this environment. Run `slipway deploy` first.')
23
+ const apps = env.app || []
24
+ if (apps.length === 0) {
25
+ error('No app deployed in this environment. Run `slipway slide` first.')
25
26
  }
26
27
 
27
- const app = env.app[0]
28
+ const app = options.app
29
+ ? apps.find(a => a.slug === options.app)
30
+ : apps.find(a => a.isDefault) || apps[0]
31
+
32
+ if (!app) {
33
+ error(`App "${options.app}" not found. Available: ${apps.map(a => a.slug).join(', ')}`)
34
+ }
28
35
  if (app.status !== 'running') {
29
36
  error(`App is not running (status: ${app.status})`)
30
37
  }
@@ -4,6 +4,56 @@ import { api } from '../lib/api.js'
4
4
  import { isLoggedIn, getCredentials } from '../lib/config.js'
5
5
  import { error, requireProject, createSpinner } from '../lib/utils.js'
6
6
 
7
+ const maritimeMessages = {
8
+ pending: [
9
+ 'Waiting for the tide...',
10
+ 'Checking the compass...',
11
+ 'Reading the stars...',
12
+ 'Gathering the crew...',
13
+ ],
14
+ building: [
15
+ 'Hoisting the sails...',
16
+ 'Loading the cargo...',
17
+ 'Checking the rigging...',
18
+ 'Swabbing the deck...',
19
+ 'Tying the knots...',
20
+ 'Hammering the hull...',
21
+ ],
22
+ pushing: [
23
+ 'Signaling the fleet...',
24
+ 'Sending up a flare...',
25
+ 'Raising the flag...',
26
+ ],
27
+ deploying: [
28
+ 'Charting the course...',
29
+ 'Setting sail...',
30
+ 'Catching the wind...',
31
+ 'Navigating the waters...',
32
+ 'Full speed ahead...',
33
+ 'Approaching the harbor...',
34
+ ],
35
+ }
36
+
37
+ const statusLabels = {
38
+ pending: 'Waiting',
39
+ building: 'Building',
40
+ pushing: 'Pushing',
41
+ deploying: 'Deploying',
42
+ }
43
+
44
+ function createMessageRotator(spinner, status) {
45
+ const messages = maritimeMessages[status]
46
+ if (!messages) return null
47
+ let index = 0
48
+ const label = statusLabels[status] || status
49
+ spinner.setText(`${label} — ${c.dim(messages[0])}`)
50
+ index = 1
51
+ return setInterval(() => {
52
+ spinner.setText(`${label} — ${c.dim(messages[index % messages.length])}`)
53
+ index++
54
+ }, 4000)
55
+ }
56
+
7
57
  export default async function slide(options) {
8
58
  if (!isLoggedIn()) {
9
59
  error('Not logged in. Run `slipway login` first.')
@@ -49,13 +99,14 @@ export default async function slide(options) {
49
99
  environment,
50
100
  {
51
101
  message: options.message,
102
+ appSlug: options.app,
52
103
  ...getGitInfo()
53
104
  }
54
105
  )
55
106
 
56
107
  deploySpin.succeed('Deployment started')
57
108
  console.log()
58
- console.log(` ${c.dim('Deployment ID:')} ${deployment.id.substring(0, 8)}`)
109
+ console.log(` ${c.dim('Deployment ID:')} ${deployment.id}`)
59
110
  console.log()
60
111
 
61
112
  // 4. Watch deployment status via SSE (with polling fallback)
@@ -78,7 +129,7 @@ export default async function slide(options) {
78
129
  } else if (result.status === 'failed') {
79
130
  console.log(` ${c.error('✗')} Deployment failed`)
80
131
  console.log()
81
- console.log(` ${c.dim('Run')} ${c.highlight(`slipway logs -d ${deployment.id.substring(0, 8)}`)} ${c.dim('to view logs.')}`)
132
+ console.log(` ${c.dim('Run')} ${c.highlight(`slipway logs -d ${deployment.id}`)} ${c.dim('to view logs.')}`)
82
133
  console.log()
83
134
  process.exit(1)
84
135
  } else {
@@ -182,9 +233,11 @@ function watchDeploymentSSE(deploymentId) {
182
233
  const controller = new AbortController()
183
234
  let currentSpin = null
184
235
  let lastStatus = ''
236
+ let messageRotatorInterval = null
185
237
 
186
238
  const timeout = setTimeout(() => {
187
239
  controller.abort()
240
+ if (messageRotatorInterval) clearInterval(messageRotatorInterval)
188
241
  if (currentSpin) currentSpin.stop()
189
242
  reject(new Error('timeout'))
190
243
  }, 10 * 60 * 1000) // 10 minute timeout
@@ -206,6 +259,7 @@ function watchDeploymentSSE(deploymentId) {
206
259
  let buffer = ''
207
260
 
208
261
  currentSpin = createSpinner('Building...').start()
262
+ messageRotatorInterval = createMessageRotator(currentSpin, 'building')
209
263
 
210
264
  while (true) {
211
265
  const { done, value } = await reader.read()
@@ -226,12 +280,12 @@ function watchDeploymentSSE(deploymentId) {
226
280
  // Update spinner based on status
227
281
  if (data.status !== lastStatus) {
228
282
  lastStatus = data.status
283
+ if (messageRotatorInterval) clearInterval(messageRotatorInterval)
229
284
  if (currentSpin) currentSpin.stop()
230
285
 
231
- if (data.status === 'building') {
232
- currentSpin = createSpinner('Building...').start()
233
- } else if (data.status === 'deploying') {
234
- currentSpin = createSpinner('Deploying...').start()
286
+ if (['building', 'pushing', 'deploying'].includes(data.status)) {
287
+ currentSpin = createSpinner('...').start()
288
+ messageRotatorInterval = createMessageRotator(currentSpin, data.status)
235
289
  } else if (data.status === 'running' || data.status === 'failed' || data.status === 'cancelled') {
236
290
  clearTimeout(timeout)
237
291
  controller.abort()
@@ -242,10 +296,12 @@ function watchDeploymentSSE(deploymentId) {
242
296
 
243
297
  // Show build output if available
244
298
  if (data.output) {
299
+ if (messageRotatorInterval) clearInterval(messageRotatorInterval)
245
300
  if (currentSpin) currentSpin.stop()
246
301
  console.log(` ${c.dim(data.output)}`)
247
- if (lastStatus === 'building' || lastStatus === 'deploying') {
248
- currentSpin = createSpinner(lastStatus === 'building' ? 'Building...' : 'Deploying...').start()
302
+ if (['building', 'pushing', 'deploying'].includes(lastStatus)) {
303
+ currentSpin = createSpinner('...').start()
304
+ messageRotatorInterval = createMessageRotator(currentSpin, lastStatus)
249
305
  }
250
306
  }
251
307
  } catch {
@@ -254,9 +310,20 @@ function watchDeploymentSSE(deploymentId) {
254
310
  }
255
311
  }
256
312
  }
313
+
314
+ // Stream ended — resolve with last known status or fall back to polling
315
+ clearTimeout(timeout)
316
+ if (messageRotatorInterval) clearInterval(messageRotatorInterval)
317
+ if (currentSpin) currentSpin.stop()
318
+ if (lastStatus && ['running', 'failed', 'cancelled'].includes(lastStatus)) {
319
+ resolve({ status: lastStatus })
320
+ } else {
321
+ reject(new Error('Stream ended before deployment completed'))
322
+ }
257
323
  })
258
324
  .catch((err) => {
259
325
  clearTimeout(timeout)
326
+ if (messageRotatorInterval) clearInterval(messageRotatorInterval)
260
327
  if (currentSpin) currentSpin.stop()
261
328
  if (err.name !== 'AbortError') {
262
329
  reject(err)
@@ -271,6 +338,7 @@ function watchDeploymentSSE(deploymentId) {
271
338
  async function watchDeploymentPolling(deploymentId) {
272
339
  const spin = createSpinner('Building...').start()
273
340
  let lastStatus = ''
341
+ let messageRotatorInterval = createMessageRotator(spin, 'building')
274
342
 
275
343
  const maxAttempts = 300 // 10 minutes at 2s intervals
276
344
  let attempts = 0
@@ -286,13 +354,12 @@ async function watchDeploymentPolling(deploymentId) {
286
354
  // Update spinner text based on status
287
355
  if (status !== lastStatus) {
288
356
  lastStatus = status
289
- spin.stop()
357
+ if (messageRotatorInterval) clearInterval(messageRotatorInterval)
290
358
 
291
- if (status === 'building') {
292
- createSpinner('Building...').start()
293
- } else if (status === 'deploying') {
294
- createSpinner('Deploying...').start()
359
+ if (['building', 'pushing', 'deploying'].includes(status)) {
360
+ messageRotatorInterval = createMessageRotator(spin, status)
295
361
  } else if (status === 'running' || status === 'failed' || status === 'cancelled') {
362
+ spin.stop()
296
363
  return { status }
297
364
  }
298
365
  }
@@ -301,6 +368,7 @@ async function watchDeploymentPolling(deploymentId) {
301
368
  }
302
369
  }
303
370
 
371
+ if (messageRotatorInterval) clearInterval(messageRotatorInterval)
304
372
  spin.stop()
305
373
  return { status: 'timeout' }
306
374
  }
@@ -15,11 +15,18 @@ export default async function terminal(options) {
15
15
  // Get environment to find container name
16
16
  const { environment: env } = await api.environments.get(project.project, environment)
17
17
 
18
- if (!env.app || env.app.length === 0) {
19
- error('No app deployed in this environment. Run `slipway deploy` first.')
18
+ const apps = env.app || []
19
+ if (apps.length === 0) {
20
+ error('No app deployed in this environment. Run `slipway slide` first.')
20
21
  }
21
22
 
22
- const app = env.app[0]
23
+ const app = options.app
24
+ ? apps.find(a => a.slug === options.app)
25
+ : apps.find(a => a.isDefault) || apps[0]
26
+
27
+ if (!app) {
28
+ error(`App "${options.app}" not found. Available: ${apps.map(a => a.slug).join(', ')}`)
29
+ }
23
30
  if (app.status !== 'running') {
24
31
  error(`App is not running (status: ${app.status})`)
25
32
  }
package/src/index.js CHANGED
@@ -78,11 +78,16 @@ const commands = {
78
78
  },
79
79
 
80
80
  // Deployment commands
81
+ push: {
82
+ description: 'Push source code without deploying',
83
+ options: {}
84
+ },
81
85
  slide: {
82
- description: 'Deploy the current project',
86
+ description: 'Push and deploy the current project',
83
87
  aliases: ['deploy', 'launch'],
84
88
  options: {
85
89
  env: { type: 'string', short: 'e', default: 'production' },
90
+ app: { type: 'string', short: 'a' },
86
91
  message: { type: 'string', short: 'm' }
87
92
  }
88
93
  },
@@ -97,6 +102,7 @@ const commands = {
97
102
  description: 'View application logs',
98
103
  options: {
99
104
  env: { type: 'string', short: 'e', default: 'production' },
105
+ app: { type: 'string', short: 'a' },
100
106
  follow: { type: 'boolean', short: 'f' },
101
107
  tail: { type: 'string', short: 'n', default: '100' },
102
108
  deployment: { type: 'string', short: 'd' }
@@ -151,18 +157,46 @@ const commands = {
151
157
  }
152
158
  },
153
159
 
160
+ // Backup commands
161
+ 'backup:create': {
162
+ description: 'Create a manual database backup',
163
+ args: '<service-name>',
164
+ options: { env: { type: 'string', short: 'e', default: 'production' } }
165
+ },
166
+ 'backup:list': {
167
+ description: 'List backups for a database service',
168
+ args: '<service-name>',
169
+ options: { env: { type: 'string', short: 'e', default: 'production' } }
170
+ },
171
+ 'backup:restore': {
172
+ description: 'Restore a database backup',
173
+ args: '<backup-id>',
174
+ options: {}
175
+ },
176
+
177
+ // Admin commands
178
+ 'audit-log': {
179
+ description: 'View audit log entries',
180
+ options: {
181
+ page: { type: 'string', short: 'p', default: '1' },
182
+ limit: { type: 'string', short: 'n', default: '20' }
183
+ }
184
+ },
185
+
154
186
  // Container access
155
187
  terminal: {
156
188
  description: 'Open a terminal session in the running container',
157
189
  options: {
158
- env: { type: 'string', short: 'e', default: 'production' }
190
+ env: { type: 'string', short: 'e', default: 'production' },
191
+ app: { type: 'string', short: 'a' }
159
192
  }
160
193
  },
161
194
  run: {
162
195
  description: 'Run a command in the container',
163
196
  args: '<command...>',
164
197
  options: {
165
- env: { type: 'string', short: 'e', default: 'production' }
198
+ env: { type: 'string', short: 'e', default: 'production' },
199
+ app: { type: 'string', short: 'a' }
166
200
  }
167
201
  }
168
202
  }
@@ -182,11 +216,13 @@ function showHelp() {
182
216
  'Authentication': ['login', 'logout', 'whoami'],
183
217
  'Project': ['projects', 'project:update', 'init', 'link'],
184
218
  'Environments': ['environments', 'environment:create', 'environment:update'],
185
- 'Deployment': ['slide', 'deployments', 'logs'],
219
+ 'Deployment': ['push', 'slide', 'deployments', 'logs'],
186
220
  'Database': ['db:create', 'db:url'],
187
221
  'Services': ['services'],
222
+ 'Backups': ['backup:create', 'backup:list', 'backup:restore'],
188
223
  'Env Variables': ['env', 'env:set', 'env:unset'],
189
- 'Container': ['terminal', 'run']
224
+ 'Container': ['terminal', 'run'],
225
+ 'Admin': ['audit-log']
190
226
  }
191
227
 
192
228
  for (const [groupName, cmds] of Object.entries(groups)) {
package/src/lib/api.js CHANGED
@@ -33,7 +33,17 @@ async function apiRequest(method, path, options = {}) {
33
33
 
34
34
  try {
35
35
  const response = await fetch(url, fetchOptions)
36
- const body = await response.json()
36
+ const text = await response.text()
37
+
38
+ let body
39
+ try {
40
+ body = JSON.parse(text)
41
+ } catch {
42
+ if (!response.ok) {
43
+ throw new APIError(text || `Request failed with status ${response.status}`, response.status)
44
+ }
45
+ throw new APIError(`Unexpected response from server: ${text}`, response.status)
46
+ }
37
47
 
38
48
  if (!response.ok) {
39
49
  const message = body.message || body.error || `Request failed with status ${response.status}`
@@ -73,7 +83,17 @@ async function apiUpload(path, fieldName, buffer, filename) {
73
83
  body: formData
74
84
  })
75
85
 
76
- const body = await response.json()
86
+ const text = await response.text()
87
+
88
+ let body
89
+ try {
90
+ body = JSON.parse(text)
91
+ } catch {
92
+ if (!response.ok) {
93
+ throw new APIError(text || `Upload failed with status ${response.status}`, response.status)
94
+ }
95
+ throw new APIError(`Unexpected response from server: ${text}`, response.status)
96
+ }
77
97
 
78
98
  if (!response.ok) {
79
99
  const message = body.message || body.error || `Upload failed with status ${response.status}`
@@ -134,3 +154,15 @@ api.services = {
134
154
  get: (id) => api.get(`/services/${id}`),
135
155
  delete: (id) => api.delete(`/services/${id}`)
136
156
  }
157
+
158
+ // Backup endpoints
159
+ api.backups = {
160
+ create: (serviceId) => api.post(`/services/${serviceId}/backups`),
161
+ list: (serviceId) => api.get(`/services/${serviceId}/backups`),
162
+ restore: (backupId) => api.post(`/backups/${backupId}/restore`)
163
+ }
164
+
165
+ // Audit log endpoints
166
+ api.auditLogs = {
167
+ list: (page = 1, limit = 20) => api.get(`/audit-logs?page=${page}&limit=${limit}`)
168
+ }
package/src/lib/utils.js CHANGED
@@ -32,6 +32,15 @@ export function formatDate(timestamp) {
32
32
  return date.toLocaleString()
33
33
  }
34
34
 
35
+ export function formatBytes(bytes) {
36
+ if (!bytes) return 'N/A'
37
+ const units = ['B', 'KB', 'MB', 'GB']
38
+ let i = 0
39
+ let size = bytes
40
+ while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
41
+ return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
42
+ }
43
+
35
44
  export function formatDuration(seconds) {
36
45
  if (!seconds) return 'N/A'
37
46
  if (seconds < 60) return `${seconds}s`
@@ -59,22 +68,27 @@ export function createSpinner(text) {
59
68
  const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
60
69
  let i = 0
61
70
  let interval = null
71
+ let currentText = text
62
72
 
63
73
  return {
64
74
  start() {
65
- process.stdout.write(`\r${c.info(frames[0])} ${text}`)
75
+ process.stdout.write(`\r${c.info(frames[0])} ${currentText}`)
66
76
  interval = setInterval(() => {
67
77
  i = (i + 1) % frames.length
68
- process.stdout.write(`\r${c.info(frames[i])} ${text}`)
78
+ process.stdout.write(`\x1b[2K\r${c.info(frames[i])} ${currentText}`)
69
79
  }, 80)
70
80
  return this
71
81
  },
82
+ setText(newText) {
83
+ currentText = newText
84
+ return this
85
+ },
72
86
  stop(finalText) {
73
87
  if (interval) {
74
88
  clearInterval(interval)
75
89
  interval = null
76
90
  }
77
- process.stdout.write('\r' + ' '.repeat(text.length + 4) + '\r')
91
+ process.stdout.write(`\x1b[2K\r`)
78
92
  if (finalText) console.log(finalText)
79
93
  return this
80
94
  },