launchpd 0.1.2 → 0.1.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.

Potentially problematic release.


This version of launchpd might be problematic. Click here for more details.

@@ -1,4 +1,8 @@
1
1
  import chalk from 'chalk';
2
+ import ora from 'ora';
3
+
4
+ // Store active spinner reference
5
+ let activeSpinner = null;
2
6
 
3
7
  /**
4
8
  * Log a success message
@@ -11,9 +15,16 @@ export function success(message) {
11
15
  /**
12
16
  * Log an error message
13
17
  * @param {string} message
18
+ * @param {object} options - Optional error details
19
+ * @param {boolean} options.verbose - Show verbose error details
20
+ * @param {Error} options.cause - Original error for verbose mode
14
21
  */
15
- export function error(message) {
22
+ export function error(message, options = {}) {
16
23
  console.error(chalk.red.bold('✗'), chalk.red(message));
24
+ if (options.verbose && options.cause) {
25
+ console.error(chalk.gray(' Stack trace:'));
26
+ console.error(chalk.gray(` ${options.cause.stack || options.cause.message}`));
27
+ }
17
28
  }
18
29
 
19
30
  /**
@@ -31,3 +42,115 @@ export function info(message) {
31
42
  export function warning(message) {
32
43
  console.log(chalk.yellow.bold('⚠'), chalk.yellow(message));
33
44
  }
45
+
46
+ /**
47
+ * Create and start a spinner
48
+ * @param {string} text - Initial spinner text
49
+ * @returns {object} - Spinner instance with helper methods
50
+ */
51
+ export function spinner(text) {
52
+ activeSpinner = ora({
53
+ text,
54
+ color: 'cyan',
55
+ spinner: 'dots',
56
+ }).start();
57
+
58
+ return {
59
+ /**
60
+ * Update spinner text
61
+ * @param {string} newText
62
+ */
63
+ update(newText) {
64
+ if (activeSpinner) {
65
+ activeSpinner.text = newText;
66
+ }
67
+ },
68
+
69
+ /**
70
+ * Mark spinner as successful and stop
71
+ * @param {string} text - Success message
72
+ */
73
+ succeed(text) {
74
+ if (activeSpinner) {
75
+ activeSpinner.succeed(chalk.green(text));
76
+ activeSpinner = null;
77
+ }
78
+ },
79
+
80
+ /**
81
+ * Mark spinner as failed and stop
82
+ * @param {string} text - Failure message
83
+ */
84
+ fail(text) {
85
+ if (activeSpinner) {
86
+ activeSpinner.fail(chalk.red(text));
87
+ activeSpinner = null;
88
+ }
89
+ },
90
+
91
+ /**
92
+ * Stop spinner with info message
93
+ * @param {string} text - Info message
94
+ */
95
+ info(text) {
96
+ if (activeSpinner) {
97
+ activeSpinner.info(chalk.blue(text));
98
+ activeSpinner = null;
99
+ }
100
+ },
101
+
102
+ /**
103
+ * Stop spinner with warning
104
+ * @param {string} text - Warning message
105
+ */
106
+ warn(text) {
107
+ if (activeSpinner) {
108
+ activeSpinner.warn(chalk.yellow(text));
109
+ activeSpinner = null;
110
+ }
111
+ },
112
+
113
+ /**
114
+ * Stop spinner without any symbol
115
+ */
116
+ stop() {
117
+ if (activeSpinner) {
118
+ activeSpinner.stop();
119
+ activeSpinner = null;
120
+ }
121
+ },
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Format bytes to human readable size (KB/MB/GB)
127
+ * @param {number} bytes - Size in bytes
128
+ * @param {number} decimals - Number of decimal places
129
+ * @returns {string} - Formatted size string
130
+ */
131
+ export function formatSize(bytes, decimals = 2) {
132
+ if (bytes === 0) return '0 Bytes';
133
+
134
+ const k = 1024;
135
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
136
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
137
+
138
+ return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
139
+ }
140
+
141
+ /**
142
+ * Log helpful error with suggestions
143
+ * @param {string} message - Error message
144
+ * @param {string[]} suggestions - Array of suggested actions
145
+ * @param {object} options - Error options
146
+ */
147
+ export function errorWithSuggestions(message, suggestions = [], options = {}) {
148
+ error(message, options);
149
+ if (suggestions.length > 0) {
150
+ console.log('');
151
+ console.log(chalk.yellow('💡 Suggestions:'));
152
+ suggestions.forEach(suggestion => {
153
+ console.log(chalk.gray(` • ${suggestion}`));
154
+ });
155
+ }
156
+ }
@@ -1,114 +1,85 @@
1
- import { S3Client, GetObjectCommand, PutObjectCommand, ListObjectsV2Command, CopyObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
1
+ /**
2
+ * Metadata utilities for Launchpd CLI
3
+ * All operations now go through the API proxy
4
+ */
5
+
2
6
  import { config } from '../config.js';
3
7
 
4
- const METADATA_KEY = '_meta/deployments.json';
8
+ const API_BASE_URL = config.apiUrl;
5
9
 
6
10
  /**
7
- * Create S3-compatible client for Cloudflare R2
11
+ * Get API key for requests
8
12
  */
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
- });
13
+ function getApiKey() {
14
+ return process.env.STATICLAUNCH_API_KEY || 'public-beta-key';
18
15
  }
19
16
 
20
17
  /**
21
- * Get existing deployments metadata from R2
22
- * @returns {Promise<{version: number, deployments: Array}>}
18
+ * Make an authenticated API request
23
19
  */
24
- async function getDeploymentsData(client) {
20
+ async function apiRequest(endpoint, options = {}) {
21
+ const url = `${API_BASE_URL}${endpoint}`;
22
+
23
+ const headers = {
24
+ 'Content-Type': 'application/json',
25
+ 'X-API-Key': getApiKey(),
26
+ ...options.headers,
27
+ };
28
+
25
29
  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
- }
30
+ const response = await fetch(url, {
31
+ ...options,
32
+ headers,
33
+ });
37
34
 
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
35
+ const data = await response.json();
45
36
 
46
- const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
47
- const backupKey = `_meta/backups/deployments-${timestamp}.json`;
37
+ if (!response.ok) {
38
+ throw new Error(data.error || `API error: ${response.status}`);
39
+ }
48
40
 
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
- }));
41
+ return data;
42
+ } catch (err) {
43
+ if (err.message.includes('fetch failed') || err.message.includes('ENOTFOUND')) {
44
+ return null;
45
+ }
46
+ throw err;
47
+ }
55
48
  }
