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 +78 -74
- package/bin/cli.js +24 -7
- package/package.json +4 -3
- package/src/commands/deploy.js +127 -17
- package/src/commands/index.js +3 -0
- package/src/commands/init.js +90 -0
- package/src/commands/list.js +22 -18
- package/src/commands/rollback.js +6 -1
- package/src/commands/status.js +64 -0
- package/src/commands/versions.js +20 -10
- package/src/utils/api.js +2 -1
- package/src/utils/expiration.js +3 -5
- package/src/utils/ignore.js +53 -0
- package/src/utils/projectConfig.js +73 -0
- package/src/utils/prompt.js +4 -1
- package/src/utils/quota.js +57 -25
- package/src/utils/upload.js +22 -5
- package/src/utils/validator.js +90 -0
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/launchpd)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://github.com/kents00/launchpd)
|
|
54
8
|
|
|
55
|
-
|
|
9
|
+
---
|
|
56
10
|
|
|
57
|
-
|
|
58
|
-
launchpd rollback my-subdomain
|
|
59
|
-
launchpd rollback my-subdomain --to 2
|
|
60
|
-
```
|
|
11
|
+
## Features
|
|
61
12
|
|
|
62
|
-
|
|
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
|
-
|
|
21
|
+
## Quick Start
|
|
65
22
|
|
|
66
23
|
```bash
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
### Login with your API key
|
|
24
|
+
# Install globally
|
|
25
|
+
npm install -g launchpd
|
|
71
26
|
|
|
72
|
-
|
|
73
|
-
launchpd
|
|
27
|
+
# Deploy your current folder
|
|
28
|
+
launchpd deploy .
|
|
74
29
|
```
|
|
75
30
|
|
|
76
|
-
|
|
31
|
+
---
|
|
77
32
|
|
|
78
|
-
|
|
79
|
-
launchpd whoami
|
|
80
|
-
launchpd quota
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
### Logout
|
|
33
|
+
## Installation
|
|
84
34
|
|
|
85
35
|
```bash
|
|
86
|
-
launchpd
|
|
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
|
-
|
|
92
|
-
|
|
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/
|
|
5
|
-
|
|
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('
|
|
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.
|
|
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": "^
|
|
61
|
+
"@vitest/coverage-v8": "^4.0.17",
|
|
61
62
|
"eslint": "^9.0.0",
|
|
62
|
-
"vitest": "^
|
|
63
|
+
"vitest": "^4.0.17"
|
|
63
64
|
},
|
|
64
65
|
"engines": {
|
|
65
66
|
"node": ">=20.0.0"
|
package/src/commands/deploy.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
225
|
+
const isUpdate = (configSubdomain && subdomain === configSubdomain);
|
|
226
|
+
|
|
227
|
+
const quotaCheck = await checkQuota(subdomain, estimatedBytes, { isUpdate });
|
|
138
228
|
|
|
139
229
|
if (!quotaCheck.allowed) {
|
|
140
|
-
|
|
141
|
-
|
|
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
|
}
|
package/src/commands/index.js
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/list.js
CHANGED
|
@@ -32,6 +32,7 @@ export async function list(options) {
|
|
|
32
32
|
version: d.version,
|
|
33
33
|
timestamp: d.created_at,
|
|
34
34
|
expiresAt: d.expires_at,
|
|
35
|
+
message: d.message,
|
|
35
36
|
isActive: d.active_version === d.version,
|
|
36
37
|
}));
|
|
37
38
|
source = 'api';
|
|
@@ -65,12 +66,13 @@ export async function list(options) {
|
|
|
65
66
|
// Header
|
|
66
67
|
console.log(
|
|
67
68
|
chalk.gray(
|
|
68
|
-
padRight('URL',
|
|
69
|
-
padRight('
|
|
70
|
-
padRight('
|
|
71
|
-
padRight('
|
|
72
|
-
padRight('
|
|
73
|
-
'
|
|
69
|
+
padRight('URL', 35) +
|
|
70
|
+
padRight('VER', 6) +
|
|
71
|
+
padRight('FOLDER', 15) +
|
|
72
|
+
padRight('FILES', 7) +
|
|
73
|
+
padRight('SIZE', 10) +
|
|
74
|
+
padRight('DATE', 12) +
|
|
75
|
+
'STATUS'
|
|
74
76
|
)
|
|
75
77
|
);
|
|
76
78
|
console.log(chalk.gray('─'.repeat(100)));
|
|
@@ -84,26 +86,28 @@ export async function list(options) {
|
|
|
84
86
|
|
|
85
87
|
// Determine status with colors
|
|
86
88
|
let status;
|
|
87
|
-
if (dep.expiresAt) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
} else {
|
|
91
|
-
status = chalk.yellow(`⏱ ${formatTimeRemaining(dep.expiresAt)}`);
|
|
92
|
-
}
|
|
93
|
-
} else {
|
|
89
|
+
if (dep.expiresAt && isExpired(dep.expiresAt)) {
|
|
90
|
+
status = chalk.red.bold('● expired');
|
|
91
|
+
} else if (dep.isActive) {
|
|
94
92
|
status = chalk.green.bold('● active');
|
|
93
|
+
} else if (dep.expiresAt) {
|
|
94
|
+
status = chalk.yellow(`⏱ ${formatTimeRemaining(dep.expiresAt)}`);
|
|
95
|
+
} else {
|
|
96
|
+
status = chalk.gray('○ inactive');
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
// Version
|
|
98
|
-
const
|
|
99
|
+
// Version info
|
|
100
|
+
const versionStr = `v${dep.version || 1}`;
|
|
99
101
|
|
|
100
102
|
console.log(
|
|
101
|
-
chalk.cyan(padRight(url,
|
|
103
|
+
chalk.cyan(padRight(url, 35)) +
|
|
104
|
+
chalk.magenta(padRight(versionStr, 6)) +
|
|
102
105
|
chalk.white(padRight(dep.folderName || '-', 15)) +
|
|
103
106
|
chalk.white(padRight(String(dep.fileCount), 7)) +
|
|
104
|
-
chalk.white(padRight(size,
|
|
107
|
+
chalk.white(padRight(size, 10)) +
|
|
105
108
|
chalk.gray(padRight(date, 12)) +
|
|
106
|
-
status +
|
|
109
|
+
status +
|
|
110
|
+
(dep.message ? chalk.gray(` - ${dep.message}`) : '')
|
|
107
111
|
);
|
|
108
112
|
}
|
|
109
113
|
|
package/src/commands/rollback.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/commands/versions.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getVersionsForSubdomain, getActiveVersion } from '../utils/metadata.js';
|
|
2
2
|
import { getVersions as getVersionsFromAPI } from '../utils/api.js';
|
|
3
3
|
import { isLoggedIn } from '../utils/credentials.js';
|
|
4
|
-
import { success, errorWithSuggestions, info, spinner, formatSize } from '../utils/logger.js';
|
|
4
|
+
import { success, errorWithSuggestions, info, spinner, warning, formatSize } from '../utils/logger.js';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -23,6 +23,12 @@ export async function versions(subdomainInput, options) {
|
|
|
23
23
|
process.exit(1);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
if (options.to) {
|
|
27
|
+
warning(`The --to option is for the ${chalk.bold('rollback')} command.`);
|
|
28
|
+
info(`Try: ${chalk.cyan(`launchpd rollback ${subdomain} --to ${options.to}`)}`);
|
|
29
|
+
console.log('');
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
try {
|
|
27
33
|
const fetchSpinner = spinner(`Fetching versions for ${subdomain}...`);
|
|
28
34
|
|
|
@@ -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(
|
|
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(
|
|
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
|
}
|
package/src/utils/expiration.js
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
* Parse a time string into milliseconds
|
|
3
3
|
* Supports: 30m, 1h, 2h, 1d, 7d, etc.
|
|
4
4
|
* Minimum: 30 minutes
|
|
5
|
-
*
|
|
6
|
-
* @param {string} timeStr - Time string (e.g., "30m", "2h", "1d")
|
|
7
|
-
* @returns {number} Milliseconds
|
|
8
5
|
*/
|
|
6
|
+
|
|
7
|
+
export const MIN_EXPIRATION_MS = 30 * 60 * 1000;
|
|
9
8
|
export function parseTimeString(timeStr) {
|
|
10
9
|
const regex = /^(\d+)([mhd])$/i;
|
|
11
10
|
const match = regex.exec(timeStr);
|
|
@@ -33,8 +32,7 @@ export function parseTimeString(timeStr) {
|
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
// Minimum 30 minutes
|
|
36
|
-
|
|
37
|
-
if (ms < minMs) {
|
|
35
|
+
if (ms < MIN_EXPIRATION_MS) {
|
|
38
36
|
throw new Error('Minimum expiration time is 30 minutes (30m)');
|
|
39
37
|
}
|
|
40
38
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Shared ignore lists for Launchpd CLI
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Directories to ignore during scanning, validation, and upload
|
|
7
|
+
export const IGNORE_DIRECTORIES = new Set([
|
|
8
|
+
'node_modules',
|
|
9
|
+
'.git',
|
|
10
|
+
'.svn',
|
|
11
|
+
'.hg',
|
|
12
|
+
'vendor',
|
|
13
|
+
'composer',
|
|
14
|
+
'.env',
|
|
15
|
+
'.env.local',
|
|
16
|
+
'.env.production',
|
|
17
|
+
'.env.development',
|
|
18
|
+
'dist',
|
|
19
|
+
'build',
|
|
20
|
+
'.next',
|
|
21
|
+
'.nuxt',
|
|
22
|
+
'.svelte-kit',
|
|
23
|
+
'coverage',
|
|
24
|
+
'.cache'
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// Files to ignore during scanning, validation, and upload
|
|
28
|
+
export const IGNORE_FILES = new Set([
|
|
29
|
+
'.launchpd.json',
|
|
30
|
+
'package-lock.json',
|
|
31
|
+
'yarn.lock',
|
|
32
|
+
'pnpm-lock.yaml',
|
|
33
|
+
'.DS_Store',
|
|
34
|
+
'Thumbs.db',
|
|
35
|
+
'desktop.ini',
|
|
36
|
+
'.gitignore',
|
|
37
|
+
'.npmignore',
|
|
38
|
+
'README.md',
|
|
39
|
+
'LICENSE'
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if a path or filename should be ignored
|
|
44
|
+
* @param {string} name - Base name of the file or directory
|
|
45
|
+
* @param {boolean} isDir - Whether the path is a directory
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function isIgnored(name, isDir = false) {
|
|
49
|
+
if (isDir) {
|
|
50
|
+
return IGNORE_DIRECTORIES.has(name);
|
|
51
|
+
}
|
|
52
|
+
return IGNORE_FILES.has(name) || IGNORE_DIRECTORIES.has(name);
|
|
53
|
+
}
|
|
@@ -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
|
+
}
|
package/src/utils/prompt.js
CHANGED
|
@@ -8,12 +8,15 @@ export function prompt(question) {
|
|
|
8
8
|
const rl = createInterface({
|
|
9
9
|
input: process.stdin,
|
|
10
10
|
output: process.stdout,
|
|
11
|
+
terminal: true
|
|
11
12
|
});
|
|
12
13
|
|
|
13
14
|
return new Promise((resolve) => {
|
|
14
15
|
rl.question(question, (answer) => {
|
|
15
16
|
rl.close();
|
|
16
|
-
|
|
17
|
+
// Small delay to allow Windows handles to cleanup before process.exit might be called
|
|
18
|
+
// Increased to 100ms to be safer against UV_HANDLE_CLOSING assertion on Windows
|
|
19
|
+
setTimeout(() => resolve(answer.trim()), 100);
|
|
17
20
|
});
|
|
18
21
|
});
|
|
19
22
|
}
|
package/src/utils/quota.js
CHANGED
|
@@ -15,20 +15,49 @@ const API_BASE_URL = `https://api.${config.domain}`;
|
|
|
15
15
|
*
|
|
16
16
|
* @param {string} subdomain - Target subdomain (null for new site)
|
|
17
17
|
* @param {number} estimatedBytes - Estimated upload size in bytes
|
|
18
|
+
* @param {object} options - Options
|
|
19
|
+
* @param {boolean} options.isUpdate - Whether this is known to be an update
|
|
18
20
|
* @returns {Promise<{allowed: boolean, isNewSite: boolean, quota: object, warnings: string[]}>}
|
|
19
21
|
*/
|
|
20
|
-
export async function checkQuota(subdomain, estimatedBytes = 0) {
|
|
22
|
+
export async function checkQuota(subdomain, estimatedBytes = 0, options = {}) {
|
|
21
23
|
const creds = await getCredentials();
|
|
22
24
|
|
|
23
25
|
let quotaData;
|
|
24
26
|
|
|
25
27
|
if (creds?.apiKey) {
|
|
26
28
|
// Authenticated user
|
|
27
|
-
quotaData = await checkAuthenticatedQuota(creds.apiKey);
|
|
29
|
+
quotaData = await checkAuthenticatedQuota(creds.apiKey, options.isUpdate);
|
|
28
30
|
} else {
|
|
29
31
|
// Anonymous user
|
|
30
32
|
quotaData = await checkAnonymousQuota();
|
|
31
33
|
}
|
|
34
|
+
// ... skipped ...
|
|
35
|
+
/**
|
|
36
|
+
* Check quota for authenticated user
|
|
37
|
+
*/
|
|
38
|
+
async function checkAuthenticatedQuota(apiKey, isUpdate = false) {
|
|
39
|
+
try {
|
|
40
|
+
const url = new URL(`${API_BASE_URL}/api/quota`);
|
|
41
|
+
if (isUpdate) {
|
|
42
|
+
url.searchParams.append('is_update', 'true');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const response = await fetch(url.toString(), {
|
|
46
|
+
headers: {
|
|
47
|
+
'X-API-Key': apiKey,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return await response.json();
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// ... skipped ...
|
|
32
61
|
|
|
33
62
|
if (!quotaData) {
|
|
34
63
|
// API unavailable, allow deployment (fail-open for MVP)
|
|
@@ -40,10 +69,25 @@ export async function checkQuota(subdomain, estimatedBytes = 0) {
|
|
|
40
69
|
};
|
|
41
70
|
}
|
|
42
71
|
|
|
72
|
+
// DEBUG: Write input options to file
|
|
73
|
+
try {
|
|
74
|
+
const { appendFileSync } = await import('node:fs');
|
|
75
|
+
appendFileSync('quota_debug_trace.txt', `\n[${new Date().toISOString()}] Check: ${subdomain}, isUpdate: ${options.isUpdate}, type: ${typeof options.isUpdate}`);
|
|
76
|
+
} catch {
|
|
77
|
+
// Ignore trace errors
|
|
78
|
+
}
|
|
79
|
+
|
|
43
80
|
// Check if this is an existing site the user owns
|
|
44
|
-
|
|
81
|
+
// If explicitly marked as update, assume user owns it
|
|
82
|
+
let isNewSite = true;
|
|
83
|
+
if (options.isUpdate) {
|
|
84
|
+
isNewSite = false;
|
|
85
|
+
} else if (subdomain) {
|
|
86
|
+
isNewSite = !await userOwnsSite(creds?.apiKey, subdomain);
|
|
87
|
+
}
|
|
45
88
|
|
|
46
89
|
const warnings = [...(quotaData.warnings || [])];
|
|
90
|
+
|
|
47
91
|
const allowed = true;
|
|
48
92
|
|
|
49
93
|
// Check if blocked (anonymous limit reached)
|
|
@@ -107,6 +151,8 @@ export async function checkQuota(subdomain, estimatedBytes = 0) {
|
|
|
107
151
|
}
|
|
108
152
|
}
|
|
109
153
|
|
|
154
|
+
|
|
155
|
+
|
|
110
156
|
return {
|
|
111
157
|
allowed,
|
|
112
158
|
isNewSite,
|
|
@@ -115,26 +161,6 @@ export async function checkQuota(subdomain, estimatedBytes = 0) {
|
|
|
115
161
|
};
|
|
116
162
|
}
|
|
117
163
|
|
|
118
|
-
/**
|
|
119
|
-
* Check quota for authenticated user
|
|
120
|
-
*/
|
|
121
|
-
async function checkAuthenticatedQuota(apiKey) {
|
|
122
|
-
try {
|
|
123
|
-
const response = await fetch(`${API_BASE_URL}/api/quota`, {
|
|
124
|
-
headers: {
|
|
125
|
-
'X-API-Key': apiKey,
|
|
126
|
-
},
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
if (!response.ok) {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return await response.json();
|
|
134
|
-
} catch {
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
164
|
|
|
139
165
|
/**
|
|
140
166
|
* Check quota for anonymous user
|
|
@@ -180,12 +206,18 @@ async function userOwnsSite(apiKey, subdomain) {
|
|
|
180
206
|
});
|
|
181
207
|
|
|
182
208
|
if (!response.ok) {
|
|
209
|
+
console.log('Fetch subdomains failed:', response.status);
|
|
183
210
|
return false;
|
|
184
211
|
}
|
|
185
212
|
|
|
186
213
|
const data = await response.json();
|
|
187
|
-
|
|
188
|
-
|
|
214
|
+
console.log('User subdomains:', data.subdomains?.map(s => s.subdomain));
|
|
215
|
+
console.log('Checking for:', subdomain);
|
|
216
|
+
const owns = data.subdomains?.some(s => s.subdomain === subdomain) || false;
|
|
217
|
+
console.log('Owns site?', owns);
|
|
218
|
+
return owns;
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.log('Error checking ownership:', err);
|
|
189
221
|
return false;
|
|
190
222
|
}
|
|
191
223
|
}
|
package/src/utils/upload.js
CHANGED
|
@@ -4,6 +4,7 @@ import mime from 'mime-types';
|
|
|
4
4
|
import { config } from '../config.js';
|
|
5
5
|
import { getApiKey, getApiSecret } from './credentials.js';
|
|
6
6
|
import { createHmac } from 'node:crypto';
|
|
7
|
+
import { isIgnored } from './ignore.js';
|
|
7
8
|
|
|
8
9
|
const API_BASE_URL = config.apiUrl;
|
|
9
10
|
|
|
@@ -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(
|
|
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
|
+
}
|