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.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/bin/cli.js +94 -0
- package/bin/setup.js +40 -0
- package/package.json +67 -0
- package/src/commands/auth.js +357 -0
- package/src/commands/deploy.js +242 -0
- package/src/commands/index.js +9 -0
- package/src/commands/list.js +133 -0
- package/src/commands/rollback.js +119 -0
- package/src/commands/versions.js +117 -0
- package/src/config.js +14 -0
- package/src/utils/api.js +182 -0
- package/src/utils/credentials.js +153 -0
- package/src/utils/expiration.js +89 -0
- package/src/utils/id.js +17 -0
- package/src/utils/index.js +14 -0
- package/src/utils/localConfig.js +85 -0
- package/src/utils/logger.js +152 -0
- package/src/utils/machineId.js +28 -0
- package/src/utils/metadata.js +201 -0
- package/src/utils/prompt.js +87 -0
- package/src/utils/quota.js +231 -0
- package/src/utils/upload.js +181 -0
|
@@ -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
|
+
};
|