launchpd 0.1.1 → 0.1.4
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/LICENSE +4 -0
- package/README.md +1 -10
- package/bin/setup.js +18 -40
- package/package.json +1 -5
- package/src/commands/deploy.js +14 -27
- package/src/config.js +8 -30
- package/src/utils/metadata.js +96 -249
- package/src/utils/upload.js +88 -23
package/LICENSE
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
+
<<<<<<< HEAD
|
|
3
4
|
Copyright (c) 2026 Launchpd
|
|
5
|
+
=======
|
|
6
|
+
Copyright (c) 2026 Kent Edoloverio
|
|
7
|
+
>>>>>>> 12ada1bf2dcc63674c3b6955e525a51209308d45
|
|
4
8
|
|
|
5
9
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
10
|
of this software and associated documentation files (the "Software"), to deal
|
package/README.md
CHANGED
|
@@ -7,7 +7,6 @@ Deploy static sites instantly to a live URL. No config, no complexity.
|
|
|
7
7
|
```bash
|
|
8
8
|
npm install -g launchpd
|
|
9
9
|
launchpd deploy ./my-site
|
|
10
|
-
# → https://abc123.launchpd.cloud
|
|
11
10
|
```
|
|
12
11
|
|
|
13
12
|
## Installation
|
|
@@ -30,7 +29,6 @@ launchpd deploy ./my-folder
|
|
|
30
29
|
|
|
31
30
|
```bash
|
|
32
31
|
launchpd deploy ./my-folder --name my-project
|
|
33
|
-
# → https://my-project.launchpd.cloud
|
|
34
32
|
```
|
|
35
33
|
|
|
36
34
|
### Set expiration time
|
|
@@ -101,13 +99,6 @@ launchpd logout
|
|
|
101
99
|
| Retention | 7 days | 30 days |
|
|
102
100
|
| Versions | 1 | 10 |
|
|
103
101
|
|
|
104
|
-
## How It Works
|
|
105
|
-
|
|
106
|
-
```
|
|
107
|
-
Your folder → Launchpd CLI → Global CDN → Live URL
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
Files are uploaded to Cloudflare's global edge network, giving your site fast load times worldwide with automatic SSL.
|
|
111
102
|
|
|
112
103
|
## Support
|
|
113
104
|
|
|
@@ -116,4 +107,4 @@ Files are uploaded to Cloudflare's global edge network, giving your site fast lo
|
|
|
116
107
|
|
|
117
108
|
## License
|
|
118
109
|
|
|
119
|
-
MIT
|
|
110
|
+
MIT
|
package/bin/setup.js
CHANGED
|
@@ -1,62 +1,40 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { config
|
|
4
|
-
import { info, success
|
|
3
|
+
import { config } from '../src/config.js';
|
|
4
|
+
import { info, success } from '../src/utils/logger.js';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Setup script to
|
|
8
|
+
* Setup script to display CLI information
|
|
9
9
|
*/
|
|
10
10
|
async function setup() {
|
|
11
11
|
console.log('\n' + chalk.bold.blue('═══════════════════════════════════════'));
|
|
12
|
-
console.log(chalk.bold.blue(' Launchpd
|
|
12
|
+
console.log(chalk.bold.blue(' Launchpd CLI'));
|
|
13
13
|
console.log(chalk.bold.blue('═══════════════════════════════════════\n'));
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
info('Checking configuration...\n');
|
|
15
|
+
info('Launchpd is ready to use!\n');
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (!validation.valid) {
|
|
21
|
-
error(`Missing required environment variables:`);
|
|
22
|
-
for (const missing of validation.missing) {
|
|
23
|
-
console.log(chalk.red(` ✗ ${missing}`));
|
|
24
|
-
}
|
|
25
|
-
console.log('\n' + chalk.yellow('Setup Instructions:'));
|
|
26
|
-
console.log(' 1. Copy .env.example to .env:');
|
|
27
|
-
console.log(chalk.gray(' cp .env.example .env'));
|
|
28
|
-
console.log(' 2. Edit .env and add your Cloudflare R2 credentials');
|
|
29
|
-
console.log(' 3. Get credentials from: https://dash.cloudflare.com → R2 → API Tokens\n');
|
|
30
|
-
process.exit(1);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Display current configuration
|
|
34
|
-
console.log(chalk.green('✓ All required environment variables set\n'));
|
|
35
|
-
|
|
36
|
-
console.log(chalk.bold('Current Configuration:'));
|
|
17
|
+
console.log(chalk.bold('Configuration:'));
|
|
37
18
|
console.log(chalk.gray('─'.repeat(50)));
|
|
38
19
|
console.log(chalk.cyan(' Domain: '), config.domain);
|
|
39
|
-
console.log(chalk.cyan('
|
|
40
|
-
console.log(chalk.cyan('
|
|
41
|
-
console.log(chalk.cyan(' Access Key: '), config.r2.accessKeyId ? '✓ Set' : '✗ Missing');
|
|
42
|
-
console.log(chalk.cyan(' Secret Key: '), config.r2.secretAccessKey ? '✓ Set' : '✗ Missing');
|
|
20
|
+
console.log(chalk.cyan(' API: '), config.apiUrl);
|
|
21
|
+
console.log(chalk.cyan(' Version: '), config.version);
|
|
43
22
|
console.log(chalk.gray('─'.repeat(50)) + '\n');
|
|
44
23
|
|
|
45
|
-
|
|
46
|
-
console.log(chalk.
|
|
47
|
-
console.log(chalk.gray(' 1. Test dry-run deployment:'));
|
|
48
|
-
console.log(chalk.cyan(' npm run dev'));
|
|
49
|
-
console.log(chalk.gray(' 2. Deploy Worker:'));
|
|
50
|
-
console.log(chalk.cyan(' cd worker && wrangler deploy'));
|
|
51
|
-
console.log(chalk.gray(' 3. Verify DNS settings in Cloudflare Dashboard:'));
|
|
52
|
-
console.log(chalk.cyan(` DNS → Records → Add A record * → 192.0.2.1 (Proxied)`));
|
|
53
|
-
console.log(chalk.gray(' 4. Deploy your first site:'));
|
|
24
|
+
console.log(chalk.bold('Quick Start:'));
|
|
25
|
+
console.log(chalk.gray(' Deploy your first site:'));
|
|
54
26
|
console.log(chalk.cyan(' launchpd deploy ./your-folder\n'));
|
|
55
27
|
|
|
56
|
-
|
|
28
|
+
console.log(chalk.gray(' Login for more quota:'));
|
|
29
|
+
console.log(chalk.cyan(' launchpd login\n'));
|
|
30
|
+
|
|
31
|
+
console.log(chalk.gray(' List your deployments:'));
|
|
32
|
+
console.log(chalk.cyan(' launchpd list\n'));
|
|
33
|
+
|
|
34
|
+
success('No configuration needed - just deploy!');
|
|
57
35
|
}
|
|
58
36
|
|
|
59
37
|
setup().catch(err => {
|
|
60
|
-
error(`Setup failed: ${err.message}`);
|
|
38
|
+
console.error(`Setup failed: ${err.message}`);
|
|
61
39
|
process.exit(1);
|
|
62
40
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "launchpd",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Deploy static sites instantly to a live URL",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"start": "node bin/cli.js",
|
|
23
23
|
"dev": "node bin/cli.js deploy ../examples/test-site --dry-run",
|
|
24
|
-
"setup": "node bin/setup.js",
|
|
25
24
|
"test": "vitest run",
|
|
26
25
|
"test:watch": "vitest",
|
|
27
26
|
"test:coverage": "vitest run --coverage",
|
|
@@ -35,7 +34,6 @@
|
|
|
35
34
|
"deploy",
|
|
36
35
|
"cli",
|
|
37
36
|
"cloudflare",
|
|
38
|
-
"r2",
|
|
39
37
|
"cdn",
|
|
40
38
|
"website",
|
|
41
39
|
"publish"
|
|
@@ -52,10 +50,8 @@
|
|
|
52
50
|
},
|
|
53
51
|
"homepage": "https://launchpd.cloud",
|
|
54
52
|
"dependencies": {
|
|
55
|
-
"@aws-sdk/client-s3": "^3.700.0",
|
|
56
53
|
"chalk": "^5.4.0",
|
|
57
54
|
"commander": "^14.0.0",
|
|
58
|
-
"dotenv": "^16.4.0",
|
|
59
55
|
"mime-types": "^2.1.35",
|
|
60
56
|
"nanoid": "^5.1.0"
|
|
61
57
|
},
|
package/src/commands/deploy.js
CHANGED
|
@@ -2,10 +2,10 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
2
2
|
import { readdir } from 'node:fs/promises';
|
|
3
3
|
import { resolve, basename, join } from 'node:path';
|
|
4
4
|
import { generateSubdomain } from '../utils/id.js';
|
|
5
|
-
import { uploadFolder } from '../utils/upload.js';
|
|
6
|
-
import {
|
|
5
|
+
import { uploadFolder, finalizeUpload } from '../utils/upload.js';
|
|
6
|
+
import { getNextVersion } from '../utils/metadata.js';
|
|
7
7
|
import { saveLocalDeployment } from '../utils/localConfig.js';
|
|
8
|
-
import {
|
|
8
|
+
import { getNextVersionFromAPI } from '../utils/api.js';
|
|
9
9
|
import { success, error, info, warning } from '../utils/logger.js';
|
|
10
10
|
import { calculateExpiresAt, formatTimeRemaining } from '../utils/expiration.js';
|
|
11
11
|
import { checkQuota, displayQuotaWarnings } from '../utils/quota.js';
|
|
@@ -135,42 +135,29 @@ export async function deploy(folder, options) {
|
|
|
135
135
|
|
|
136
136
|
// Perform actual upload
|
|
137
137
|
try {
|
|
138
|
-
// Get next version number for this subdomain (try API first, fallback to
|
|
138
|
+
// Get next version number for this subdomain (try API first, fallback to local)
|
|
139
139
|
let version = await getNextVersionFromAPI(subdomain);
|
|
140
140
|
if (version === null) {
|
|
141
141
|
version = await getNextVersion(subdomain);
|
|
142
142
|
}
|
|
143
143
|
info(`Deploying as version ${version}...`);
|
|
144
144
|
|
|
145
|
+
// Upload all files via API proxy
|
|
146
|
+
const folderName = basename(folderPath);
|
|
145
147
|
const { totalBytes } = await uploadFolder(folderPath, subdomain, version);
|
|
146
148
|
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
// Record deployment metadata
|
|
151
|
-
info('Recording deployment metadata...');
|
|
152
|
-
|
|
153
|
-
// Try API first for centralized storage
|
|
154
|
-
const folderName = basename(folderPath);
|
|
155
|
-
const apiResult = await recordToAPI({
|
|
149
|
+
// Finalize upload: set active version and record metadata
|
|
150
|
+
info('Finalizing deployment...');
|
|
151
|
+
await finalizeUpload(
|
|
156
152
|
subdomain,
|
|
157
|
-
|
|
153
|
+
version,
|
|
158
154
|
fileCount,
|
|
159
155
|
totalBytes,
|
|
160
|
-
|
|
161
|
-
expiresAt
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (apiResult) {
|
|
165
|
-
// API succeeded - deployment is centrally tracked
|
|
166
|
-
info('Deployment recorded to central API');
|
|
167
|
-
} else {
|
|
168
|
-
// API unavailable - fallback to R2 metadata
|
|
169
|
-
warning('Central API unavailable, using local fallback');
|
|
170
|
-
await recordMetadata(subdomain, folderPath, fileCount, totalBytes, version, expiresAt);
|
|
171
|
-
}
|
|
156
|
+
folderName,
|
|
157
|
+
expiresAt?.toISOString() || null
|
|
158
|
+
);
|
|
172
159
|
|
|
173
|
-
//
|
|
160
|
+
// Save locally for quick access
|
|
174
161
|
await saveLocalDeployment({
|
|
175
162
|
subdomain,
|
|
176
163
|
folderName,
|
package/src/config.js
CHANGED
|
@@ -1,36 +1,14 @@
|
|
|
1
|
-
import 'dotenv/config';
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
* Application configuration
|
|
2
|
+
* Application configuration for Launchpd CLI
|
|
3
|
+
* No credentials needed - uploads go through the API proxy
|
|
5
4
|
*/
|
|
6
5
|
export const config = {
|
|
7
|
-
r2: {
|
|
8
|
-
accountId: process.env.R2_ACCOUNT_ID || '',
|
|
9
|
-
accessKeyId: process.env.R2_ACCESS_KEY_ID || '',
|
|
10
|
-
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || '',
|
|
11
|
-
bucketName: process.env.R2_BUCKET_NAME || 'launchpd',
|
|
12
|
-
},
|
|
13
|
-
|
|
14
6
|
// Base domain for deployments
|
|
15
|
-
domain:
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Validate required configuration
|
|
20
|
-
* @returns {boolean} true if all required config is present
|
|
21
|
-
*/
|
|
22
|
-
export function validateConfig() {
|
|
23
|
-
const required = [
|
|
24
|
-
'R2_ACCOUNT_ID',
|
|
25
|
-
'R2_ACCESS_KEY_ID',
|
|
26
|
-
'R2_SECRET_ACCESS_KEY',
|
|
27
|
-
];
|
|
7
|
+
domain: 'launchpd.cloud',
|
|
28
8
|
|
|
29
|
-
|
|
9
|
+
// API endpoint
|
|
10
|
+
apiUrl: 'https://api.launchpd.cloud',
|
|
30
11
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return { valid: true, missing: [] };
|
|
36
|
-
}
|
|
12
|
+
// CLI version
|
|
13
|
+
version: '0.1.1',
|
|
14
|
+
};
|
package/src/utils/metadata.js
CHANGED
|
@@ -1,114 +1,85 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Metadata utilities for Launchpd CLI
|
|
3
|
+
* All operations now go through the API proxy
|
|
4
|
+
*/
|
|
5
|
+
|
|
2
6
|
import { config } from '../config.js';
|
|
3
7
|
|
|
4
|
-
const
|
|
8
|
+
const API_BASE_URL = config.apiUrl;
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
|
-
*
|
|
11
|
+
* Get API key for requests
|
|
8
12
|
*/
|
|
9
|
-
function
|
|
10
|
-
return
|
|
11
|
-
region: 'auto',
|
|
12
|
-
endpoint: `https://${config.r2.accountId}.r2.cloudflarestorage.com`,
|
|
13
|
-
credentials: {
|
|
14
|
-
accessKeyId: config.r2.accessKeyId,
|
|
15
|
-
secretAccessKey: config.r2.secretAccessKey,
|
|
16
|
-
},
|
|
17
|
-
});
|
|
13
|
+
function getApiKey() {
|
|
14
|
+
return process.env.STATICLAUNCH_API_KEY || 'public-beta-key';
|
|
18
15
|
}
|
|
19
16
|
|
|
20
17
|
/**
|
|
21
|
-
*
|
|
22
|
-
* @returns {Promise<{version: number, deployments: Array}>}
|
|
18
|
+
* Make an authenticated API request
|
|
23
19
|
*/
|
|
24
|
-
async function
|
|
20
|
+
async function apiRequest(endpoint, options = {}) {
|
|
21
|
+
const url = `${API_BASE_URL}${endpoint}`;
|
|
22
|
+
|
|
23
|
+
const headers = {
|
|
24
|
+
'Content-Type': 'application/json',
|
|
25
|
+
'X-API-Key': getApiKey(),
|
|
26
|
+
...options.headers,
|
|
27
|
+
};
|
|
28
|
+
|
|
25
29
|
try {
|
|
26
|
-
const response = await
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
})
|
|
30
|
-
const text = await response.Body.transformToString();
|
|
31
|
-
return JSON.parse(text);
|
|
32
|
-
} catch {
|
|
33
|
-
// File doesn't exist yet, return empty structure
|
|
34
|
-
return { version: 1, deployments: [] };
|
|
35
|
-
}
|
|
36
|
-
}
|
|
30
|
+
const response = await fetch(url, {
|
|
31
|
+
...options,
|
|
32
|
+
headers,
|
|
33
|
+
});
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
* Create a timestamped backup of the metadata before overwriting
|
|
40
|
-
* @param {S3Client} client
|
|
41
|
-
* @param {object} data - Current metadata to backup
|
|
42
|
-
*/
|
|
43
|
-
async function backupMetadata(client, data) {
|
|
44
|
-
if (data.deployments.length === 0) return; // Nothing to backup
|
|
35
|
+
const data = await response.json();
|
|
45
36
|
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new Error(data.error || `API error: ${response.status}`);
|
|
39
|
+
}
|
|
48
40
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
41
|
+
return data;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.message.includes('fetch failed') || err.message.includes('ENOTFOUND')) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
55
48
|
}
|
|
56
49
|
|
|
57
50
|
/**
|
|
58
|
-
* Record a deployment to
|
|
51
|
+
* Record a deployment to the API
|
|
59
52
|
* @param {string} subdomain - Deployed subdomain
|
|
60
53
|
* @param {string} folderPath - Original folder path
|
|
61
54
|
* @param {number} fileCount - Number of files deployed
|
|
62
55
|
* @param {number} totalBytes - Total bytes uploaded
|
|
63
56
|
* @param {number} version - Version number for this deployment
|
|
64
|
-
* @param {
|
|
57
|
+
* @param {Date|null} expiresAt - Expiration date, or null for no expiration
|
|
65
58
|
*/
|
|
66
59
|
export async function recordDeployment(subdomain, folderPath, fileCount, totalBytes = 0, version = 1, expiresAt = null) {
|
|
67
|
-
const client = createR2Client();
|
|
68
|
-
|
|
69
|
-
// Get existing data
|
|
70
|
-
const data = await getDeploymentsData(client);
|
|
71
|
-
|
|
72
|
-
// Backup before modifying (prevents accidental data loss)
|
|
73
|
-
await backupMetadata(client, data);
|
|
74
|
-
|
|
75
|
-
// Extract folder name from path
|
|
76
60
|
const folderName = folderPath.split(/[\\/]/).pop() || 'unknown';
|
|
77
61
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
data.deployments.push(deployment);
|
|
92
|
-
|
|
93
|
-
// Write back to R2
|
|
94
|
-
await client.send(new PutObjectCommand({
|
|
95
|
-
Bucket: config.r2.bucketName,
|
|
96
|
-
Key: METADATA_KEY,
|
|
97
|
-
Body: JSON.stringify(data, null, 2),
|
|
98
|
-
ContentType: 'application/json',
|
|
99
|
-
}));
|
|
100
|
-
|
|
101
|
-
return deployment;
|
|
62
|
+
return apiRequest('/api/deployments', {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
body: JSON.stringify({
|
|
65
|
+
subdomain,
|
|
66
|
+
folderName,
|
|
67
|
+
fileCount,
|
|
68
|
+
totalBytes,
|
|
69
|
+
version,
|
|
70
|
+
cliVersion: config.version,
|
|
71
|
+
expiresAt: expiresAt?.toISOString() || null,
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
102
74
|
}
|
|
103
75
|
|
|
104
76
|
/**
|
|
105
|
-
* List all deployments
|
|
77
|
+
* List all deployments for the current user
|
|
106
78
|
* @returns {Promise<Array>} Array of deployment records
|
|
107
79
|
*/
|
|
108
80
|
export async function listDeploymentsFromR2() {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
return data.deployments;
|
|
81
|
+
const result = await apiRequest('/api/deployments');
|
|
82
|
+
return result?.deployments || [];
|
|
112
83
|
}
|
|
113
84
|
|
|
114
85
|
/**
|
|
@@ -117,15 +88,13 @@ export async function listDeploymentsFromR2() {
|
|
|
117
88
|
* @returns {Promise<number>} Next version number
|
|
118
89
|
*/
|
|
119
90
|
export async function getNextVersion(subdomain) {
|
|
120
|
-
const
|
|
121
|
-
const data = await getDeploymentsData(client);
|
|
91
|
+
const result = await apiRequest(`/api/versions/${subdomain}`);
|
|
122
92
|
|
|
123
|
-
|
|
124
|
-
if (existingDeployments.length === 0) {
|
|
93
|
+
if (!result || !result.versions || result.versions.length === 0) {
|
|
125
94
|
return 1;
|
|
126
95
|
}
|
|
127
96
|
|
|
128
|
-
const maxVersion = Math.max(...
|
|
97
|
+
const maxVersion = Math.max(...result.versions.map(v => v.version));
|
|
129
98
|
return maxVersion + 1;
|
|
130
99
|
}
|
|
131
100
|
|
|
@@ -135,74 +104,20 @@ export async function getNextVersion(subdomain) {
|
|
|
135
104
|
* @returns {Promise<Array>} Array of deployment versions
|
|
136
105
|
*/
|
|
137
106
|
export async function getVersionsForSubdomain(subdomain) {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return data.deployments
|
|
142
|
-
.filter(d => d.subdomain === subdomain)
|
|
143
|
-
.sort((a, b) => (b.version || 1) - (a.version || 1));
|
|
107
|
+
const result = await apiRequest(`/api/versions/${subdomain}`);
|
|
108
|
+
return result?.versions || [];
|
|
144
109
|
}
|
|
145
110
|
|
|
146
111
|
/**
|
|
147
|
-
*
|
|
148
|
-
* @param {string} subdomain - The subdomain
|
|
149
|
-
* @param {number} fromVersion - Source version
|
|
150
|
-
* @param {number} toVersion - Target version (new active version)
|
|
151
|
-
*/
|
|
152
|
-
export async function copyVersionFiles(subdomain, fromVersion, toVersion) {
|
|
153
|
-
const client = createR2Client();
|
|
154
|
-
|
|
155
|
-
// List all files in the source version
|
|
156
|
-
const sourcePrefix = `${subdomain}/v${fromVersion}/`;
|
|
157
|
-
const targetPrefix = `${subdomain}/v${toVersion}/`;
|
|
158
|
-
|
|
159
|
-
const listResponse = await client.send(new ListObjectsV2Command({
|
|
160
|
-
Bucket: config.r2.bucketName,
|
|
161
|
-
Prefix: sourcePrefix,
|
|
162
|
-
}));
|
|
163
|
-
|
|
164
|
-
if (!listResponse.Contents || listResponse.Contents.length === 0) {
|
|
165
|
-
throw new Error(`No files found for version ${fromVersion}`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
let copiedCount = 0;
|
|
169
|
-
|
|
170
|
-
for (const object of listResponse.Contents) {
|
|
171
|
-
const sourceKey = object.Key;
|
|
172
|
-
const targetKey = sourceKey.replace(sourcePrefix, targetPrefix);
|
|
173
|
-
|
|
174
|
-
// Copy file to new version
|
|
175
|
-
await client.send(new CopyObjectCommand({
|
|
176
|
-
Bucket: config.r2.bucketName,
|
|
177
|
-
CopySource: `${config.r2.bucketName}/${sourceKey}`,
|
|
178
|
-
Key: targetKey,
|
|
179
|
-
}));
|
|
180
|
-
|
|
181
|
-
copiedCount++;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return { copiedCount, fromVersion, toVersion };
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Update the active pointer for a subdomain (for rollback)
|
|
112
|
+
* Set the active version for a subdomain (rollback)
|
|
189
113
|
* @param {string} subdomain - The subdomain
|
|
190
114
|
* @param {number} version - Version to make active
|
|
191
115
|
*/
|
|
192
116
|
export async function setActiveVersion(subdomain, version) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
await client.send(new PutObjectCommand({
|
|
199
|
-
Bucket: config.r2.bucketName,
|
|
200
|
-
Key: pointerKey,
|
|
201
|
-
Body: JSON.stringify({ activeVersion: version, updatedAt: new Date().toISOString() }),
|
|
202
|
-
ContentType: 'application/json',
|
|
203
|
-
}));
|
|
204
|
-
|
|
205
|
-
return { subdomain, activeVersion: version };
|
|
117
|
+
return apiRequest(`/api/versions/${subdomain}/rollback`, {
|
|
118
|
+
method: 'PUT',
|
|
119
|
+
body: JSON.stringify({ version }),
|
|
120
|
+
});
|
|
206
121
|
}
|
|
207
122
|
|
|
208
123
|
/**
|
|
@@ -211,144 +126,76 @@ export async function setActiveVersion(subdomain, version) {
|
|
|
211
126
|
* @returns {Promise<number>} Active version number
|
|
212
127
|
*/
|
|
213
128
|
export async function getActiveVersion(subdomain) {
|
|
214
|
-
const
|
|
129
|
+
const result = await apiRequest(`/api/versions/${subdomain}`);
|
|
130
|
+
return result?.activeVersion || 1;
|
|
131
|
+
}
|
|
215
132
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
133
|
+
/**
|
|
134
|
+
* Copy files from one version to another (for rollback)
|
|
135
|
+
* Note: This is now handled server-side by the API
|
|
136
|
+
* @param {string} subdomain - The subdomain
|
|
137
|
+
* @param {number} fromVersion - Source version
|
|
138
|
+
* @param {number} toVersion - Target version
|
|
139
|
+
*/
|
|
140
|
+
export async function copyVersionFiles(subdomain, fromVersion, toVersion) {
|
|
141
|
+
// Rollback is now handled by setActiveVersion - no need to copy files
|
|
142
|
+
// The worker serves files from the specified version directly
|
|
143
|
+
return { fromVersion, toVersion, note: 'Handled by API' };
|
|
228
144
|
}
|
|
229
145
|
|
|
230
146
|
/**
|
|
231
147
|
* List all files for a specific version
|
|
232
148
|
* @param {string} subdomain - The subdomain
|
|
233
149
|
* @param {number} version - Version number
|
|
234
|
-
* @returns {Promise<Array>} Array of file
|
|
150
|
+
* @returns {Promise<Array>} Array of file info
|
|
235
151
|
*/
|
|
236
152
|
export async function listVersionFiles(subdomain, version) {
|
|
237
|
-
const
|
|
153
|
+
const result = await apiRequest(`/api/deployments/${subdomain}`);
|
|
238
154
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const response = await client.send(new ListObjectsV2Command({
|
|
242
|
-
Bucket: config.r2.bucketName,
|
|
243
|
-
Prefix: prefix,
|
|
244
|
-
}));
|
|
245
|
-
|
|
246
|
-
if (!response.Contents) {
|
|
155
|
+
if (!result || !result.versions) {
|
|
247
156
|
return [];
|
|
248
157
|
}
|
|
249
158
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
size: obj.Size,
|
|
253
|
-
lastModified: obj.LastModified,
|
|
254
|
-
}));
|
|
159
|
+
const versionInfo = result.versions.find(v => v.version === version);
|
|
160
|
+
return versionInfo ? [{ version, fileCount: versionInfo.file_count, totalBytes: versionInfo.total_bytes }] : [];
|
|
255
161
|
}
|
|
256
162
|
|
|
257
163
|
/**
|
|
258
164
|
* Delete all files for a subdomain (all versions)
|
|
259
|
-
*
|
|
260
|
-
* @
|
|
165
|
+
* Note: This should be an admin operation, not available to CLI users
|
|
166
|
+
* @param {string} _subdomain - The subdomain to delete
|
|
261
167
|
*/
|
|
262
|
-
export async function deleteSubdomain(
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const prefix = `${subdomain}/`;
|
|
267
|
-
|
|
268
|
-
let deletedCount = 0;
|
|
269
|
-
let continuationToken;
|
|
270
|
-
|
|
271
|
-
do {
|
|
272
|
-
const response = await client.send(new ListObjectsV2Command({
|
|
273
|
-
Bucket: config.r2.bucketName,
|
|
274
|
-
Prefix: prefix,
|
|
275
|
-
ContinuationToken: continuationToken,
|
|
276
|
-
}));
|
|
277
|
-
|
|
278
|
-
if (response.Contents && response.Contents.length > 0) {
|
|
279
|
-
for (const object of response.Contents) {
|
|
280
|
-
await client.send(new DeleteObjectCommand({
|
|
281
|
-
Bucket: config.r2.bucketName,
|
|
282
|
-
Key: object.Key,
|
|
283
|
-
}));
|
|
284
|
-
deletedCount++;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
continuationToken = response.NextContinuationToken;
|
|
289
|
-
} while (continuationToken);
|
|
290
|
-
|
|
291
|
-
return { deletedCount };
|
|
168
|
+
export async function deleteSubdomain(_subdomain) {
|
|
169
|
+
// This operation is not available in the consumer CLI
|
|
170
|
+
// It should be handled through the admin dashboard or worker
|
|
171
|
+
throw new Error('Subdomain deletion is not available in the CLI. Contact support.');
|
|
292
172
|
}
|
|
293
173
|
|
|
294
174
|
/**
|
|
295
175
|
* Get all expired deployments
|
|
176
|
+
* Note: Cleanup is handled server-side automatically
|
|
296
177
|
* @returns {Promise<Array>} Array of expired deployment records
|
|
297
178
|
*/
|
|
298
179
|
export async function getExpiredDeployments() {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const now = Date.now();
|
|
302
|
-
|
|
303
|
-
return data.deployments.filter(d =>
|
|
304
|
-
d.expiresAt && new Date(d.expiresAt).getTime() < now
|
|
305
|
-
);
|
|
180
|
+
// Expiration cleanup is handled server-side
|
|
181
|
+
return [];
|
|
306
182
|
}
|
|
307
183
|
|
|
308
184
|
/**
|
|
309
185
|
* Remove deployment records for a subdomain from metadata
|
|
310
|
-
*
|
|
186
|
+
* Note: This should be an admin operation
|
|
187
|
+
* @param {string} _subdomain - The subdomain to remove
|
|
311
188
|
*/
|
|
312
|
-
export async function removeDeploymentRecords(
|
|
313
|
-
|
|
314
|
-
const data = await getDeploymentsData(client);
|
|
315
|
-
|
|
316
|
-
// Backup before modifying
|
|
317
|
-
await backupMetadata(client, data);
|
|
318
|
-
|
|
319
|
-
// Filter out the subdomain's deployments
|
|
320
|
-
data.deployments = data.deployments.filter(d => d.subdomain !== subdomain);
|
|
321
|
-
|
|
322
|
-
// Write back
|
|
323
|
-
await client.send(new PutObjectCommand({
|
|
324
|
-
Bucket: config.r2.bucketName,
|
|
325
|
-
Key: METADATA_KEY,
|
|
326
|
-
Body: JSON.stringify(data, null, 2),
|
|
327
|
-
ContentType: 'application/json',
|
|
328
|
-
}));
|
|
189
|
+
export async function removeDeploymentRecords(_subdomain) {
|
|
190
|
+
throw new Error('Deployment record removal is not available in the CLI. Contact support.');
|
|
329
191
|
}
|
|
330
192
|
|
|
331
193
|
/**
|
|
332
194
|
* Clean up all expired deployments
|
|
195
|
+
* Note: This is now handled automatically by the worker
|
|
333
196
|
* @returns {Promise<{cleaned: string[], errors: string[]}>}
|
|
334
197
|
*/
|
|
335
198
|
export async function cleanupExpiredDeployments() {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const errors = [];
|
|
339
|
-
|
|
340
|
-
// Get unique subdomains
|
|
341
|
-
const subdomains = [...new Set(expired.map(d => d.subdomain))];
|
|
342
|
-
|
|
343
|
-
for (const subdomain of subdomains) {
|
|
344
|
-
try {
|
|
345
|
-
await deleteSubdomain(subdomain);
|
|
346
|
-
await removeDeploymentRecords(subdomain);
|
|
347
|
-
cleaned.push(subdomain);
|
|
348
|
-
} catch (err) {
|
|
349
|
-
errors.push(`${subdomain}: ${err.message}`);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
return { cleaned, errors };
|
|
199
|
+
// Cleanup is handled server-side automatically
|
|
200
|
+
return { cleaned: [], errors: [], note: 'Handled automatically by server' };
|
|
354
201
|
}
|
package/src/utils/upload.js
CHANGED
|
@@ -1,22 +1,16 @@
|
|
|
1
|
-
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
2
1
|
import { readdir, readFile } from 'node:fs/promises';
|
|
3
2
|
import { join, relative, posix, sep } from 'node:path';
|
|
4
3
|
import mime from 'mime-types';
|
|
5
4
|
import { config } from '../config.js';
|
|
6
5
|
import { info } from './logger.js';
|
|
7
6
|
|
|
7
|
+
const API_BASE_URL = `https://api.${config.domain}`;
|
|
8
|
+
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
+
* Get API key for requests
|
|
10
11
|
*/
|
|
11
|
-
function
|
|
12
|
-
return
|
|
13
|
-
region: 'auto',
|
|
14
|
-
endpoint: `https://${config.r2.accountId}.r2.cloudflarestorage.com`,
|
|
15
|
-
credentials: {
|
|
16
|
-
accessKeyId: config.r2.accessKeyId,
|
|
17
|
-
secretAccessKey: config.r2.secretAccessKey,
|
|
18
|
-
},
|
|
19
|
-
});
|
|
12
|
+
function getApiKey() {
|
|
13
|
+
return process.env.STATICLAUNCH_API_KEY || 'public-beta-key';
|
|
20
14
|
}
|
|
21
15
|
|
|
22
16
|
/**
|
|
@@ -29,13 +23,76 @@ function toPosixPath(windowsPath) {
|
|
|
29
23
|
}
|
|
30
24
|
|
|
31
25
|
/**
|
|
32
|
-
* Upload a
|
|
26
|
+
* Upload a single file via API proxy
|
|
27
|
+
* @param {Buffer} content - File content
|
|
28
|
+
* @param {string} subdomain - Target subdomain
|
|
29
|
+
* @param {number} version - Version number
|
|
30
|
+
* @param {string} filePath - Relative file path
|
|
31
|
+
* @param {string} contentType - MIME type
|
|
32
|
+
*/
|
|
33
|
+
async function uploadFile(content, subdomain, version, filePath, contentType) {
|
|
34
|
+
const response = await fetch(`${API_BASE_URL}/api/upload/file`, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: {
|
|
37
|
+
'X-API-Key': getApiKey(),
|
|
38
|
+
'X-Subdomain': subdomain,
|
|
39
|
+
'X-Version': String(version),
|
|
40
|
+
'X-File-Path': filePath,
|
|
41
|
+
'X-Content-Type': contentType,
|
|
42
|
+
'Content-Type': 'application/octet-stream',
|
|
43
|
+
},
|
|
44
|
+
body: content,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const error = await response.json().catch(() => ({ error: 'Upload failed' }));
|
|
49
|
+
throw new Error(error.error || `Upload failed: ${response.status}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return response.json();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mark upload complete and set active version
|
|
57
|
+
* @param {string} subdomain - Target subdomain
|
|
58
|
+
* @param {number} version - Version number
|
|
59
|
+
* @param {number} fileCount - Number of files uploaded
|
|
60
|
+
* @param {number} totalBytes - Total bytes uploaded
|
|
61
|
+
* @param {string} folderName - Original folder name
|
|
62
|
+
* @param {string|null} expiresAt - ISO expiration timestamp
|
|
63
|
+
*/
|
|
64
|
+
async function completeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt) {
|
|
65
|
+
const response = await fetch(`${API_BASE_URL}/api/upload/complete`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
'X-API-Key': getApiKey(),
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
subdomain,
|
|
73
|
+
version,
|
|
74
|
+
fileCount,
|
|
75
|
+
totalBytes,
|
|
76
|
+
folderName,
|
|
77
|
+
expiresAt,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
const error = await response.json().catch(() => ({ error: 'Complete upload failed' }));
|
|
83
|
+
throw new Error(error.error || `Complete upload failed: ${response.status}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return response.json();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Upload a folder to Launchpd via API proxy
|
|
33
91
|
* @param {string} localPath - Local folder path
|
|
34
92
|
* @param {string} subdomain - Subdomain to use as bucket prefix
|
|
35
93
|
* @param {number} version - Version number for this deployment
|
|
36
94
|
*/
|
|
37
95
|
export async function uploadFolder(localPath, subdomain, version = 1) {
|
|
38
|
-
const client = createR2Client();
|
|
39
96
|
const files = await readdir(localPath, { recursive: true, withFileTypes: true });
|
|
40
97
|
|
|
41
98
|
let uploaded = 0;
|
|
@@ -48,27 +105,35 @@ export async function uploadFolder(localPath, subdomain, version = 1) {
|
|
|
48
105
|
// Build full local path
|
|
49
106
|
const fullPath = join(file.parentPath || file.path, file.name);
|
|
50
107
|
|
|
51
|
-
// Build R2 key
|
|
108
|
+
// Build relative path for R2 key
|
|
52
109
|
const relativePath = relative(localPath, fullPath);
|
|
53
|
-
const
|
|
110
|
+
const posixPath = toPosixPath(relativePath);
|
|
54
111
|
|
|
55
112
|
// Detect content type
|
|
56
113
|
const contentType = mime.lookup(file.name) || 'application/octet-stream';
|
|
57
114
|
|
|
58
|
-
// Read file and upload
|
|
115
|
+
// Read file and upload via API
|
|
59
116
|
const body = await readFile(fullPath);
|
|
60
117
|
totalBytes += body.length;
|
|
61
118
|
|
|
62
|
-
await
|
|
63
|
-
Bucket: config.r2.bucketName,
|
|
64
|
-
Key: key,
|
|
65
|
-
Body: body,
|
|
66
|
-
ContentType: contentType,
|
|
67
|
-
}));
|
|
119
|
+
await uploadFile(body, subdomain, version, posixPath, contentType);
|
|
68
120
|
|
|
69
121
|
uploaded++;
|
|
70
|
-
info(` Uploaded (${uploaded}/${total}): ${
|
|
122
|
+
info(` Uploaded (${uploaded}/${total}): ${posixPath}`);
|
|
71
123
|
}
|
|
72
124
|
|
|
73
125
|
return { uploaded, subdomain, totalBytes };
|
|
74
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Complete the upload and set active version
|
|
130
|
+
* @param {string} subdomain - Target subdomain
|
|
131
|
+
* @param {number} version - Version number
|
|
132
|
+
* @param {number} fileCount - Number of files
|
|
133
|
+
* @param {number} totalBytes - Total bytes
|
|
134
|
+
* @param {string} folderName - Folder name
|
|
135
|
+
* @param {string|null} expiresAt - Expiration ISO timestamp
|
|
136
|
+
*/
|
|
137
|
+
export async function finalizeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt = null) {
|
|
138
|
+
return completeUpload(subdomain, version, fileCount, totalBytes, folderName, expiresAt);
|
|
139
|
+
}
|