@drifting-ink/cli
Version:
Static site deployment CLI for drifting.ink
739 lines (624 loc) β’ 23.5 kB
JavaScript
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();