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,182 @@
1
+ /**
2
+ * API Client for StaticLaunch Metadata API
3
+ * Communicates with the Cloudflare Worker API endpoints
4
+ */
5
+
6
+ import { config } from '../config.js';
7
+ import { getApiKey, getApiSecret } from './credentials.js';
8
+ import { createHmac } from 'node:crypto';
9
+ import { getMachineId } from './machineId.js';
10
+
11
+ const API_BASE_URL = config.apiUrl;
12
+
13
+ /**
14
+ * Make an authenticated API request
15
+ */
16
+ async function apiRequest(endpoint, options = {}) {
17
+ const url = `${API_BASE_URL}${endpoint}`;
18
+
19
+ const apiKey = await getApiKey();
20
+ const apiSecret = await getApiSecret();
21
+ const headers = {
22
+ 'Content-Type': 'application/json',
23
+ 'X-API-Key': apiKey,
24
+ 'X-Device-Fingerprint': getMachineId(),
25
+ ...options.headers,
26
+ };
27
+
28
+ // Add HMAC signature if secret is available
29
+ if (apiSecret) {
30
+ const timestamp = Date.now().toString();
31
+ const method = (options.method || 'GET').toUpperCase();
32
+ const body = options.body || '';
33
+
34
+ // HMAC-SHA256(secret, method + path + timestamp + body)
35
+ const hmac = createHmac('sha256', apiSecret);
36
+ hmac.update(method);
37
+ hmac.update(endpoint);
38
+ hmac.update(timestamp);
39
+ hmac.update(body);
40
+
41
+ const signature = hmac.digest('hex');
42
+
43
+ headers['X-Timestamp'] = timestamp;
44
+ headers['X-Signature'] = signature;
45
+ }
46
+
47
+ try {
48
+ const response = await fetch(url, {
49
+ ...options,
50
+ headers,
51
+ });
52
+
53
+ const data = await response.json();
54
+
55
+ if (!response.ok) {
56
+ throw new Error(data.error || `API error: ${response.status}`);
57
+ }
58
+
59
+ return data;
60
+ } catch (err) {
61
+ // If API is unavailable, return null to allow fallback to local storage
62
+ if (err.message.includes('fetch failed') || err.message.includes('ENOTFOUND')) {
63
+ return null;
64
+ }
65
+ throw err;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Get the next version number for a subdomain
71
+ */
72
+ export async function getNextVersionFromAPI(subdomain) {
73
+ const result = await apiRequest(`/api/versions/${subdomain}`);
74
+ if (!result || !result.versions || result.versions.length === 0) {
75
+ return 1;
76
+ }
77
+ const maxVersion = Math.max(...result.versions.map(v => v.version));
78
+ return maxVersion + 1;
79
+ }
80
+
81
+ /**
82
+ * Record a new deployment in the API
83
+ */
84
+ export async function recordDeployment(deploymentData) {
85
+ const { subdomain, folderName, fileCount, totalBytes, version, expiresAt } = deploymentData;
86
+
87
+ return apiRequest('/api/deployments', {
88
+ method: 'POST',
89
+ body: JSON.stringify({
90
+ subdomain,
91
+ folderName,
92
+ fileCount,
93
+ totalBytes,
94
+ version,
95
+ cliVersion: '0.1.0',
96
+ expiresAt,
97
+ }),
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Get list of user's deployments
103
+ */
104
+ export async function listDeployments(limit = 50, offset = 0) {
105
+ return apiRequest(`/api/deployments?limit=${limit}&offset=${offset}`);
106
+ }
107
+
108
+ /**
109
+ * Get deployment details for a subdomain
110
+ */
111
+ export async function getDeployment(subdomain) {
112
+ return apiRequest(`/api/deployments/${subdomain}`);
113
+ }
114
+
115
+ /**
116
+ * Get version history for a subdomain
117
+ */
118
+ export async function getVersions(subdomain) {
119
+ return apiRequest(`/api/versions/${subdomain}`);
120
+ }
121
+
122
+ /**
123
+ * Rollback to a specific version
124
+ */
125
+ export async function rollbackVersion(subdomain, version) {
126
+ return apiRequest(`/api/versions/${subdomain}/rollback`, {
127
+ method: 'PUT',
128
+ body: JSON.stringify({ version }),
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Check if a subdomain is available
134
+ */
135
+ export async function checkSubdomainAvailable(subdomain) {
136
+ const result = await apiRequest(`/api/public/check/${subdomain}`);
137
+ return result?.available ?? true;
138
+ }
139
+
140
+ /**
141
+ * Reserve a subdomain
142
+ */
143
+ export async function reserveSubdomain(subdomain) {
144
+ return apiRequest('/api/subdomains/reserve', {
145
+ method: 'POST',
146
+ body: JSON.stringify({ subdomain }),
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Get user's subdomains
152
+ */
153
+ export async function listSubdomains() {
154
+ return apiRequest('/api/subdomains');
155
+ }
156
+
157
+ /**
158
+ * Get current user info
159
+ */
160
+ export async function getCurrentUser() {
161
+ return apiRequest('/api/users/me');
162
+ }
163
+
164
+ /**
165
+ * Health check
166
+ */
167
+ export async function healthCheck() {
168
+ return apiRequest('/api/health');
169
+ }
170
+
171
+ export default {
172
+ recordDeployment,
173
+ listDeployments,
174
+ getDeployment,
175
+ getVersions,
176
+ rollbackVersion,
177
+ checkSubdomainAvailable,
178
+ reserveSubdomain,
179
+ listSubdomains,
180
+ getCurrentUser,
181
+ healthCheck,
182
+ };
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Credentials management for StaticLaunch CLI
3
+ * Stores API key and user info in ~/.staticlaunch/credentials.json
4
+ */
5
+
6
+ import { existsSync } from 'node:fs';
7
+ import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ import { randomBytes } from 'node:crypto';
11
+
12
+ /**
13
+ * Get the credentials directory path
14
+ */
15
+ function getConfigDir() {
16
+ return join(homedir(), '.staticlaunch');
17
+ }
18
+
19
+ /**
20
+ * Get the credentials file path
21
+ */
22
+ function getCredentialsPath() {
23
+ return join(getConfigDir(), 'credentials.json');
24
+ }
25
+
26
+ /**
27
+ * Get the client token path (for anonymous tracking)
28
+ */
29
+ function getClientTokenPath() {
30
+ return join(getConfigDir(), 'client_token');
31
+ }
32
+
33
+ /**
34
+ * Ensure config directory exists
35
+ */
36
+ async function ensureConfigDir() {
37
+ const dir = getConfigDir();
38
+ if (!existsSync(dir)) {
39
+ await mkdir(dir, { recursive: true });
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Get or create a persistent client token for anonymous tracking
45
+ * This helps identify the same anonymous user across sessions
46
+ */
47
+ export async function getClientToken() {
48
+ await ensureConfigDir();
49
+ const tokenPath = getClientTokenPath();
50
+
51
+ try {
52
+ if (existsSync(tokenPath)) {
53
+ return await readFile(tokenPath, 'utf-8');
54
+ }
55
+ } catch {
56
+ // Token file corrupted, regenerate
57
+ }
58
+
59
+ // Generate new token
60
+ const token = `cli_${randomBytes(16).toString('hex')}`;
61
+ await writeFile(tokenPath, token, 'utf-8');
62
+ return token;
63
+ }
64
+
65
+ /**
66
+ * Get stored credentials
67
+ * @returns {Promise<{apiKey: string, userId: string, email: string, tier: string} | null>}
68
+ */
69
+ export async function getCredentials() {
70
+ const filePath = getCredentialsPath();
71
+ try {
72
+ if (existsSync(filePath)) {
73
+ const text = await readFile(filePath, 'utf-8');
74
+ const data = JSON.parse(text);
75
+
76
+ // Validate the structure
77
+ if (data.apiKey) {
78
+ return {
79
+ apiKey: data.apiKey,
80
+ apiSecret: data.apiSecret || null,
81
+ userId: data.userId || null,
82
+ email: data.email || null,
83
+ tier: data.tier || 'free',
84
+ savedAt: data.savedAt || null,
85
+ };
86
+ }
87
+ }
88
+ } catch {
89
+ // Corrupted or invalid JSON file
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Save credentials
96
+ * @param {object} credentials - Credentials to save
97
+ */
98
+ export async function saveCredentials(credentials) {
99
+ await ensureConfigDir();
100
+
101
+ const data = {
102
+ apiKey: credentials.apiKey,
103
+ apiSecret: credentials.apiSecret || null,
104
+ userId: credentials.userId || null,
105
+ email: credentials.email || null,
106
+ tier: credentials.tier || 'free',
107
+ savedAt: new Date().toISOString(),
108
+ };
109
+
110
+ await writeFile(
111
+ getCredentialsPath(),
112
+ JSON.stringify(data, null, 2),
113
+ 'utf-8'
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Delete stored credentials (logout)
119
+ */
120
+ export async function clearCredentials() {
121
+ const filePath = getCredentialsPath();
122
+ try {
123
+ if (existsSync(filePath)) {
124
+ await unlink(filePath);
125
+ }
126
+ } catch {
127
+ // File doesn't exist or can't be deleted
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Check if user is logged in
133
+ */
134
+ export async function isLoggedIn() {
135
+ const creds = await getCredentials();
136
+ return creds !== null && creds.apiKey !== null;
137
+ }
138
+
139
+ /**
140
+ * Get the API key for requests (falls back to public beta key)
141
+ */
142
+ export async function getApiKey() {
143
+ const creds = await getCredentials();
144
+ return creds?.apiKey || process.env.STATICLAUNCH_API_KEY || 'public-beta-key';
145
+ }
146
+
147
+ /**
148
+ * Get the API secret for requests
149
+ */
150
+ export async function getApiSecret() {
151
+ const creds = await getCredentials();
152
+ return creds?.apiSecret || process.env.STATICLAUNCH_API_SECRET || null;
153
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Parse a time string into milliseconds
3
+ * Supports: 30m, 1h, 2h, 1d, 7d, etc.
4
+ * Minimum: 30 minutes
5
+ *
6
+ * @param {string} timeStr - Time string (e.g., "30m", "2h", "1d")
7
+ * @returns {number} Milliseconds
8
+ */
9
+ export function parseTimeString(timeStr) {
10
+ const regex = /^(\d+)([mhd])$/i;
11
+ const match = regex.exec(timeStr);
12
+
13
+ if (!match) {
14
+ throw new Error(`Invalid time format: "${timeStr}". Use format like 30m, 2h, 1d`);
15
+ }
16
+
17
+ const value = Number.parseInt(match[1], 10);
18
+ const unit = match[2].toLowerCase();
19
+
20
+ let ms;
21
+ switch (unit) {
22
+ case 'm':
23
+ ms = value * 60 * 1000;
24
+ break;
25
+ case 'h':
26
+ ms = value * 60 * 60 * 1000;
27
+ break;
28
+ case 'd':
29
+ ms = value * 24 * 60 * 60 * 1000;
30
+ break;
31
+ default:
32
+ throw new Error(`Unknown time unit: ${unit}`);
33
+ }
34
+
35
+ // Minimum 30 minutes
36
+ const minMs = 30 * 60 * 1000;
37
+ if (ms < minMs) {
38
+ throw new Error('Minimum expiration time is 30 minutes (30m)');
39
+ }
40
+
41
+ return ms;
42
+ }
43
+
44
+ /**
45
+ * Calculate expiration timestamp from a time string
46
+ * @param {string} timeStr - Time string (e.g., "30m", "2h", "1d")
47
+ * @returns {Date} Date object of expiration
48
+ */
49
+ export function calculateExpiresAt(timeStr) {
50
+ const ms = parseTimeString(timeStr);
51
+ return new Date(Date.now() + ms);
52
+ }
53
+
54
+ /**
55
+ * Format remaining time until expiration
56
+ * @param {string} expiresAt - ISO timestamp
57
+ * @returns {string} Human-readable time remaining
58
+ */
59
+ export function formatTimeRemaining(expiresAt) {
60
+ const now = Date.now();
61
+ const expiry = new Date(expiresAt).getTime();
62
+ const remaining = expiry - now;
63
+
64
+ if (remaining <= 0) {
65
+ return 'expired';
66
+ }
67
+
68
+ const minutes = Math.floor(remaining / (60 * 1000));
69
+ const hours = Math.floor(remaining / (60 * 60 * 1000));
70
+ const days = Math.floor(remaining / (24 * 60 * 60 * 1000));
71
+
72
+ if (days > 0) {
73
+ return `${days}d ${hours % 24}h remaining`;
74
+ } else if (hours > 0) {
75
+ return `${hours}h ${minutes % 60}m remaining`;
76
+ } else {
77
+ return `${minutes}m remaining`;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Check if a deployment has expired
83
+ * @param {string} expiresAt - ISO timestamp or null
84
+ * @returns {boolean}
85
+ */
86
+ export function isExpired(expiresAt) {
87
+ if (!expiresAt) return false;
88
+ return new Date(expiresAt).getTime() < Date.now();
89
+ }
@@ -0,0 +1,17 @@
1
+ import { customAlphabet } from 'nanoid';
2
+
3
+ /**
4
+ * Generate a subdomain-safe unique ID
5
+ * Uses lowercase alphanumeric characters only (valid for DNS)
6
+ * 12 characters provides ~62 bits of entropy
7
+ */
8
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
9
+ const nanoid = customAlphabet(alphabet, 12);
10
+
11
+ /**
12
+ * Generate a unique subdomain ID
13
+ * @returns {string} A 12-character lowercase alphanumeric string
14
+ */
15
+ export function generateSubdomain() {
16
+ return nanoid();
17
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Utils index - exports all utility functions
3
+ */
4
+
5
+ export * from './api.js';
6
+ export * from './credentials.js';
7
+ export * from './expiration.js';
8
+ export * from './id.js';
9
+ export * from './localConfig.js';
10
+ export * from './logger.js';
11
+ export * from './metadata.js';
12
+ export * from './quota.js';
13
+ export * from './prompt.js';
14
+ export * from './upload.js';
@@ -0,0 +1,85 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+
6
+ /**
7
+ * Get the local config directory path
8
+ * ~/.staticlaunch/ on Unix, %USERPROFILE%\.staticlaunch\ on Windows
9
+ */
10
+ function getConfigDir() {
11
+ return join(homedir(), '.staticlaunch');
12
+ }
13
+
14
+ /**
15
+ * Get the local deployments file path
16
+ */
17
+ function getDeploymentsPath() {
18
+ return join(getConfigDir(), 'deployments.json');
19
+ }
20
+
21
+ /**
22
+ * Ensure config directory exists
23
+ */
24
+ async function ensureConfigDir() {
25
+ const dir = getConfigDir();
26
+ if (!existsSync(dir)) {
27
+ await mkdir(dir, { recursive: true });
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Get local deployments data
33
+ * @returns {Promise<{version: number, deployments: Array}>}
34
+ */
35
+ async function getLocalData() {
36
+ const filePath = getDeploymentsPath();
37
+ try {
38
+ if (existsSync(filePath)) {
39
+ const text = await readFile(filePath, 'utf-8');
40
+ return JSON.parse(text);
41
+ }
42
+ } catch {
43
+ // Corrupted or invalid JSON file, return empty structure
44
+ }
45
+ return { version: 1, deployments: [] };
46
+ }
47
+
48
+ /**
49
+ * Save a deployment record locally
50
+ * This provides quick access to user's own deployments without R2 read
51
+ * @param {object} deployment - Deployment record
52
+ */
53
+ export async function saveLocalDeployment(deployment) {
54
+ await ensureConfigDir();
55
+
56
+ const data = await getLocalData();
57
+ data.deployments.push(deployment);
58
+
59
+ await writeFile(
60
+ getDeploymentsPath(),
61
+ JSON.stringify(data, null, 2),
62
+ 'utf-8'
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Get all local deployments (user's own deployments from this machine)
68
+ * @returns {Promise<Array>} Array of deployment records
69
+ */
70
+ export async function getLocalDeployments() {
71
+ const data = await getLocalData();
72
+ return data.deployments;
73
+ }
74
+
75
+ /**
76
+ * Clear local deployments history
77
+ */
78
+ export async function clearLocalDeployments() {
79
+ await ensureConfigDir();
80
+ await writeFile(
81
+ getDeploymentsPath(),
82
+ JSON.stringify({ version: 1, deployments: [] }, null, 2),
83
+ 'utf-8'
84
+ );
85
+ }
@@ -0,0 +1,152 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+
4
+ // Store active spinner reference
5
+ let activeSpinner = null;
6
+
7
+ /**
8
+ * Log a success message
9
+ * @param {string} message
10
+ */
11
+ export function success(message) {
12
+ console.log(chalk.green.bold('✓'), chalk.green(message));
13
+ }
14
+
15
+ /**
16
+ * Log an error message
17
+ * @param {string} message
18
+ * @param {object} options - Optional error details
19
+ * @param {boolean} options.verbose - Show verbose error details
20
+ * @param {Error} options.cause - Original error for verbose mode
21
+ */
22
+ export function error(message, options = {}) {
23
+ console.error(chalk.red.bold('✗'), chalk.red(message));
24
+ if (options.verbose && options.cause) {
25
+ console.error(chalk.gray(' Stack trace:'));
26
+ console.error(chalk.gray(` ${options.cause.stack || options.cause.message}`));
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Log an info message
32
+ * @param {string} message
33
+ */
34
+ export function info(message) {
35
+ console.log(chalk.blue('ℹ'), chalk.white(message));
36
+ }
37
+
38
+ export function warning(message) {
39
+ console.log(chalk.yellow.bold('⚠'), chalk.yellow(message));
40
+ }
41
+
42
+ /**
43
+ * Create and start a spinner
44
+ * @param {string} text - Initial spinner text
45
+ * @returns {object} - Spinner instance with helper methods
46
+ */
47
+ export function spinner(text) {
48
+ activeSpinner = ora({
49
+ text,
50
+ color: 'cyan',
51
+ spinner: 'dots',
52
+ }).start();
53
+
54
+ return {
55
+ /**
56
+ * Update spinner text
57
+ * @param {string} newText
58
+ */
59
+ update(newText) {
60
+ if (activeSpinner) {
61
+ activeSpinner.text = newText;
62
+ }
63
+ },
64
+
65
+ /**
66
+ * Mark spinner as successful and stop
67
+ * @param {string} text - Success message
68
+ */
69
+ succeed(text) {
70
+ if (activeSpinner) {
71
+ activeSpinner.succeed(chalk.green(text));
72
+ activeSpinner = null;
73
+ }
74
+ },
75
+
76
+ /**
77
+ * Mark spinner as failed and stop
78
+ * @param {string} text - Failure message
79
+ */
80
+ fail(text) {
81
+ if (activeSpinner) {
82
+ activeSpinner.fail(chalk.red(text));
83
+ activeSpinner = null;
84
+ }
85
+ },
86
+
87
+ /**
88
+ * Stop spinner with info message
89
+ * @param {string} text - Info message
90
+ */
91
+ info(text) {
92
+ if (activeSpinner) {
93
+ activeSpinner.info(chalk.blue(text));
94
+ activeSpinner = null;
95
+ }
96
+ },
97
+
98
+ /**
99
+ * Stop spinner with warning
100
+ * @param {string} text - Warning message
101
+ */
102
+ warn(text) {
103
+ if (activeSpinner) {
104
+ activeSpinner.warn(chalk.yellow(text));
105
+ activeSpinner = null;
106
+ }
107
+ },
108
+
109
+ /**
110
+ * Stop spinner without any symbol
111
+ */
112
+ stop() {
113
+ if (activeSpinner) {
114
+ activeSpinner.stop();
115
+ activeSpinner = null;
116
+ }
117
+ },
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Format bytes to human readable size (KB/MB/GB)
123
+ * @param {number} bytes - Size in bytes
124
+ * @param {number} decimals - Number of decimal places
125
+ * @returns {string} - Formatted size string
126
+ */
127
+ export function formatSize(bytes, decimals = 2) {
128
+ if (bytes === 0) return '0 Bytes';
129
+
130
+ const k = 1024;
131
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
132
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
133
+
134
+ return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
135
+ }
136
+
137
+ /**
138
+ * Log helpful error with suggestions
139
+ * @param {string} message - Error message
140
+ * @param {string[]} suggestions - Array of suggested actions
141
+ * @param {object} options - Error options
142
+ */
143
+ export function errorWithSuggestions(message, suggestions = [], options = {}) {
144
+ error(message, options);
145
+ if (suggestions.length > 0) {
146
+ console.log('');
147
+ console.log(chalk.yellow('💡 Suggestions:'));
148
+ suggestions.forEach(suggestion => {
149
+ console.log(chalk.gray(` • ${suggestion}`));
150
+ });
151
+ }
152
+ }