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.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/bin/cli.js +94 -0
- package/bin/setup.js +40 -0
- package/package.json +67 -0
- package/src/commands/auth.js +357 -0
- package/src/commands/deploy.js +242 -0
- package/src/commands/index.js +9 -0
- package/src/commands/list.js +133 -0
- package/src/commands/rollback.js +119 -0
- package/src/commands/versions.js +117 -0
- package/src/config.js +14 -0
- package/src/utils/api.js +182 -0
- package/src/utils/credentials.js +153 -0
- package/src/utils/expiration.js +89 -0
- package/src/utils/id.js +17 -0
- package/src/utils/index.js +14 -0
- package/src/utils/localConfig.js +85 -0
- package/src/utils/logger.js +152 -0
- package/src/utils/machineId.js +28 -0
- package/src/utils/metadata.js +201 -0
- package/src/utils/prompt.js +87 -0
- package/src/utils/quota.js +231 -0
- package/src/utils/upload.js +181 -0
|
@@ -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
|
+
}
|