UNPKG

@drifting-ink/cli

Version:

Static site deployment CLI for drifting.ink

739 lines (624 loc) β€’ 23.5 kB
#!/usr/bin/env node import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import os from 'os'; import open from 'open'; const CONFIG_DIR = path.join(os.homedir(), '.drifting'); const CONFIG_FILE = path.join(CONFIG_DIR, 'config'); // Parse command line arguments const args = process.argv.slice(2); if (args.includes('--clear-token') || args.includes('logout')) { clearToken(); process.exit(0); } // Parse arguments with named flags let directory = '.'; // Default to current directory let subdomain = null; let serverUrl = 'https://drifting.ink'; // Find named arguments for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--subdomain' || arg === '-s') { if (i + 1 < args.length) { subdomain = args[i + 1]; i++; // Skip next argument as it's the value } else { console.error('Error: --subdomain requires a value'); process.exit(1); } } else if (arg === '--server' || arg === '--url') { if (i + 1 < args.length) { serverUrl = args[i + 1]; i++; // Skip next argument as it's the value } else { console.error('Error: --server requires a value'); process.exit(1); } } else if (arg.startsWith('-')) { console.error(`Error: Unknown flag ${arg}`); showUsage(); process.exit(1); } else { // First non-flag argument is the directory directory = arg; } } async function main() { try { // Check if directory exists if (!fs.existsSync(directory)) { console.error(`Error: Directory '${directory}' does not exist`); process.exit(1); } // Get absolute path for project identification const projectPath = path.resolve(directory); // Check for CNAME file first (only if subdomain not explicitly provided) if (!subdomain) { const cname = readCNAMEFile(directory); if (cname) { const cnameSubdomain = extractSubdomainFromCNAME(cname, serverUrl); if (cnameSubdomain) { console.log(`πŸ“‹ Found CNAME file pointing to ${cname}`); console.log(`🎯 Using subdomain: ${cnameSubdomain}`); subdomain = cnameSubdomain; } else { console.log(`⚠️ CNAME file contains ${cname}, but it doesn't match server ${serverUrl}`); } } } // Get CLI token let cliToken = process.env.DRIFTING_CLI_TOKEN || loadTokenFromConfig(serverUrl); // Validate token if we have one (but skip validation for environment tokens) if (cliToken && !process.env.DRIFTING_CLI_TOKEN) { console.log('πŸ” Validating stored token...'); const isValid = await validateToken(cliToken, serverUrl); if (!isValid) { console.log('⚠️ Stored token is invalid or expired'); cliToken = null; } } if (!cliToken) { cliToken = await authenticateWithBrowser(serverUrl); } // If no subdomain provided (and no CNAME found), prompt for site selection let shouldPromptCNAME = false; if (!subdomain) { subdomain = await promptSiteSelection(cliToken, serverUrl); shouldPromptCNAME = true; // User selected a site, so we can offer to create CNAME } console.log(`πŸš€ Deploying site to subdomain '${subdomain}'...`); // Check if site exists const siteExists = await checkSiteExists(subdomain, cliToken, serverUrl); if (!siteExists) { const shouldCreate = await promptSiteCreation(subdomain); if (shouldCreate) { await createSite(subdomain, cliToken, serverUrl); } else { console.log('❌ Site creation cancelled. Deployment aborted.'); process.exit(0); } } // Scan and encode files const files = await scanDirectory(directory); console.log(`πŸ“¦ Uploading ${files.length} files...`); // Upload to API await uploadFiles(files, subdomain, cliToken, serverUrl); console.log('βœ… Site deployed successfully!'); // Extract hostname and protocol from server URL for display const serverUrlObj = new URL(serverUrl); const hostname = serverUrlObj.hostname; const port = serverUrlObj.port; const protocol = serverUrlObj.protocol; const displayUrl = port && port !== '443' && port !== '80' ? `${protocol}//${subdomain}.${hostname}:${port}` : `${protocol}//${subdomain}.${hostname}`; console.log(`🌐 Your site is now available at: ${displayUrl}`); // Offer to create CNAME file if user selected a deployment location if (shouldPromptCNAME && !readCNAMEFile(directory)) { const shouldCreateCNAME = await promptCNAMECreation(projectPath, subdomain, serverUrl); if (shouldCreateCNAME) { const cnameValue = `${subdomain}.${hostname}`; createCNAMEFile(directory, cnameValue); } } } catch (error) { console.error('❌ Error:', error.message); process.exit(1); } finally { // Ensure stdin is properly closed to allow process termination if (process.stdin.readable) { process.stdin.pause(); } } } function showUsage() { console.log(`Usage: drifting [directory] [options] drifting --clear-token # Clear stored authentication token Arguments: directory Path to static site directory (defaults to current directory) Options: -s, --subdomain <name> Subdomain for your site (if omitted, shows interactive selection) --server <url> Server URL (defaults to https://drifting.ink) --clear-token Clear stored authentication token Interactive Features: β€’ Run without --subdomain to select from existing sites or create new one β€’ If no sites exist, guided setup to create your first site β€’ Automatic authentication via browser with token caching CNAME File Support: β€’ If a CNAME file exists pointing to a subdomain of your server, automatically uses it β€’ After selecting a deployment target, offers to create a CNAME file for future deployments β€’ Project-level preferences stored in global config (~/.drifting/config) Authentication: Opens browser for authentication with drifting.ink automatically. Tokens saved to ~/.drifting/config for 24 hours. Override with DRIFTING_CLI_TOKEN environment variable. Examples: drifting # Deploy current dir, select site interactively drifting --subdomain mysite # Deploy current dir to 'mysite' drifting ./dist # Deploy ./dist, select site interactively drifting ./dist --subdomain mysite # Deploy ./dist to 'mysite' drifting --subdomain mysite --server http://localhost:4000 # Deploy to local server drifting ./dist -s mysite # Short form with directory # With CNAME file containing 'mysite.drifting.ink' drifting # Automatically deploys to 'mysite' Config file: ~/.drifting/config # Global authentication tokens and preferences`); } function clearToken() { if (fs.existsSync(CONFIG_FILE)) { fs.unlinkSync(CONFIG_FILE); console.log('πŸ—‘οΈ Cleared all stored authentication tokens'); console.log('You\'ll need to re-authenticate on your next deployment'); } else { console.log('ℹ️ No stored tokens found'); } } function readCNAMEFile(directory) { const cnamePath = path.join(directory, 'CNAME'); if (!fs.existsSync(cnamePath)) { return null; } try { const content = fs.readFileSync(cnamePath, 'utf8').trim(); // CNAME files typically contain a single domain name return content.split('\n')[0].trim(); } catch (error) { console.warn('⚠️ Could not read CNAME file:', error.message); return null; } } function extractSubdomainFromCNAME(cname, serverUrl) { if (!cname) return null; try { const serverUrlObj = new URL(serverUrl); const serverHost = serverUrlObj.hostname; // Check if the CNAME domain is a subdomain of our server if (cname.endsWith(`.${serverHost}`)) { // Extract the subdomain part const subdomain = cname.replace(`.${serverHost}`, ''); // Make sure it's a simple subdomain (no additional dots) if (!subdomain.includes('.') && subdomain.length > 0) { return subdomain; } } return null; } catch (error) { return null; } } function createCNAMEFile(directory, cname) { const cnamePath = path.join(directory, 'CNAME'); try { fs.writeFileSync(cnamePath, cname); console.log(`πŸ“ Created CNAME file: ${cname}`); } catch (error) { console.warn('⚠️ Could not create CNAME file:', error.message); } } async function promptCNAMECreation(projectPath, subdomain, serverUrl) { // Check if user has opted out of CNAME prompts for this project if (shouldSkipCNAMEPrompt(projectPath, serverUrl)) { return false; } const serverUrlObj = new URL(serverUrl); const hostname = serverUrlObj.hostname; const cnameValue = `${subdomain}.${hostname}`; console.log(`\nπŸ“‹ Would you like to create a CNAME file for this deployment?`); console.log(` This will create a file containing: ${cnameValue}`); console.log(` Future deployments will automatically use this subdomain.`); return new Promise((resolve) => { process.stdout.write(`\nCreate CNAME file? (y/n/never): `); process.stdin.setEncoding('utf8'); process.stdin.resume(); process.stdin.once('data', (input) => { const answer = input.toString().trim().toLowerCase(); if (answer === 'never') { // Save preference to not ask again for this project addProjectToSkipCNAME(projectPath, serverUrl); console.log('ℹ️ Won\'t ask about CNAME files for this project again'); resolve(false); } else if (answer === 'y' || answer === 'yes') { resolve(true); } else { resolve(false); } }); }); } function loadTokenFromConfig(serverUrl) { if (!fs.existsSync(CONFIG_FILE)) return null; try { const config = fs.readFileSync(CONFIG_FILE, 'utf8'); // Try new format first (server-specific tokens) const serverSection = config.match(new RegExp(`\\[${escapeRegex(serverUrl)}\\]([\\s\\S]*?)(?=\\n\\[|$)`)); if (serverSection) { const tokenMatch = serverSection[1].match(/CLI_TOKEN=(.+)/); if (tokenMatch) return tokenMatch[1]; } // Fallback to old format for backward compatibility const match = config.match(/CLI_TOKEN=(.+)/); return match ? match[1] : null; } catch { return null; } } function shouldSkipCNAMEPrompt(projectPath, serverUrl) { if (!fs.existsSync(CONFIG_FILE)) return false; try { const config = fs.readFileSync(CONFIG_FILE, 'utf8'); const serverSection = config.match(new RegExp(`\\[${escapeRegex(serverUrl)}\\]([\\s\\S]*?)(?=\\n\\[|$)`)); if (serverSection) { const skipProjects = serverSection[1].match(/SKIP_CNAME_PROJECTS=(.+)/); if (skipProjects) { const projects = skipProjects[1].split(','); return projects.includes(projectPath); } } return false; } catch { return false; } } function addProjectToSkipCNAME(projectPath, serverUrl) { try { let config = ''; if (fs.existsSync(CONFIG_FILE)) { config = fs.readFileSync(CONFIG_FILE, 'utf8'); } // Find the server section const serverRegex = new RegExp(`(\\[${escapeRegex(serverUrl)}\\])([\\s\\S]*?)(?=\\n\\[|$)`); const serverMatch = config.match(serverRegex); if (serverMatch) { const [fullMatch, serverHeader, serverContent] = serverMatch; const skipMatch = serverContent.match(/SKIP_CNAME_PROJECTS=(.+)/); let newServerContent; if (skipMatch) { // Add to existing list const existingProjects = skipMatch[1].split(','); if (!existingProjects.includes(projectPath)) { existingProjects.push(projectPath); const newProjects = existingProjects.join(','); newServerContent = serverContent.replace(/SKIP_CNAME_PROJECTS=.+/, `SKIP_CNAME_PROJECTS=${newProjects}`); } else { newServerContent = serverContent; // Already in list } } else { // Add new SKIP_CNAME_PROJECTS line newServerContent = serverContent.trim() + `\nSKIP_CNAME_PROJECTS=${projectPath}`; } config = config.replace(serverRegex, serverHeader + newServerContent); } fs.writeFileSync(CONFIG_FILE, config, { mode: 0o600 }); } catch (error) { console.warn('⚠️ Could not save CNAME preference:', error.message); } } function escapeRegex(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function saveTokenToConfig(token, serverUrl) { if (!fs.existsSync(CONFIG_DIR)) { fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); } let config = ''; const timestamp = new Date().toISOString(); // Load existing config if it exists if (fs.existsSync(CONFIG_FILE)) { try { config = fs.readFileSync(CONFIG_FILE, 'utf8'); } catch { // If we can't read the file, start fresh config = ''; } } // Remove existing entry for this server if it exists const serverRegex = new RegExp(`\\[${escapeRegex(serverUrl)}\\][\\s\\S]*?(?=\\n\\[|$)`, 'g'); config = config.replace(serverRegex, '').trim(); // Add header if this is a new file or if it doesn't have one if (!config.includes('# drifting.ink CLI Configuration')) { config = `# drifting.ink CLI Configuration # This file is automatically generated and updated # Tokens are stored per server URL for security ${config}`.trim(); } // Add the new server section const serverSection = ` [${serverUrl}] CLI_TOKEN=${token} UPDATED=${timestamp}`; config = config + serverSection; fs.writeFileSync(CONFIG_FILE, config, { mode: 0o600 }); } async function authenticateWithBrowser(serverUrl) { console.log('πŸ” Authentication required for deployment'); console.log('Initiating automated browser authentication...'); const sessionId = crypto.randomBytes(16).toString('hex'); const authUrl = `${serverUrl}/cli/auth/${sessionId}`; const sessionUrl = `${serverUrl}/api/cli/session/${sessionId}`; console.log('πŸ“± Opening browser for authentication...'); await open(authUrl); console.log('⏳ Waiting for authentication...'); for (let i = 0; i < 60; i++) { // 5 minutes max await new Promise(resolve => setTimeout(resolve, 5000)); try { const response = await fetch(sessionUrl); if (response.status === 200) { const data = await response.json(); if (data.status === 'completed') { console.log('βœ… Authentication successful!'); saveTokenToConfig(data.cli_token, serverUrl); console.log('πŸ’Ύ Token saved for future deployments (expires in 24 hours)'); return data.cli_token; } process.stdout.write('.'); } else if (response.status === 404) { throw new Error('Authentication session not found'); } else if (response.status === 410) { throw new Error('Authentication session expired'); } } catch (error) { if (error.message.includes('session')) throw error; // Network error, continue polling process.stdout.write('.'); } } throw new Error('Authentication timed out'); } async function scanDirectory(directory) { const files = []; function scanRecursive(dir, basePath = '') { const items = fs.readdirSync(dir); for (const item of items) { const fullPath = path.join(dir, item); const relativePath = path.join(basePath, item); // Skip hidden files and build artifacts if (item.startsWith('.') || item.includes('node_modules') || item.endsWith('.log') || item.endsWith('.tmp') || item.endsWith('~')) { continue; } const stats = fs.statSync(fullPath); if (stats.isDirectory()) { scanRecursive(fullPath, relativePath); } else { const content = fs.readFileSync(fullPath).toString('base64'); files.push({ path: relativePath.replace(/\\/g, '/'), // Normalize path separators content }); } } } scanRecursive(directory); return files; } async function validateToken(cliToken, serverUrl) { try { const response = await fetch(`${serverUrl}/api/sites`, { method: 'GET', headers: { 'Authorization': `Bearer ${cliToken}`, 'Content-Type': 'application/json' } }); // Token is valid if we get a 200 response return response.status === 200; } catch (error) { // Network errors mean we can't validate, so assume token is invalid return false; } } async function fetchUserSites(cliToken, serverUrl) { try { console.log(`πŸ“‘ Fetching sites from ${serverUrl}/api/sites...`); const response = await fetch(`${serverUrl}/api/sites`, { method: 'GET', headers: { 'Authorization': `Bearer ${cliToken}`, 'Content-Type': 'application/json' } }); console.log(`πŸ“‘ Response status: ${response.status}`); if (response.ok) { const data = await response.json(); return data.sites; } else if (response.status === 401) { throw new Error('Invalid or expired CLI token'); } else { const errorText = await response.text(); console.log(`πŸ“‘ Error response: ${errorText}`); throw new Error(`Server responded with ${response.status}: ${errorText}`); } } catch (error) { if (error.message.includes('fetch')) { throw new Error(`Cannot connect to server at ${serverUrl}. Make sure the server is running and accessible.`); } throw new Error(`Failed to fetch sites: ${error.message}`); } } async function promptSiteSelection(cliToken, serverUrl) { const sites = await fetchUserSites(cliToken, serverUrl); if (sites.length === 0) { console.log('\nπŸ“ No sites found. Let\'s create your first site!'); const subdomain = await promptForInput('Enter subdomain for your new site'); const shouldCreate = await promptSiteCreation(subdomain); if (shouldCreate) { await createSite(subdomain, cliToken, serverUrl); return subdomain; } else { console.log('❌ Site creation cancelled.'); process.exit(0); } } console.log('\nπŸ“‹ Select a site to deploy to:'); sites.forEach((site, index) => { const status = site.active ? 'βœ…' : '⏸️'; console.log(` ${index + 1}. ${site.name} (${site.subdomain}) ${status}`); }); console.log(` ${sites.length + 1}. πŸ†• Create new site`); const choice = await promptForInput(`\nSelect site (1-${sites.length + 1})`); const choiceNum = parseInt(choice); if (isNaN(choiceNum) || choiceNum < 1 || choiceNum > sites.length + 1) { console.log('❌ Invalid selection.'); process.exit(1); } if (choiceNum === sites.length + 1) { // Create new site const subdomain = await promptForInput('Enter subdomain for your new site'); const shouldCreate = await promptSiteCreation(subdomain); if (shouldCreate) { await createSite(subdomain, cliToken, serverUrl); return subdomain; } else { console.log('❌ Site creation cancelled.'); process.exit(0); } } else { // Select existing site const selectedSite = sites[choiceNum - 1]; return selectedSite.subdomain; } } async function checkSiteExists(subdomain, cliToken, serverUrl) { try { const response = await fetch(`${serverUrl}/api/sites/${subdomain}`, { method: 'GET', headers: { 'Authorization': `Bearer ${cliToken}` } }); if (response.ok) { const data = await response.json(); return data.exists; } else if (response.status === 401) { throw new Error('Invalid or expired CLI token'); } else { throw new Error('Failed to check site existence'); } } catch (error) { throw new Error(`Failed to check site existence: ${error.message}`); } } async function promptSiteCreation(subdomain) { return new Promise((resolve) => { process.stdout.write(`\nπŸ†• Site '${subdomain}' doesn't exist. Create it? (y/N): `); process.stdin.setEncoding('utf8'); process.stdin.resume(); process.stdin.once('data', (input) => { const answer = input.toString().trim().toLowerCase(); resolve(answer === 'y' || answer === 'yes'); }); }); } async function promptForInput(question, defaultValue = '') { return new Promise((resolve) => { const prompt = defaultValue ? `${question} (default: ${defaultValue}): ` : `${question}: `; process.stdout.write(prompt); process.stdin.setEncoding('utf8'); process.stdin.resume(); process.stdin.once('data', (input) => { const answer = input.toString().trim(); resolve(answer || defaultValue); }); }); } async function createSite(subdomain, cliToken, serverUrl) { console.log('\nπŸ“ Setting up your new site...'); const name = await promptForInput('Site name (optional)', subdomain); const description = await promptForInput('Description (optional)', ''); // Pause stdin after interactive prompts are done process.stdin.pause(); const siteData = { subdomain: subdomain, name: name, description: description, active: true }; try { const response = await fetch(`${serverUrl}/api/sites`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cliToken}` }, body: JSON.stringify(siteData) }); if (response.ok) { const data = await response.json(); console.log(`βœ… Site '${data.site.name}' created successfully!`); } else { const errorText = await response.text(); let errorMessage; try { const errorData = JSON.parse(errorText); if (errorData.errors) { const errorMessages = Object.entries(errorData.errors) .map(([field, messages]) => `${field}: ${messages.join(', ')}`) .join('; '); errorMessage = `Validation failed: ${errorMessages}`; } else { errorMessage = errorData.error || errorText; } } catch { errorMessage = errorText; } throw new Error(`Failed to create site: ${errorMessage}`); } } catch (error) { throw new Error(`Failed to create site: ${error.message}`); } } async function uploadFiles(files, subdomain, cliToken, serverUrl) { const response = await fetch(`${serverUrl}/api/upload/${subdomain}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cliToken}` }, body: JSON.stringify({ files }) }); if (!response.ok) { const errorText = await response.text(); switch (response.status) { case 401: if (!process.env.DRIFTING_CLI_TOKEN && fs.existsSync(CONFIG_FILE)) { clearToken(); console.log('πŸ—‘οΈ Cleared expired token from config'); console.log('Please run the deployment again to authenticate with a fresh token'); } throw new Error('Invalid or expired CLI token'); case 404: throw new Error(`Site '${subdomain}' not found. Please create the site first through the web interface`); case 422: throw new Error(`Upload failed: ${errorText}`); default: throw new Error(`Unexpected response (HTTP ${response.status}): ${errorText}`); } } } main();