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 +4 -4
- package/package.json +1 -1
- package/src/commands/audit-log.js +54 -0
- package/src/commands/backup-create.js +46 -0
- package/src/commands/backup-list.js +65 -0
- package/src/commands/backup-restore.js +25 -0
- package/src/commands/deployments.js +2 -2
- package/src/commands/env-set.js +1 -1
- package/src/commands/env-unset.js +1 -1
- package/src/commands/link.js +1 -1
- package/src/commands/logs.js +10 -3
- package/src/commands/push.js +94 -0
- package/src/commands/run.js +10 -3
- package/src/commands/slide.js +81 -13
- package/src/commands/terminal.js +10 -3
- package/src/index.js +41 -5
- package/src/lib/api.js +34 -2
- package/src/lib/utils.js +17 -3
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
|
-
#
|
|
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
|
@@ -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
|
|
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
|
|
55
|
+
String(d.id),
|
|
56
56
|
statusColor(d.status),
|
|
57
57
|
d.environment,
|
|
58
58
|
d.gitBranch || '-',
|
package/src/commands/env-set.js
CHANGED
|
@@ -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
|
|
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
|
|
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')
|
package/src/commands/link.js
CHANGED
|
@@ -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
|
|
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')
|
package/src/commands/logs.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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 =
|
|
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
|
+
}
|
package/src/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
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 =
|
|
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
|
}
|
package/src/commands/slide.js
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
232
|
-
currentSpin = createSpinner('
|
|
233
|
-
|
|
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 (
|
|
248
|
-
currentSpin = createSpinner(
|
|
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
|
-
|
|
357
|
+
if (messageRotatorInterval) clearInterval(messageRotatorInterval)
|
|
290
358
|
|
|
291
|
-
if (
|
|
292
|
-
|
|
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
|
}
|
package/src/commands/terminal.js
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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 =
|
|
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: '
|
|
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
|
|
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
|
|
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])} ${
|
|
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])} ${
|
|
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(
|
|
91
|
+
process.stdout.write(`\x1b[2K\r`)
|
|
78
92
|
if (finalText) console.log(finalText)
|
|
79
93
|
return this
|
|
80
94
|
},
|