launchpd 0.1.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.

Potentially problematic release.


This version of launchpd might be problematic. Click here for more details.

@@ -0,0 +1,101 @@
1
+ import { getVersionsForSubdomain, setActiveVersion, getActiveVersion } from '../utils/metadata.js';
2
+ import { getVersions as getVersionsFromAPI, rollbackVersion as rollbackViaAPI } from '../utils/api.js';
3
+ import { success, error, info, warning } from '../utils/logger.js';
4
+
5
+ /**
6
+ * Rollback a subdomain to a previous version
7
+ * @param {string} subdomain - Subdomain to rollback
8
+ * @param {object} options - Command options
9
+ * @param {number} options.to - Specific version to rollback to (optional)
10
+ */
11
+ export async function rollback(subdomain, options) {
12
+ try {
13
+ info(`Checking versions for ${subdomain}...`);
14
+
15
+ // Get all versions for this subdomain (try API first)
16
+ let versions = [];
17
+ let currentActive = 1;
18
+ let useAPI = false;
19
+
20
+ const apiResult = await getVersionsFromAPI(subdomain);
21
+ if (apiResult && apiResult.versions) {
22
+ versions = apiResult.versions.map(v => ({
23
+ version: v.version,
24
+ timestamp: v.created_at,
25
+ fileCount: v.file_count,
26
+ }));
27
+ currentActive = apiResult.activeVersion || 1;
28
+ useAPI = true;
29
+ } else {
30
+ // Fallback to R2 metadata
31
+ versions = await getVersionsForSubdomain(subdomain);
32
+ currentActive = await getActiveVersion(subdomain);
33
+ }
34
+
35
+ if (versions.length === 0) {
36
+ error(`No deployments found for subdomain: ${subdomain}`);
37
+ process.exit(1);
38
+ }
39
+
40
+ if (versions.length === 1) {
41
+ warning('Only one version exists. Nothing to rollback to.');
42
+ process.exit(1);
43
+ }
44
+
45
+ info(`Current active version: v${currentActive}`);
46
+
47
+ // Determine target version
48
+ let targetVersion;
49
+ if (options.to) {
50
+ targetVersion = Number.parseInt(options.to, 10);
51
+ const versionExists = versions.some(v => v.version === targetVersion);
52
+ if (!versionExists) {
53
+ error(`Version ${targetVersion} does not exist.`);
54
+ info('Available versions:');
55
+ versions.forEach(v => {
56
+ info(` v${v.version} - ${v.timestamp}`);
57
+ });
58
+ process.exit(1);
59
+ }
60
+ } else {
61
+ // Default: rollback to previous version
62
+ const sortedVersions = versions.map(v => v.version).sort((a, b) => b - a);
63
+ const currentIndex = sortedVersions.indexOf(currentActive);
64
+ if (currentIndex === sortedVersions.length - 1) {
65
+ warning('Already at the oldest version. Cannot rollback further.');
66
+ process.exit(1);
67
+ }
68
+ targetVersion = sortedVersions[currentIndex + 1];
69
+ }
70
+
71
+ if (targetVersion === currentActive) {
72
+ warning(`Version ${targetVersion} is already active.`);
73
+ process.exit(0);
74
+ }
75
+
76
+ info(`Rolling back from v${currentActive} to v${targetVersion}...`);
77
+
78
+ // Set the target version as active
79
+ if (useAPI) {
80
+ // Use API for centralized rollback (updates both D1 and R2)
81
+ const result = await rollbackViaAPI(subdomain, targetVersion);
82
+ if (!result) {
83
+ warning('API unavailable, falling back to local rollback');
84
+ await setActiveVersion(subdomain, targetVersion);
85
+ }
86
+ } else {
87
+ await setActiveVersion(subdomain, targetVersion);
88
+ }
89
+
90
+ // Find the target version's deployment record for file count
91
+ const targetDeployment = versions.find(v => v.version === targetVersion);
92
+
93
+ success(`Rolled back to v${targetVersion} successfully!`);
94
+ console.log(`\n 🔄 https://${subdomain}.launchpd.cloud\n`);
95
+ info(`Restored deployment from: ${targetDeployment?.timestamp || 'unknown'}`);
96
+
97
+ } catch (err) {
98
+ error(`Rollback failed: ${err.message}`);
99
+ process.exit(1);
100
+ }
101
+ }
@@ -0,0 +1,75 @@
1
+ import { getVersionsForSubdomain, getActiveVersion } from '../utils/metadata.js';
2
+ import { getVersions as getVersionsFromAPI } from '../utils/api.js';
3
+ import { success, error, info } from '../utils/logger.js';
4
+
5
+ /**
6
+ * List all versions for a subdomain
7
+ * @param {string} subdomain - Subdomain to list versions for
8
+ * @param {object} options - Command options
9
+ * @param {boolean} options.json - Output as JSON
10
+ */
11
+ export async function versions(subdomain, options) {
12
+ try {
13
+ info(`Fetching versions for ${subdomain}...`);
14
+
15
+ let versionList = [];
16
+ let activeVersion = 1;
17
+
18
+ // Try API first
19
+ const apiResult = await getVersionsFromAPI(subdomain);
20
+ if (apiResult && apiResult.versions) {
21
+ versionList = apiResult.versions.map(v => ({
22
+ version: v.version,
23
+ timestamp: v.created_at,
24
+ fileCount: v.file_count,
25
+ totalBytes: v.total_bytes,
26
+ }));
27
+ activeVersion = apiResult.activeVersion || 1;
28
+ } else {
29
+ // Fallback to R2 metadata
30
+ versionList = await getVersionsForSubdomain(subdomain);
31
+ activeVersion = await getActiveVersion(subdomain);
32
+ }
33
+
34
+ if (versionList.length === 0) {
35
+ error(`No deployments found for subdomain: ${subdomain}`);
36
+ process.exit(1);
37
+ }
38
+
39
+ if (options.json) {
40
+ console.log(JSON.stringify({
41
+ subdomain,
42
+ activeVersion,
43
+ versions: versionList.map(v => ({
44
+ version: v.version,
45
+ timestamp: v.timestamp,
46
+ fileCount: v.fileCount,
47
+ totalBytes: v.totalBytes,
48
+ isActive: v.version === activeVersion,
49
+ })),
50
+ }, null, 2));
51
+ return;
52
+ }
53
+
54
+ console.log('');
55
+ success(`Versions for ${subdomain}.launchpd.cloud:`);
56
+ console.log('');
57
+
58
+ for (const v of versionList) {
59
+ const isActive = v.version === activeVersion;
60
+ const activeMarker = isActive ? ' ← active' : '';
61
+ const sizeKB = v.totalBytes ? `${(v.totalBytes / 1024).toFixed(1)} KB` : 'unknown size';
62
+ const date = new Date(v.timestamp).toLocaleString();
63
+
64
+ console.log(` v${v.version} │ ${date} │ ${v.fileCount} files │ ${sizeKB}${activeMarker}`);
65
+ }
66
+
67
+ console.log('');
68
+ info(`Use 'launchpd rollback ${subdomain} --to <n>' to restore a version.`);
69
+ console.log('');
70
+
71
+ } catch (err) {
72
+ error(`Failed to list versions: ${err.message}`);
73
+ process.exit(1);
74
+ }
75
+ }
package/src/config.js ADDED
@@ -0,0 +1,36 @@
1
+ import 'dotenv/config';
2
+
3
+ /**
4
+ * Application configuration loaded from environment variables
5
+ */
6
+ 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
+ // Base domain for deployments
15
+ domain: process.env.STATICLAUNCH_DOMAIN || 'launchpd.cloud',
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
+ ];
28
+
29
+ const missing = required.filter(key => !process.env[key]);
30
+
31
+ if (missing.length > 0) {
32
+ return { valid: false, missing };
33
+ }
34
+
35
+ return { valid: true, missing: [] };
36
+ }
@@ -0,0 +1,158 @@
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
+
8
+ const API_BASE_URL = `https://api.${config.domain}`;
9
+ const API_KEY = process.env.STATICLAUNCH_API_KEY || 'public-beta-key';
10
+
11
+ /**
12
+ * Make an authenticated API request
13
+ */
14
+ async function apiRequest(endpoint, options = {}) {
15
+ const url = `${API_BASE_URL}${endpoint}`;
16
+
17
+ const headers = {
18
+ 'Content-Type': 'application/json',
19
+ 'X-API-Key': API_KEY,
20
+ ...options.headers,
21
+ };
22
+
23
+ try {
24
+ const response = await fetch(url, {
25
+ ...options,
26
+ headers,
27
+ });
28
+
29
+ const data = await response.json();
30
+
31
+ if (!response.ok) {
32
+ throw new Error(data.error || `API error: ${response.status}`);
33
+ }
34
+
35
+ return data;
36
+ } catch (err) {
37
+ // If API is unavailable, return null to allow fallback to local storage
38
+ if (err.message.includes('fetch failed') || err.message.includes('ENOTFOUND')) {
39
+ return null;
40
+ }
41
+ throw err;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Get the next version number for a subdomain
47
+ */
48
+ export async function getNextVersionFromAPI(subdomain) {
49
+ const result = await apiRequest(`/api/versions/${subdomain}`);
50
+ if (!result || !result.versions || result.versions.length === 0) {
51
+ return 1;
52
+ }
53
+ const maxVersion = Math.max(...result.versions.map(v => v.version));
54
+ return maxVersion + 1;
55
+ }
56
+
57
+ /**
58
+ * Record a new deployment in the API
59
+ */
60
+ export async function recordDeployment(deploymentData) {
61
+ const { subdomain, folderName, fileCount, totalBytes, version, expiresAt } = deploymentData;
62
+
63
+ return apiRequest('/api/deployments', {
64
+ method: 'POST',
65
+ body: JSON.stringify({
66
+ subdomain,
67
+ folderName,
68
+ fileCount,
69
+ totalBytes,
70
+ version,
71
+ cliVersion: '0.1.0',
72
+ expiresAt,
73
+ }),
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Get list of user's deployments
79
+ */
80
+ export async function listDeployments(limit = 50, offset = 0) {
81
+ return apiRequest(`/api/deployments?limit=${limit}&offset=${offset}`);
82
+ }
83
+
84
+ /**
85
+ * Get deployment details for a subdomain
86
+ */
87
+ export async function getDeployment(subdomain) {
88
+ return apiRequest(`/api/deployments/${subdomain}`);
89
+ }
90
+
91
+ /**
92
+ * Get version history for a subdomain
93
+ */
94
+ export async function getVersions(subdomain) {
95
+ return apiRequest(`/api/versions/${subdomain}`);
96
+ }
97
+
98
+ /**
99
+ * Rollback to a specific version
100
+ */
101
+ export async function rollbackVersion(subdomain, version) {
102
+ return apiRequest(`/api/versions/${subdomain}/rollback`, {
103
+ method: 'PUT',
104
+ body: JSON.stringify({ version }),
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Check if a subdomain is available
110
+ */
111
+ export async function checkSubdomainAvailable(subdomain) {
112
+ const result = await apiRequest(`/api/public/check/${subdomain}`);
113
+ return result?.available ?? true;
114
+ }
115
+
116
+ /**
117
+ * Reserve a subdomain
118
+ */
119
+ export async function reserveSubdomain(subdomain) {
120
+ return apiRequest('/api/subdomains/reserve', {
121
+ method: 'POST',
122
+ body: JSON.stringify({ subdomain }),
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Get user's subdomains
128
+ */
129
+ export async function listSubdomains() {
130
+ return apiRequest('/api/subdomains');
131
+ }
132
+
133
+ /**
134
+ * Get current user info
135
+ */
136
+ export async function getCurrentUser() {
137
+ return apiRequest('/api/users/me');
138
+ }
139
+
140
+ /**
141
+ * Health check
142
+ */
143
+ export async function healthCheck() {
144
+ return apiRequest('/api/health');
145
+ }
146
+
147
+ export default {
148
+ recordDeployment,
149
+ listDeployments,
150
+ getDeployment,
151
+ getVersions,
152
+ rollbackVersion,
153
+ checkSubdomainAvailable,
154
+ reserveSubdomain,
155
+ listSubdomains,
156
+ getCurrentUser,
157
+ healthCheck,
158
+ };
@@ -0,0 +1,143 @@
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
+ userId: data.userId || null,
81
+ email: data.email || null,
82
+ tier: data.tier || 'free',
83
+ savedAt: data.savedAt || null,
84
+ };
85
+ }
86
+ }
87
+ } catch {
88
+ // Corrupted or invalid JSON file
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Save credentials
95
+ * @param {object} credentials - Credentials to save
96
+ */
97
+ export async function saveCredentials(credentials) {
98
+ await ensureConfigDir();
99
+
100
+ const data = {
101
+ apiKey: credentials.apiKey,
102
+ userId: credentials.userId || null,
103
+ email: credentials.email || null,
104
+ tier: credentials.tier || 'free',
105
+ savedAt: new Date().toISOString(),
106
+ };
107
+
108
+ await writeFile(
109
+ getCredentialsPath(),
110
+ JSON.stringify(data, null, 2),
111
+ 'utf-8'
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Delete stored credentials (logout)
117
+ */
118
+ export async function clearCredentials() {
119
+ const filePath = getCredentialsPath();
120
+ try {
121
+ if (existsSync(filePath)) {
122
+ await unlink(filePath);
123
+ }
124
+ } catch {
125
+ // File doesn't exist or can't be deleted
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Check if user is logged in
131
+ */
132
+ export async function isLoggedIn() {
133
+ const creds = await getCredentials();
134
+ return creds !== null && creds.apiKey !== null;
135
+ }
136
+
137
+ /**
138
+ * Get the API key for requests (falls back to public beta key)
139
+ */
140
+ export async function getApiKey() {
141
+ const creds = await getCredentials();
142
+ return creds?.apiKey || process.env.STATICLAUNCH_API_KEY || 'public-beta-key';
143
+ }
@@ -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 {string} ISO timestamp of expiration
48
+ */
49
+ export function calculateExpiresAt(timeStr) {
50
+ const ms = parseTimeString(timeStr);
51
+ return new Date(Date.now() + ms).toISOString();
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,13 @@
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 './upload.js';