56
49
 
57
50
  /**
58
- * Record a deployment to R2 metadata
51
+ * Record a deployment to the API
59
52
  * @param {string} subdomain - Deployed subdomain
60
53
  * @param {string} folderPath - Original folder path
61
54
  * @param {number} fileCount - Number of files deployed
62
55
  * @param {number} totalBytes - Total bytes uploaded
63
56
  * @param {number} version - Version number for this deployment
64
- * @param {string|null} expiresAt - ISO timestamp for expiration, or null for no expiration
57
+ * @param {Date|null} expiresAt - Expiration date, or null for no expiration
65
58
  */
66
59
  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
60
  const folderName = folderPath.split(/[\\/]/).pop() || 'unknown';
77
61
 
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;
62
+ return apiRequest('/api/deployments', {
63
+ method: 'POST',
64
+ body: JSON.stringify({
65
+ subdomain,
66
+ folderName,
67
+ fileCount,
68
+ totalBytes,
69
+ version,
70
+ cliVersion: config.version,
71
+ expiresAt: expiresAt?.toISOString() || null,
72
+ }),
73
+ });
102
74
  }
103
75
 
104
76
  /**
105
- * List all deployments from R2 metadata
77
+ * List all deployments for the current user
106
78
  * @returns {Promise<Array>} Array of deployment records
107
79
  */
108
80
  export async function listDeploymentsFromR2() {
109
- const client = createR2Client();
110
- const data = await getDeploymentsData(client);
111
- return data.deployments;
81
+ const result = await apiRequest('/api/deployments');
82
+ return result?.deployments || [];
112
83
  }
113
84
 
114
85
  /**
@@ -117,15 +88,13 @@ export async function listDeploymentsFromR2() {
117
88
  * @returns {Promise<number>} Next version number
118
89
  */
119
90
  export async function getNextVersion(subdomain) {
120
- const client = createR2Client();
121
- const data = await getDeploymentsData(client);
91
+ const result = await apiRequest(`/api/versions/${subdomain}`);
122
92
 
123
- const existingDeployments = data.deployments.filter(d => d.subdomain === subdomain);
124
- if (existingDeployments.length === 0) {
93
+ if (!result || !result.versions || result.versions.length === 0) {
125
94
  return 1;
126
95
  }
127
96
 
128
- const maxVersion = Math.max(...existingDeployments.map(d => d.version || 1));
97
+ const maxVersion = Math.max(...result.versions.map(v => v.version));
129
98
  return maxVersion + 1;
130
99
  }
131
100
 
@@ -135,74 +104,20 @@ export async function getNextVersion(subdomain) {
135
104
  * @returns {Promise<Array>} Array of deployment versions
136
105
  */
137
106
  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));
