launchpd 1.0.0 → 1.0.1

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,99 @@
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
- ```
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)
11
8
 
12
- ## Installation
13
-
14
- ```bash
15
- npm install -g launchpd
16
- ```
9
+ ---
17
10
 
18
- Requires Node.js 20 or higher.
11
+ ## Features
19
12
 
20
- ## Usage
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.
21
20
 
22
- ### Deploy a folder
23
-
24
- ```bash
25
- launchpd deploy ./my-folder
26
- ```
27
-
28
- ### Use a custom subdomain
21
+ ## Quick Start
29
22
 
30
23
  ```bash
31
- launchpd deploy ./my-folder --name my-project
32
- ```
33
-
34
- ### Set expiration time
24
+ # Install globally
25
+ npm install -g launchpd
35
26
 
36
- ```bash
37
- launchpd deploy ./my-folder --expires 2h
38
- # Auto-deletes after 2 hours
27
+ # Deploy your current folder
28
+ launchpd deploy .
39
29
  ```
40
30
 
31
+ ---
41
32
 
42
-
43
- ### List your deployments
44
-
45
- ```bash
46
- launchpd list
47
- ```
48
-
49
- ### View version history
33
+ ## Installation
50
34
 
51
35
  ```bash
52
- launchpd versions my-subdomain
36
+ npm install -g launchpd
53
37
  ```
38
+ *Requires **Node.js 20** or higher.*
54
39
 
55
- ### Rollback to previous version
40
+ ---
56
41
 
57
- ```bash
58
- launchpd rollback my-subdomain
59
- launchpd rollback my-subdomain --to 2
60
- ```
42
+ ## Command Reference
61
43
 
62
- ## Authentication
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`) |
63
52
 
64
- ### Register for a free account
53
+ ### Management
54
+ | Command | Description |
55
+ | :--- | :--- |
56
+ | `launchpd status` | Show linked subdomain and latest deployment info |
57
+ | `launchpd list` | View your active deployments |
58
+ | `launchpd versions <subdomain>` | See version history with messages |
59
+ | `launchpd rollback <subdomain>` | Rollback to the previous version |
60
+ | `launchpd rollback <id> --to <v>` | Rollback to a specific version number |
65
61
 
66
- ```bash
67
- launchpd register
68
- ```
62
+ ### Identity & Auth
63
+ | Command | Description |
64
+ | :--- | :--- |
65
+ | `launchpd register` | Open the dashboard to create an account |
66
+ | `launchpd login` | Authenticate with your API key |
67
+ | `launchpd whoami` | Show current account status |
68
+ | `launchpd quota` | View storage and site limits |
69
+ | `launchpd logout` | Remove stored credentials |
69
70
 
70
- ### Login with your API key
71
+ ---
71
72
 
72
- ```bash
73
- launchpd login
74
- ```
73
+ ## Why Register?
75
74
 
76
- ### Check current user and quota
75
+ While anonymous deployments are great for testing, registered users get more power:
77
76
 
78
- ```bash
79
- launchpd whoami
80
- launchpd quota
81
- ```
77
+ | Feature | Anonymous | Registered (Free) |
78
+ | :--- | :--- | :--- |
79
+ | **Max Sites** | 3 | 10+ |
80
+ | **Storage** | 50MB | 100MB+ |
81
+ | **Custom Names** | No | **Yes** |
82
+ | **Retention** | 7 Days | **Permanent** |
83
+ | **Versions** | 1 per site | 10 per site |
82
84
 
83
- ### Logout
85
+ Run `launchpd register` to unlock these benefits!
84
86
 
85
- ```bash
86
- launchpd logout
87
- ```
87
+ ---
88
88
 
89
89
  ## Support
90
90
 
