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 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 <id> --to <v>` | Rollback to a specific version number |
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('<folder>', 'Path to the folder to deploy')
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchpd",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Deploy static sites instantly to a live URL",
5
5
  "keywords": [
6
6
  "static",
@@ -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.parentPath
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
- const fileCount = files.filter(f => f.isFile()).length;
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
- validationSpinner.fail('Deployment blocked: Non-static files detected');
98
- errorWithSuggestions('Your project contains files that are not allowed.', [
99
- 'Launchpd only supports static files (HTML, CSS, JS, images, etc.)',
100
- 'Remove framework files, backend code, and build metadata:',
101
- ...validation.violations.map(v => ` - ${v}`).slice(0, 10),
102
- validation.violations.length > 10 ? ` - ...and ${validation.violations.length - 10} more` : '',
103
- 'If you use a framework (React, Vue, etc.), deploy the "dist" or "build" folder instead.'
104
- ], { verbose });
105
- process.exit(1);
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
- const projectRoot = findProjectRoot(folderPath);
124
- const config = await getProjectConfig(projectRoot);
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
- if (!subdomain) {
132
- subdomain = generateSubdomain();
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 quotaCheck = await checkQuota(subdomain, estimatedBytes);
225
+ const isUpdate = (configSubdomain && subdomain === configSubdomain);
226
+
227
+ const quotaCheck = await checkQuota(subdomain, estimatedBytes, { isUpdate });
171
228
 
172
229
  if (!quotaCheck.allowed) {
173
- quotaSpinner.fail('Deployment blocked due to quota limits');
174
- process.exit(1);
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 || null
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
  }
@@ -9,3 +9,4 @@ export { versions } from './versions.js';
9
9
  export { init } from './init.js';
10
10
  export { status } from './status.js';
11
11
  export { login, logout, register, whoami, quota } from './auth.js';
12
+
@@ -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 readline from 'node:readline/promises';
6
- import { stdin as input, stdout as output } from 'node:process';
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
- warning('This directory is already part of a Launchpd project.');
17
- info(`Project root: ${projectRoot}`);
18
- return;
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
- process.exit(1);
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 rl.question('Enter subdomain name (e.g. my-awesome-site): ');
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
- process.exit(1);
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
- checkSpinner.fail(`Subdomain "${subdomain}" is already taken.`);
51
- rl.close();
52
- process.exit(1);
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
- await initProjectConfig(subdomain);
59
- success(`Project initialized! Linked to: ${subdomain}.launchpd.cloud`);
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
  }
@@ -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', 40) +
69
- padRight('Folder', 15) +
70
- padRight('Files', 7) +
71
- padRight('Size', 12) +
72
- padRight('Date', 12) +
73
- 'Status'
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
- if (isExpired(dep.expiresAt)) {
89
- status = chalk.red.bold('● expired');
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 badge
98
- const versionBadge = chalk.magenta(`v${dep.version || 1}`);
99
+ // Version info
100
+ const versionStr = `v${dep.version || 1}`;
99
101
 
100
102
  console.log(
101
- chalk.cyan(padRight(url, 40)) +
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, 12)) +
107
+ chalk.white(padRight(size, 10)) +
105
108
  chalk.gray(padRight(date, 12)) +
106
- status + ' ' + versionBadge
109
+ status +
110
+ (dep.message ? chalk.gray(` - ${dep.message}`) : '')
107
111
  );
108
112
  }
109
113
 
@@ -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 {
@@ -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
 
@@ -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
- const minMs = 30 * 60 * 1000;
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
  */
@@ -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
- resolve(answer.trim());
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
  }
@@ -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
- const isNewSite = subdomain ? !await userOwnsSite(creds?.apiKey, subdomain) : true;
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
- return data.subdomains?.some(s => s.subdomain === subdomain) || false;
188
- } catch {
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
  }
@@ -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(file.parentPath || file.path, file.name);
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
@@ -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)