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
package/src/utils/api.js
ADDED
|
@@ -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
|
+
}
|
package/src/utils/id.js
ADDED
|
@@ -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
|
+
}
|