91
- - [Report issues](https://github.com/kents00/launchpd/issues)
92
- - [Documentation](https://launchpd.cloud/docs)
91
+ * **Bugs & Feedback**: [GitHub Issues](https://github.com/kents00/launchpd/issues)
92
+ * **Website**: [launchpd.cloud](https://launchpd.cloud)
93
+ * **Docs**: [launchpd.cloud/docs](https://launchpd.cloud/docs)
94
+
95
+ ---
93
96
 
94
97
  ## License
95
98
 
96
- MIT
99
+ [MIT](LICENSE) © [Kent Edoloverio](https://github.com/kents00)
package/bin/cli.js CHANGED
@@ -1,11 +1,7 @@
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';
9
5
 
10
6
  const program = new Command();
11
7
 
@@ -19,6 +15,7 @@ program
19
15
  .description('Deploy a folder to a live URL')
20
16
  .argument('<folder>', 'Path to the folder to deploy')
21
17
  .option('--name <subdomain>', 'Use a custom subdomain (optional)')
18
+ .option('-m, --message <text>', 'Deployment message (optional)')
22
19
  .option('--expires <time>', 'Auto-delete after time (e.g., 30m, 2h, 1d). Minimum: 30m')
23
20
  .option('--verbose', 'Show detailed error information')
24
21
  .action(async (folder, options) => {
@@ -55,6 +52,21 @@ program
55
52
  await rollback(subdomain, options);
56
53
  });
57
54
 
55
+ program
56
+ .command('init')
57
+ .description('Initialize a new project in the current directory')
58
+ .option('--name <subdomain>', 'Subdomain to link to')
59
+ .action(async (options) => {
60
+ await init(options);
61
+ });
62
+
63
+ program
64
+ .command('status')
65
+ .description('Show current project status')
66
+ .action(async () => {
67
+ await status();
68
+ });
69
+
58
70
  // Authentication commands
59
71
  program
60
72
  .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.1",
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,18 @@
1
1
  import { existsSync, statSync } from 'node:fs';
2
+ import chalk from 'chalk';
2
3
  import { readdir } from 'node:fs/promises';
3
4
  import { resolve, basename, join } from 'node:path';
4
5
  import { generateSubdomain } from '../utils/id.js';
5
6
  import { uploadFolder, finalizeUpload } from '../utils/upload.js';
6
7
  import { getNextVersion } from '../utils/metadata.js';
7
8
  import { saveLocalDeployment } from '../utils/localConfig.js';
8
- import { getNextVersionFromAPI } from '../utils/api.js';
9
+ import { getNextVersionFromAPI, checkSubdomainAvailable, listSubdomains } from '../utils/api.js';
10
+ import { getProjectConfig, findProjectRoot } from '../utils/projectConfig.js';
9
11
  import { success, errorWithSuggestions, info, warning, spinner, formatSize } from '../utils/logger.js';
10
12
  import { calculateExpiresAt, formatTimeRemaining } from '../utils/expiration.js';
11
13
  import { checkQuota, displayQuotaWarnings } from '../utils/quota.js';
12
14
  import { getCredentials } from '../utils/credentials.js';
15
+ import { validateStaticOnly } from '../utils/validator.js';
13
16
 
14
17
  /**
15
18
  * Calculate total size of a folder
@@ -87,6 +90,22 @@ export async function deploy(folder, options) {
87
90
  }
88
91
  scanSpinner.succeed(`Found ${fileCount} file(s)`);
89
92
 
93
+ // Static-Only Validation
94
+ const validationSpinner = spinner('Validating files...');
95
+ const validation = await validateStaticOnly(folderPath);
96
+ 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);
106
+ }
107
+ validationSpinner.succeed('Project validated (Static files only)');
108
+
90
109
  // Generate or use provided subdomain
91
110
  // Anonymous users cannot use custom subdomains
92
111
  const creds = await getCredentials();
@@ -97,14 +116,28 @@ export async function deploy(folder, options) {
97
116
  console.log('');
98
117
  }
99
118
 
100
- const subdomain = (options.name && creds?.email) ? options.name.toLowerCase() : generateSubdomain();
119
+ // Detect project config if no name provided
120
+ let subdomain = (options.name && creds?.email) ? options.name.toLowerCase() : null;
121
+
122
+ if (!subdomain) {
123
+ const projectRoot = findProjectRoot(folderPath);
124
+ const config = await getProjectConfig(projectRoot);
125
+ if (config?.subdomain) {
126
+ subdomain = config.subdomain;
127
+ info(`Using project subdomain: ${chalk.bold(subdomain)}`);
128
+ }
129
+ }
130
+
131
+ if (!subdomain) {
132
+ subdomain = generateSubdomain();
133
+ }
134
+
101
135
  const url = `https://${subdomain}.launchpd.cloud`;
102
136
 
103
- // Check if custom subdomain is taken
104
- if (options.name && creds?.email) {
137
+ // Check if custom subdomain is taken (only if explicitly provided or new)
138
+ if (options.name || !subdomain) {
105
139
  const checkSpinner = spinner('Checking subdomain availability...');
106
140
  try {
107
- const { checkSubdomainAvailable, listSubdomains } = await import('../utils/api.js');
108
141
  const isAvailable = await checkSubdomainAvailable(subdomain);
109
142
 
110
143
  if (!isAvailable) {
@@ -183,7 +216,8 @@ export async function deploy(folder, options) {
183
216
  fileCount,
184
217
  totalBytes,
185
218
  folderName,
186
- expiresAt?.toISOString() || null
219
+ expiresAt?.toISOString() || null,
220
+ options.message || null
187
221
  );
188
222
  finalizeSpinner.succeed('Deployment finalized');
189
223
 
@@ -6,4 +6,6 @@ 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';
@@ -0,0 +1,71 @@
1
+ import { initProjectConfig, findProjectRoot } from '../utils/projectConfig.js';
2
+ import { checkSubdomainAvailable, reserveSubdomain } from '../utils/api.js';
3
+ import { isLoggedIn } from '../utils/credentials.js';
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';
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
+ warning('This directory is already part of a Launchpd project.');
17
+ info(`Project root: ${projectRoot}`);
18
+ return;
19
+ }
20
+
21
+ if (!await isLoggedIn()) {
22
+ errorWithSuggestions('You must be logged in to initialize a project.', [
23
+ 'Run "launchpd login" to log in',
24
+ 'Run "launchpd register" to create an account'
25
+ ]);
26
+ process.exit(1);
27
+ }
28
+
29
+ const rl = readline.createInterface({ input, output });
30
+ let subdomain = options.name;
31
+
32
+ if (!subdomain) {
33
+ info('Linking this directory to a Launchpd subdomain...');
34
+ subdomain = await rl.question('Enter subdomain name (e.g. my-awesome-site): ');
35
+ }
36
+
37
+ if (!subdomain || !/^[a-z0-9-]+$/.test(subdomain)) {
38
+ rl.close();
39
+ errorWithSuggestions('Invalid subdomain. Use lowercase alphanumeric and hyphens only.', [
40
+ 'Example: my-site-123',
41
+ 'No spaces or special characters'
42
+ ]);
43
+ process.exit(1);
44
+ }
45
+
46
+ const checkSpinner = spinner(`Checking if "${subdomain}" is available...`);
47
+ try {
48
+ const isAvailable = await checkSubdomainAvailable(subdomain);
49
+ if (!isAvailable) {
50
+ checkSpinner.fail(`Subdomain "${subdomain}" is already taken.`);
51
+ rl.close();
52
+ process.exit(1);
53
+ }
54
+ checkSpinner.succeed(`Subdomain "${subdomain}" is available!`);
55
+
56
+ const reserveStatus = await reserveSubdomain(subdomain);
57
+ if (reserveStatus) {
58
+ await initProjectConfig(subdomain);
59
+ success(`Project initialized! Linked to: ${subdomain}.launchpd.cloud`);
60
+ info('Now you can run "launchpd deploy" without specifying a name.');
61
+ }
62
+ } catch (err) {
63
+ checkSpinner.fail('Failed to initialize project');
64
+ errorWithSuggestions(err.message, [
65
+ 'Check your internet connection',
66
+ 'Try a different subdomain name'
67
+ ]);
68
+ } finally {
69
+ rl.close();
70
+ }
71
+ }
@@ -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,55 @@
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 chalk from 'chalk';
5
+
6
+ /**
7
+ * Show current project status
8
+ */
9
+ export async function status(_options) {
10
+ const projectRoot = findProjectRoot();
11
+ if (!projectRoot) {
12
+ warning('Not a Launchpd project (no .launchpd.json found)');
13
+ info('Run "launchpd init" to link this directory to a subdomain.');
14
+ return;
15
+ }
16
+
17
+ const config = await getProjectConfig(projectRoot);
18
+ if (!config || !config.subdomain) {
19
+ errorWithSuggestions('Invalid project configuration.', [
20
+ 'Try deleting .launchpd.json and running "launchpd init" again'
21
+ ]);
22
+ return;
23
+ }
24
+
25
+ info(`Project root: ${chalk.cyan(projectRoot)}`);
26
+ info(`Linked subdomain: ${chalk.bold.green(config.subdomain)}.launchpd.cloud`);
27
+
28
+ const statusSpinner = spinner('Fetching latest deployment info...');
29
+ try {
30
+ const deploymentData = await getDeployment(config.subdomain);
31
+ statusSpinner.stop();
32
+
33
+ if (deploymentData && deploymentData.versions && deploymentData.versions.length > 0) {
34
+ const active = deploymentData.versions.find(v => v.version === deploymentData.activeVersion) || deploymentData.versions[0];
35
+
36
+ console.log('\nDeployment Status:');
37
+ console.log(` Active Version: ${chalk.cyan(`v${active.version}`)}`);
38
+ console.log(` Deployed At: ${new Date(active.created_at || active.timestamp).toLocaleString()}`);
39
+ if (active.message) {
40
+ console.log(` Message: ${chalk.italic(active.message)}`);
41
+ }
42
+ console.log(` File Count: ${active.file_count || active.fileCount}`);
43
+ console.log(` Total Size: ${formatSize(active.total_bytes || active.totalBytes)}`);
44
+ console.log(` URL: ${chalk.underline.blue(`https://${config.subdomain}.launchpd.cloud`)}`);
45
+ console.log('');
46
+ } else {
47
+ warning('\nNo deployments found for this project yet.');
48
+ info('Run "launchpd deploy <folder>" to push your first version.');
49
+ }
50
+ } catch {
51
+ statusSpinner.fail('Failed to fetch deployment status');
52
+ info(`Subdomain: ${config.subdomain}`);
53
+ // Don't exit, just show what we have
54
+ }
55
+ }
@@ -34,9 +34,10 @@ export async function versions(subdomainInput, options) {
34
34
  if (apiResult && apiResult.versions) {
35
35
  versionList = apiResult.versions.map(v => ({
36
36
  version: v.version,
37
- timestamp: v.created_at,
38
- fileCount: v.file_count,
39
- totalBytes: v.total_bytes,
37
+ timestamp: v.created_at || v.timestamp,
38
+ fileCount: v.file_count || v.fileCount,
39
+ totalBytes: v.total_bytes || v.totalBytes,
40
+ message: v.message || '',
40
41
  }));
41
42
  activeVersion = apiResult.activeVersion || 1;
42
43
  } else {
@@ -67,6 +68,7 @@ export async function versions(subdomainInput, options) {
67
68
  fileCount: v.fileCount,
68
69
  totalBytes: v.totalBytes,
69
70
  isActive: v.version === activeVersion,
71
+ message: v.message,
70
72
  })),
71
73
  }, null, 2));
72
74
  return;
@@ -77,8 +79,8 @@ export async function versions(subdomainInput, options) {
77
79
  console.log('');
78
80
 
79
81
  // Table header
80
- console.log(chalk.gray(' Version Date Files Size Status'));
81
- console.log(chalk.gray(' ' + '─'.repeat(70)));
82
+ console.log(chalk.gray(' Version Date Files Size Status Message'));
83
+ console.log(chalk.gray(' ' + '─'.repeat(100)));
82
84
 
83
85
  for (const v of versionList) {
84
86
  const isActive = v.version === activeVersion;
@@ -95,13 +97,15 @@ export async function versions(subdomainInput, options) {
95
97
  const filesStr = chalk.white(filesRaw.padEnd(10));
96
98
  const sizeStr = chalk.white(sizeRaw.padEnd(12));
97
99
  const statusStr = isActive
98
- ? chalk.green.bold('● active')
99
- : chalk.gray('○ inactive');
100
+ ? chalk.green.bold('● active'.padEnd(12))
101
+ : chalk.gray('○ inactive'.padEnd(12));
100
102
 
101
- console.log(` ${versionStr}${dateStr}${filesStr}${sizeStr}${statusStr}`);
103
+ const messageStr = chalk.italic.gray(v.message || '');
104
+
105
+ console.log(` ${versionStr}${dateStr}${filesStr}${sizeStr}${statusStr}${messageStr}`);
102
106
  }
103
107
 
104
- console.log(chalk.gray(' ' + '─'.repeat(70)));
108
+ console.log(chalk.gray(' ' + '─'.repeat(100)));
105
109
  console.log('');
106
110
  info(`Use ${chalk.cyan(`launchpd rollback ${subdomain} --to <n>`)} to restore a version.`);
107
111
  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
  }
@@ -0,0 +1,59 @@
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
+ * Initialize a new project config
51
+ */
52
+ export async function initProjectConfig(subdomain, projectDir = process.cwd()) {
53
+ const config = {
54
+ subdomain,
55
+ createdAt: new Date().toISOString(),
56
+ updatedAt: new Date().toISOString()
57
+ };
58
+ return await saveProjectConfig(config, projectDir);
59
+ }
@@ -72,7 +72,7 @@ async function uploadFile(content, subdomain, version, filePath, contentType) {
72
72
  * @param {string} folderName - Original folder name
73
73
  * @param {string|null} expiresAt - ISO expiration timestamp
74
74
  */
75
- async function completeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt) {
75
+ async function completeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt, message) {
76
76
  const apiKey = await getApiKey();
77
77
  const apiSecret = await getApiSecret();
78
78
  const headers = {
@@ -87,6 +87,7 @@ async function completeUpload(subdomain, version, fileCount, totalBytes, folderN
87
87
  totalBytes,
88
88
  folderName,
89
89
  expiresAt,
90
+ message,
90
91
  });
91
92
 
92
93
  if (apiSecret) {
@@ -176,6 +177,6 @@ export async function uploadFolder(localPath, subdomain, version = 1, onProgress
176
177
  * @param {string} folderName - Folder name
177
178
  * @param {string|null} expiresAt - Expiration ISO timestamp
178
179
  */
179
- export async function finalizeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt = null) {
180
- return completeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt);
180
+ export async function finalizeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt = null, message = null) {
181
+ return completeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt, message);
181
182
  }
@@ -0,0 +1,84 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { extname } from 'node:path';
3
+
4
+ // Allowed static file extensions
5
+ const ALLOWED_EXTENSIONS = new Set([
6
+ '.html', '.htm',
7
+ '.css', '.scss', '.sass',
8
+ '.js', '.mjs', '.cjs',
9
+ '.json', '.jsonld',
10
+ '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.avif',
11
+ '.woff', '.woff2', '.ttf', '.otf', '.eot',
12
+ '.mp4', '.webm', '.ogg', '.mp3', '.wav', '.flac',
13
+ '.pdf', '.txt', '.md', '.xml', '.yaml', '.yml'
14
+ ]);
15
+
16
+ // Forbidden indicators (frameworks, build tools, backend code)
17
+ const FORBIDDEN_INDICATORS = [
18
+ // Build systems & Frameworks
19
+ 'package.json',
20
+ 'package-lock.json',
21
+ 'yarn.lock',
22
+ 'pnpm-lock.yaml',
23
+ 'node_modules',
24
+ 'composer.json',
25
+ 'vendor',
26
+ 'requirements.txt',
27
+ 'Gemfile',
28
+ 'Makefile',
29
+ 'tsconfig.json',
30
+ 'next.config.js',
31
+ 'nuxt.config.js',
32
+ 'svelte.config.js',
33
+ 'vite.config.js',
34
+ 'webpack.config.js',
35
+ 'rollup.config.js',
36
+ 'angular.json',
37
+
38
+ // Backend/Source
39
+ '.jsx', '.tsx', '.ts', '.vue', '.svelte', '.php', '.py', '.rb', '.go', '.rs', '.java', '.cs', '.cpp', '.c',
40
+ '.env', '.env.local', '.env.production',
41
+ '.dockerfile', 'docker-compose.yml',
42
+
43
+ // Hidden/System
44
+ '.git', '.svn', '.hg'
45
+ ];
46
+
47
+ /**
48
+ * Validates that a folder contains ONLY static files.
49
+ * @param {string} folderPath
50
+ * @returns {Promise<{success: boolean, violations: string[]}>}
51
+ */
52
+ export async function validateStaticOnly(folderPath) {
53
+ const violations = [];
54
+
55
+ try {
56
+ const files = await readdir(folderPath, { recursive: true, withFileTypes: true });
57
+
58
+ for (const file of files) {
59
+ const fileName = file.name.toLowerCase();
60
+ const ext = extname(fileName);
61
+
62
+ // 1. Check if the file/dir itself is a forbidden indicator
63
+ if (FORBIDDEN_INDICATORS.includes(fileName) || FORBIDDEN_INDICATORS.includes(ext)) {
64
+ violations.push(file.name);
65
+ continue;
66
+ }
67
+
68
+ // 2. Check extension for non-allowed types (only for files)
69
+ if (file.isFile()) {
70
+ // Ignore files without extensions or if they start with a dot (but handle indicators above)
71
+ if (ext && !ALLOWED_EXTENSIONS.has(ext)) {
72
+ violations.push(file.name);
73
+ }
74
+ }
75
+ }
76
+
77
+ return {
78
+ success: violations.length === 0,
79
+ violations: [...new Set(violations)] // Deduplicate
80
+ };
81
+ } catch (err) {
82
+ throw new Error(`Failed to validate folder: ${err.message}`);
83
+ }
84
+ }