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.

@@ -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
+ }