launchpd 1.0.0

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.
@@ -0,0 +1,28 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { hostname, platform, arch, userInfo } from 'node:os';
3
+
4
+ /**
5
+ * Generate a unique machine identifier based on system traits.
6
+ * Uses SHA-256 to hash a combination of hostname, platform, architecture, and username.
7
+ * This provides a persistent ID even if the IP address changes.
8
+ *
9
+ * @returns {string} Hex string of the machine ID hash
10
+ */
11
+ export function getMachineId() {
12
+ try {
13
+ const parts = [
14
+ hostname(),
15
+ platform(),
16
+ arch(),
17
+ userInfo().username
18
+ ];
19
+
20
+ const rawId = parts.join('|');
21
+ return createHash('sha256').update(rawId).digest('hex');
22
+ } catch (err) {
23
+ // Fallback if userInfo() fails (e.g. restricted environments)
24
+ // Use a random ID for this session, better than crashing
25
+ console.warn('Could not generate stable machine ID:', err.message);
26
+ return 'unknown-device-' + Math.random().toString(36).substring(2);
27
+ }
28
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Metadata utilities for Launchpd CLI
3
+ * All operations now go through the API proxy
4
+ */
5
+
6
+ import { config } from '../config.js';
7
+
8
+ const API_BASE_URL = config.apiUrl;
9
+
10
+ /**
11
+ * Get API key for requests
12
+ */
13
+ function getApiKey() {
14
+ return process.env.STATICLAUNCH_API_KEY || 'public-beta-key';
15
+ }
16
+
17
+ /**
18
+ * Make an authenticated API request
19
+ */
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
+
29
+ try {
30
+ const response = await fetch(url, {
31
+ ...options,
32
+ headers,
33
+ });
34
+
35
+ const data = await response.json();
36
+
37
+ if (!response.ok) {
38
+ throw new Error(data.error || `API error: ${response.status}`);
39
+ }
40
+
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
+ }
48
+ }
49
+
50
+ /**
51
+ * Record a deployment to the API
52
+ * @param {string} subdomain - Deployed subdomain
53
+ * @param {string} folderPath - Original folder path
54
+ * @param {number} fileCount - Number of files deployed
55
+ * @param {number} totalBytes - Total bytes uploaded
56
+ * @param {number} version - Version number for this deployment
57
+ * @param {Date|null} expiresAt - Expiration date, or null for no expiration
58
+ */
59
+ export async function recordDeployment(subdomain, folderPath, fileCount, totalBytes = 0, version = 1, expiresAt = null) {
60
+ const folderName = folderPath.split(/[\\/]/).pop() || 'unknown';
61
+
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
+ });
74
+ }
75
+
76
+ /**
77
+ * List all deployments for the current user
78
+ * @returns {Promise<Array>} Array of deployment records
79
+ */
80
+ export async function listDeploymentsFromR2() {
81
+ const result = await apiRequest('/api/deployments');
82
+ return result?.deployments || [];
83
+ }
84
+
85
+ /**
86
+ * Get the next version number for a subdomain
87
+ * @param {string} subdomain - The subdomain to check
88
+ * @returns {Promise<number>} Next version number
89
+ */
90
+ export async function getNextVersion(subdomain) {
91
+ const result = await apiRequest(`/api/versions/${subdomain}`);
92
+
93
+ if (!result || !result.versions || result.versions.length === 0) {
94
+ return 1;
95
+ }
96
+
97
+ const maxVersion = Math.max(...result.versions.map(v => v.version));
98
+ return maxVersion + 1;
99
+ }
100
+
101
+ /**
102
+ * Get all versions for a specific subdomain
103
+ * @param {string} subdomain - The subdomain to get versions for
104
+ * @returns {Promise<Array>} Array of deployment versions
105
+ */
106
+ export async function getVersionsForSubdomain(subdomain) {
107
+ const result = await apiRequest(`/api/versions/${subdomain}`);
108
+ return result?.versions || [];
109
+ }
110
+
111
+ /**
112
+ * Set the active version for a subdomain (rollback)
113
+ * @param {string} subdomain - The subdomain
114
+ * @param {number} version - Version to make active
115
+ */
116
+ export async function setActiveVersion(subdomain, version) {
117
+ return apiRequest(`/api/versions/${subdomain}/rollback`, {
118
+ method: 'PUT',
119
+ body: JSON.stringify({ version }),
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Get the active version for a subdomain
125
+ * @param {string} subdomain - The subdomain
126
+ * @returns {Promise<number>} Active version number
127
+ */
128
+ export async function getActiveVersion(subdomain) {
129
+ const result = await apiRequest(`/api/versions/${subdomain}`);
130
+ return result?.activeVersion || 1;
131
+ }
132
+
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' };
144
+ }
145
+
146
+ /**
147
+ * List all files for a specific version
148
+ * @param {string} subdomain - The subdomain
149
+ * @param {number} version - Version number
150
+ * @returns {Promise<Array>} Array of file info
151
+ */
152
+ export async function listVersionFiles(subdomain, version) {
153
+ const result = await apiRequest(`/api/deployments/${subdomain}`);
154
+
155
+ if (!result || !result.versions) {
156
+ return [];
157
+ }
158
+
159
+ const versionInfo = result.versions.find(v => v.version === version);
160
+ return versionInfo ? [{ version, fileCount: versionInfo.file_count, totalBytes: versionInfo.total_bytes }] : [];
161
+ }
162
+
163
+ /**
164
+ * Delete all files for a subdomain (all versions)
165
+ * Note: This should be an admin operation, not available to CLI users
166
+ * @param {string} _subdomain - The subdomain to delete
167
+ */
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.');
172
+ }
173
+
174
+ /**
175
+ * Get all expired deployments
176
+ * Note: Cleanup is handled server-side automatically
177
+ * @returns {Promise<Array>} Array of expired deployment records
178
+ */
179
+ export async function getExpiredDeployments() {
180
+ // Expiration cleanup is handled server-side
181
+ return [];
182
+ }
183
+
184
+ /**
185
+ * Remove deployment records for a subdomain from metadata
186
+ * Note: This should be an admin operation
187
+ * @param {string} _subdomain - The subdomain to remove
188
+ */
189
+ export async function removeDeploymentRecords(_subdomain) {
190
+ throw new Error('Deployment record removal is not available in the CLI. Contact support.');
191
+ }
192
+
193
+ /**
194
+ * Clean up all expired deployments
195
+ * Note: This is now handled automatically by the worker
196
+ * @returns {Promise<{cleaned: string[], errors: string[]}>}
197
+ */
198
+ export async function cleanupExpiredDeployments() {
199
+ // Cleanup is handled server-side automatically
200
+ return { cleaned: [], errors: [], note: 'Handled automatically by server' };
201
+ }
@@ -0,0 +1,87 @@
1
+
2
+ import { createInterface, emitKeypressEvents } from 'node:readline';
3
+
4
+ /**
5
+ * Prompt for user input
6
+ */
7
+ export function prompt(question) {
8
+ const rl = createInterface({
9
+ input: process.stdin,
10
+ output: process.stdout,
11
+ });
12
+
13
+ return new Promise((resolve) => {
14
+ rl.question(question, (answer) => {
15
+ rl.close();
16
+ resolve(answer.trim());
17
+ });
18
+ });
19
+ }
20
+
21
+ /**
22
+ * Prompt for secret user input (masks with *)
23
+ */
24
+ export function promptSecret(question) {
25
+ return new Promise((resolve) => {
26
+ const { stdin, stdout } = process;
27
+
28
+ stdout.write(question);
29
+
30
+ // Prepare stdin
31
+ if (stdin.isTTY) {
32
+ stdin.setRawMode(true);
33
+ }
34
+ stdin.resume();
35
+ stdin.setEncoding('utf8');
36
+
37
+ // Enable keypress events
38
+ emitKeypressEvents(stdin);
39
+
40
+ let secret = '';
41
+
42
+ const handler = (str, key) => {
43
+ key = key || {};
44
+
45
+ // Check for Ctrl+C
46
+ if (key.ctrl && key.name === 'c') {
47
+ cleanup();
48
+ stdout.write('\n'); // Newline before exit
49
+ process.exit();
50
+ }
51
+
52
+ // Check for Enter
53
+ if (key.name === 'return' || key.name === 'enter') {
54
+ cleanup();
55
+ stdout.write('\n');
56
+ resolve(secret.trim());
57
+ return;
58
+ }
59
+
60
+ // Check for Backspace
61
+ if (key.name === 'backspace') {
62
+ if (secret.length > 0) {
63
+ secret = secret.slice(0, -1);
64
+ // Move cursor back, overwrite with space, move back again
65
+ stdout.write('\b \b');
66
+ }
67
+ return;
68
+ }
69
+
70
+ // Printable characters (exclude control keys)
71
+ if (str && str.length === 1 && str.match(/[ -~]/) && !key.ctrl && !key.meta) {
72
+ secret += str;
73
+ stdout.write('*');
74
+ }
75
+ };
76
+
77
+ function cleanup() {
78
+ if (stdin.isTTY) {
79
+ stdin.setRawMode(false);
80
+ }
81
+ stdin.pause();
82
+ stdin.removeListener('keypress', handler);
83
+ }
84
+
85
+ stdin.on('keypress', handler);
86
+ });
87
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Quota checking for StaticLaunch CLI
3
+ * Validates user can deploy before uploading
4
+ */
5
+
6
+ import { config } from '../config.js';
7
+ import { getCredentials, getClientToken } from './credentials.js';
8
+ import { warning, error, info } from './logger.js';
9
+
10
+ const API_BASE_URL = `https://api.${config.domain}`;
11
+
12
+ /**
13
+ * Check quota before deployment
14
+ * Returns quota info and whether deployment is allowed
15
+ *
16
+ * @param {string} subdomain - Target subdomain (null for new site)
17
+ * @param {number} estimatedBytes - Estimated upload size in bytes
18
+ * @returns {Promise<{allowed: boolean, isNewSite: boolean, quota: object, warnings: string[]}>}
19
+ */
20
+ export async function checkQuota(subdomain, estimatedBytes = 0) {
21
+ const creds = await getCredentials();
22
+
23
+ let quotaData;
24
+
25
+ if (creds?.apiKey) {
26
+ // Authenticated user
27
+ quotaData = await checkAuthenticatedQuota(creds.apiKey);
28
+ } else {
29
+ // Anonymous user
30
+ quotaData = await checkAnonymousQuota();
31
+ }
32
+
33
+ if (!quotaData) {
34
+ // API unavailable, allow deployment (fail-open for MVP)
35
+ return {
36
+ allowed: true,
37
+ isNewSite: true,
38
+ quota: null,
39
+ warnings: ['⚠️ Could not verify quota (API unavailable)'],
40
+ };
41
+ }
42
+
43
+ // Check if this is an existing site the user owns
44
+ const isNewSite = subdomain ? !await userOwnsSite(creds?.apiKey, subdomain) : true;
45
+
46
+ const warnings = [...(quotaData.warnings || [])];
47
+ const allowed = true;
48
+
49
+ // Check if blocked (anonymous limit reached)
50
+ if (quotaData.blocked) {
51
+ console.log(quotaData.upgradeMessage);
52
+ return {
53
+ allowed: false,
54
+ isNewSite,
55
+ quota: quotaData,
56
+ warnings: [],
57
+ };
58
+ }
59
+
60
+ // Check site limit for new sites
61
+ if (isNewSite && !quotaData.canCreateNewSite) {
62
+ error(`Site limit reached (${quotaData.limits.maxSites} sites)`);
63
+ if (!creds?.apiKey) {
64
+ showUpgradePrompt();
65
+ } else {
66
+ info('Upgrade to Pro for more sites, or delete an existing site');
67
+ }
68
+ return {
69
+ allowed: false,
70
+ isNewSite,
71
+ quota: quotaData,
72
+ warnings,
73
+ };
74
+ }
75
+
76
+ // Check storage limit
77
+ const storageAfter = (quotaData.usage?.storageUsed || 0) + estimatedBytes;
78
+ if (storageAfter > quotaData.limits.maxStorageBytes) {
79
+ const overBy = storageAfter - quotaData.limits.maxStorageBytes;
80
+ error(`Storage limit exceeded by ${formatBytes(overBy)}`);
81
+ error(`Current: ${formatBytes(quotaData.usage.storageUsed)} / ${formatBytes(quotaData.limits.maxStorageBytes)}`);
82
+ if (!creds?.apiKey) {
83
+ showUpgradePrompt();
84
+ } else {
85
+ info('Upgrade to Pro for more storage, or delete old deployments');
86
+ }
87
+ return {
88
+ allowed: false,
89
+ isNewSite,
90
+ quota: quotaData,
91
+ warnings,
92
+ };
93
+ }
94
+
95
+ // Add storage warning if close to limit
96
+ const storagePercentage = storageAfter / quotaData.limits.maxStorageBytes;
97
+ if (storagePercentage > 0.8) {
98
+ warnings.push(`⚠️ Storage ${Math.round(storagePercentage * 100)}% used (${formatBytes(storageAfter)} / ${formatBytes(quotaData.limits.maxStorageBytes)})`);
99
+ }
100
+
101
+ // Add site count warning if close to limit
102
+ if (isNewSite) {
103
+ const sitesAfter = (quotaData.usage?.siteCount || 0) + 1;
104
+ const sitePercentage = sitesAfter / quotaData.limits.maxSites;
105
+ if (sitePercentage > 0.8) {
106
+ warnings.push(`⚠️ ${quotaData.limits.maxSites - sitesAfter} site(s) remaining after this deploy`);
107
+ }
108
+ }
109
+
110
+ return {
111
+ allowed,
112
+ isNewSite,
113
+ quota: quotaData,
114
+ warnings,
115
+ };
116
+ }
117
+
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
+
139
+ /**
140
+ * Check quota for anonymous user
141
+ */
142
+ async function checkAnonymousQuota() {
143
+ try {
144
+ const clientToken = await getClientToken();
145
+
146
+ const response = await fetch(`${API_BASE_URL}/api/quota/anonymous`, {
147
+ method: 'POST',
148
+ headers: {
149
+ 'Content-Type': 'application/json',
150
+ },
151
+ body: JSON.stringify({
152
+ clientToken,
153
+ }),
154
+ });
155
+
156
+ if (!response.ok) {
157
+ return null;
158
+ }
159
+
160
+ return await response.json();
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Check if user owns a subdomain
168
+ */
169
+ async function userOwnsSite(apiKey, subdomain) {
170
+ if (!apiKey) {
171
+ // For anonymous, we track by client token in deployments
172
+ return false;
173
+ }
174
+
175
+ try {
176
+ const response = await fetch(`${API_BASE_URL}/api/subdomains`, {
177
+ headers: {
178
+ 'X-API-Key': apiKey,
179
+ },
180
+ });
181
+
182
+ if (!response.ok) {
183
+ return false;
184
+ }
185
+
186
+ const data = await response.json();
187
+ return data.subdomains?.some(s => s.subdomain === subdomain) || false;
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Show upgrade prompt for anonymous users
195
+ */
196
+ function showUpgradePrompt() {
197
+ console.log('');
198
+ console.log('╔══════════════════════════════════════════════════════════════╗');
199
+ console.log('║ Upgrade to Launchpd Free Tier ║');
200
+ console.log('╠══════════════════════════════════════════════════════════════╣');
201
+ console.log('║ Register for FREE to unlock: ║');
202
+ console.log('║ → 10 sites (instead of 3) ║');
203
+ console.log('║ → 100MB storage (instead of 50MB) ║');
204
+ console.log('║ → 30-day retention (instead of 7 days) ║');
205
+ console.log('║ → 10 version history per site ║');
206
+ console.log('╠══════════════════════════════════════════════════════════════╣');
207
+ console.log('║ Run: launchpd register ║');
208
+ console.log('╚══════════════════════════════════════════════════════════════╝');
209
+ console.log('');
210
+ }
211
+
212
+ /**
213
+ * Display quota warnings
214
+ */
215
+ export function displayQuotaWarnings(warnings) {
216
+ if (warnings && warnings.length > 0) {
217
+ console.log('');
218
+ warnings.forEach(w => warning(w));
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Format bytes to human readable
224
+ */
225
+ export function formatBytes(bytes) {
226
+ if (bytes === 0) return '0 B';
227
+ const k = 1024;
228
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
229
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
230
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
231
+ }