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