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,324 @@
1
+ /**
2
+ * Authentication commands for StaticLaunch CLI
3
+ * login, logout, register, whoami
4
+ */
5
+
6
+ import { createInterface } from 'node:readline';
7
+ import { exec } from 'node:child_process';
8
+ import { config } from '../config.js';
9
+ import { getCredentials, saveCredentials, clearCredentials, isLoggedIn } from '../utils/credentials.js';
10
+ import { success, error, info, warning } from '../utils/logger.js';
11
+
12
+ const API_BASE_URL = `https://api.${config.domain}`;
13
+ const REGISTER_URL = `https://portal.${config.domain}/auth/register`;
14
+
15
+ /**
16
+ * Prompt for user input
17
+ */
18
+ function prompt(question) {
19
+ const rl = createInterface({
20
+ input: process.stdin,
21
+ output: process.stdout,
22
+ });
23
+
24
+ return new Promise((resolve) => {
25
+ rl.question(question, (answer) => {
26
+ rl.close();
27
+ resolve(answer.trim());
28
+ });
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Validate API key with the server
34
+ */
35
+ async function validateApiKey(apiKey) {
36
+ try {
37
+ const response = await fetch(`${API_BASE_URL}/api/quota`, {
38
+ headers: {
39
+ 'X-API-Key': apiKey,
40
+ },
41
+ });
42
+
43
+ if (!response.ok) {
44
+ return null;
45
+ }
46
+
47
+ const data = await response.json();
48
+ if (data.authenticated) {
49
+ return data;
50
+ }
51
+ return null;
52
+ } catch (err) {
53
+ error(`Failed to validate API key: ${err.message}`);
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Login command - prompts for API key and validates it
60
+ */
61
+ export async function login() {
62
+ // Check if already logged in
63
+ if (await isLoggedIn()) {
64
+ const creds = await getCredentials();
65
+ warning(`Already logged in as ${creds.email || creds.userId}`);
66
+ info('Run "launchpd logout" to switch accounts');
67
+ return;
68
+ }
69
+
70
+ console.log('\nšŸ” Launchpd Login\n');
71
+ console.log('Enter your API key from the dashboard.');
72
+ console.log(`Don't have one? Run "launchpd register" first.\n`);
73
+
74
+ const apiKey = await prompt('API Key: ');
75
+
76
+ if (!apiKey) {
77
+ error('API key is required');
78
+ process.exit(1);
79
+ }
80
+
81
+ info('Validating API key...');
82
+
83
+ const result = await validateApiKey(apiKey);
84
+
85
+ if (!result) {
86
+ error('Invalid API key. Please check and try again.');
87
+ console.log(`\nGet your API key at: https://portal.${config.domain}/api-keys`);
88
+ process.exit(1);
89
+ }
90
+
91
+ // Save credentials
92
+ await saveCredentials({
93
+ apiKey,
94
+ userId: result.user?.id,
95
+ email: result.user?.email,
96
+ tier: result.tier,
97
+ });
98
+
99
+ success(`Logged in successfully!`);
100
+ console.log(`\n Email: ${result.user?.email || 'N/A'}`);
101
+ console.log(` Tier: ${result.tier}`);
102
+ console.log(` Sites: ${result.usage?.siteCount || 0}/${result.limits?.maxSites || '?'}`);
103
+ console.log(` Storage: ${result.usage?.storageUsedMB || 0}MB/${result.limits?.maxStorageMB || '?'}MB\n`);
104
+ }
105
+
106
+ /**
107
+ * Logout command - clears stored credentials
108
+ */
109
+ export async function logout() {
110
+ const loggedIn = await isLoggedIn();
111
+
112
+ if (!loggedIn) {
113
+ warning('Not currently logged in');
114
+ return;
115
+ }
116
+
117
+ const creds = await getCredentials();
118
+ await clearCredentials();
119
+
120
+ success(`Logged out successfully`);
121
+ if (creds?.email) {
122
+ info(`Was logged in as: ${creds.email}`);
123
+ }
124
+ console.log(`\nYou can still deploy anonymously (limited to 3 sites, 50MB).`);
125
+ }
126
+
127
+ /**
128
+ * Register command - opens browser to registration page
129
+ */
130
+ export async function register() {
131
+ console.log('\nšŸš€ Register for Launchpd\n');
132
+ console.log(`Opening registration page: ${REGISTER_URL}\n`);
133
+
134
+ // Open browser based on platform
135
+ const platform = process.platform;
136
+ let cmd;
137
+
138
+ if (platform === 'darwin') {
139
+ cmd = `open "${REGISTER_URL}"`;
140
+ } else if (platform === 'win32') {
141
+ cmd = `start "" "${REGISTER_URL}"`;
142
+ } else {
143
+ cmd = `xdg-open "${REGISTER_URL}"`;
144
+ }
145
+
146
+ exec(cmd, (err) => {
147
+ if (err) {
148
+ console.log(`Please open this URL in your browser:\n ${REGISTER_URL}\n`);
149
+ }
150
+ });
151
+
152
+ console.log('After registering:');
153
+ console.log(' 1. Get your API key from the dashboard');
154
+ console.log(' 2. Run: launchpd login');
155
+ console.log('');
156
+
157
+ info('Registration benefits:');
158
+ console.log(' āœ“ 10 sites (instead of 3)');
159
+ console.log(' āœ“ 100MB storage (instead of 50MB)');
160
+ console.log(' āœ“ 30-day retention (instead of 7 days)');
161
+ console.log(' āœ“ 10 versions per site');
162
+ console.log('');
163
+ }
164
+
165
+ /**
166
+ * Whoami command - shows current user info and quota status
167
+ */
168
+ export async function whoami() {
169
+ const creds = await getCredentials();
170
+
171
+ if (!creds) {
172
+ console.log('\nšŸ‘¤ Not logged in (anonymous mode)\n');
173
+ console.log('Anonymous limits:');
174
+ console.log(' • 3 sites maximum');
175
+ console.log(' • 50MB total storage');
176
+ console.log(' • 7-day retention');
177
+ console.log(' • 1 version per site');
178
+ console.log(`\nRun "launchpd login" to authenticate`);
179
+ console.log(`Run "launchpd register" to create an account\n`);
180
+ return;
181
+ }
182
+
183
+ info('Fetching account status...');
184
+
185
+ // Validate and get current quota
186
+ const result = await validateApiKey(creds.apiKey);
187
+
188
+ if (!result) {
189
+ warning('Session expired or API key invalid');
190
+ await clearCredentials();
191
+ error('Please login again with: launchpd login');
192
+ process.exit(1);
193
+ }
194
+
195
+ console.log(`\nšŸ‘¤ Logged in as: ${result.user?.email || result.user?.id}\n`);
196
+
197
+ console.log('Account Info:');
198
+ console.log(` User ID: ${result.user?.id}`);
199
+ console.log(` Email: ${result.user?.email || 'Not set'}`);
200
+ console.log(` Tier: ${result.tier}`);
201
+ console.log('');
202
+
203
+ console.log('Usage:');
204
+ console.log(` Sites: ${result.usage?.siteCount || 0} / ${result.limits?.maxSites}`);
205
+ console.log(` Storage: ${result.usage?.storageUsedMB || 0}MB / ${result.limits?.maxStorageMB}MB`);
206
+ console.log(` Sites remaining: ${result.usage?.sitesRemaining || 0}`);
207
+ console.log(` Storage remaining: ${result.usage?.storageRemainingMB || 0}MB`);
208
+ console.log('');
209
+
210
+ console.log('Limits:');
211
+ console.log(` Max versions per site: ${result.limits?.maxVersionsPerSite}`);
212
+ console.log(` Retention: ${result.limits?.retentionDays} days`);
213
+ console.log('');
214
+
215
+ // Show warnings
216
+ if (result.warnings && result.warnings.length > 0) {
217
+ console.log('āš ļø Warnings:');
218
+ result.warnings.forEach(w => console.log(` ${w}`));
219
+ console.log('');
220
+ }
221
+
222
+ if (!result.canCreateNewSite) {
223
+ warning('You cannot create new sites (limit reached)');
224
+ info('You can still update existing sites');
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Quota command - shows detailed quota information
230
+ */
231
+ export async function quota() {
232
+ const creds = await getCredentials();
233
+
234
+ if (!creds) {
235
+ console.log('\nšŸ“Š Anonymous Quota Status\n');
236
+ console.log('You are not logged in.');
237
+ console.log('');
238
+ console.log('Anonymous tier limits:');
239
+ console.log(' ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”');
240
+ console.log(' │ Sites: 3 maximum │');
241
+ console.log(' │ Storage: 50MB total │');
242
+ console.log(' │ Retention: 7 days │');
243
+ console.log(' │ Versions: 1 per site │');
244
+ console.log(' ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜');
245
+ console.log('');
246
+ console.log('šŸ’” Register for FREE to unlock more:');
247
+ console.log(' → 10 sites');
248
+ console.log(' → 100MB storage');
249
+ console.log(' → 30-day retention');
250
+ console.log(' → 10 versions per site');
251
+ console.log('');
252
+ console.log('Run: launchpd register');
253
+ console.log('');
254
+ return;
255
+ }
256
+
257
+ info('Fetching quota status...');
258
+
259
+ const result = await validateApiKey(creds.apiKey);
260
+
261
+ if (!result) {
262
+ error('Failed to fetch quota. API key may be invalid.');
263
+ process.exit(1);
264
+ }
265
+
266
+ console.log(`\nšŸ“Š Quota Status for: ${result.user?.email || creds.email}\n`);
267
+
268
+ // Sites usage
269
+ const sitesUsed = result.usage?.siteCount || 0;
270
+ const sitesMax = result.limits?.maxSites || 10;
271
+ const sitesPercent = Math.round((sitesUsed / sitesMax) * 100);
272
+ const sitesBar = createProgressBar(sitesUsed, sitesMax);
273
+
274
+ console.log(`Sites: ${sitesBar} ${sitesUsed}/${sitesMax} (${sitesPercent}%)`);
275
+
276
+ // Storage usage
277
+ const storageMB = result.usage?.storageUsedMB || 0;
278
+ const storageMaxMB = result.limits?.maxStorageMB || 100;
279
+ const storagePercent = Math.round((storageMB / storageMaxMB) * 100);
280
+ const storageBar = createProgressBar(storageMB, storageMaxMB);
281
+
282
+ console.log(`Storage: ${storageBar} ${storageMB}MB/${storageMaxMB}MB (${storagePercent}%)`);
283
+
284
+ console.log('');
285
+ console.log(`Tier: ${result.tier || 'free'}`);
286
+ console.log(`Retention: ${result.limits?.retentionDays || 30} days`);
287
+ console.log(`Max versions: ${result.limits?.maxVersionsPerSite || 10} per site`);
288
+ console.log('');
289
+
290
+ // Status indicators
291
+ if (result.canCreateNewSite === false) {
292
+ warning('āš ļø Site limit reached - cannot create new sites');
293
+ }
294
+
295
+ if (storagePercent > 80) {
296
+ warning(`āš ļø Storage ${storagePercent}% used - consider cleaning up old deployments`);
297
+ }
298
+
299
+ if (result.tier === 'free') {
300
+ console.log('');
301
+ info('šŸ’Ž Upgrade to Pro for 50 sites, 1GB storage, and 50 versions');
302
+ }
303
+ console.log('');
304
+ }
305
+
306
+ /**
307
+ * Create a simple progress bar
308
+ */
309
+ function createProgressBar(current, max, width = 20) {
310
+ const filled = Math.round((current / max) * width);
311
+ const empty = width - filled;
312
+ const percent = (current / max) * 100;
313
+
314
+ let bar = '';
315
+ if (percent >= 90) {
316
+ bar = 'ā–ˆ'.repeat(filled) + 'ā–‘'.repeat(empty);
317
+ } else if (percent >= 70) {
318
+ bar = 'ā–ˆ'.repeat(filled) + 'ā–‘'.repeat(empty);
319
+ } else {
320
+ bar = 'ā–ˆ'.repeat(filled) + 'ā–‘'.repeat(empty);
321
+ }
322
+
323
+ return `[${bar}]`;
324
+ }
@@ -0,0 +1,194 @@
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 } from '../utils/upload.js';
6
+ import { recordDeployment as recordMetadata, getNextVersion, setActiveVersion } from '../utils/metadata.js';
7
+ import { saveLocalDeployment } from '../utils/localConfig.js';
8
+ import { recordDeployment as recordToAPI, getNextVersionFromAPI } from '../utils/api.js';
9
+ import { success, error, info, warning } 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 {boolean} options.dryRun - Skip actual upload
43
+ * @param {string} options.name - Custom subdomain
44
+ * @param {string} options.expires - Expiration time (e.g., "30m", "2h", "1d")
45
+ */
46
+ export async function deploy(folder, options) {
47
+ const folderPath = resolve(folder);
48
+
49
+ // Parse expiration if provided
50
+ let expiresAt = null;
51
+ if (options.expires) {
52
+ try {
53
+ expiresAt = calculateExpiresAt(options.expires);
54
+ } catch (err) {
55
+ error(err.message);
56
+ process.exit(1);
57
+ }
58
+ }
59
+
60
+ // Validate folder exists
61
+ if (!existsSync(folderPath)) {
62
+ error(`Folder not found: ${folderPath}`);
63
+ process.exit(1);
64
+ }
65
+
66
+ // Check folder is not empty
67
+ const files = await readdir(folderPath, { recursive: true, withFileTypes: true });
68
+ const fileCount = files.filter(f => f.isFile()).length;
69
+
70
+ if (fileCount === 0) {
71
+ error('Folder is empty. Nothing to deploy.');
72
+ process.exit(1);
73
+ }
74
+
75
+ // Generate or use provided subdomain
76
+ const subdomain = options.name || generateSubdomain();
77
+ const url = `https://${subdomain}.launchpd.cloud`;
78
+
79
+ // Calculate estimated upload size
80
+ const estimatedBytes = await calculateFolderSize(folderPath);
81
+
82
+ // Check quota before deploying
83
+ info('Checking quota...');
84
+ const quotaCheck = await checkQuota(subdomain, estimatedBytes);
85
+
86
+ if (!quotaCheck.allowed) {
87
+ error('Deployment blocked due to quota limits');
88
+ process.exit(1);
89
+ }
90
+
91
+ // Display any warnings
92
+ displayQuotaWarnings(quotaCheck.warnings);
93
+
94
+ // Show current user status
95
+ const creds = await getCredentials();
96
+ if (creds?.email) {
97
+ info(`Deploying as: ${creds.email}`);
98
+ } else {
99
+ info('Deploying as: anonymous (run "launchpd login" for more quota)');
100
+ }
101
+
102
+ info(`Deploying ${fileCount} file(s) from ${folderPath}`);
103
+ info(`Target: ${url}`);
104
+ info(`Size: ${(estimatedBytes / 1024 / 1024).toFixed(2)}MB`);
105
+
106
+ if (options.dryRun) {
107
+ warning('Dry run mode - skipping upload');
108
+
109
+ // List files that would be uploaded
110
+ for (const file of files) {
111
+ if (file.isFile()) {
112
+ const relativePath = file.parentPath
113
+ ? `${file.parentPath.replace(folderPath, '')}/${file.name}`.replace(/^[\\/]/, '')
114
+ : file.name;
115
+ info(` Would upload: ${relativePath.replaceAll('\\', '/')}`);
116
+ }
117
+ }
118
+
119
+ success(`Dry run complete. ${fileCount} file(s) would be deployed to:`);
120
+ console.log(`\n ${url}\n`);
121
+
122
+ // Show quota status after dry run
123
+ if (quotaCheck.quota) {
124
+ console.log('Quota after this deploy:');
125
+ const storageAfter = (quotaCheck.quota.usage?.storageUsed || 0) + estimatedBytes;
126
+ const sitesAfter = quotaCheck.isNewSite
127
+ ? (quotaCheck.quota.usage?.siteCount || 0) + 1
128
+ : quotaCheck.quota.usage?.siteCount || 0;
129
+ console.log(` Sites: ${sitesAfter}/${quotaCheck.quota.limits.maxSites}`);
130
+ console.log(` Storage: ${(storageAfter / 1024 / 1024).toFixed(1)}MB/${quotaCheck.quota.limits.maxStorageMB}MB`);
131
+ console.log('');
132
+ }
133
+ return;
134
+ }
135
+
136
+ // Perform actual upload
137
+ try {
138
+ // Get next version number for this subdomain (try API first, fallback to R2)
139
+ let version = await getNextVersionFromAPI(subdomain);
140
+ if (version === null) {
141
+ version = await getNextVersion(subdomain);
142
+ }
143
+ info(`Deploying as version ${version}...`);
144
+
145
+ const { totalBytes } = await uploadFolder(folderPath, subdomain, version);
146
+
147
+ // Set this version as active
148
+ await setActiveVersion(subdomain, version);
149
+
150
+ // Record deployment metadata
151
+ info('Recording deployment metadata...');
152
+
153
+ // Try API first for centralized storage
154
+ const folderName = basename(folderPath);
155
+ const apiResult = await recordToAPI({
156
+ subdomain,
157
+ folderName,
158
+ fileCount,
159
+ totalBytes,
160
+ version,
161
+ expiresAt: expiresAt?.toISOString() || null,
162
+ });
163
+
164
+ if (apiResult) {
165
+ // API succeeded - deployment is centrally tracked
166
+ info('Deployment recorded to central API');
167
+ } else {
168
+ // API unavailable - fallback to R2 metadata
169
+ warning('Central API unavailable, using local fallback');
170
+ await recordMetadata(subdomain, folderPath, fileCount, totalBytes, version, expiresAt);
171
+ }
172
+
173
+ // Always save locally for quick access
174
+ await saveLocalDeployment({
175
+ subdomain,
176
+ folderName,
177
+ fileCount,
178
+ totalBytes,
179
+ version,
180
+ timestamp: new Date().toISOString(),
181
+ expiresAt: expiresAt?.toISOString() || null,
182
+ });
183
+
184
+ success(`Deployed successfully! (v${version})`);
185
+ console.log(`\n šŸš€ ${url}`);
186
+ if (expiresAt) {
187
+ warning(` ā° Expires: ${formatTimeRemaining(expiresAt)}`);
188
+ }
189
+ console.log('');
190
+ } catch (err) {
191
+ error(`Upload failed: ${err.message}`);
192
+ process.exit(1);
193
+ }
194
+ }
@@ -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,111 @@
1
+ import { getLocalDeployments } from '../utils/localConfig.js';
2
+ import { listDeployments as listFromAPI } from '../utils/api.js';
3
+ import { error, info, warning } 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
+ */
13
+ export async function list(options) {
14
+ try {
15
+ let deployments = [];
16
+ let source = 'local';
17
+
18
+ // Try API first unless --local flag is set
19
+ if (!options.local) {
20
+ const apiResult = await listFromAPI();
21
+ if (apiResult && apiResult.deployments) {
22
+ deployments = apiResult.deployments.map(d => ({
23
+ subdomain: d.subdomain,
24
+ folderName: d.folder_name,
25
+ fileCount: d.file_count,
26
+ totalBytes: d.total_bytes,
27
+ version: d.version,
28
+ timestamp: d.created_at,
29
+ expiresAt: d.expires_at,
30
+ isActive: d.active_version === d.version,
31
+ }));
32
+ source = 'api';
33
+ }
34
+ }
35
+
36
+ // Fallback to local storage if API unavailable
37
+ if (deployments.length === 0) {
38
+ deployments = await getLocalDeployments();
39
+ source = 'local';
40
+ }
41
+
42
+ if (deployments.length === 0) {
43
+ warning('No deployments found.');
44
+ info('Deploy a folder with: launchpd deploy ./my-folder');
45
+ return;
46
+ }
47
+
48
+ if (options.json) {
49
+ console.log(JSON.stringify(deployments, null, 2));
50
+ return;
51
+ }
52
+
53
+ // Display as table
54
+ console.log('');
55
+ console.log(chalk.bold('Your Deployments:'));
56
+ console.log(chalk.gray('─'.repeat(95)));
57
+
58
+ // Header
59
+ console.log(
60
+ chalk.gray(
61
+ padRight('URL', 40) +
62
+ padRight('Folder', 15) +
63
+ padRight('Files', 7) +
64
+ padRight('Date', 12) +
65
+ 'Status'
66
+ )
67
+ );
68
+ console.log(chalk.gray('─'.repeat(95)));
69
+
70
+ // Rows (most recent first)
71
+ const sorted = [...deployments].reverse();
72
+ for (const dep of sorted) {
73
+ const url = `https://${dep.subdomain}.launchpd.cloud`;
74
+ const date = new Date(dep.timestamp).toLocaleDateString();
75
+
76
+ // Determine status
77
+ let status = chalk.green('active');
78
+ if (dep.expiresAt) {
79
+ if (isExpired(dep.expiresAt)) {
80
+ status = chalk.red('expired');
81
+ } else {
82
+ status = chalk.yellow(formatTimeRemaining(dep.expiresAt));
83
+ }
84
+ }
85
+
86
+ console.log(
87
+ chalk.cyan(padRight(url, 40)) +
88
+ padRight(dep.folderName || '-', 15) +
89
+ padRight(String(dep.fileCount), 7) +
90
+ chalk.gray(padRight(date, 12)) +
91
+ status
92
+ );
93
+ }
94
+
95
+ console.log(chalk.gray('─'.repeat(95)));
96
+ console.log(chalk.gray(`Total: ${deployments.length} deployment(s)`) + (source === 'api' ? chalk.green(' (synced)') : chalk.yellow(' (local only)')));
97
+ console.log('');
98
+
99
+ } catch (err) {
100
+ error(`Failed to list deployments: ${err.message}`);
101
+ process.exit(1);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Pad string to the right
107
+ */
108
+ function padRight(str, len) {
109
+ if (str.length >= len) return str.substring(0, len - 1) + ' ';
110
+ return str + ' '.repeat(len - str.length);
111
+ }