launchpd 1.0.3 → 1.0.6
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/LICENSE +21 -21
- package/README.md +61 -39
- package/bin/cli.js +149 -124
- package/bin/setup.js +42 -40
- package/package.json +4 -4
- package/src/commands/auth.js +516 -522
- package/src/commands/deploy.js +745 -386
- package/src/commands/index.js +14 -7
- package/src/commands/init.js +95 -72
- package/src/commands/list.js +120 -122
- package/src/commands/rollback.js +139 -102
- package/src/commands/status.js +75 -51
- package/src/commands/versions.js +153 -113
- package/src/config.js +32 -31
- package/src/utils/api.js +220 -195
- package/src/utils/credentials.js +88 -85
- package/src/utils/endpoint.js +58 -0
- package/src/utils/errors.js +79 -69
- package/src/utils/expiration.js +49 -47
- package/src/utils/id.js +5 -5
- package/src/utils/ignore.js +35 -36
- package/src/utils/index.js +10 -11
- package/src/utils/localConfig.js +39 -43
- package/src/utils/logger.js +113 -106
- package/src/utils/machineId.js +15 -19
- package/src/utils/metadata.js +113 -87
- package/src/utils/projectConfig.js +48 -45
- package/src/utils/prompt.js +91 -82
- package/src/utils/quota.js +261 -225
- package/src/utils/remoteSource.js +680 -0
- package/src/utils/upload.js +197 -127
- package/src/utils/validator.js +116 -68
package/src/utils/quota.js
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
* Validates user can deploy before uploading
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { config } from '../config.js'
|
|
7
|
-
import { getCredentials, getClientToken } from './credentials.js'
|
|
8
|
-
import { warning, error, info, log, raw } from './logger.js'
|
|
6
|
+
import { config } from '../config.js'
|
|
7
|
+
import { getCredentials, getClientToken } from './credentials.js'
|
|
8
|
+
import { warning, error, info, log, raw } from './logger.js'
|
|
9
|
+
import { createFetchTimeout, API_TIMEOUT_MS } from './api.js'
|
|
9
10
|
|
|
10
|
-
const API_BASE_URL = `https://api.${config.domain}
|
|
11
|
+
const API_BASE_URL = `https://api.${config.domain}`
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Check quota before deployment
|
|
@@ -19,267 +20,302 @@ const API_BASE_URL = `https://api.${config.domain}`;
|
|
|
19
20
|
* @param {boolean} options.isUpdate - Whether this is known to be an update
|
|
20
21
|
* @returns {Promise<{allowed: boolean, isNewSite: boolean, quota: object, warnings: string[]}>}
|
|
21
22
|
*/
|
|
22
|
-
export async function checkQuota(subdomain, estimatedBytes = 0, options = {}) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const url = new URL(`${API_BASE_URL}/api/quota`);
|
|
41
|
-
if (isUpdate) {
|
|
42
|
-
url.searchParams.append('is_update', 'true');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const response = await fetch(url.toString(), {
|
|
46
|
-
headers: {
|
|
47
|
-
'X-API-Key': apiKey,
|
|
48
|
-
},
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
if (!response.ok) {
|
|
52
|
-
if (options.verbose || process.env.DEBUG) {
|
|
53
|
-
raw(`Quota check failed: ${response.status} ${response.statusText}`, 'error');
|
|
54
|
-
const text = await response.text();
|
|
55
|
-
raw(`Response: ${text}`, 'error');
|
|
56
|
-
}
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return await response.json();
|
|
61
|
-
} catch (err) {
|
|
62
|
-
if (options.verbose || process.env.DEBUG) {
|
|
63
|
-
raw('Quota check error:', 'error');
|
|
64
|
-
raw(err, 'error');
|
|
65
|
-
if (err.cause) raw('Cause:', 'error');
|
|
66
|
-
if (err.cause) raw(err.cause, 'error');
|
|
67
|
-
}
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
// ... skipped ...
|
|
72
|
-
|
|
73
|
-
if (!quotaData) {
|
|
74
|
-
// API unavailable, allow deployment (fail-open for MVP)
|
|
75
|
-
return {
|
|
76
|
-
allowed: true,
|
|
77
|
-
isNewSite: true,
|
|
78
|
-
quota: null,
|
|
79
|
-
warnings: ['Could not verify quota (API unavailable)'],
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// DEBUG: Write input options to file
|
|
23
|
+
export async function checkQuota (subdomain, estimatedBytes = 0, options = {}) {
|
|
24
|
+
const creds = await getCredentials()
|
|
25
|
+
|
|
26
|
+
let quotaData
|
|
27
|
+
|
|
28
|
+
if (creds?.apiKey) {
|
|
29
|
+
// Authenticated user
|
|
30
|
+
quotaData = await checkAuthenticatedQuota(creds.apiKey, options.isUpdate)
|
|
31
|
+
} else {
|
|
32
|
+
// Anonymous user
|
|
33
|
+
quotaData = await checkAnonymousQuota()
|
|
34
|
+
}
|
|
35
|
+
// ... skipped ...
|
|
36
|
+
/**
|
|
37
|
+
* Check quota for authenticated user
|
|
38
|
+
*/
|
|
39
|
+
async function checkAuthenticatedQuota (apiKey, isUpdate = false) {
|
|
40
|
+
const { signal, clear } = createFetchTimeout(API_TIMEOUT_MS)
|
|
84
41
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const warningMsg = `You have ${remaining} site(s) remaining`;
|
|
106
|
-
// Only push if not already present in warnings from backend
|
|
107
|
-
if (!warnings.some(w => w.toLowerCase().includes('site(s) remaining'))) {
|
|
108
|
-
warnings.push(warningMsg);
|
|
42
|
+
const url = new URL(`${API_BASE_URL}/api/quota`)
|
|
43
|
+
if (isUpdate) {
|
|
44
|
+
url.searchParams.append('is_update', 'true')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const response = await fetch(url.toString(), {
|
|
48
|
+
headers: {
|
|
49
|
+
'X-API-Key': apiKey
|
|
50
|
+
},
|
|
51
|
+
signal
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
if (options.verbose || process.env.DEBUG) {
|
|
56
|
+
raw(
|
|
57
|
+
`Quota check failed: ${response.status} ${response.statusText}`,
|
|
58
|
+
'error'
|
|
59
|
+
)
|
|
60
|
+
const text = await response.text()
|
|
61
|
+
raw(`Response: ${text}`, 'error')
|
|
109
62
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// Determine if deployment is allowed based on API flags or local calculations
|
|
113
|
-
const allowed = quotaData.canDeploy !== undefined ? quotaData.canDeploy : true;
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
114
65
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
66
|
+
return await response.json()
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (options.verbose || process.env.DEBUG) {
|
|
69
|
+
raw('Quota check error:', 'error')
|
|
70
|
+
raw(err, 'error')
|
|
71
|
+
if (err.cause) raw('Cause:', 'error')
|
|
72
|
+
if (err.cause) raw(err.cause, 'error')
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
} finally {
|
|
76
|
+
clear()
|
|
124
77
|
}
|
|
78
|
+
}
|
|
79
|
+
// ... skipped ...
|
|
125
80
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
info('Check your quota status: launchpd whoami');
|
|
136
|
-
}
|
|
137
|
-
return {
|
|
138
|
-
allowed: false,
|
|
139
|
-
isNewSite,
|
|
140
|
-
quota: quotaData,
|
|
141
|
-
warnings,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
81
|
+
if (!quotaData) {
|
|
82
|
+
// API unavailable, deny deployment (fail-closed)
|
|
83
|
+
return {
|
|
84
|
+
allowed: false,
|
|
85
|
+
isNewSite: true,
|
|
86
|
+
quota: null,
|
|
87
|
+
warnings: [
|
|
88
|
+
'Could not verify quota (API unavailable). Please try again later.'
|
|
89
|
+
]
|
|
144
90
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check if this is an existing site the user owns
|
|
94
|
+
// If explicitly marked as update, assume user owns it
|
|
95
|
+
let isNewSite = true
|
|
96
|
+
if (options.isUpdate) {
|
|
97
|
+
isNewSite = false
|
|
98
|
+
} else if (subdomain) {
|
|
99
|
+
isNewSite = !(await userOwnsSite(creds?.apiKey, subdomain))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const warnings = [...(quotaData.warnings || [])]
|
|
103
|
+
|
|
104
|
+
// Add quota warning (de-duplicated) - early so it shows even if blocked later
|
|
105
|
+
const remaining = quotaData.usage?.sitesRemaining
|
|
106
|
+
if (typeof remaining === 'number') {
|
|
107
|
+
const warningMsg = `You have ${remaining} site(s) remaining`
|
|
108
|
+
// Only push if not already present in warnings from backend
|
|
109
|
+
if (!warnings.some((w) => w.toLowerCase().includes('site(s) remaining'))) {
|
|
110
|
+
warnings.push(warningMsg)
|
|
166
111
|
}
|
|
112
|
+
}
|
|
167
113
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (storagePercentage > 0.8) {
|
|
171
|
-
warnings.push(`Storage ${Math.round(storagePercentage * 100)}% used (${formatBytes(storageAfter)} / ${formatBytes(quotaData.limits.maxStorageBytes)})`);
|
|
172
|
-
}
|
|
114
|
+
// Determine if deployment is allowed based on API flags or local calculations
|
|
115
|
+
const allowed = quotaData.canDeploy ?? true
|
|
173
116
|
|
|
117
|
+
// Check if blocked (anonymous limit reached or explicitly blocked by backend)
|
|
118
|
+
if (quotaData.blocked) {
|
|
119
|
+
if (quotaData.upgradeMessage) log(quotaData.upgradeMessage)
|
|
174
120
|
return {
|
|
175
|
-
|
|
121
|
+
allowed: false,
|
|
122
|
+
isNewSite,
|
|
123
|
+
quota: quotaData,
|
|
124
|
+
warnings: []
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check site limit for new sites
|
|
129
|
+
if (isNewSite) {
|
|
130
|
+
const canCreate =
|
|
131
|
+
quotaData.canCreateNewSite === undefined
|
|
132
|
+
? remaining > 0
|
|
133
|
+
: quotaData.canCreateNewSite
|
|
134
|
+
if (canCreate) {
|
|
135
|
+
// Site creation is allowed, continue
|
|
136
|
+
} else {
|
|
137
|
+
error(
|
|
138
|
+
`Site limit reached (${quotaData.limits?.maxSites || 'unknown'} sites)`
|
|
139
|
+
)
|
|
140
|
+
if (creds?.apiKey) {
|
|
141
|
+
info('Upgrade to Pro for more sites, or delete an existing site')
|
|
142
|
+
info('Check your quota status: launchpd whoami')
|
|
143
|
+
} else {
|
|
144
|
+
showUpgradePrompt()
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
allowed: false,
|
|
176
148
|
isNewSite,
|
|
177
149
|
quota: quotaData,
|
|
178
|
-
warnings
|
|
179
|
-
|
|
150
|
+
warnings
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check storage limit
|
|
156
|
+
const maxStorage =
|
|
157
|
+
quotaData.limits?.maxStorageBytes ||
|
|
158
|
+
quotaData.limits?.maxStorageMB * 1024 * 1024
|
|
159
|
+
const storageUsed =
|
|
160
|
+
quotaData.usage?.storageUsed ||
|
|
161
|
+
quotaData.usage?.storageUsedMB * 1024 * 1024 ||
|
|
162
|
+
0
|
|
163
|
+
const storageAfter = storageUsed + estimatedBytes
|
|
164
|
+
|
|
165
|
+
if (maxStorage && storageAfter > maxStorage) {
|
|
166
|
+
const overBy = storageAfter - quotaData.limits.maxStorageBytes
|
|
167
|
+
error(`Storage limit exceeded by ${formatBytes(overBy)}`)
|
|
168
|
+
error(
|
|
169
|
+
`Current: ${formatBytes(quotaData.usage.storageUsed)} / ${formatBytes(quotaData.limits.maxStorageBytes)}`
|
|
170
|
+
)
|
|
171
|
+
if (creds?.apiKey) {
|
|
172
|
+
info('Upgrade to Pro for more storage, or delete old deployments')
|
|
173
|
+
} else {
|
|
174
|
+
showUpgradePrompt()
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
allowed: false,
|
|
178
|
+
isNewSite,
|
|
179
|
+
quota: quotaData,
|
|
180
|
+
warnings
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Add storage warning if close to limit
|
|
185
|
+
const storagePercentage = storageAfter / quotaData.limits.maxStorageBytes
|
|
186
|
+
if (storagePercentage > 0.8) {
|
|
187
|
+
warnings.push(
|
|
188
|
+
`Storage ${Math.round(storagePercentage * 100)}% used (${formatBytes(storageAfter)} / ${formatBytes(quotaData.limits.maxStorageBytes)})`
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
allowed,
|
|
194
|
+
isNewSite,
|
|
195
|
+
quota: quotaData,
|
|
196
|
+
warnings
|
|
197
|
+
}
|
|
180
198
|
}
|
|
181
199
|
|
|
182
|
-
|
|
183
200
|
/**
|
|
184
201
|
* Check quota for anonymous user
|
|
185
202
|
*/
|
|
186
|
-
async function checkAnonymousQuota() {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (!response.ok) {
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
+
async function checkAnonymousQuota () {
|
|
204
|
+
const { signal, clear } = createFetchTimeout(API_TIMEOUT_MS)
|
|
205
|
+
try {
|
|
206
|
+
const clientToken = await getClientToken()
|
|
207
|
+
|
|
208
|
+
// Validate client token format before sending to network
|
|
209
|
+
// This ensures we only send properly formatted tokens, not arbitrary file data
|
|
210
|
+
if (
|
|
211
|
+
!clientToken ||
|
|
212
|
+
typeof clientToken !== 'string' ||
|
|
213
|
+
!/^cli_[a-f0-9]{32}$/.test(clientToken)
|
|
214
|
+
) {
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
203
217
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
218
|
+
const response = await fetch(`${API_BASE_URL}/api/quota/anonymous`, {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: {
|
|
221
|
+
'Content-Type': 'application/json'
|
|
222
|
+
},
|
|
223
|
+
body: JSON.stringify({
|
|
224
|
+
clientToken
|
|
225
|
+
}),
|
|
226
|
+
signal
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
if (!response.ok) {
|
|
230
|
+
return null
|
|
207
231
|
}
|
|
232
|
+
|
|
233
|
+
return await response.json()
|
|
234
|
+
} catch {
|
|
235
|
+
return null
|
|
236
|
+
} finally {
|
|
237
|
+
clear()
|
|
238
|
+
}
|
|
208
239
|
}
|
|
209
240
|
|
|
210
241
|
/**
|
|
211
242
|
* Check if user owns a subdomain
|
|
212
243
|
*/
|
|
213
|
-
async function userOwnsSite(apiKey, subdomain) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
244
|
+
async function userOwnsSite (apiKey, subdomain) {
|
|
245
|
+
if (!apiKey) {
|
|
246
|
+
// For anonymous, we track by client token in deployments
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const { signal, clear } = createFetchTimeout(API_TIMEOUT_MS)
|
|
251
|
+
try {
|
|
252
|
+
const response = await fetch(`${API_BASE_URL}/api/subdomains`, {
|
|
253
|
+
headers: {
|
|
254
|
+
'X-API-Key': apiKey
|
|
255
|
+
},
|
|
256
|
+
signal
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
if (!response.ok) {
|
|
260
|
+
log(`Fetch subdomains failed: ${response.status}`)
|
|
261
|
+
return false
|
|
217
262
|
}
|
|
218
263
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
return false;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const data = await response.json();
|
|
232
|
-
if (process.env.DEBUG) {
|
|
233
|
-
log(`User subdomains: ${data.subdomains?.map(s => s.subdomain)}`);
|
|
234
|
-
log(`Checking for: ${subdomain}`);
|
|
235
|
-
}
|
|
236
|
-
const owns = data.subdomains?.some(s => s.subdomain === subdomain) || false;
|
|
237
|
-
if (process.env.DEBUG) {
|
|
238
|
-
log(`Owns site? ${owns}`);
|
|
239
|
-
}
|
|
240
|
-
return owns;
|
|
241
|
-
} catch (err) {
|
|
242
|
-
log(`Error checking ownership: ${err.message}`);
|
|
243
|
-
return false;
|
|
264
|
+
const data = await response.json()
|
|
265
|
+
if (process.env.DEBUG) {
|
|
266
|
+
log(`User subdomains: ${data.subdomains?.map((s) => s.subdomain)}`)
|
|
267
|
+
log(`Checking for: ${subdomain}`)
|
|
268
|
+
}
|
|
269
|
+
const owns =
|
|
270
|
+
data.subdomains?.some((s) => s.subdomain === subdomain) || false
|
|
271
|
+
if (process.env.DEBUG) {
|
|
272
|
+
log(`Owns site? ${owns}`)
|
|
244
273
|
}
|
|
274
|
+
return owns
|
|
275
|
+
} catch (err) {
|
|
276
|
+
log(`Error checking ownership: ${err.message}`)
|
|
277
|
+
return false
|
|
278
|
+
} finally {
|
|
279
|
+
clear()
|
|
280
|
+
}
|
|
245
281
|
}
|
|
246
282
|
|
|
247
283
|
/**
|
|
248
284
|
* Show upgrade prompt for anonymous users
|
|
249
285
|
*/
|
|
250
|
-
function showUpgradePrompt() {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
286
|
+
function showUpgradePrompt () {
|
|
287
|
+
log('')
|
|
288
|
+
log('╔══════════════════════════════════════════════════════════════╗')
|
|
289
|
+
log('║ Upgrade to Launchpd Free Tier ║')
|
|
290
|
+
log('╠══════════════════════════════════════════════════════════════╣')
|
|
291
|
+
log('║ Register for FREE to unlock: ║')
|
|
292
|
+
log('║ → 10 sites (instead of 3) ║')
|
|
293
|
+
log('║ → 100MB storage (instead of 50MB) ║')
|
|
294
|
+
log('║ → 30-day retention (instead of 7 days) ║')
|
|
295
|
+
log('║ → 10 version history per site ║')
|
|
296
|
+
log('╠══════════════════════════════════════════════════════════════╣')
|
|
297
|
+
log('║ Run: launchpd register ║')
|
|
298
|
+
log('╚══════════════════════════════════════════════════════════════╝')
|
|
299
|
+
log('')
|
|
264
300
|
}
|
|
265
301
|
|
|
266
302
|
/**
|
|
267
303
|
* Display quota warnings
|
|
268
304
|
*/
|
|
269
|
-
export function displayQuotaWarnings(warnings) {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
305
|
+
export function displayQuotaWarnings (warnings) {
|
|
306
|
+
if (warnings && warnings.length > 0) {
|
|
307
|
+
log('')
|
|
308
|
+
warnings.forEach((w) => warning(w))
|
|
309
|
+
}
|
|
274
310
|
}
|
|
275
311
|
|
|
276
312
|
/**
|
|
277
313
|
* Format bytes to human readable
|
|
278
314
|
*/
|
|
279
|
-
export function formatBytes(bytes) {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
315
|
+
export function formatBytes (bytes) {
|
|
316
|
+
if (bytes === 0) return '0 B'
|
|
317
|
+
const k = 1024
|
|
318
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
319
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
320
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
|
285
321
|
}
|