launchpd 1.0.1 → 1.0.2
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/README.md +2 -1
- package/bin/cli.js +7 -2
- package/package.json +1 -1
- package/src/commands/deploy.js +106 -30
- package/src/commands/index.js +1 -0
- package/src/commands/init.js +40 -21
- package/src/commands/list.js +22 -18
- package/src/commands/status.js +9 -0
- package/src/commands/versions.js +7 -1
- package/src/utils/expiration.js +3 -5
- package/src/utils/ignore.js +53 -0
- package/src/utils/projectConfig.js +14 -0
- package/src/utils/prompt.js +4 -1
- package/src/utils/quota.js +57 -25
- package/src/utils/upload.js +18 -2
- package/src/utils/validator.js +6 -0
package/README.md
CHANGED
|
@@ -49,6 +49,7 @@ npm install -g launchpd
|
|
|
49
49
|
| `launchpd deploy . -m "Fix layout"` | Deploy with a message (like a git commit) |
|
|
50
50
|
| `launchpd deploy . --name site` | Deploy with a custom subdomain explicitly |
|
|
51
51
|
| `launchpd deploy . --expires 2h` | Set auto-deletion (e.g., `30m`, `1d`, `7d`) |
|
|
52
|
+
| `launchpd deploy . --open` | Deploy and immediately open the site in your browser |
|
|
52
53
|
|
|
53
54
|
### Management
|
|
54
55
|
| Command | Description |
|
|
@@ -57,7 +58,7 @@ npm install -g launchpd
|
|
|
57
58
|
| `launchpd list` | View your active deployments |
|
|
58
59
|
| `launchpd versions <subdomain>` | See version history with messages |
|
|
59
60
|
| `launchpd rollback <subdomain>` | Rollback to the previous version |
|
|
60
|
-
| `launchpd rollback <
|
|
61
|
+
| `launchpd rollback <subdomain> --to <v>` | Rollback to a specific version number |
|
|
61
62
|
|
|
62
63
|
### Identity & Auth
|
|
63
64
|
| Command | Description |
|
package/bin/cli.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { Command } from 'commander';
|
|
4
4
|
import { deploy, list, rollback, versions, init, status, login, logout, register, whoami, quota } from '../src/commands/index.js';
|
|
5
5
|
|
|
6
|
+
|
|
6
7
|
const program = new Command();
|
|
7
8
|
|
|
8
9
|
program
|
|
@@ -13,13 +14,16 @@ program
|
|
|
13
14
|
program
|
|
14
15
|
.command('deploy')
|
|
15
16
|
.description('Deploy a folder to a live URL')
|
|
16
|
-
.argument('
|
|
17
|
+
.argument('[folder]', 'Path to the folder to deploy', '.')
|
|
17
18
|
.option('--name <subdomain>', 'Use a custom subdomain (optional)')
|
|
18
19
|
.option('-m, --message <text>', 'Deployment message (optional)')
|
|
19
20
|
.option('--expires <time>', 'Auto-delete after time (e.g., 30m, 2h, 1d). Minimum: 30m')
|
|
21
|
+
.option('-y, --yes', 'Auto-confirm all prompts')
|
|
22
|
+
.option('--force', 'Force deployment even with warnings')
|
|
23
|
+
.option('-o, --open', 'Open the site URL in the default browser after deployment')
|
|
20
24
|
.option('--verbose', 'Show detailed error information')
|
|
21
25
|
.action(async (folder, options) => {
|
|
22
|
-
await deploy(folder, options);
|
|
26
|
+
await deploy(folder || '.', options);
|
|
23
27
|
});
|
|
24
28
|
|
|
25
29
|
program
|
|
@@ -37,6 +41,7 @@ program
|
|
|
37
41
|
.description('List all versions for a subdomain')
|
|
38
42
|
.argument('<subdomain>', 'The subdomain to list versions for')
|
|
39
43
|
.option('--json', 'Output as JSON')
|
|
44
|
+
.option('--to <n>', 'Specific version number to rollback to (use with rollback)')
|
|
40
45
|
.option('--verbose', 'Show detailed error information')
|
|
41
46
|
.action(async (subdomain, options) => {
|
|
42
47
|
await versions(subdomain, options);
|
package/package.json
CHANGED
package/src/commands/deploy.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { exec } from 'node:child_process';
|
|
2
3
|
import chalk from 'chalk';
|
|
3
4
|
import { readdir } from 'node:fs/promises';
|
|
4
|
-
import { resolve, basename, join } from 'node:path';
|
|
5
|
+
import { resolve, basename, join, relative, sep } from 'node:path';
|
|
5
6
|
import { generateSubdomain } from '../utils/id.js';
|
|
6
7
|
import { uploadFolder, finalizeUpload } from '../utils/upload.js';
|
|
7
8
|
import { getNextVersion } from '../utils/metadata.js';
|
|
8
9
|
import { saveLocalDeployment } from '../utils/localConfig.js';
|
|
9
10
|
import { getNextVersionFromAPI, checkSubdomainAvailable, listSubdomains } from '../utils/api.js';
|
|
10
|
-
import { getProjectConfig, findProjectRoot } from '../utils/projectConfig.js';
|
|
11
|
+
import { getProjectConfig, findProjectRoot, updateProjectConfig } from '../utils/projectConfig.js';
|
|
11
12
|
import { success, errorWithSuggestions, info, warning, spinner, formatSize } from '../utils/logger.js';
|
|
12
13
|
import { calculateExpiresAt, formatTimeRemaining } from '../utils/expiration.js';
|
|
13
14
|
import { checkQuota, displayQuotaWarnings } from '../utils/quota.js';
|
|
14
15
|
import { getCredentials } from '../utils/credentials.js';
|
|
15
16
|
import { validateStaticOnly } from '../utils/validator.js';
|
|
17
|
+
import { isIgnored } from '../utils/ignore.js';
|
|
18
|
+
import { prompt } from '../utils/prompt.js';
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* Calculate total size of a folder
|
|
@@ -22,10 +25,17 @@ async function calculateFolderSize(folderPath) {
|
|
|
22
25
|
let totalSize = 0;
|
|
23
26
|
|
|
24
27
|
for (const file of files) {
|
|
28
|
+
const parentDir = file.parentPath || file.path;
|
|
29
|
+
const relativePath = relative(folderPath, join(parentDir, file.name));
|
|
30
|
+
const pathParts = relativePath.split(sep);
|
|
31
|
+
|
|
32
|
+
// Skip ignored directories/files in the path
|
|
33
|
+
if (pathParts.some(part => isIgnored(part, file.isDirectory()))) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
if (file.isFile()) {
|
|
26
|
-
const fullPath = file.
|
|
27
|
-
? join(file.parentPath, file.name)
|
|
28
|
-
: join(folderPath, file.name);
|
|
38
|
+
const fullPath = join(parentDir, file.name);
|
|
29
39
|
try {
|
|
30
40
|
const stats = statSync(fullPath);
|
|
31
41
|
totalSize += stats.size;
|
|
@@ -65,6 +75,16 @@ export async function deploy(folder, options) {
|
|
|
65
75
|
}
|
|
66
76
|
}
|
|
67
77
|
|
|
78
|
+
// Validate deployment message is provided
|
|
79
|
+
if (!options.message) {
|
|
80
|
+
errorWithSuggestions('Deployment message is required.', [
|
|
81
|
+
'Use -m or --message to provide a description',
|
|
82
|
+
'Example: launchpd deploy . -m "Fix layout"',
|
|
83
|
+
'Example: launchpd deploy . -m "Initial deployment"'
|
|
84
|
+
], { verbose });
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
68
88
|
// Validate folder exists
|
|
69
89
|
if (!existsSync(folderPath)) {
|
|
70
90
|
errorWithSuggestions(`Folder not found: ${folderPath}`, [
|
|
@@ -78,33 +98,52 @@ export async function deploy(folder, options) {
|
|
|
78
98
|
// Check folder is not empty
|
|
79
99
|
const scanSpinner = spinner('Scanning folder...');
|
|
80
100
|
const files = await readdir(folderPath, { recursive: true, withFileTypes: true });
|
|
81
|
-
|
|
101
|
+
|
|
102
|
+
// Filter out ignored files for the count
|
|
103
|
+
const activeFiles = files.filter(file => {
|
|
104
|
+
if (!file.isFile()) return false;
|
|
105
|
+
const parentDir = file.parentPath || file.path;
|
|
106
|
+
const relativePath = relative(folderPath, join(parentDir, file.name));
|
|
107
|
+
const pathParts = relativePath.split(sep);
|
|
108
|
+
return !pathParts.some(part => isIgnored(part, file.isDirectory()));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const fileCount = activeFiles.length;
|
|
82
112
|
|
|
83
113
|
if (fileCount === 0) {
|
|
84
|
-
scanSpinner.fail('Folder is empty');
|
|
114
|
+
scanSpinner.fail('Folder is empty or only contains ignored files');
|
|
85
115
|
errorWithSuggestions('Nothing to deploy.', [
|
|
86
116
|
'Add some files to your folder',
|
|
117
|
+
'Make sure your files are not in ignored directories (like node_modules)',
|
|
87
118
|
'Make sure index.html exists for static sites',
|
|
88
119
|
], { verbose });
|
|
89
120
|
process.exit(1);
|
|
90
121
|
}
|
|
91
|
-
scanSpinner.succeed(`Found ${fileCount} file(s)`);
|
|
122
|
+
scanSpinner.succeed(`Found ${fileCount} file(s) (ignored system files skipped)`);
|
|
92
123
|
|
|
93
124
|
// Static-Only Validation
|
|
94
125
|
const validationSpinner = spinner('Validating files...');
|
|
95
126
|
const validation = await validateStaticOnly(folderPath);
|
|
96
127
|
if (!validation.success) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
'
|
|
100
|
-
'
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
'
|
|
104
|
-
|
|
105
|
-
|
|
128
|
+
if (options.force) {
|
|
129
|
+
validationSpinner.warn('Static-only validation failed, but proceeding due to --force');
|
|
130
|
+
warning('Non-static files detected.');
|
|
131
|
+
warning(chalk.bold.red('IMPORTANT: Launchpd only hosts STATIC files.'));
|
|
132
|
+
warning('Backend code (Node.js, PHP, etc.) will NOT be executed on the server.');
|
|
133
|
+
} else {
|
|
134
|
+
validationSpinner.fail('Deployment blocked: Non-static files detected');
|
|
135
|
+
errorWithSuggestions('Your project contains files that are not allowed.', [
|
|
136
|
+
'Launchpd only supports static files (HTML, CSS, JS, images, etc.)',
|
|
137
|
+
'Remove framework files, backend code, and build metadata:',
|
|
138
|
+
...validation.violations.map(v => ` - ${v}`).slice(0, 10),
|
|
139
|
+
validation.violations.length > 10 ? ` - ...and ${validation.violations.length - 10} more` : '',
|
|
140
|
+
'If you use a framework (React, Vue, etc.), deploy the "dist" or "build" folder instead.',
|
|
141
|
+
], { verbose });
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
validationSpinner.succeed('Project validated (Static files only)');
|
|
106
146
|
}
|
|
107
|
-
validationSpinner.succeed('Project validated (Static files only)');
|
|
108
147
|
|
|
109
148
|
// Generate or use provided subdomain
|
|
110
149
|
// Anonymous users cannot use custom subdomains
|
|
@@ -118,18 +157,34 @@ export async function deploy(folder, options) {
|
|
|
118
157
|
|
|
119
158
|
// Detect project config if no name provided
|
|
120
159
|
let subdomain = (options.name && creds?.email) ? options.name.toLowerCase() : null;
|
|
160
|
+
let configSubdomain = null;
|
|
161
|
+
|
|
162
|
+
const projectRoot = findProjectRoot(folderPath);
|
|
163
|
+
const config = await getProjectConfig(projectRoot);
|
|
164
|
+
if (config?.subdomain) {
|
|
165
|
+
configSubdomain = config.subdomain;
|
|
166
|
+
}
|
|
121
167
|
|
|
122
168
|
if (!subdomain) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (config?.subdomain) {
|
|
126
|
-
subdomain = config.subdomain;
|
|
169
|
+
if (configSubdomain) {
|
|
170
|
+
subdomain = configSubdomain;
|
|
127
171
|
info(`Using project subdomain: ${chalk.bold(subdomain)}`);
|
|
172
|
+
} else {
|
|
173
|
+
subdomain = generateSubdomain();
|
|
128
174
|
}
|
|
129
|
-
}
|
|
175
|
+
} else if (configSubdomain && subdomain !== configSubdomain) {
|
|
176
|
+
warning(`Mismatch: This project is linked to ${chalk.bold(configSubdomain)} but you are deploying to ${chalk.bold(subdomain)}`);
|
|
130
177
|
|
|
131
|
-
|
|
132
|
-
|
|
178
|
+
let shouldUpdate = options.yes;
|
|
179
|
+
if (!shouldUpdate) {
|
|
180
|
+
const confirm = await prompt(`Would you like to update this project's default subdomain to "${subdomain}"? (Y/N): `);
|
|
181
|
+
shouldUpdate = (confirm.toLowerCase() === 'y' || confirm.toLowerCase() === 'yes');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (shouldUpdate) {
|
|
185
|
+
await updateProjectConfig({ subdomain }, projectRoot);
|
|
186
|
+
success(`Project configuration updated to: ${subdomain}`);
|
|
187
|
+
}
|
|
133
188
|
}
|
|
134
189
|
|
|
135
190
|
const url = `https://${subdomain}.launchpd.cloud`;
|
|
@@ -167,13 +222,23 @@ export async function deploy(folder, options) {
|
|
|
167
222
|
|
|
168
223
|
// Check quota before deploying
|
|
169
224
|
const quotaSpinner = spinner('Checking quota...');
|
|
170
|
-
const
|
|
225
|
+
const isUpdate = (configSubdomain && subdomain === configSubdomain);
|
|
226
|
+
|
|
227
|
+
const quotaCheck = await checkQuota(subdomain, estimatedBytes, { isUpdate });
|
|
171
228
|
|
|
172
229
|
if (!quotaCheck.allowed) {
|
|
173
|
-
|
|
174
|
-
|
|
230
|
+
if (options.force) {
|
|
231
|
+
quotaSpinner.warn('Deployment blocked due to quota limits, but proceeding due to --force');
|
|
232
|
+
warning('Uploading anyway... (server might still reject if physical limit is hit)');
|
|
233
|
+
} else {
|
|
234
|
+
quotaSpinner.fail('Deployment blocked due to quota limits');
|
|
235
|
+
info('Try running "launchpd quota" to check your storage.');
|
|
236
|
+
info('Use --force to try anyway (if you think this is a mistake)');
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
quotaSpinner.succeed('Quota check passed');
|
|
175
241
|
}
|
|
176
|
-
quotaSpinner.succeed('Quota check passed');
|
|
177
242
|
|
|
178
243
|
// Display any warnings
|
|
179
244
|
displayQuotaWarnings(quotaCheck.warnings);
|
|
@@ -217,7 +282,7 @@ export async function deploy(folder, options) {
|
|
|
217
282
|
totalBytes,
|
|
218
283
|
folderName,
|
|
219
284
|
expiresAt?.toISOString() || null,
|
|
220
|
-
options.message
|
|
285
|
+
options.message
|
|
221
286
|
);
|
|
222
287
|
finalizeSpinner.succeed('Deployment finalized');
|
|
223
288
|
|
|
@@ -234,6 +299,17 @@ export async function deploy(folder, options) {
|
|
|
234
299
|
|
|
235
300
|
success(`Deployed successfully! (v${version})`);
|
|
236
301
|
console.log(`\n${url}`);
|
|
302
|
+
|
|
303
|
+
if (options.open) {
|
|
304
|
+
const platform = process.platform;
|
|
305
|
+
let cmd;
|
|
306
|
+
if (platform === 'darwin') cmd = `open "${url}"`;
|
|
307
|
+
else if (platform === 'win32') cmd = `start "" "${url}"`;
|
|
308
|
+
else cmd = `xdg-open "${url}"`;
|
|
309
|
+
|
|
310
|
+
exec(cmd);
|
|
311
|
+
}
|
|
312
|
+
|
|
237
313
|
if (expiresAt) {
|
|
238
314
|
warning(`Expires: ${formatTimeRemaining(expiresAt)}`);
|
|
239
315
|
}
|
package/src/commands/index.js
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { initProjectConfig, findProjectRoot } from '../utils/projectConfig.js';
|
|
2
|
-
import { checkSubdomainAvailable, reserveSubdomain } from '../utils/api.js';
|
|
1
|
+
import { initProjectConfig, findProjectRoot, getProjectConfig, saveProjectConfig } from '../utils/projectConfig.js';
|
|
2
|
+
import { checkSubdomainAvailable, reserveSubdomain, listSubdomains } from '../utils/api.js';
|
|
3
3
|
import { isLoggedIn } from '../utils/credentials.js';
|
|
4
4
|
import { success, errorWithSuggestions, info, spinner, warning } from '../utils/logger.js';
|
|
5
|
-
import
|
|
6
|
-
import
|
|
5
|
+
import { prompt } from '../utils/prompt.js';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Initialize a new project in the current directory
|
|
@@ -13,9 +13,13 @@ import { stdin as input, stdout as output } from 'node:process';
|
|
|
13
13
|
export async function init(options) {
|
|
14
14
|
const projectRoot = findProjectRoot();
|
|
15
15
|
if (projectRoot) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const config = await getProjectConfig(projectRoot);
|
|
17
|
+
warning(`This directory is already part of a Launchpd project linked to: ${chalk.bold(config?.subdomain)}`);
|
|
18
|
+
|
|
19
|
+
const confirm = await prompt('Would you like to re-link this project to a different subdomain? (y/N): ');
|
|
20
|
+
if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
if (!await isLoggedIn()) {
|
|
@@ -23,40 +27,57 @@ export async function init(options) {
|
|
|
23
27
|
'Run "launchpd login" to log in',
|
|
24
28
|
'Run "launchpd register" to create an account'
|
|
25
29
|
]);
|
|
26
|
-
|
|
30
|
+
return;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
const rl = readline.createInterface({ input, output });
|
|
30
33
|
let subdomain = options.name;
|
|
31
34
|
|
|
32
35
|
if (!subdomain) {
|
|
33
36
|
info('Linking this directory to a Launchpd subdomain...');
|
|
34
|
-
subdomain = await
|
|
37
|
+
subdomain = await prompt('Enter subdomain name (e.g. my-awesome-site): ');
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
if (!subdomain || !/^[a-z0-9-]+$/.test(subdomain)) {
|
|
38
|
-
rl.close();
|
|
39
41
|
errorWithSuggestions('Invalid subdomain. Use lowercase alphanumeric and hyphens only.', [
|
|
40
42
|
'Example: my-site-123',
|
|
41
43
|
'No spaces or special characters'
|
|
42
44
|
]);
|
|
43
|
-
|
|
45
|
+
return;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
const checkSpinner = spinner(`Checking if "${subdomain}" is available...`);
|
|
47
49
|
try {
|
|
48
50
|
const isAvailable = await checkSubdomainAvailable(subdomain);
|
|
51
|
+
let owned = false;
|
|
52
|
+
|
|
49
53
|
if (!isAvailable) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
54
|
+
// Check if user owns it
|
|
55
|
+
const apiResult = await listSubdomains();
|
|
56
|
+
const ownedSubdomains = apiResult?.subdomains || [];
|
|
57
|
+
owned = ownedSubdomains.some(s => s.subdomain === subdomain);
|
|
58
|
+
|
|
59
|
+
if (!owned) {
|
|
60
|
+
checkSpinner.fail(`Subdomain "${subdomain}" is already taken.`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
checkSpinner.info(`Subdomain "${subdomain}" is already yours.`);
|
|
64
|
+
} else {
|
|
65
|
+
checkSpinner.succeed(`Subdomain "${subdomain}" is available!`);
|
|
53
66
|
}
|
|
54
|
-
checkSpinner.succeed(`Subdomain "${subdomain}" is available!`);
|
|
55
67
|
|
|
56
|
-
const reserveStatus = await reserveSubdomain(subdomain);
|
|
68
|
+
const reserveStatus = owned ? true : await reserveSubdomain(subdomain);
|
|
57
69
|
if (reserveStatus) {
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
if (projectRoot) {
|
|
71
|
+
// Re-link
|
|
72
|
+
const config = await getProjectConfig(projectRoot);
|
|
73
|
+
config.subdomain = subdomain;
|
|
74
|
+
config.updatedAt = new Date().toISOString();
|
|
75
|
+
await saveProjectConfig(config, projectRoot);
|
|
76
|
+
success(`Project re-linked! New subdomain: ${subdomain}.launchpd.cloud`);
|
|
77
|
+
} else {
|
|
78
|
+
await initProjectConfig(subdomain);
|
|
79
|
+
success(`Project initialized! Linked to: ${subdomain}.launchpd.cloud`);
|
|
80
|
+
}
|
|
60
81
|
info('Now you can run "launchpd deploy" without specifying a name.');
|
|
61
82
|
}
|
|
62
83
|
} catch (err) {
|
|
@@ -65,7 +86,5 @@ export async function init(options) {
|
|
|
65
86
|
'Check your internet connection',
|
|
66
87
|
'Try a different subdomain name'
|
|
67
88
|
]);
|
|
68
|
-
} finally {
|
|
69
|
-
rl.close();
|
|
70
89
|
}
|
|
71
90
|
}
|
package/src/commands/list.js
CHANGED
|
@@ -32,6 +32,7 @@ export async function list(options) {
|
|
|
32
32
|
version: d.version,
|
|
33
33
|
timestamp: d.created_at,
|
|
34
34
|
expiresAt: d.expires_at,
|
|
35
|
+
message: d.message,
|
|
35
36
|
isActive: d.active_version === d.version,
|
|
36
37
|
}));
|
|
37
38
|
source = 'api';
|
|
@@ -65,12 +66,13 @@ export async function list(options) {
|
|
|
65
66
|
// Header
|
|
66
67
|
console.log(
|
|
67
68
|
chalk.gray(
|
|
68
|
-
padRight('URL',
|
|
69
|
-
padRight('
|
|
70
|
-
padRight('
|
|
71
|
-
padRight('
|
|
72
|
-
padRight('
|
|
73
|
-
'
|
|
69
|
+
padRight('URL', 35) +
|
|
70
|
+
padRight('VER', 6) +
|
|
71
|
+
padRight('FOLDER', 15) +
|
|
72
|
+
padRight('FILES', 7) +
|
|
73
|
+
padRight('SIZE', 10) +
|
|
74
|
+
padRight('DATE', 12) +
|
|
75
|
+
'STATUS'
|
|
74
76
|
)
|
|
75
77
|
);
|
|
76
78
|
console.log(chalk.gray('─'.repeat(100)));
|
|
@@ -84,26 +86,28 @@ export async function list(options) {
|
|
|
84
86
|
|
|
85
87
|
// Determine status with colors
|
|
86
88
|
let status;
|
|
87
|
-
if (dep.expiresAt) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
} else {
|
|
91
|
-
status = chalk.yellow(`⏱ ${formatTimeRemaining(dep.expiresAt)}`);
|
|
92
|
-
}
|
|
93
|
-
} else {
|
|
89
|
+
if (dep.expiresAt && isExpired(dep.expiresAt)) {
|
|
90
|
+
status = chalk.red.bold('● expired');
|
|
91
|
+
} else if (dep.isActive) {
|
|
94
92
|
status = chalk.green.bold('● active');
|
|
93
|
+
} else if (dep.expiresAt) {
|
|
94
|
+
status = chalk.yellow(`⏱ ${formatTimeRemaining(dep.expiresAt)}`);
|
|
95
|
+
} else {
|
|
96
|
+
status = chalk.gray('○ inactive');
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
// Version
|
|
98
|
-
const
|
|
99
|
+
// Version info
|
|
100
|
+
const versionStr = `v${dep.version || 1}`;
|
|
99
101
|
|
|
100
102
|
console.log(
|
|
101
|
-
chalk.cyan(padRight(url,
|
|
103
|
+
chalk.cyan(padRight(url, 35)) +
|
|
104
|
+
chalk.magenta(padRight(versionStr, 6)) +
|
|
102
105
|
chalk.white(padRight(dep.folderName || '-', 15)) +
|
|
103
106
|
chalk.white(padRight(String(dep.fileCount), 7)) +
|
|
104
|
-
chalk.white(padRight(size,
|
|
107
|
+
chalk.white(padRight(size, 10)) +
|
|
105
108
|
chalk.gray(padRight(date, 12)) +
|
|
106
|
-
status +
|
|
109
|
+
status +
|
|
110
|
+
(dep.message ? chalk.gray(` - ${dep.message}`) : '')
|
|
107
111
|
);
|
|
108
112
|
}
|
|
109
113
|
|
package/src/commands/status.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getProjectConfig, findProjectRoot } from '../utils/projectConfig.js';
|
|
2
2
|
import { getDeployment } from '../utils/api.js';
|
|
3
3
|
import { errorWithSuggestions, info, spinner, warning, formatSize } from '../utils/logger.js';
|
|
4
|
+
import { formatTimeRemaining } from '../utils/expiration.js';
|
|
4
5
|
import chalk from 'chalk';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -41,6 +42,14 @@ export async function status(_options) {
|
|
|
41
42
|
}
|
|
42
43
|
console.log(` File Count: ${active.file_count || active.fileCount}`);
|
|
43
44
|
console.log(` Total Size: ${formatSize(active.total_bytes || active.totalBytes)}`);
|
|
45
|
+
|
|
46
|
+
// Show expiration if set
|
|
47
|
+
if (active.expires_at || active.expiresAt) {
|
|
48
|
+
const expiryStr = formatTimeRemaining(active.expires_at || active.expiresAt);
|
|
49
|
+
const expiryColor = expiryStr === 'expired' ? chalk.red : chalk.yellow;
|
|
50
|
+
console.log(` Expires: ${expiryColor(expiryStr)}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
console.log(` URL: ${chalk.underline.blue(`https://${config.subdomain}.launchpd.cloud`)}`);
|
|
45
54
|
console.log('');
|
|
46
55
|
} else {
|
package/src/commands/versions.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getVersionsForSubdomain, getActiveVersion } from '../utils/metadata.js';
|
|
2
2
|
import { getVersions as getVersionsFromAPI } from '../utils/api.js';
|
|
3
3
|
import { isLoggedIn } from '../utils/credentials.js';
|
|
4
|
-
import { success, errorWithSuggestions, info, spinner, formatSize } from '../utils/logger.js';
|
|
4
|
+
import { success, errorWithSuggestions, info, spinner, warning, formatSize } from '../utils/logger.js';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -23,6 +23,12 @@ export async function versions(subdomainInput, options) {
|
|
|
23
23
|
process.exit(1);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
if (options.to) {
|
|
27
|
+
warning(`The --to option is for the ${chalk.bold('rollback')} command.`);
|
|
28
|
+
info(`Try: ${chalk.cyan(`launchpd rollback ${subdomain} --to ${options.to}`)}`);
|
|
29
|
+
console.log('');
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
try {
|
|
27
33
|
const fetchSpinner = spinner(`Fetching versions for ${subdomain}...`);
|
|
28
34
|
|
package/src/utils/expiration.js
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
* Parse a time string into milliseconds
|
|
3
3
|
* Supports: 30m, 1h, 2h, 1d, 7d, etc.
|
|
4
4
|
* Minimum: 30 minutes
|
|
5
|
-
*
|
|
6
|
-
* @param {string} timeStr - Time string (e.g., "30m", "2h", "1d")
|
|
7
|
-
* @returns {number} Milliseconds
|
|
8
5
|
*/
|
|
6
|
+
|
|
7
|
+
export const MIN_EXPIRATION_MS = 30 * 60 * 1000;
|
|
9
8
|
export function parseTimeString(timeStr) {
|
|
10
9
|
const regex = /^(\d+)([mhd])$/i;
|
|
11
10
|
const match = regex.exec(timeStr);
|
|
@@ -33,8 +32,7 @@ export function parseTimeString(timeStr) {
|
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
// Minimum 30 minutes
|
|
36
|
-
|
|
37
|
-
if (ms < minMs) {
|
|
35
|
+
if (ms < MIN_EXPIRATION_MS) {
|
|
38
36
|
throw new Error('Minimum expiration time is 30 minutes (30m)');
|
|
39
37
|
}
|
|
40
38
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Shared ignore lists for Launchpd CLI
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Directories to ignore during scanning, validation, and upload
|
|
7
|
+
export const IGNORE_DIRECTORIES = new Set([
|
|
8
|
+
'node_modules',
|
|
9
|
+
'.git',
|
|
10
|
+
'.svn',
|
|
11
|
+
'.hg',
|
|
12
|
+
'vendor',
|
|
13
|
+
'composer',
|
|
14
|
+
'.env',
|
|
15
|
+
'.env.local',
|
|
16
|
+
'.env.production',
|
|
17
|
+
'.env.development',
|
|
18
|
+
'dist',
|
|
19
|
+
'build',
|
|
20
|
+
'.next',
|
|
21
|
+
'.nuxt',
|
|
22
|
+
'.svelte-kit',
|
|
23
|
+
'coverage',
|
|
24
|
+
'.cache'
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// Files to ignore during scanning, validation, and upload
|
|
28
|
+
export const IGNORE_FILES = new Set([
|
|
29
|
+
'.launchpd.json',
|
|
30
|
+
'package-lock.json',
|
|
31
|
+
'yarn.lock',
|
|
32
|
+
'pnpm-lock.yaml',
|
|
33
|
+
'.DS_Store',
|
|
34
|
+
'Thumbs.db',
|
|
35
|
+
'desktop.ini',
|
|
36
|
+
'.gitignore',
|
|
37
|
+
'.npmignore',
|
|
38
|
+
'README.md',
|
|
39
|
+
'LICENSE'
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if a path or filename should be ignored
|
|
44
|
+
* @param {string} name - Base name of the file or directory
|
|
45
|
+
* @param {boolean} isDir - Whether the path is a directory
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function isIgnored(name, isDir = false) {
|
|
49
|
+
if (isDir) {
|
|
50
|
+
return IGNORE_DIRECTORIES.has(name);
|
|
51
|
+
}
|
|
52
|
+
return IGNORE_FILES.has(name) || IGNORE_DIRECTORIES.has(name);
|
|
53
|
+
}
|
|
@@ -46,6 +46,20 @@ export async function saveProjectConfig(config, projectDir = process.cwd()) {
|
|
|
46
46
|
return configPath;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Update project configuration
|
|
51
|
+
*/
|
|
52
|
+
export async function updateProjectConfig(updates, projectDir = findProjectRoot()) {
|
|
53
|
+
if (!projectDir) return null;
|
|
54
|
+
const config = await getProjectConfig(projectDir);
|
|
55
|
+
const newConfig = {
|
|
56
|
+
...config,
|
|
57
|
+
...updates,
|
|
58
|
+
updatedAt: new Date().toISOString()
|
|
59
|
+
};
|
|
60
|
+
return await saveProjectConfig(newConfig, projectDir);
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
/**
|
|
50
64
|
* Initialize a new project config
|
|
51
65
|
*/
|
package/src/utils/prompt.js
CHANGED
|
@@ -8,12 +8,15 @@ export function prompt(question) {
|
|
|
8
8
|
const rl = createInterface({
|
|
9
9
|
input: process.stdin,
|
|
10
10
|
output: process.stdout,
|
|
11
|
+
terminal: true
|
|
11
12
|
});
|
|
12
13
|
|
|
13
14
|
return new Promise((resolve) => {
|
|
14
15
|
rl.question(question, (answer) => {
|
|
15
16
|
rl.close();
|
|
16
|
-
|
|
17
|
+
// Small delay to allow Windows handles to cleanup before process.exit might be called
|
|
18
|
+
// Increased to 100ms to be safer against UV_HANDLE_CLOSING assertion on Windows
|
|
19
|
+
setTimeout(() => resolve(answer.trim()), 100);
|
|
17
20
|
});
|
|
18
21
|
});
|
|
19
22
|
}
|
package/src/utils/quota.js
CHANGED
|
@@ -15,20 +15,49 @@ const API_BASE_URL = `https://api.${config.domain}`;
|
|
|
15
15
|
*
|
|
16
16
|
* @param {string} subdomain - Target subdomain (null for new site)
|
|
17
17
|
* @param {number} estimatedBytes - Estimated upload size in bytes
|
|
18
|
+
* @param {object} options - Options
|
|
19
|
+
* @param {boolean} options.isUpdate - Whether this is known to be an update
|
|
18
20
|
* @returns {Promise<{allowed: boolean, isNewSite: boolean, quota: object, warnings: string[]}>}
|
|
19
21
|
*/
|
|
20
|
-
export async function checkQuota(subdomain, estimatedBytes = 0) {
|
|
22
|
+
export async function checkQuota(subdomain, estimatedBytes = 0, options = {}) {
|
|
21
23
|
const creds = await getCredentials();
|
|
22
24
|
|
|
23
25
|
let quotaData;
|
|
24
26
|
|
|
25
27
|
if (creds?.apiKey) {
|
|
26
28
|
// Authenticated user
|
|
27
|
-
quotaData = await checkAuthenticatedQuota(creds.apiKey);
|
|
29
|
+
quotaData = await checkAuthenticatedQuota(creds.apiKey, options.isUpdate);
|
|
28
30
|
} else {
|
|
29
31
|
// Anonymous user
|
|
30
32
|
quotaData = await checkAnonymousQuota();
|
|
31
33
|
}
|
|
34
|
+
// ... skipped ...
|
|
35
|
+
/**
|
|
36
|
+
* Check quota for authenticated user
|
|
37
|
+
*/
|
|
38
|
+
async function checkAuthenticatedQuota(apiKey, isUpdate = false) {
|
|
39
|
+
try {
|
|
40
|
+
const url = new URL(`${API_BASE_URL}/api/quota`);
|
|
41
|
+
if (isUpdate) {
|
|
42
|
+
url.searchParams.append('is_update', 'true');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const response = await fetch(url.toString(), {
|
|
46
|
+
headers: {
|
|
47
|
+
'X-API-Key': apiKey,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return await response.json();
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// ... skipped ...
|
|
32
61
|
|
|
33
62
|
if (!quotaData) {
|
|
34
63
|
// API unavailable, allow deployment (fail-open for MVP)
|
|
@@ -40,10 +69,25 @@ export async function checkQuota(subdomain, estimatedBytes = 0) {
|
|
|
40
69
|
};
|
|
41
70
|
}
|
|
42
71
|
|
|
72
|
+
// DEBUG: Write input options to file
|
|
73
|
+
try {
|
|
74
|
+
const { appendFileSync } = await import('node:fs');
|
|
75
|
+
appendFileSync('quota_debug_trace.txt', `\n[${new Date().toISOString()}] Check: ${subdomain}, isUpdate: ${options.isUpdate}, type: ${typeof options.isUpdate}`);
|
|
76
|
+
} catch {
|
|
77
|
+
// Ignore trace errors
|
|
78
|
+
}
|
|
79
|
+
|
|
43
80
|
// Check if this is an existing site the user owns
|
|
44
|
-
|
|
81
|
+
// If explicitly marked as update, assume user owns it
|
|
82
|
+
let isNewSite = true;
|
|
83
|
+
if (options.isUpdate) {
|
|
84
|
+
isNewSite = false;
|
|
85
|
+
} else if (subdomain) {
|
|
86
|
+
isNewSite = !await userOwnsSite(creds?.apiKey, subdomain);
|
|
87
|
+
}
|
|
45
88
|
|
|
46
89
|
const warnings = [...(quotaData.warnings || [])];
|
|
90
|
+
|
|
47
91
|
const allowed = true;
|
|
48
92
|
|
|
49
93
|
// Check if blocked (anonymous limit reached)
|
|
@@ -107,6 +151,8 @@ export async function checkQuota(subdomain, estimatedBytes = 0) {
|
|
|
107
151
|
}
|
|
108
152
|
}
|
|
109
153
|
|
|
154
|
+
|
|
155
|
+
|
|
110
156
|
return {
|
|
111
157
|
allowed,
|
|
112
158
|
isNewSite,
|
|
@@ -115,26 +161,6 @@ export async function checkQuota(subdomain, estimatedBytes = 0) {
|
|
|
115
161
|
};
|
|
116
162
|
}
|
|
117
163
|
|
|
118
|
-
/**
|
|
119
|
-
* Check quota for authenticated user
|
|
120
|
-
*/
|
|
121
|
-
async function checkAuthenticatedQuota(apiKey) {
|
|
122
|
-
try {
|
|
123
|
-
const response = await fetch(`${API_BASE_URL}/api/quota`, {
|
|
124
|
-
headers: {
|
|
125
|
-
'X-API-Key': apiKey,
|
|
126
|
-
},
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
if (!response.ok) {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return await response.json();
|
|
134
|
-
} catch {
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
164
|
|
|
139
165
|
/**
|
|
140
166
|
* Check quota for anonymous user
|
|
@@ -180,12 +206,18 @@ async function userOwnsSite(apiKey, subdomain) {
|
|
|
180
206
|
});
|
|
181
207
|
|
|
182
208
|
if (!response.ok) {
|
|
209
|
+
console.log('Fetch subdomains failed:', response.status);
|
|
183
210
|
return false;
|
|
184
211
|
}
|
|
185
212
|
|
|
186
213
|
const data = await response.json();
|
|
187
|
-
|
|
188
|
-
|
|
214
|
+
console.log('User subdomains:', data.subdomains?.map(s => s.subdomain));
|
|
215
|
+
console.log('Checking for:', subdomain);
|
|
216
|
+
const owns = data.subdomains?.some(s => s.subdomain === subdomain) || false;
|
|
217
|
+
console.log('Owns site?', owns);
|
|
218
|
+
return owns;
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.log('Error checking ownership:', err);
|
|
189
221
|
return false;
|
|
190
222
|
}
|
|
191
223
|
}
|
package/src/utils/upload.js
CHANGED
|
@@ -4,6 +4,7 @@ import mime from 'mime-types';
|
|
|
4
4
|
import { config } from '../config.js';
|
|
5
5
|
import { getApiKey, getApiSecret } from './credentials.js';
|
|
6
6
|
import { createHmac } from 'node:crypto';
|
|
7
|
+
import { isIgnored } from './ignore.js';
|
|
7
8
|
|
|
8
9
|
const API_BASE_URL = config.apiUrl;
|
|
9
10
|
|
|
@@ -141,11 +142,26 @@ export async function uploadFolder(localPath, subdomain, version = 1, onProgress
|
|
|
141
142
|
for (const file of files) {
|
|
142
143
|
if (!file.isFile()) continue;
|
|
143
144
|
|
|
145
|
+
const fileName = file.name;
|
|
146
|
+
const parentDir = file.parentPath || file.path;
|
|
147
|
+
|
|
148
|
+
// Skip ignored directories in the path
|
|
149
|
+
const relativePath = relative(localPath, join(parentDir, fileName));
|
|
150
|
+
const pathParts = relativePath.split(sep);
|
|
151
|
+
|
|
152
|
+
if (pathParts.some(part => isIgnored(part, true))) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Skip ignored files
|
|
157
|
+
if (isIgnored(fileName, false)) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
144
161
|
// Build full local path
|
|
145
|
-
const fullPath = join(
|
|
162
|
+
const fullPath = join(parentDir, fileName);
|
|
146
163
|
|
|
147
164
|
// Build relative path for R2 key
|
|
148
|
-
const relativePath = relative(localPath, fullPath);
|
|
149
165
|
const posixPath = toPosixPath(relativePath);
|
|
150
166
|
|
|
151
167
|
// Detect content type
|
package/src/utils/validator.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir } from 'node:fs/promises';
|
|
2
2
|
import { extname } from 'node:path';
|
|
3
|
+
import { isIgnored } from './ignore.js';
|
|
3
4
|
|
|
4
5
|
// Allowed static file extensions
|
|
5
6
|
const ALLOWED_EXTENSIONS = new Set([
|
|
@@ -65,6 +66,11 @@ export async function validateStaticOnly(folderPath) {
|
|
|
65
66
|
continue;
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
// 2. Skip ignored files and directories
|
|
70
|
+
if (isIgnored(fileName, file.isDirectory())) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
// 2. Check extension for non-allowed types (only for files)
|
|
69
75
|
if (file.isFile()) {
|
|
70
76
|
// Ignore files without extensions or if they start with a dot (but handle indicators above)
|