slipway-cli 0.0.0 → 0.0.1

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.1",
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
  }
@@ -49,13 +49,14 @@ export default async function slide(options) {
49
49
  environment,
50
50
  {
51
51
  message: options.message,
52
+ appSlug: options.app,
52
53
  ...getGitInfo()
53
54
  }
54
55
  )
55
56
 
56
57
  deploySpin.succeed('Deployment started')
57
58
  console.log()
58
- console.log(` ${c.dim('Deployment ID:')} ${deployment.id.substring(0, 8)}`)
59
+ console.log(` ${c.dim('Deployment ID:')} ${deployment.id}`)
59
60
  console.log()
60
61
 
61
62
  // 4. Watch deployment status via SSE (with polling fallback)
@@ -78,7 +79,7 @@ export default async function slide(options) {
78
79
  } else if (result.status === 'failed') {
79
80
  console.log(` ${c.error('✗')} Deployment failed`)
80
81
  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(` ${c.dim('Run')} ${c.highlight(`slipway logs -d ${deployment.id}`)} ${c.dim('to view logs.')}`)
82
83
  console.log()
83
84
  process.exit(1)
84
85
  } else {
@@ -254,6 +255,15 @@ function watchDeploymentSSE(deploymentId) {
254
255
  }
255
256
  }
256
257
  }
258
+
259
+ // Stream ended — resolve with last known status or fall back to polling
260
+ clearTimeout(timeout)
261
+ if (currentSpin) currentSpin.stop()
262
+ if (lastStatus && ['running', 'failed', 'cancelled'].includes(lastStatus)) {
263
+ resolve({ status: lastStatus })
264
+ } else {
265
+ reject(new Error('Stream ended before deployment completed'))
266
+ }
257
267
  })
258
268
  .catch((err) => {
259
269
  clearTimeout(timeout)
@@ -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`