launchpd 0.1.0
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.
Potentially problematic release.
This version of launchpd might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/bin/cli.js +90 -0
- package/bin/setup.js +62 -0
- package/package.json +68 -0
- package/src/commands/auth.js +324 -0
- package/src/commands/deploy.js +194 -0
- package/src/commands/index.js +9 -0
- package/src/commands/list.js +111 -0
- package/src/commands/rollback.js +101 -0
- package/src/commands/versions.js +75 -0
- package/src/config.js +36 -0
- package/src/utils/api.js +158 -0
- package/src/utils/credentials.js +143 -0
- package/src/utils/expiration.js +89 -0
- package/src/utils/id.js +17 -0
- package/src/utils/index.js +13 -0
- package/src/utils/localConfig.js +85 -0
- package/src/utils/logger.js +33 -0
- package/src/utils/metadata.js +354 -0
- package/src/utils/quota.js +229 -0
- package/src/utils/upload.js +74 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get the local config directory path
|
|
8
|
+
* ~/.staticlaunch/ on Unix, %USERPROFILE%\.staticlaunch\ on Windows
|
|
9
|
+
*/
|
|
10
|
+
function getConfigDir() {
|
|
11
|
+
return join(homedir(), '.staticlaunch');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get the local deployments file path
|
|
16
|
+
*/
|
|
17
|
+
function getDeploymentsPath() {
|
|
18
|
+
return join(getConfigDir(), 'deployments.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ensure config directory exists
|
|
23
|
+
*/
|
|
24
|
+
async function ensureConfigDir() {
|
|
25
|
+
const dir = getConfigDir();
|
|
26
|
+
if (!existsSync(dir)) {
|
|
27
|
+
await mkdir(dir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get local deployments data
|
|
33
|
+
* @returns {Promise<{version: number, deployments: Array}>}
|
|
34
|
+
*/
|
|
35
|
+
async function getLocalData() {
|
|
36
|
+
const filePath = getDeploymentsPath();
|
|
37
|
+
try {
|
|
38
|
+
if (existsSync(filePath)) {
|
|
39
|
+
const text = await readFile(filePath, 'utf-8');
|
|
40
|
+
return JSON.parse(text);
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// Corrupted or invalid JSON file, return empty structure
|
|
44
|
+
}
|
|
45
|
+
return { version: 1, deployments: [] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Save a deployment record locally
|
|
50
|
+
* This provides quick access to user's own deployments without R2 read
|
|
51
|
+
* @param {object} deployment - Deployment record
|
|
52
|
+
*/
|
|
53
|
+
export async function saveLocalDeployment(deployment) {
|
|
54
|
+
await ensureConfigDir();
|
|
55
|
+
|
|
56
|
+
const data = await getLocalData();
|
|
57
|
+
data.deployments.push(deployment);
|
|
58
|
+
|
|
59
|
+
await writeFile(
|
|
60
|
+
getDeploymentsPath(),
|
|
61
|
+
JSON.stringify(data, null, 2),
|
|
62
|
+
'utf-8'
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get all local deployments (user's own deployments from this machine)
|
|
68
|
+
* @returns {Promise<Array>} Array of deployment records
|
|
69
|
+
*/
|
|
70
|
+
export async function getLocalDeployments() {
|
|
71
|
+
const data = await getLocalData();
|
|
72
|
+
return data.deployments;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Clear local deployments history
|
|
77
|
+
*/
|
|
78
|
+
export async function clearLocalDeployments() {
|
|
79
|
+
await ensureConfigDir();
|
|
80
|
+
await writeFile(
|
|
81
|
+
getDeploymentsPath(),
|
|
82
|
+
JSON.stringify({ version: 1, deployments: [] }, null, 2),
|
|
83
|
+
'utf-8'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Log a success message
|
|
5
|
+
* @param {string} message
|
|
6
|
+
*/
|
|
7
|
+
export function success(message) {
|
|
8
|
+
console.log(chalk.green.bold('✓'), chalk.green(message));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Log an error message
|
|
13
|
+
* @param {string} message
|
|
14
|
+
*/
|
|
15
|
+
export function error(message) {
|
|
16
|
+
console.error(chalk.red.bold('✗'), chalk.red(message));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Log an info message
|
|
21
|
+
* @param {string} message
|
|
22
|
+
*/
|
|
23
|
+
export function info(message) {
|
|
24
|
+
console.log(chalk.blue('ℹ'), chalk.white(message));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Log a warning message
|
|
29
|
+
* @param {string} message
|
|
30
|
+
*/
|
|
31
|
+
export function warning(message) {
|
|
32
|
+
console.log(chalk.yellow.bold('⚠'), chalk.yellow(message));
|
|
33
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { S3Client, GetObjectCommand, PutObjectCommand, ListObjectsV2Command, CopyObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
|
2
|
+
import { config } from '../config.js';
|
|
3
|
+
|
|
4
|
+
const METADATA_KEY = '_meta/deployments.json';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create S3-compatible client for Cloudflare R2
|
|
8
|
+
*/
|
|
9
|
+
function createR2Client() {
|
|
10
|
+
return new S3Client({
|
|
11
|
+
region: 'auto',
|
|
12
|
+
endpoint: `https://${config.r2.accountId}.r2.cloudflarestorage.com`,
|
|
13
|
+
credentials: {
|
|
14
|
+
accessKeyId: config.r2.accessKeyId,
|
|
15
|
+
secretAccessKey: config.r2.secretAccessKey,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get existing deployments metadata from R2
|
|
22
|
+
* @returns {Promise<{version: number, deployments: Array}>}
|
|
23
|
+
*/
|
|
24
|
+
async function getDeploymentsData(client) {
|
|
25
|
+
try {
|
|
26
|
+
const response = await client.send(new GetObjectCommand({
|
|
27
|
+
Bucket: config.r2.bucketName,
|
|
28
|
+
Key: METADATA_KEY,
|
|
29
|
+
}));
|
|
30
|
+
const text = await response.Body.transformToString();
|
|
31
|
+
return JSON.parse(text);
|
|
32
|
+
} catch {
|
|
33
|
+
// File doesn't exist yet, return empty structure
|
|
34
|
+
return { version: 1, deployments: [] };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a timestamped backup of the metadata before overwriting
|
|
40
|
+
* @param {S3Client} client
|
|
41
|
+
* @param {object} data - Current metadata to backup
|
|
42
|
+
*/
|
|
43
|
+
async function backupMetadata(client, data) {
|
|
44
|
+
if (data.deployments.length === 0) return; // Nothing to backup
|
|
45
|
+
|
|
46
|
+
const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
|
|
47
|
+
const backupKey = `_meta/backups/deployments-${timestamp}.json`;
|
|
48
|
+
|
|
49
|
+
await client.send(new PutObjectCommand({
|
|
50
|
+
Bucket: config.r2.bucketName,
|
|
51
|
+
Key: backupKey,
|
|
52
|
+
Body: JSON.stringify(data, null, 2),
|
|
53
|
+
ContentType: 'application/json',
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Record a deployment to R2 metadata
|
|
59
|
+
* @param {string} subdomain - Deployed subdomain
|
|
60
|
+
* @param {string} folderPath - Original folder path
|
|
61
|
+
* @param {number} fileCount - Number of files deployed
|
|
62
|
+
* @param {number} totalBytes - Total bytes uploaded
|
|
63
|
+
* @param {number} version - Version number for this deployment
|
|
64
|
+
* @param {string|null} expiresAt - ISO timestamp for expiration, or null for no expiration
|
|
65
|
+
*/
|
|
66
|
+
export async function recordDeployment(subdomain, folderPath, fileCount, totalBytes = 0, version = 1, expiresAt = null) {
|
|
67
|
+
const client = createR2Client();
|
|
68
|
+
|
|
69
|
+
// Get existing data
|
|
70
|
+
const data = await getDeploymentsData(client);
|
|
71
|
+
|
|
72
|
+
// Backup before modifying (prevents accidental data loss)
|
|
73
|
+
await backupMetadata(client, data);
|
|
74
|
+
|
|
75
|
+
// Extract folder name from path
|
|
76
|
+
const folderName = folderPath.split(/[\\/]/).pop() || 'unknown';
|
|
77
|
+
|
|
78
|
+
// Append new deployment
|
|
79
|
+
const deployment = {
|
|
80
|
+
subdomain,
|
|
81
|
+
timestamp: new Date().toISOString(),
|
|
82
|
+
folderName,
|
|
83
|
+
fileCount,
|
|
84
|
+
totalBytes,
|
|
85
|
+
cliVersion: '0.1.0',
|
|
86
|
+
version,
|
|
87
|
+
isActive: true,
|
|
88
|
+
expiresAt,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
data.deployments.push(deployment);
|
|
92
|
+
|
|
93
|
+
// Write back to R2
|
|
94
|
+
await client.send(new PutObjectCommand({
|
|
95
|
+
Bucket: config.r2.bucketName,
|
|
96
|
+
Key: METADATA_KEY,
|
|
97
|
+
Body: JSON.stringify(data, null, 2),
|
|
98
|
+
ContentType: 'application/json',
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
return deployment;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* List all deployments from R2 metadata
|
|
106
|
+
* @returns {Promise<Array>} Array of deployment records
|
|
107
|
+
*/
|
|
108
|
+
export async function listDeploymentsFromR2() {
|
|
109
|
+
const client = createR2Client();
|
|
110
|
+
const data = await getDeploymentsData(client);
|
|
111
|
+
return data.deployments;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get the next version number for a subdomain
|
|
116
|
+
* @param {string} subdomain - The subdomain to check
|
|
117
|
+
* @returns {Promise<number>} Next version number
|
|
118
|
+
*/
|
|
119
|
+
export async function getNextVersion(subdomain) {
|
|
120
|
+
const client = createR2Client();
|
|
121
|
+
const data = await getDeploymentsData(client);
|
|
122
|
+
|
|
123
|
+
const existingDeployments = data.deployments.filter(d => d.subdomain === subdomain);
|
|
124
|
+
if (existingDeployments.length === 0) {
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const maxVersion = Math.max(...existingDeployments.map(d => d.version || 1));
|
|
129
|
+
return maxVersion + 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get all versions for a specific subdomain
|
|
134
|
+
* @param {string} subdomain - The subdomain to get versions for
|
|
135
|
+
* @returns {Promise<Array>} Array of deployment versions
|
|
136
|
+
*/
|
|
137
|
+
export async function getVersionsForSubdomain(subdomain) {
|
|
138
|
+
const client = createR2Client();
|
|
139
|
+
const data = await getDeploymentsData(client);
|
|
140
|
+
|
|
141
|
+
return data.deployments
|
|
142
|
+
.filter(d => d.subdomain === subdomain)
|
|
143
|
+
.sort((a, b) => (b.version || 1) - (a.version || 1));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Copy files from one version to another (for rollback)
|
|
148
|
+
* @param {string} subdomain - The subdomain
|
|
149
|
+
* @param {number} fromVersion - Source version
|
|
150
|
+
* @param {number} toVersion - Target version (new active version)
|
|
151
|
+
*/
|
|
152
|
+
export async function copyVersionFiles(subdomain, fromVersion, toVersion) {
|
|
153
|
+
const client = createR2Client();
|
|
154
|
+
|
|
155
|
+
// List all files in the source version
|
|
156
|
+
const sourcePrefix = `${subdomain}/v${fromVersion}/`;
|
|
157
|
+
const targetPrefix = `${subdomain}/v${toVersion}/`;
|
|
158
|
+
|
|
159
|
+
const listResponse = await client.send(new ListObjectsV2Command({
|
|
160
|
+
Bucket: config.r2.bucketName,
|
|
161
|
+
Prefix: sourcePrefix,
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
if (!listResponse.Contents || listResponse.Contents.length === 0) {
|
|
165
|
+
throw new Error(`No files found for version ${fromVersion}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let copiedCount = 0;
|
|
169
|
+
|
|
170
|
+
for (const object of listResponse.Contents) {
|
|
171
|
+
const sourceKey = object.Key;
|
|
172
|
+
const targetKey = sourceKey.replace(sourcePrefix, targetPrefix);
|
|
173
|
+
|
|
174
|
+
// Copy file to new version
|
|
175
|
+
await client.send(new CopyObjectCommand({
|
|
176
|
+
Bucket: config.r2.bucketName,
|
|
177
|
+
CopySource: `${config.r2.bucketName}/${sourceKey}`,
|
|
178
|
+
Key: targetKey,
|
|
179
|
+
}));
|
|
180
|
+
|
|
181
|
+
copiedCount++;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { copiedCount, fromVersion, toVersion };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Update the active pointer for a subdomain (for rollback)
|
|
189
|
+
* @param {string} subdomain - The subdomain
|
|
190
|
+
* @param {number} version - Version to make active
|
|
191
|
+
*/
|
|
192
|
+
export async function setActiveVersion(subdomain, version) {
|
|
193
|
+
const client = createR2Client();
|
|
194
|
+
|
|
195
|
+
// Create/update an active pointer file
|
|
196
|
+
const pointerKey = `${subdomain}/_active`;
|
|
197
|
+
|
|
198
|
+
await client.send(new PutObjectCommand({
|
|
199
|
+
Bucket: config.r2.bucketName,
|
|
200
|
+
Key: pointerKey,
|
|
201
|
+
Body: JSON.stringify({ activeVersion: version, updatedAt: new Date().toISOString() }),
|
|
202
|
+
ContentType: 'application/json',
|
|
203
|
+
}));
|
|
204
|
+
|
|
205
|
+
return { subdomain, activeVersion: version };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get the active version for a subdomain
|
|
210
|
+
* @param {string} subdomain - The subdomain
|
|
211
|
+
* @returns {Promise<number>} Active version number
|
|
212
|
+
*/
|
|
213
|
+
export async function getActiveVersion(subdomain) {
|
|
214
|
+
const client = createR2Client();
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const response = await client.send(new GetObjectCommand({
|
|
218
|
+
Bucket: config.r2.bucketName,
|
|
219
|
+
Key: `${subdomain}/_active`,
|
|
220
|
+
}));
|
|
221
|
+
const text = await response.Body.transformToString();
|
|
222
|
+
const data = JSON.parse(text);
|
|
223
|
+
return data.activeVersion || 1;
|
|
224
|
+
} catch {
|
|
225
|
+
// No active pointer, default to version 1
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* List all files for a specific version
|
|
232
|
+
* @param {string} subdomain - The subdomain
|
|
233
|
+
* @param {number} version - Version number
|
|
234
|
+
* @returns {Promise<Array>} Array of file keys
|
|
235
|
+
*/
|
|
236
|
+
export async function listVersionFiles(subdomain, version) {
|
|
237
|
+
const client = createR2Client();
|
|
238
|
+
|
|
239
|
+
const prefix = `${subdomain}/v${version}/`;
|
|
240
|
+
|
|
241
|
+
const response = await client.send(new ListObjectsV2Command({
|
|
242
|
+
Bucket: config.r2.bucketName,
|
|
243
|
+
Prefix: prefix,
|
|
244
|
+
}));
|
|
245
|
+
|
|
246
|
+
if (!response.Contents) {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return response.Contents.map(obj => ({
|
|
251
|
+
key: obj.Key,
|
|
252
|
+
size: obj.Size,
|
|
253
|
+
lastModified: obj.LastModified,
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Delete all files for a subdomain (all versions)
|
|
259
|
+
* @param {string} subdomain - The subdomain to delete
|
|
260
|
+
* @returns {Promise<{deletedCount: number}>}
|
|
261
|
+
*/
|
|
262
|
+
export async function deleteSubdomain(subdomain) {
|
|
263
|
+
const client = createR2Client();
|
|
264
|
+
|
|
265
|
+
// List all files for this subdomain
|
|
266
|
+
const prefix = `${subdomain}/`;
|
|
267
|
+
|
|
268
|
+
let deletedCount = 0;
|
|
269
|
+
let continuationToken;
|
|
270
|
+
|
|
271
|
+
do {
|
|
272
|
+
const response = await client.send(new ListObjectsV2Command({
|
|
273
|
+
Bucket: config.r2.bucketName,
|
|
274
|
+
Prefix: prefix,
|
|
275
|
+
ContinuationToken: continuationToken,
|
|
276
|
+
}));
|
|
277
|
+
|
|
278
|
+
if (response.Contents && response.Contents.length > 0) {
|
|
279
|
+
for (const object of response.Contents) {
|
|
280
|
+
await client.send(new DeleteObjectCommand({
|
|
281
|
+
Bucket: config.r2.bucketName,
|
|
282
|
+
Key: object.Key,
|
|
283
|
+
}));
|
|
284
|
+
deletedCount++;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
continuationToken = response.NextContinuationToken;
|
|
289
|
+
} while (continuationToken);
|
|
290
|
+
|
|
291
|
+
return { deletedCount };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get all expired deployments
|
|
296
|
+
* @returns {Promise<Array>} Array of expired deployment records
|
|
297
|
+
*/
|
|
298
|
+
export async function getExpiredDeployments() {
|
|
299
|
+
const client = createR2Client();
|
|
300
|
+
const data = await getDeploymentsData(client);
|
|
301
|
+
const now = Date.now();
|
|
302
|
+
|
|
303
|
+
return data.deployments.filter(d =>
|
|
304
|
+
d.expiresAt && new Date(d.expiresAt).getTime() < now
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Remove deployment records for a subdomain from metadata
|
|
310
|
+
* @param {string} subdomain - The subdomain to remove
|
|
311
|
+
*/
|
|
312
|
+
export async function removeDeploymentRecords(subdomain) {
|
|
313
|
+
const client = createR2Client();
|
|
314
|
+
const data = await getDeploymentsData(client);
|
|
315
|
+
|
|
316
|
+
// Backup before modifying
|
|
317
|
+
await backupMetadata(client, data);
|
|
318
|
+
|
|
319
|
+
// Filter out the subdomain's deployments
|
|
320
|
+
data.deployments = data.deployments.filter(d => d.subdomain !== subdomain);
|
|
321
|
+
|
|
322
|
+
// Write back
|
|
323
|
+
await client.send(new PutObjectCommand({
|
|
324
|
+
Bucket: config.r2.bucketName,
|
|
325
|
+
Key: METADATA_KEY,
|
|
326
|
+
Body: JSON.stringify(data, null, 2),
|
|
327
|
+
ContentType: 'application/json',
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Clean up all expired deployments
|
|
333
|
+
* @returns {Promise<{cleaned: string[], errors: string[]}>}
|
|
334
|
+
*/
|
|
335
|
+
export async function cleanupExpiredDeployments() {
|
|
336
|
+
const expired = await getExpiredDeployments();
|
|
337
|
+
const cleaned = [];
|
|
338
|
+
const errors = [];
|
|
339
|
+
|
|
340
|
+
// Get unique subdomains
|
|
341
|
+
const subdomains = [...new Set(expired.map(d => d.subdomain))];
|
|
342
|
+
|
|
343
|
+
for (const subdomain of subdomains) {
|
|
344
|
+
try {
|
|
345
|
+
await deleteSubdomain(subdomain);
|
|
346
|
+
await removeDeploymentRecords(subdomain);
|
|
347
|
+
cleaned.push(subdomain);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
errors.push(`${subdomain}: ${err.message}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return { cleaned, errors };
|
|
354
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota checking for StaticLaunch CLI
|
|
3
|
+
* Validates user can deploy before uploading
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { config } from '../config.js';
|
|
7
|
+
import { getCredentials, getClientToken } from './credentials.js';
|
|
8
|
+
import { warning, error, info } from './logger.js';
|
|
9
|
+
|
|
10
|
+
const API_BASE_URL = `https://api.${config.domain}`;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check quota before deployment
|
|
14
|
+
* Returns quota info and whether deployment is allowed
|
|
15
|
+
*
|
|
16
|
+
* @param {string} subdomain - Target subdomain (null for new site)
|
|
17
|
+
* @param {number} estimatedBytes - Estimated upload size in bytes
|
|
18
|
+
* @returns {Promise<{allowed: boolean, isNewSite: boolean, quota: object, warnings: string[]}>}
|
|
19
|
+
*/
|
|
20
|
+
export async function checkQuota(subdomain, estimatedBytes = 0) {
|
|
21
|
+
const creds = await getCredentials();
|
|
22
|
+
|
|
23
|
+
let quotaData;
|
|
24
|
+
|
|
25
|
+
if (creds?.apiKey) {
|
|
26
|
+
// Authenticated user
|
|
27
|
+
quotaData = await checkAuthenticatedQuota(creds.apiKey);
|
|
28
|
+
} else {
|
|
29
|
+
// Anonymous user
|
|
30
|
+
quotaData = await checkAnonymousQuota();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!quotaData) {
|
|
34
|
+
// API unavailable, allow deployment (fail-open for MVP)
|
|
35
|
+
return {
|
|
36
|
+
allowed: true,
|
|
37
|
+
isNewSite: true,
|
|
38
|
+
quota: null,
|
|
39
|
+
warnings: ['⚠️ Could not verify quota (API unavailable)'],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check if this is an existing site the user owns
|
|
44
|
+
const isNewSite = subdomain ? !await userOwnsSite(creds?.apiKey, subdomain) : true;
|
|
45
|
+
|
|
46
|
+
const warnings = [...(quotaData.warnings || [])];
|
|
47
|
+
const allowed = true;
|
|
48
|
+
|
|
49
|
+
// Check if blocked (anonymous limit reached)
|
|
50
|
+
if (quotaData.blocked) {
|
|
51
|
+
console.log(quotaData.upgradeMessage);
|
|
52
|
+
return {
|
|
53
|
+
allowed: false,
|
|
54
|
+
isNewSite,
|
|
55
|
+
quota: quotaData,
|
|
56
|
+
warnings: [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check site limit for new sites
|
|
61
|
+
if (isNewSite && !quotaData.canCreateNewSite) {
|
|
62
|
+
error(`Site limit reached (${quotaData.limits.maxSites} sites)`);
|
|
63
|
+
if (!creds?.apiKey) {
|
|
64
|
+
showUpgradePrompt();
|
|
65
|
+
} else {
|
|
66
|
+
info('Upgrade to Pro for more sites, or delete an existing site');
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
allowed: false,
|
|
70
|
+
isNewSite,
|
|
71
|
+
quota: quotaData,
|
|
72
|
+
warnings,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check storage limit
|
|
77
|
+
const storageAfter = (quotaData.usage?.storageUsed || 0) + estimatedBytes;
|
|
78
|
+
if (storageAfter > quotaData.limits.maxStorageBytes) {
|
|
79
|
+
const overBy = storageAfter - quotaData.limits.maxStorageBytes;
|
|
80
|
+
error(`Storage limit exceeded by ${formatBytes(overBy)}`);
|
|
81
|
+
error(`Current: ${formatBytes(quotaData.usage.storageUsed)} / ${formatBytes(quotaData.limits.maxStorageBytes)}`);
|
|
82
|
+
if (!creds?.apiKey) {
|
|
83
|
+
showUpgradePrompt();
|
|
84
|
+
} else {
|
|
85
|
+
info('Upgrade to Pro for more storage, or delete old deployments');
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
allowed: false,
|
|
89
|
+
isNewSite,
|
|
90
|
+
quota: quotaData,
|
|
91
|
+
warnings,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Add storage warning if close to limit
|
|
96
|
+
const storagePercentage = storageAfter / quotaData.limits.maxStorageBytes;
|
|
97
|
+
if (storagePercentage > 0.8) {
|
|
98
|
+
warnings.push(`⚠️ Storage ${Math.round(storagePercentage * 100)}% used after this deploy`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Add site count warning if close to limit
|
|
102
|
+
if (isNewSite) {
|
|
103
|
+
const sitesAfter = (quotaData.usage?.siteCount || 0) + 1;
|
|
104
|
+
const sitePercentage = sitesAfter / quotaData.limits.maxSites;
|
|
105
|
+
if (sitePercentage > 0.8) {
|
|
106
|
+
warnings.push(`⚠️ ${quotaData.limits.maxSites - sitesAfter} site(s) remaining after this deploy`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
allowed,
|
|
112
|
+
isNewSite,
|
|
113
|
+
quota: quotaData,
|
|
114
|
+
warnings,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check quota for authenticated user
|
|
120
|
+
*/
|
|
121
|
+
async function checkAuthenticatedQuota(apiKey) {
|
|
122
|
+
try {
|
|
123
|
+
const response = await fetch(`${API_BASE_URL}/api/quota`, {
|
|
124
|
+
headers: {
|
|
125
|
+
'X-API-Key': apiKey,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return await response.json();
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Check quota for anonymous user
|
|
141
|
+
*/
|
|
142
|
+
async function checkAnonymousQuota() {
|
|
143
|
+
try {
|
|
144
|
+
const clientToken = await getClientToken();
|
|
145
|
+
|
|
146
|
+
const response = await fetch(`${API_BASE_URL}/api/quota/anonymous`, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: {
|
|
149
|
+
'Content-Type': 'application/json',
|
|
150
|
+
},
|
|
151
|
+
body: JSON.stringify({
|
|
152
|
+
clientToken,
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return await response.json();
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Check if user owns a subdomain
|
|
168
|
+
*/
|
|
169
|
+
async function userOwnsSite(apiKey, subdomain) {
|
|
170
|
+
if (!apiKey) {
|
|
171
|
+
// For anonymous, we track by client token in deployments
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const response = await fetch(`${API_BASE_URL}/api/subdomains`, {
|
|
177
|
+
headers: {
|
|
178
|
+
'X-API-Key': apiKey,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const data = await response.json();
|
|
187
|
+
return data.subdomains?.some(s => s.subdomain === subdomain) || false;
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Show upgrade prompt for anonymous users
|
|
195
|
+
*/
|
|
196
|
+
function showUpgradePrompt() {
|
|
197
|
+
console.log('');
|
|
198
|
+
console.log('╔══════════════════════════════════════════════════════════════╗');
|
|
199
|
+
console.log('║ 🚀 Upgrade to Launchpd Free Tier ║');
|
|
200
|
+
console.log('╠══════════════════════════════════════════════════════════════╣');
|
|
201
|
+
console.log('║ Register for FREE to unlock: ║');
|
|
202
|
+
console.log('║ → 10 sites (instead of 3) ║');
|
|
203
|
+
console.log('║ → 100MB storage (instead of 50MB) ║');
|
|
204
|
+
console.log('║ → 30-day retention (instead of 7 days) ║');
|
|
205
|
+
console.log('║ → 10 version history per site ║');
|
|
206
|
+
console.log('╠══════════════════════════════════════════════════════════════╣');
|
|
207
|
+
console.log('║ Run: launchpd register ║');
|
|
208
|
+
console.log('╚══════════════════════════════════════════════════════════════╝');
|
|
209
|
+
console.log('');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Display quota warnings
|
|
214
|
+
*/
|
|
215
|
+
export function displayQuotaWarnings(warnings) {
|
|
216
|
+
if (warnings && warnings.length > 0) {
|
|
217
|
+
console.log('');
|
|
218
|
+
warnings.forEach(w => warning(w));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Format bytes to human readable
|
|
224
|
+
*/
|
|
225
|
+
function formatBytes(bytes) {
|
|
226
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
227
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
228
|
+
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
229
|
+
}
|