107
+ const result = await apiRequest(`/api/versions/${subdomain}`);
108
+ return result?.versions || [];
144
109
  }
145
110
 
146
111
  /**
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)
112
+ * Set the active version for a subdomain (rollback)
189
113
  * @param {string} subdomain - The subdomain
190
114
  * @param {number} version - Version to make active
191
115
  */
192
116
  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 };
117
+ return apiRequest(`/api/versions/${subdomain}/rollback`, {
118
+ method: 'PUT',
119
+ body: JSON.stringify({ version }),
120
+ });
206
121
  }
207
122
 
208
123
  /**
@@ -211,144 +126,76 @@ export async function setActiveVersion(subdomain, version) {
211
126
  * @returns {Promise<number>} Active version number
212
127
  */
213
128
  export async function getActiveVersion(subdomain) {
214
- const client = createR2Client();
129
+ const result = await apiRequest(`/api/versions/${subdomain}`);
130
+ return result?.activeVersion || 1;
131
+ }
215
132
 
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
- }
133
+ /**
134
+ * Copy files from one version to another (for rollback)
135
+ * Note: This is now handled server-side by the API
136
+ * @param {string} subdomain - The subdomain
137
+ * @param {number} fromVersion - Source version
138
+ * @param {number} toVersion - Target version
139
+ */
140
+ export async function copyVersionFiles(subdomain, fromVersion, toVersion) {
141
+ // Rollback is now handled by setActiveVersion - no need to copy files
142
+ // The worker serves files from the specified version directly
143
+ return { fromVersion, toVersion, note: 'Handled by API' };
228
144
  }
229
145
 
230
146
  /**
231
147
  * List all files for a specific version
232
148
  * @param {string} subdomain - The subdomain
233
149
  * @param {number} version - Version number
234
- * @returns {Promise<Array>} Array of file keys
150
+ * @returns {Promise<Array>} Array of file info
235
151
  */
236
152
  export async function listVersionFiles(subdomain, version) {
237
- const client = createR2Client();
153
+ const result = await apiRequest(`/api/deployments/${subdomain}`);
238
154
 
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) {
155
+ if (!result || !result.versions) {
247
156
  return [];
248
157
  }
249
158
 
250
- return response.Contents.map(obj => ({
251
- key: obj.Key,
252
- size: obj.Size,
253
- lastModified: obj.LastModified,
254
- }));
159
+ const versionInfo = result.versions.find(v => v.version === version);
160
+ return versionInfo ? [{ version, fileCount: versionInfo.file_count, totalBytes: versionInfo.total_bytes }] : [];
255
161
  }
256
162
 
257
163
  /**
258
164
  * Delete all files for a subdomain (all versions)
259
- * @param {string} subdomain - The subdomain to delete
260
- * @returns {Promise<{deletedCount: number}>}
165
+ * Note: This should be an admin operation, not available to CLI users
166
+ * @param {string} _subdomain - The subdomain to delete
261
167
  */
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 };
168
+ export async function deleteSubdomain(_subdomain) {
169
+ // This operation is not available in the consumer CLI
170
+ // It should be handled through the admin dashboard or worker
171
+ throw new Error('Subdomain deletion is not available in the CLI. Contact support.');
292
172
  }
293
173
 
294
174
  /**
295
175
  * Get all expired deployments
176
+ * Note: Cleanup is handled server-side automatically
296
177
  * @returns {Promise<Array>} Array of expired deployment records
297
178
  */
298
179
  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
- );
180
+ // Expiration cleanup is handled server-side
181
+ return [];
306
182
  }
307
183
 
308
184
  /**
309
185
  * Remove deployment records for a subdomain from metadata
310
- * @param {string} subdomain - The subdomain to remove
186
+ * Note: This should be an admin operation
187
+ * @param {string} _subdomain - The subdomain to remove
311
188
  */
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
- }));
189
+ export async function removeDeploymentRecords(_subdomain) {
190
+ throw new Error('Deployment record removal is not available in the CLI. Contact support.');
329
191
  }
330
192
 
331
193
  /**
332
194
  * Clean up all expired deployments
195
+ * Note: This is now handled automatically by the worker
333
196
  * @returns {Promise<{cleaned: string[], errors: string[]}>}
334
197
  */
335
198
  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 };
199
+ // Cleanup is handled server-side automatically
200
+ return { cleaned: [], errors: [], note: 'Handled automatically by server' };
354
201
  }