launchpd 1.0.0 → 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
@@ -1,96 +1,100 @@
1
1
  # Launchpd
2
2
 
3
- Deploy static sites instantly to a live URL. No config, no complexity.
3
+ **Deploy static sites instantly to a live URL. No config, no complexity.**
4
4
 
5
- ## Quick Start
6
-
7
- ```bash
8
- npm install -g launchpd
9
- launchpd deploy ./my-site
10
- ```
11
-
12
- ## Installation
13
-
14
- ```bash
15
- npm install -g launchpd
16
- ```
17
-
18
- Requires Node.js 20 or higher.
19
-
20
- ## Usage
21
-
22
- ### Deploy a folder
23
-
24
- ```bash
25
- launchpd deploy ./my-folder
26
- ```
27
-
28
- ### Use a custom subdomain
29
-
30
- ```bash
31
- launchpd deploy ./my-folder --name my-project
32
- ```
33
-
34
- ### Set expiration time
35
-
36
- ```bash
37
- launchpd deploy ./my-folder --expires 2h
38
- # Auto-deletes after 2 hours
39
- ```
40
-
41
-
42
-
43
- ### List your deployments
44
-
45
- ```bash
46
- launchpd list
47
- ```
48
-
49
- ### View version history
50
-
51
- ```bash
52
- launchpd versions my-subdomain
53
- ```
5
+ [![npm version](https://img.shields.io/npm/v/launchpd.svg)](https://www.npmjs.com/package/launchpd)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![GitHub stars](https://img.shields.io/github/stars/kents00/launchpd.svg?style=social)](https://github.com/kents00/launchpd)
54
8
 
55
- ### Rollback to previous version
9
+ ---
56
10
 
57
- ```bash
58
- launchpd rollback my-subdomain
59
- launchpd rollback my-subdomain --to 2
60
- ```
11
+ ## Features
61
12
 
62
- ## Authentication
13
+ * **Blazing Fast**: Deploy folders in seconds with a single command.
14
+ * **Project-Based**: Link local folders to subdomains once and deploy without re-typing names.
15
+ * **Zero Config**: No complex setup; optionally use `.launchpd.json` for project persistence.
16
+ * **Version Control**: Every deployment is versioned with messages. Roll back instantly.
17
+ * **Static-Only Security**: Strict validation ensures only high-performance static assets are deployed.
18
+ * **Secure**: Private uploads with API key authentication or safe anonymous testing.
19
+ * **Auto-Expiration**: Set temporary deployments that delete themselves automatically.
63
20
 
64
- ### Register for a free account
21
+ ## Quick Start
65
22
 
66
23
  ```bash
67
- launchpd register
68
- ```
69
-
70
- ### Login with your API key
24
+ # Install globally
25
+ npm install -g launchpd
71
26
 
72
- ```bash
73
- launchpd login
27
+ # Deploy your current folder
28
+ launchpd deploy .
74
29
  ```
75
30
 
76
- ### Check current user and quota
31
+ ---
77
32
 
78
- ```bash
79
- launchpd whoami
80
- launchpd quota
81
- ```
82
-
83
- ### Logout
33
+ ## Installation
84
34
 
85
35
  ```bash
86
- launchpd logout
36
+ npm install -g launchpd
87
37
  ```
38
+ *Requires **Node.js 20** or higher.*
39
+
40
+ ---
41
+
42
+ ## Command Reference
43
+
44
+ ### Deployment
45
+ | Command | Description |
46
+ | :--- | :--- |
47
+ | `launchpd init` | Link current folder to a subdomain (persisted in `.launchpd.json`) |
48
+ | `launchpd deploy <folder>` | Deploy a local folder (uses linked subdomain if available) |
49
+ | `launchpd deploy . -m "Fix layout"` | Deploy with a message (like a git commit) |
50
+ | `launchpd deploy . --name site` | Deploy with a custom subdomain explicitly |
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 |
53
+
54
+ ### Management
55
+ | Command | Description |
56
+ | :--- | :--- |
57
+ | `launchpd status` | Show linked subdomain and latest deployment info |
58
+ | `launchpd list` | View your active deployments |
59
+ | `launchpd versions <subdomain>` | See version history with messages |
60
+ | `launchpd rollback <subdomain>` | Rollback to the previous version |
61
+ | `launchpd rollback <subdomain> --to <v>` | Rollback to a specific version number |
62
+
63
+ ### Identity & Auth
64
+ | Command | Description |
65
+ | :--- | :--- |
66
+ | `launchpd register` | Open the dashboard to create an account |
67
+ | `launchpd login` | Authenticate with your API key |
68
+ | `launchpd whoami` | Show current account status |
69
+ | `launchpd quota` | View storage and site limits |
70
+ | `launchpd logout` | Remove stored credentials |
71
+
72
+ ---
73
+
74
+ ## Why Register?
75
+
76
+ While anonymous deployments are great for testing, registered users get more power:
77
+
78
+ | Feature | Anonymous | Registered (Free) |
79
+ | :--- | :--- | :--- |
80
+ | **Max Sites** | 3 | 10+ |
81
+ | **Storage** | 50MB | 100MB+ |
82
+ | **Custom Names** | No | **Yes** |
83
+ | **Retention** | 7 Days | **Permanent** |
84
+ | **Versions** | 1 per site | 10 per site |
85
+
86
+ Run `launchpd register` to unlock these benefits!
87
+
88
+ ---
88
89
 
89
90
  ## Support
90
91
 
91
- - [Report issues](https://github.com/kents00/launchpd/issues)
92
- - [Documentation](https://launchpd.cloud/docs)
92
+ * **Bugs & Feedback**: [GitHub Issues](https://github.com/kents00/launchpd/issues)
93
+ * **Website**: [launchpd.cloud](https://launchpd.cloud)
94
+ * **Docs**: [launchpd.cloud/docs](https://launchpd.cloud/docs)
95
+
96
+ ---
93
97
 
94
98
  ## License
95
99
 
96
- MIT
100
+ [MIT](LICENSE) © [Kent Edoloverio](https://github.com/kents00)
package/bin/cli.js CHANGED
@@ -1,11 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { Command } from 'commander';
4
- import { deploy } from '../src/commands/deploy.js';
5
- import { list } from '../src/commands/list.js';
6
- import { rollback } from '../src/commands/rollback.js';
7
- import { versions } from '../src/commands/versions.js';
8
- import { login, logout, register, whoami, quota } from '../src/commands/auth.js';
4
+ import { deploy, list, rollback, versions, init, status, login, logout, register, whoami, quota } from '../src/commands/index.js';
5
+
9
6
 
10
7
  const program = new Command();
11
8
 
@@ -17,12 +14,16 @@ program
17
14
  program
18
15
  .command('deploy')
19
16
  .description('Deploy a folder to a live URL')
20
- .argument('<folder>', 'Path to the folder to deploy')
17
+ .argument('[folder]', 'Path to the folder to deploy', '.')
21
18
  .option('--name <subdomain>', 'Use a custom subdomain (optional)')
19
+ .option('-m, --message <text>', 'Deployment message (optional)')
22
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')
23
24
  .option('--verbose', 'Show detailed error information')
24
25
  .action(async (folder, options) => {
25
- await deploy(folder, options);
26
+ await deploy(folder || '.', options);
26
27
  });
27
28
 
28
29
  program
@@ -40,6 +41,7 @@ program
40
41
  .description('List all versions for a subdomain')
41
42
  .argument('<subdomain>', 'The subdomain to list versions for')
42
43
  .option('--json', 'Output as JSON')
44
+ .option('--to <n>', 'Specific version number to rollback to (use with rollback)')
43
45
  .option('--verbose', 'Show detailed error information')
44
46
  .action(async (subdomain, options) => {
45
47
  await versions(subdomain, options);
@@ -55,6 +57,21 @@ program
55
57
  await rollback(subdomain, options);
56
58
  });
57
59
 
60
+ program
61
+ .command('init')
62
+ .description('Initialize a new project in the current directory')
63
+ .option('--name <subdomain>', 'Subdomain to link to')
64
+ .action(async (options) => {
65
+ await init(options);
66
+ });
67
+
68
+ program
69
+ .command('status')
70
+ .description('Show current project status')
71
+ .action(async () => {
72
+ await status();
73
+ });
74
+
58
75
  // Authentication commands
59
76
  program
60
77
  .command('login')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchpd",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Deploy static sites instantly to a live URL",
5
5
  "keywords": [
6
6
  "static",
@@ -51,15 +51,16 @@
51
51
  "dependencies": {
52
52
  "chalk": "^5.4.0",
53
53
  "commander": "^14.0.0",
54
+ "launchpd": "^1.0.0",
54
55
  "mime-types": "^2.1.35",
55
56
  "nanoid": "^5.1.0",
56
57
  "ora": "^8.0.1"
57
58
  },
58
59
  "devDependencies": {
59
60
  "@eslint/js": "^9.39.2",
60
- "@vitest/coverage-v8": "^2.1.0",
61
+ "@vitest/coverage-v8": "^4.0.17",
61
62
  "eslint": "^9.0.0",
62
- "vitest": "^2.1.0"
63
+ "vitest": "^4.0.17"
63
64
  },
64
65
  "engines": {
65
66
  "node": ">=20.0.0"
@@ -1,15 +1,21 @@
1
1
  import { existsSync, statSync } from 'node:fs';
2
+ import { exec } from 'node:child_process';
3
+ import chalk from 'chalk';
2
4
  import { readdir } from 'node:fs/promises';
3
- import { resolve, basename, join } from 'node:path';
5
+ import { resolve, basename, join, relative, sep } from 'node:path';
4
6
  import { generateSubdomain } from '../utils/id.js';
5
7
  import { uploadFolder, finalizeUpload } from '../utils/upload.js';
6
8
  import { getNextVersion } from '../utils/metadata.js';
7
9
  import { saveLocalDeployment } from '../utils/localConfig.js';
8
- import { getNextVersionFromAPI } from '../utils/api.js';
10
+ import { getNextVersionFromAPI, checkSubdomainAvailable, listSubdomains } from '../utils/api.js';
11
+ import { getProjectConfig, findProjectRoot, updateProjectConfig } from '../utils/projectConfig.js';
9
12
  import { success, errorWithSuggestions, info, warning, spinner, formatSize } from '../utils/logger.js';
10
13
  import { calculateExpiresAt, formatTimeRemaining } from '../utils/expiration.js';
11
14
  import { checkQuota, displayQuotaWarnings } from '../utils/quota.js';
12
15
  import { getCredentials } from '../utils/credentials.js';
16
+ import { validateStaticOnly } from '../utils/validator.js';
17
+ import { isIgnored } from '../utils/ignore.js';
18
+ import { prompt } from '../utils/prompt.js';
13
19
 
14
20
  /**
15
21
  * Calculate total size of a folder
@@ -19,10 +25,17 @@ async function calculateFolderSize(folderPath) {
19
25
  let totalSize = 0;
20
26
 
21
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
+
22
37
  if (file.isFile()) {
23
- const fullPath = file.parentPath
24
- ? join(file.parentPath, file.name)
25
- : join(folderPath, file.name);
38
+ const fullPath = join(parentDir, file.name);
26
39
  try {
27
40
  const stats = statSync(fullPath);
28
41
  totalSize += stats.size;
@@ -62,6 +75,16 @@ export async function deploy(folder, options) {
62
75
  }
63
76
  }
64
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
+
65
88
  // Validate folder exists
66
89
  if (!existsSync(folderPath)) {
67
90
  errorWithSuggestions(`Folder not found: ${folderPath}`, [
@@ -75,17 +98,52 @@ export async function deploy(folder, options) {
75
98
  // Check folder is not empty
76
99
  const scanSpinner = spinner('Scanning folder...');
77
100
  const files = await readdir(folderPath, { recursive: true, withFileTypes: true });
78
- 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;
79
112
 
80
113
  if (fileCount === 0) {
81
- scanSpinner.fail('Folder is empty');
114
+ scanSpinner.fail('Folder is empty or only contains ignored files');
82
115
  errorWithSuggestions('Nothing to deploy.', [
83
116
  'Add some files to your folder',
117
+ 'Make sure your files are not in ignored directories (like node_modules)',
84
118
  'Make sure index.html exists for static sites',
85
119
  ], { verbose });
86
120
  process.exit(1);
87
121
  }
88
- scanSpinner.succeed(`Found ${fileCount} file(s)`);
122
+ scanSpinner.succeed(`Found ${fileCount} file(s) (ignored system files skipped)`);
123
+
124
+ // Static-Only Validation
125
+ const validationSpinner = spinner('Validating files...');
126
+ const validation = await validateStaticOnly(folderPath);
127
+ if (!validation.success) {
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)');
146
+ }
89
147
 
90
148
  // Generate or use provided subdomain
91
149
  // Anonymous users cannot use custom subdomains
@@ -97,14 +155,44 @@ export async function deploy(folder, options) {
97
155
  console.log('');
98
156
  }
99
157
 
100
- const subdomain = (options.name && creds?.email) ? options.name.toLowerCase() : generateSubdomain();
158
+ // Detect project config if no name provided
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
+ }
167
+
168
+ if (!subdomain) {
169
+ if (configSubdomain) {
170
+ subdomain = configSubdomain;
171
+ info(`Using project subdomain: ${chalk.bold(subdomain)}`);
172
+ } else {
173
+ subdomain = generateSubdomain();
174
+ }
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)}`);
177
+
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
+ }
188
+ }
189
+
101
190
  const url = `https://${subdomain}.launchpd.cloud`;
102
191
 
103
- // Check if custom subdomain is taken
104
- if (options.name && creds?.email) {
192
+ // Check if custom subdomain is taken (only if explicitly provided or new)
193
+ if (options.name || !subdomain) {
105
194
  const checkSpinner = spinner('Checking subdomain availability...');
106
195
  try {
107
- const { checkSubdomainAvailable, listSubdomains } = await import('../utils/api.js');
108
196
  const isAvailable = await checkSubdomainAvailable(subdomain);
109
197
 
110
198
  if (!isAvailable) {
@@ -134,13 +222,23 @@ export async function deploy(folder, options) {
134
222
 
135
223
  // Check quota before deploying
136
224
  const quotaSpinner = spinner('Checking quota...');
137
- const quotaCheck = await checkQuota(subdomain, estimatedBytes);
225
+ const isUpdate = (configSubdomain && subdomain === configSubdomain);
226
+
227
+ const quotaCheck = await checkQuota(subdomain, estimatedBytes, { isUpdate });
138
228
 
139
229
  if (!quotaCheck.allowed) {
140
- quotaSpinner.fail('Deployment blocked due to quota limits');
141
- 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');
142
241
  }
143
- quotaSpinner.succeed('Quota check passed');
144
242
 
145
243
  // Display any warnings
146
244
  displayQuotaWarnings(quotaCheck.warnings);
@@ -183,7 +281,8 @@ export async function deploy(folder, options) {
183
281
  fileCount,
184
282
  totalBytes,
185
283
  folderName,
186
- expiresAt?.toISOString() || null
284
+ expiresAt?.toISOString() || null,
285
+ options.message
187
286
  );
188
287
  finalizeSpinner.succeed('Deployment finalized');
189
288
 
@@ -200,6 +299,17 @@ export async function deploy(folder, options) {
200
299
 
201
300
  success(`Deployed successfully! (v${version})`);
202
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
+
203
313
  if (expiresAt) {
204
314
  warning(`Expires: ${formatTimeRemaining(expiresAt)}`);
205
315
  }
@@ -6,4 +6,7 @@ export { deploy } from './deploy.js';
6
6
  export { list } from './list.js';
7
7
  export { rollback } from './rollback.js';
8
8
  export { versions } from './versions.js';
9
+ export { init } from './init.js';
10
+ export { status } from './status.js';
9
11
  export { login, logout, register, whoami, quota } from './auth.js';
12
+
@@ -0,0 +1,90 @@
1
+ import { initProjectConfig, findProjectRoot, getProjectConfig, saveProjectConfig } from '../utils/projectConfig.js';
2
+ import { checkSubdomainAvailable, reserveSubdomain, listSubdomains } from '../utils/api.js';
3
+ import { isLoggedIn } from '../utils/credentials.js';
4
+ import { success, errorWithSuggestions, info, spinner, warning } from '../utils/logger.js';
5
+ import { prompt } from '../utils/prompt.js';
6
+ import chalk from 'chalk';
7
+
8
+ /**
9
+ * Initialize a new project in the current directory
10
+ * @param {object} options - Command options
11
+ * @param {string} options.name - Optional subdomain name
12
+ */
13
+ export async function init(options) {
14
+ const projectRoot = findProjectRoot();
15
+ if (projectRoot) {
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
+ }
23
+ }
24
+
25
+ if (!await isLoggedIn()) {
26
+ errorWithSuggestions('You must be logged in to initialize a project.', [
27
+ 'Run "launchpd login" to log in',
28
+ 'Run "launchpd register" to create an account'
29
+ ]);
30
+ return;
31
+ }
32
+
33
+ let subdomain = options.name;
34
+
35
+ if (!subdomain) {
36
+ info('Linking this directory to a Launchpd subdomain...');
37
+ subdomain = await prompt('Enter subdomain name (e.g. my-awesome-site): ');
38
+ }
39
+
40
+ if (!subdomain || !/^[a-z0-9-]+$/.test(subdomain)) {
41
+ errorWithSuggestions('Invalid subdomain. Use lowercase alphanumeric and hyphens only.', [
42
+ 'Example: my-site-123',
43
+ 'No spaces or special characters'
44
+ ]);
45
+ return;
46
+ }
47
+
48
+ const checkSpinner = spinner(`Checking if "${subdomain}" is available...`);
49
+ try {
50
+ const isAvailable = await checkSubdomainAvailable(subdomain);
51
+ let owned = false;
52
+
53
+ if (!isAvailable) {
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!`);
66
+ }
67
+
68
+ const reserveStatus = owned ? true : await reserveSubdomain(subdomain);
69
+ if (reserveStatus) {
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
+ }
81
+ info('Now you can run "launchpd deploy" without specifying a name.');
82
+ }
83
+ } catch (err) {
84
+ checkSpinner.fail('Failed to initialize project');
85
+ errorWithSuggestions(err.message, [
86
+ 'Check your internet connection',
87
+ 'Try a different subdomain name'
88
+ ]);
89
+ }
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
 
@@ -28,6 +28,7 @@ export async function rollback(subdomainInput, options) {
28
28
  version: v.version,
29
29
  timestamp: v.created_at,
30
30
  fileCount: v.file_count,
31
+ message: v.message,
31
32
  }));
32
33
  currentActive = apiResult.activeVersion || 1;
33
34
  useAPI = true;
@@ -67,7 +68,8 @@ export async function rollback(subdomainInput, options) {
67
68
  versions.forEach(v => {
68
69
  const isActive = v.version === currentActive;
69
70
  const marker = isActive ? chalk.green(' (active)') : '';
70
- console.log(` ${chalk.cyan(`v${v.version}`)} - ${chalk.gray(v.timestamp)}${marker}`);
71
+ const message = v.message ? ` - "${v.message}"` : '';
72
+ console.log(` ${chalk.cyan(`v${v.version}`)}${message} - ${chalk.gray(v.timestamp)}${marker}`);
71
73
  });
72
74
  process.exit(1);
73
75
  }
@@ -106,6 +108,9 @@ export async function rollback(subdomainInput, options) {
106
108
 
107
109
  rollbackSpinner.succeed(`Rolled back to ${chalk.cyan(`v${targetVersion}`)}`);
108
110
  console.log(`\n 🔄 https://${subdomain}.launchpd.cloud\n`);
111
+ if (targetDeployment?.message) {
112
+ info(`Version message: "${chalk.italic(targetDeployment.message)}"`);
113
+ }
109
114
  info(`Restored deployment from: ${chalk.gray(targetDeployment?.timestamp || 'unknown')}`);
110
115
 
111
116
  } catch (err) {
@@ -0,0 +1,64 @@
1
+ import { getProjectConfig, findProjectRoot } from '../utils/projectConfig.js';
2
+ import { getDeployment } from '../utils/api.js';
3
+ import { errorWithSuggestions, info, spinner, warning, formatSize } from '../utils/logger.js';
4
+ import { formatTimeRemaining } from '../utils/expiration.js';
5
+ import chalk from 'chalk';
6
+
7
+ /**
8
+ * Show current project status
9
+ */
10
+ export async function status(_options) {
11
+ const projectRoot = findProjectRoot();
12
+ if (!projectRoot) {
13
+ warning('Not a Launchpd project (no .launchpd.json found)');
14
+ info('Run "launchpd init" to link this directory to a subdomain.');
15
+ return;
16
+ }
17
+
18
+ const config = await getProjectConfig(projectRoot);
19
+ if (!config || !config.subdomain) {
20
+ errorWithSuggestions('Invalid project configuration.', [
21
+ 'Try deleting .launchpd.json and running "launchpd init" again'
22
+ ]);
23
+ return;
24
+ }
25
+
26
+ info(`Project root: ${chalk.cyan(projectRoot)}`);
27
+ info(`Linked subdomain: ${chalk.bold.green(config.subdomain)}.launchpd.cloud`);
28
+
29
+ const statusSpinner = spinner('Fetching latest deployment info...');
30
+ try {
31
+ const deploymentData = await getDeployment(config.subdomain);
32
+ statusSpinner.stop();
33
+
34
+ if (deploymentData && deploymentData.versions && deploymentData.versions.length > 0) {
35
+ const active = deploymentData.versions.find(v => v.version === deploymentData.activeVersion) || deploymentData.versions[0];
36
+
37
+ console.log('\nDeployment Status:');
38
+ console.log(` Active Version: ${chalk.cyan(`v${active.version}`)}`);
39
+ console.log(` Deployed At: ${new Date(active.created_at || active.timestamp).toLocaleString()}`);
40
+ if (active.message) {
41
+ console.log(` Message: ${chalk.italic(active.message)}`);
42
+ }
43
+ console.log(` File Count: ${active.file_count || active.fileCount}`);
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
+
53
+ console.log(` URL: ${chalk.underline.blue(`https://${config.subdomain}.launchpd.cloud`)}`);
54
+ console.log('');
55
+ } else {
56
+ warning('\nNo deployments found for this project yet.');
57
+ info('Run "launchpd deploy <folder>" to push your first version.');
58
+ }
59
+ } catch {
60
+ statusSpinner.fail('Failed to fetch deployment status');
61
+ info(`Subdomain: ${config.subdomain}`);
62
+ // Don't exit, just show what we have
63
+ }
64
+ }
@@ -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
 
@@ -34,9 +40,10 @@ export async function versions(subdomainInput, options) {
34
40
  if (apiResult && apiResult.versions) {
35
41
  versionList = apiResult.versions.map(v => ({
36
42
  version: v.version,
37
- timestamp: v.created_at,
38
- fileCount: v.file_count,
39
- totalBytes: v.total_bytes,
43
+ timestamp: v.created_at || v.timestamp,
44
+ fileCount: v.file_count || v.fileCount,
45
+ totalBytes: v.total_bytes || v.totalBytes,
46
+ message: v.message || '',
40
47
  }));
41
48
  activeVersion = apiResult.activeVersion || 1;
42
49
  } else {
@@ -67,6 +74,7 @@ export async function versions(subdomainInput, options) {
67
74
  fileCount: v.fileCount,
68
75
  totalBytes: v.totalBytes,
69
76
  isActive: v.version === activeVersion,
77
+ message: v.message,
70
78
  })),
71
79
  }, null, 2));
72
80
  return;
@@ -77,8 +85,8 @@ export async function versions(subdomainInput, options) {
77
85
  console.log('');
78
86
 
79
87
  // Table header
80
- console.log(chalk.gray(' Version Date Files Size Status'));
81
- console.log(chalk.gray(' ' + '─'.repeat(70)));
88
+ console.log(chalk.gray(' Version Date Files Size Status Message'));
89
+ console.log(chalk.gray(' ' + '─'.repeat(100)));
82
90
 
83
91
  for (const v of versionList) {
84
92
  const isActive = v.version === activeVersion;
@@ -95,13 +103,15 @@ export async function versions(subdomainInput, options) {
95
103
  const filesStr = chalk.white(filesRaw.padEnd(10));
96
104
  const sizeStr = chalk.white(sizeRaw.padEnd(12));
97
105
  const statusStr = isActive
98
- ? chalk.green.bold('● active')
99
- : chalk.gray('○ inactive');
106
+ ? chalk.green.bold('● active'.padEnd(12))
107
+ : chalk.gray('○ inactive'.padEnd(12));
108
+
109
+ const messageStr = chalk.italic.gray(v.message || '');
100
110
 
101
- console.log(` ${versionStr}${dateStr}${filesStr}${sizeStr}${statusStr}`);
111
+ console.log(` ${versionStr}${dateStr}${filesStr}${sizeStr}${statusStr}${messageStr}`);
102
112
  }
103
113
 
104
- console.log(chalk.gray(' ' + '─'.repeat(70)));
114
+ console.log(chalk.gray(' ' + '─'.repeat(100)));
105
115
  console.log('');
106
116
  info(`Use ${chalk.cyan(`launchpd rollback ${subdomain} --to <n>`)} to restore a version.`);
107
117
  console.log('');
package/src/utils/api.js CHANGED
@@ -82,7 +82,7 @@ export async function getNextVersionFromAPI(subdomain) {
82
82
  * Record a new deployment in the API
83
83
  */
84
84
  export async function recordDeployment(deploymentData) {
85
- const { subdomain, folderName, fileCount, totalBytes, version, expiresAt } = deploymentData;
85
+ const { subdomain, folderName, fileCount, totalBytes, version, expiresAt, message } = deploymentData;
86
86
 
87
87
  return apiRequest('/api/deployments', {
88
88
  method: 'POST',
@@ -94,6 +94,7 @@ export async function recordDeployment(deploymentData) {
94
94
  version,
95
95
  cliVersion: '0.1.0',
96
96
  expiresAt,
97
+ message,
97
98
  }),
98
99
  });
99
100
  }
@@ -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
+ }
@@ -0,0 +1,73 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ const PROJECT_CONFIG_FILE = '.launchpd.json';
6
+
7
+ /**
8
+ * Find project root by looking for .launchpd.json upwards
9
+ */
10
+ export function findProjectRoot(startDir = process.cwd()) {
11
+ let current = resolve(startDir);
12
+ while (true) {
13
+ if (existsSync(join(current, PROJECT_CONFIG_FILE))) {
14
+ return current;
15
+ }
16
+ const parent = resolve(current, '..');
17
+ if (parent === current) return null;
18
+ current = parent;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Get project configuration
24
+ */
25
+ export async function getProjectConfig(projectDir = findProjectRoot()) {
26
+ if (!projectDir) return null;
27
+
28
+ const configPath = join(projectDir, PROJECT_CONFIG_FILE);
29
+ try {
30
+ if (existsSync(configPath)) {
31
+ const content = await readFile(configPath, 'utf8');
32
+ return JSON.parse(content);
33
+ }
34
+ } catch {
35
+ // Silently fail or handle corrupted config
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /**
41
+ * Save project configuration
42
+ */
43
+ export async function saveProjectConfig(config, projectDir = process.cwd()) {
44
+ const configPath = join(projectDir, PROJECT_CONFIG_FILE);
45
+ await writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
46
+ return configPath;
47
+ }
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
+
63
+ /**
64
+ * Initialize a new project config
65
+ */
66
+ export async function initProjectConfig(subdomain, projectDir = process.cwd()) {
67
+ const config = {
68
+ subdomain,
69
+ createdAt: new Date().toISOString(),
70
+ updatedAt: new Date().toISOString()
71
+ };
72
+ return await saveProjectConfig(config, projectDir);
73
+ }
@@ -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
 
@@ -72,7 +73,7 @@ async function uploadFile(content, subdomain, version, filePath, contentType) {
72
73
  * @param {string} folderName - Original folder name
73
74
  * @param {string|null} expiresAt - ISO expiration timestamp
74
75
  */
75
- async function completeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt) {
76
+ async function completeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt, message) {
76
77
  const apiKey = await getApiKey();
77
78
  const apiSecret = await getApiSecret();
78
79
  const headers = {
@@ -87,6 +88,7 @@ async function completeUpload(subdomain, version, fileCount, totalBytes, folderN
87
88
  totalBytes,
88
89
  folderName,
89
90
  expiresAt,
91
+ message,
90
92
  });
91
93
 
92
94
  if (apiSecret) {
@@ -140,11 +142,26 @@ export async function uploadFolder(localPath, subdomain, version = 1, onProgress
140
142
  for (const file of files) {
141
143
  if (!file.isFile()) continue;
142
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
+
143
161
  // Build full local path
144
- const fullPath = join(file.parentPath || file.path, file.name);
162
+ const fullPath = join(parentDir, fileName);
145
163
 
146
164
  // Build relative path for R2 key
147
- const relativePath = relative(localPath, fullPath);
148
165
  const posixPath = toPosixPath(relativePath);
149
166
 
150
167
  // Detect content type
@@ -176,6 +193,6 @@ export async function uploadFolder(localPath, subdomain, version = 1, onProgress
176
193
  * @param {string} folderName - Folder name
177
194
  * @param {string|null} expiresAt - Expiration ISO timestamp
178
195
  */
179
- export async function finalizeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt = null) {
180
- return completeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt);
196
+ export async function finalizeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt = null, message = null) {
197
+ return completeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt, message);
181
198
  }
@@ -0,0 +1,90 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { extname } from 'node:path';
3
+ import { isIgnored } from './ignore.js';
4
+
5
+ // Allowed static file extensions
6
+ const ALLOWED_EXTENSIONS = new Set([
7
+ '.html', '.htm',
8
+ '.css', '.scss', '.sass',
9
+ '.js', '.mjs', '.cjs',
10
+ '.json', '.jsonld',
11
+ '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.avif',
12
+ '.woff', '.woff2', '.ttf', '.otf', '.eot',
13
+ '.mp4', '.webm', '.ogg', '.mp3', '.wav', '.flac',
14
+ '.pdf', '.txt', '.md', '.xml', '.yaml', '.yml'
15
+ ]);
16
+
17
+ // Forbidden indicators (frameworks, build tools, backend code)
18
+ const FORBIDDEN_INDICATORS = [
19
+ // Build systems & Frameworks
20
+ 'package.json',
21
+ 'package-lock.json',
22
+ 'yarn.lock',
23
+ 'pnpm-lock.yaml',
24
+ 'node_modules',
25
+ 'composer.json',
26
+ 'vendor',
27
+ 'requirements.txt',
28
+ 'Gemfile',
29
+ 'Makefile',
30
+ 'tsconfig.json',
31
+ 'next.config.js',
32
+ 'nuxt.config.js',
33
+ 'svelte.config.js',
34
+ 'vite.config.js',
35
+ 'webpack.config.js',
36
+ 'rollup.config.js',
37
+ 'angular.json',
38
+
39
+ // Backend/Source
40
+ '.jsx', '.tsx', '.ts', '.vue', '.svelte', '.php', '.py', '.rb', '.go', '.rs', '.java', '.cs', '.cpp', '.c',
41
+ '.env', '.env.local', '.env.production',
42
+ '.dockerfile', 'docker-compose.yml',
43
+
44
+ // Hidden/System
45
+ '.git', '.svn', '.hg'
46
+ ];
47
+
48
+ /**
49
+ * Validates that a folder contains ONLY static files.
50
+ * @param {string} folderPath
51
+ * @returns {Promise<{success: boolean, violations: string[]}>}
52
+ */
53
+ export async function validateStaticOnly(folderPath) {
54
+ const violations = [];
55
+
56
+ try {
57
+ const files = await readdir(folderPath, { recursive: true, withFileTypes: true });
58
+
59
+ for (const file of files) {
60
+ const fileName = file.name.toLowerCase();
61
+ const ext = extname(fileName);
62
+
63
+ // 1. Check if the file/dir itself is a forbidden indicator
64
+ if (FORBIDDEN_INDICATORS.includes(fileName) || FORBIDDEN_INDICATORS.includes(ext)) {
65
+ violations.push(file.name);
66
+ continue;
67
+ }
68
+
69
+ // 2. Skip ignored files and directories
70
+ if (isIgnored(fileName, file.isDirectory())) {
71
+ continue;
72
+ }
73
+
74
+ // 2. Check extension for non-allowed types (only for files)
75
+ if (file.isFile()) {
76
+ // Ignore files without extensions or if they start with a dot (but handle indicators above)
77
+ if (ext && !ALLOWED_EXTENSIONS.has(ext)) {
78
+ violations.push(file.name);
79
+ }
80
+ }
81
+ }
82
+
83
+ return {
84
+ success: violations.length === 0,
85
+ violations: [...new Set(violations)] // Deduplicate
86
+ };
87
+ } catch (err) {
88
+ throw new Error(`Failed to validate folder: ${err.message}`);
89
+ }
90
+ }