launchpd 1.0.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.
@@ -0,0 +1,242 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { resolve, basename, join } from 'node:path';
4
+ import { generateSubdomain } from '../utils/id.js';
5
+ import { uploadFolder, finalizeUpload } from '../utils/upload.js';
6
+ import { getNextVersion } from '../utils/metadata.js';
7
+ import { saveLocalDeployment } from '../utils/localConfig.js';
8
+ import { getNextVersionFromAPI } from '../utils/api.js';
9
+ import { success, errorWithSuggestions, info, warning, spinner, formatSize } from '../utils/logger.js';
10
+ import { calculateExpiresAt, formatTimeRemaining } from '../utils/expiration.js';
11
+ import { checkQuota, displayQuotaWarnings } from '../utils/quota.js';
12
+ import { getCredentials } from '../utils/credentials.js';
13
+
14
+ /**
15
+ * Calculate total size of a folder
16
+ */
17
+ async function calculateFolderSize(folderPath) {
18
+ const files = await readdir(folderPath, { recursive: true, withFileTypes: true });
19
+ let totalSize = 0;
20
+
21
+ for (const file of files) {
22
+ if (file.isFile()) {
23
+ const fullPath = file.parentPath
24
+ ? join(file.parentPath, file.name)
25
+ : join(folderPath, file.name);
26
+ try {
27
+ const stats = statSync(fullPath);
28
+ totalSize += stats.size;
29
+ } catch {
30
+ // File may have been deleted
31
+ }
32
+ }
33
+ }
34
+
35
+ return totalSize;
36
+ }
37
+
38
+ /**
39
+ * Deploy a local folder to StaticLaunch
40
+ * @param {string} folder - Path to folder to deploy
41
+ * @param {object} options - Command options
42
+ * @param {string} options.name - Custom subdomain
43
+ * @param {string} options.expires - Expiration time (e.g., "30m", "2h", "1d")
44
+ * @param {boolean} options.verbose - Show verbose error details
45
+ */
46
+ export async function deploy(folder, options) {
47
+ const folderPath = resolve(folder);
48
+ const verbose = options.verbose || false;
49
+
50
+ // Parse expiration if provided
51
+ let expiresAt = null;
52
+ if (options.expires) {
53
+ try {
54
+ expiresAt = calculateExpiresAt(options.expires);
55
+ } catch (err) {
56
+ errorWithSuggestions(err.message, [
57
+ 'Use format like: 30m, 2h, 1d, 7d',
58
+ 'Minimum expiration is 30 minutes',
59
+ 'Examples: --expires 1h, --expires 2d',
60
+ ], { verbose, cause: err });
61
+ process.exit(1);
62
+ }
63
+ }
64
+
65
+ // Validate folder exists
66
+ if (!existsSync(folderPath)) {
67
+ errorWithSuggestions(`Folder not found: ${folderPath}`, [
68
+ 'Check the path is correct',
69
+ 'Use an absolute path or path relative to current directory',
70
+ `Current directory: ${process.cwd()}`,
71
+ ], { verbose });
72
+ process.exit(1);
73
+ }
74
+
75
+ // Check folder is not empty
76
+ const scanSpinner = spinner('Scanning folder...');
77
+ const files = await readdir(folderPath, { recursive: true, withFileTypes: true });
78
+ const fileCount = files.filter(f => f.isFile()).length;
79
+
80
+ if (fileCount === 0) {
81
+ scanSpinner.fail('Folder is empty');
82
+ errorWithSuggestions('Nothing to deploy.', [
83
+ 'Add some files to your folder',
84
+ 'Make sure index.html exists for static sites',
85
+ ], { verbose });
86
+ process.exit(1);
87
+ }
88
+ scanSpinner.succeed(`Found ${fileCount} file(s)`);
89
+
90
+ // Generate or use provided subdomain
91
+ // Anonymous users cannot use custom subdomains
92
+ const creds = await getCredentials();
93
+ if (options.name && !creds?.email) {
94
+ warning('Custom subdomains require registration!');
95
+ info('Anonymous deployments use random subdomains.');
96
+ info('Run "launchpd register" to use --name option.');
97
+ console.log('');
98
+ }
99
+
100
+ const subdomain = (options.name && creds?.email) ? options.name.toLowerCase() : generateSubdomain();
101
+ const url = `https://${subdomain}.launchpd.cloud`;
102
+
103
+ // Check if custom subdomain is taken
104
+ if (options.name && creds?.email) {
105
+ const checkSpinner = spinner('Checking subdomain availability...');
106
+ try {
107
+ const { checkSubdomainAvailable, listSubdomains } = await import('../utils/api.js');
108
+ const isAvailable = await checkSubdomainAvailable(subdomain);
109
+
110
+ if (!isAvailable) {
111
+ // Check if the current user owns it
112
+ const result = await listSubdomains();
113
+ const owned = result?.subdomains?.some(s => s.subdomain === subdomain);
114
+
115
+ if (owned) {
116
+ checkSpinner.succeed(`Deploying new version to your subdomain: "${subdomain}"`);
117
+ } else {
118
+ checkSpinner.fail(`Subdomain "${subdomain}" is already taken`);
119
+ warning('Choose a different subdomain name with --name or deployment without it.');
120
+ process.exit(1);
121
+ }
122
+ } else {
123
+ checkSpinner.succeed(`Subdomain "${subdomain}" is available`);
124
+ }
125
+ } catch {
126
+ checkSpinner.warn('Could not verify subdomain availability');
127
+ }
128
+ }
129
+
130
+ // Calculate estimated upload size
131
+ const sizeSpinner = spinner('Calculating folder size...');
132
+ const estimatedBytes = await calculateFolderSize(folderPath);
133
+ sizeSpinner.succeed(`Size: ${formatSize(estimatedBytes)}`);
134
+
135
+ // Check quota before deploying
136
+ const quotaSpinner = spinner('Checking quota...');
137
+ const quotaCheck = await checkQuota(subdomain, estimatedBytes);
138
+
139
+ if (!quotaCheck.allowed) {
140
+ quotaSpinner.fail('Deployment blocked due to quota limits');
141
+ process.exit(1);
142
+ }
143
+ quotaSpinner.succeed('Quota check passed');
144
+
145
+ // Display any warnings
146
+ displayQuotaWarnings(quotaCheck.warnings);
147
+
148
+ // Show current user status (creds already fetched above)
149
+ if (creds?.email) {
150
+ info(`Deploying as: ${creds.email}`);
151
+ } else {
152
+ info('Deploying as: anonymous (run "launchpd login" for more quota)');
153
+ }
154
+
155
+ info(`Deploying ${fileCount} file(s) from ${folderPath}`);
156
+ info(`Target: ${url}`);
157
+
158
+ // Perform actual upload
159
+ try {
160
+ // Get next version number for this subdomain (try API first, fallback to local)
161
+ const versionSpinner = spinner('Fetching version info...');
162
+ let version = await getNextVersionFromAPI(subdomain);
163
+ if (version === null) {
164
+ version = await getNextVersion(subdomain);
165
+ }
166
+ versionSpinner.succeed(`Deploying as version ${version}`);
167
+
168
+ // Upload all files via API proxy
169
+ const folderName = basename(folderPath);
170
+ const uploadSpinner = spinner(`Uploading files... 0/${fileCount}`);
171
+
172
+ const { totalBytes } = await uploadFolder(folderPath, subdomain, version, (uploaded, total, fileName) => {
173
+ uploadSpinner.update(`Uploading files... ${uploaded}/${total} (${fileName})`);
174
+ });
175
+
176
+ uploadSpinner.succeed(`Uploaded ${fileCount} files (${formatSize(totalBytes)})`);
177
+
178
+ // Finalize upload: set active version and record metadata
179
+ const finalizeSpinner = spinner('Finalizing deployment...');
180
+ await finalizeUpload(
181
+ subdomain,
182
+ version,
183
+ fileCount,
184
+ totalBytes,
185
+ folderName,
186
+ expiresAt?.toISOString() || null
187
+ );
188
+ finalizeSpinner.succeed('Deployment finalized');
189
+
190
+ // Save locally for quick access
191
+ await saveLocalDeployment({
192
+ subdomain,
193
+ folderName,
194
+ fileCount,
195
+ totalBytes,
196
+ version,
197
+ timestamp: new Date().toISOString(),
198
+ expiresAt: expiresAt?.toISOString() || null,
199
+ });
200
+
201
+ success(`Deployed successfully! (v${version})`);
202
+ console.log(`\n${url}`);
203
+ if (expiresAt) {
204
+ warning(`Expires: ${formatTimeRemaining(expiresAt)}`);
205
+ }
206
+
207
+ // Show anonymous limit warnings
208
+ if (!creds?.email) {
209
+ console.log('');
210
+ warning('Anonymous deployment limits:');
211
+ console.log(' • 3 active sites per IP');
212
+ console.log(' • 50MB total storage');
213
+ console.log(' • 7-day site expiration');
214
+ console.log('');
215
+ info('Run "launchpd register" to unlock unlimited sites and permanent storage!');
216
+ }
217
+ console.log('');
218
+ } catch (err) {
219
+ const suggestions = [];
220
+
221
+ // Provide context-specific suggestions
222
+ if (err.message.includes('fetch failed') || err.message.includes('ENOTFOUND')) {
223
+ suggestions.push('Check your internet connection');
224
+ suggestions.push('The API server may be temporarily unavailable');
225
+ } else if (err.message.includes('401') || err.message.includes('Unauthorized')) {
226
+ suggestions.push('Run "launchpd login" to authenticate');
227
+ suggestions.push('Your API key may have expired');
228
+ } else if (err.message.includes('413') || err.message.includes('too large')) {
229
+ suggestions.push('Try deploying fewer or smaller files');
230
+ suggestions.push('Check your storage quota with "launchpd quota"');
231
+ } else if (err.message.includes('429') || err.message.includes('rate limit')) {
232
+ suggestions.push('Wait a few minutes and try again');
233
+ suggestions.push('You may be deploying too frequently');
234
+ } else {
235
+ suggestions.push('Try running with --verbose for more details');
236
+ suggestions.push('Check https://status.launchpd.cloud for service status');
237
+ }
238
+
239
+ errorWithSuggestions(`Upload failed: ${err.message}`, suggestions, { verbose, cause: err });
240
+ process.exit(1);
241
+ }
242
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Commands index - exports all CLI commands
3
+ */
4
+
5
+ export { deploy } from './deploy.js';
6
+ export { list } from './list.js';
7
+ export { rollback } from './rollback.js';
8
+ export { versions } from './versions.js';
9
+ export { login, logout, register, whoami, quota } from './auth.js';
@@ -0,0 +1,133 @@
1
+ import { getLocalDeployments } from '../utils/localConfig.js';
2
+ import { listDeployments as listFromAPI } from '../utils/api.js';
3
+ import { errorWithSuggestions, info, spinner, formatSize } from '../utils/logger.js';
4
+ import { formatTimeRemaining, isExpired } from '../utils/expiration.js';
5
+ import chalk from 'chalk';
6
+
7
+ /**
8
+ * List all deployments (from API or local storage)
9
+ * @param {object} options - Command options
10
+ * @param {boolean} options.json - Output as JSON
11
+ * @param {boolean} options.local - Only show local deployments
12
+ * @param {boolean} options.verbose - Show verbose error details
13
+ */
14
+ export async function list(options) {
15
+ const verbose = options.verbose || false;
16
+
17
+ try {
18
+ let deployments = [];
19
+ let source = 'local';
20
+
21
+ const fetchSpinner = spinner('Fetching deployments...');
22
+
23
+ // Try API first unless --local flag is set
24
+ if (!options.local) {
25
+ const apiResult = await listFromAPI();
26
+ if (apiResult && apiResult.deployments) {
27
+ deployments = apiResult.deployments.map(d => ({
28
+ subdomain: d.subdomain,
29
+ folderName: d.folder_name,
30
+ fileCount: d.file_count,
31
+ totalBytes: d.total_bytes,
32
+ version: d.version,
33
+ timestamp: d.created_at,
34
+ expiresAt: d.expires_at,
35
+ isActive: d.active_version === d.version,
36
+ }));
37
+ source = 'api';
38
+ }
39
+ }
40
+
41
+ // Fallback to local storage if API unavailable
42
+ if (deployments.length === 0) {
43
+ deployments = await getLocalDeployments();
44
+ source = 'local';
45
+ }
46
+
47
+ if (deployments.length === 0) {
48
+ fetchSpinner.warn('No deployments found');
49
+ info('Deploy a folder with: ' + chalk.cyan('launchpd deploy ./my-folder'));
50
+ return;
51
+ }
52
+
53
+ fetchSpinner.succeed(`Found ${deployments.length} deployment(s)`);
54
+
55
+ if (options.json) {
56
+ console.log(JSON.stringify(deployments, null, 2));
57
+ return;
58
+ }
59
+
60
+ // Display as table
61
+ console.log('');
62
+ console.log(chalk.bold('Your Deployments:'));
63
+ console.log(chalk.gray('─'.repeat(100)));
64
+
65
+ // Header
66
+ console.log(
67
+ chalk.gray(
68
+ padRight('URL', 40) +
69
+ padRight('Folder', 15) +
70
+ padRight('Files', 7) +
71
+ padRight('Size', 12) +
72
+ padRight('Date', 12) +
73
+ 'Status'
74
+ )
75
+ );
76
+ console.log(chalk.gray('─'.repeat(100)));
77
+
78
+ // Rows (most recent first)
79
+ const sorted = [...deployments].reverse();
80
+ for (const dep of sorted) {
81
+ const url = `https://${dep.subdomain}.launchpd.cloud`;
82
+ const date = new Date(dep.timestamp).toLocaleDateString();
83
+ const size = dep.totalBytes ? formatSize(dep.totalBytes) : '-';
84
+
85
+ // Determine status with colors
86
+ let status;
87
+ if (dep.expiresAt) {
88
+ if (isExpired(dep.expiresAt)) {
89
+ status = chalk.red.bold('● expired');
90
+ } else {
91
+ status = chalk.yellow(`⏱ ${formatTimeRemaining(dep.expiresAt)}`);
92
+ }
93
+ } else {
94
+ status = chalk.green.bold('● active');
95
+ }
96
+
97
+ // Version badge
98
+ const versionBadge = chalk.magenta(`v${dep.version || 1}`);
99
+
100
+ console.log(
101
+ chalk.cyan(padRight(url, 40)) +
102
+ chalk.white(padRight(dep.folderName || '-', 15)) +
103
+ chalk.white(padRight(String(dep.fileCount), 7)) +
104
+ chalk.white(padRight(size, 12)) +
105
+ chalk.gray(padRight(date, 12)) +
106
+ status + ' ' + versionBadge
107
+ );
108
+ }
109
+
110
+ console.log(chalk.gray('─'.repeat(100)));
111
+ const syncStatus = source === 'api'
112
+ ? chalk.green(' ✓ synced')
113
+ : chalk.yellow(' ⚠ local only');
114
+ console.log(chalk.gray(`Total: ${deployments.length} deployment(s)`) + syncStatus);
115
+ console.log('');
116
+
117
+ } catch (err) {
118
+ errorWithSuggestions(`Failed to list deployments: ${err.message}`, [
119
+ 'Check your internet connection',
120
+ 'Use --local flag to show local deployments only',
121
+ 'Try running with --verbose for more details',
122
+ ], { verbose, cause: err });
123
+ process.exit(1);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Pad string to the right
129
+ */
130
+ function padRight(str, len) {
131
+ if (str.length >= len) return str.substring(0, len - 1) + ' ';
132
+ return str + ' '.repeat(len - str.length);
133
+ }
@@ -0,0 +1,119 @@
1
+ import { getVersionsForSubdomain, setActiveVersion, getActiveVersion } from '../utils/metadata.js';
2
+ import { getVersions as getVersionsFromAPI, rollbackVersion as rollbackViaAPI } from '../utils/api.js';
3
+ import { error, errorWithSuggestions, info, warning, spinner } from '../utils/logger.js';
4
+ import chalk from 'chalk';
5
+
6
+ /**
7
+ * Rollback a subdomain to a previous version
8
+ * @param {string} subdomain - Subdomain to rollback
9
+ * @param {object} options - Command options
10
+ * @param {number} options.to - Specific version to rollback to (optional)
11
+ * @param {boolean} options.verbose - Show verbose error details
12
+ */
13
+ export async function rollback(subdomainInput, options) {
14
+ const subdomain = subdomainInput.toLowerCase();
15
+ const verbose = options.verbose || false;
16
+
17
+ try {
18
+ const fetchSpinner = spinner(`Checking versions for ${subdomain}...`);
19
+
20
+ // Get all versions for this subdomain (try API first)
21
+ let versions = [];
22
+ let currentActive = 1;
23
+ let useAPI = false;
24
+
25
+ const apiResult = await getVersionsFromAPI(subdomain);
26
+ if (apiResult && apiResult.versions) {
27
+ versions = apiResult.versions.map(v => ({
28
+ version: v.version,
29
+ timestamp: v.created_at,
30
+ fileCount: v.file_count,
31
+ }));
32
+ currentActive = apiResult.activeVersion || 1;
33
+ useAPI = true;
34
+ } else {
35
+ // Fallback to R2 metadata
36
+ versions = await getVersionsForSubdomain(subdomain);
37
+ currentActive = await getActiveVersion(subdomain);
38
+ }
39
+
40
+ if (versions.length === 0) {
41
+ fetchSpinner.fail('No deployments found');
42
+ errorWithSuggestions(`No deployments found for subdomain: ${subdomain}`, [
43
+ 'Check the subdomain name is correct',
44
+ 'Run "launchpd list" to see your deployments',
45
+ ], { verbose });
46
+ process.exit(1);
47
+ }
48
+
49
+ if (versions.length === 1) {
50
+ fetchSpinner.warn('Only one version exists');
51
+ warning('Nothing to rollback to.');
52
+ process.exit(1);
53
+ }
54
+
55
+ fetchSpinner.succeed(`Found ${versions.length} versions`);
56
+ info(`Current active version: ${chalk.cyan(`v${currentActive}`)}`);
57
+
58
+ // Determine target version
59
+ let targetVersion;
60
+ if (options.to) {
61
+ targetVersion = Number.parseInt(options.to, 10);
62
+ const versionExists = versions.some(v => v.version === targetVersion);
63
+ if (!versionExists) {
64
+ error(`Version ${targetVersion} does not exist.`);
65
+ console.log('');
66
+ info('Available versions:');
67
+ versions.forEach(v => {
68
+ const isActive = v.version === currentActive;
69
+ const marker = isActive ? chalk.green(' (active)') : '';
70
+ console.log(` ${chalk.cyan(`v${v.version}`)} - ${chalk.gray(v.timestamp)}${marker}`);
71
+ });
72
+ process.exit(1);
73
+ }
74
+ } else {
75
+ // Default: rollback to previous version
76
+ const sortedVersions = versions.map(v => v.version).sort((a, b) => b - a);
77
+ const currentIndex = sortedVersions.indexOf(currentActive);
78
+ if (currentIndex === sortedVersions.length - 1) {
79
+ warning('Already at the oldest version. Cannot rollback further.');
80
+ process.exit(1);
81
+ }
82
+ targetVersion = sortedVersions[currentIndex + 1];
83
+ }
84
+
85
+ if (targetVersion === currentActive) {
86
+ warning(`Version ${chalk.cyan(`v${targetVersion}`)} is already active.`);
87
+ process.exit(0);
88
+ }
89
+
90
+ const rollbackSpinner = spinner(`Rolling back from v${currentActive} to v${targetVersion}...`);
91
+
92
+ // Set the target version as active
93
+ if (useAPI) {
94
+ // Use API for centralized rollback (updates both D1 and R2)
95
+ const result = await rollbackViaAPI(subdomain, targetVersion);
96
+ if (!result) {
97
+ rollbackSpinner.warn('API unavailable, using local rollback');
98
+ await setActiveVersion(subdomain, targetVersion);
99
+ }
100
+ } else {
101
+ await setActiveVersion(subdomain, targetVersion);
102
+ }
103
+
104
+ // Find the target version's deployment record for file count
105
+ const targetDeployment = versions.find(v => v.version === targetVersion);
106
+
107
+ rollbackSpinner.succeed(`Rolled back to ${chalk.cyan(`v${targetVersion}`)}`);
108
+ console.log(`\n 🔄 https://${subdomain}.launchpd.cloud\n`);
109
+ info(`Restored deployment from: ${chalk.gray(targetDeployment?.timestamp || 'unknown')}`);
110
+
111
+ } catch (err) {
112
+ errorWithSuggestions(`Rollback failed: ${err.message}`, [
113
+ 'Check your internet connection',
114
+ 'Verify the subdomain and version exist',
115
+ 'Run "launchpd versions <subdomain>" to see available versions',
116
+ ], { verbose, cause: err });
117
+ process.exit(1);
118
+ }
119
+ }
@@ -0,0 +1,117 @@
1
+ import { getVersionsForSubdomain, getActiveVersion } from '../utils/metadata.js';
2
+ import { getVersions as getVersionsFromAPI } from '../utils/api.js';
3
+ import { isLoggedIn } from '../utils/credentials.js';
4
+ import { success, errorWithSuggestions, info, spinner, formatSize } from '../utils/logger.js';
5
+ import chalk from 'chalk';
6
+
7
+ /**
8
+ * List all versions for a subdomain
9
+ * @param {string} subdomain - Subdomain to list versions for
10
+ * @param {object} options - Command options
11
+ * @param {boolean} options.json - Output as JSON
12
+ * @param {boolean} options.verbose - Show verbose error details
13
+ */
14
+ export async function versions(subdomainInput, options) {
15
+ const subdomain = subdomainInput.toLowerCase();
16
+ const verbose = options.verbose || false;
17
+
18
+ if (!await isLoggedIn()) {
19
+ errorWithSuggestions('The versions feature is only available for authenticated users.', [
20
+ 'Run "launchpd login" to log in to your account',
21
+ 'Run "launchpd register" to create a new account',
22
+ ], { verbose });
23
+ process.exit(1);
24
+ }
25
+
26
+ try {
27
+ const fetchSpinner = spinner(`Fetching versions for ${subdomain}...`);
28
+
29
+ let versionList = [];
30
+ let activeVersion = 1;
31
+
32
+ // Try API first
33
+ const apiResult = await getVersionsFromAPI(subdomain);
34
+ if (apiResult && apiResult.versions) {
35
+ versionList = apiResult.versions.map(v => ({
36
+ version: v.version,
37
+ timestamp: v.created_at,
38
+ fileCount: v.file_count,
39
+ totalBytes: v.total_bytes,
40
+ }));
41
+ activeVersion = apiResult.activeVersion || 1;
42
+ } else {
43
+ // Fallback to R2 metadata
44
+ versionList = await getVersionsForSubdomain(subdomain);
45
+ activeVersion = await getActiveVersion(subdomain);
46
+ }
47
+
48
+ if (versionList.length === 0) {
49
+ fetchSpinner.fail(`No deployments found for: ${subdomain}`);
50
+ errorWithSuggestions(`No deployments found for subdomain: ${subdomain}`, [
51
+ 'Check the subdomain name is correct',
52
+ 'Run "launchpd list" to see your deployments',
53
+ 'Deploy a new site with "launchpd deploy ./folder"',
54
+ ], { verbose });
55
+ process.exit(1);
56
+ }
57
+
58
+ fetchSpinner.succeed(`Found ${versionList.length} version(s)`);
59
+
60
+ if (options.json) {
61
+ console.log(JSON.stringify({
62
+ subdomain,
63
+ activeVersion,
64
+ versions: versionList.map(v => ({
65
+ version: v.version,
66
+ timestamp: v.timestamp,
67
+ fileCount: v.fileCount,
68
+ totalBytes: v.totalBytes,
69
+ isActive: v.version === activeVersion,
70
+ })),
71
+ }, null, 2));
72
+ return;
73
+ }
74
+
75
+ console.log('');
76
+ success(`Versions for ${chalk.cyan(subdomain)}.launchpd.cloud:`);
77
+ console.log('');
78
+
79
+ // Table header
80
+ console.log(chalk.gray(' Version Date Files Size Status'));
81
+ console.log(chalk.gray(' ' + '─'.repeat(70)));
82
+
83
+ for (const v of versionList) {
84
+ const isActive = v.version === activeVersion;
85
+
86
+ // Format raw strings for correct padding calculation
87
+ const versionRaw = `v${v.version}`;
88
+ const dateRaw = new Date(v.timestamp).toLocaleString();
89
+ const filesRaw = `${v.fileCount} files`;
90
+ const sizeRaw = v.totalBytes ? formatSize(v.totalBytes) : 'unknown';
91
+
92
+ // Apply colors and padding separately
93
+ const versionStr = chalk.bold.cyan(versionRaw.padEnd(12));
94
+ const dateStr = chalk.gray(dateRaw.padEnd(25));
95
+ const filesStr = chalk.white(filesRaw.padEnd(10));
96
+ const sizeStr = chalk.white(sizeRaw.padEnd(12));
97
+ const statusStr = isActive
98
+ ? chalk.green.bold('● active')
99
+ : chalk.gray('○ inactive');
100
+
101
+ console.log(` ${versionStr}${dateStr}${filesStr}${sizeStr}${statusStr}`);
102
+ }
103
+
104
+ console.log(chalk.gray(' ' + '─'.repeat(70)));
105
+ console.log('');
106
+ info(`Use ${chalk.cyan(`launchpd rollback ${subdomain} --to <n>`)} to restore a version.`);
107
+ console.log('');
108
+
109
+ } catch (err) {
110
+ errorWithSuggestions(`Failed to list versions: ${err.message}`, [
111
+ 'Check your internet connection',
112
+ 'Verify the subdomain exists',
113
+ 'Try running with --verbose for more details',
114
+ ], { verbose, cause: err });
115
+ process.exit(1);
116
+ }
117
+ }
package/src/config.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Application configuration for Launchpd CLI
3
+ * No credentials needed - uploads go through the API proxy
4
+ */
5
+ export const config = {
6
+ // Base domain for deployments
7
+ domain: 'launchpd.cloud',
8
+
9
+ // API endpoint
10
+ apiUrl: 'https://api.launchpd.cloud',
11
+
12
+ // CLI version
13
+ version: '0.1.12',
14
+ };