#!/usr/bin/env node /** * Astro Modular Update Script * * Downloads the latest release from GitHub, replaces framework files, * and preserves user content and assets. After updating, open Obsidian * and click "Apply all settings" in the Astro Modular Settings plugin * to rewrite your config.ts with your saved settings. * * Usage: pnpm run update */ import { execSync } from 'child_process'; import { createWriteStream, existsSync, mkdirSync, cpSync, rmSync, readFileSync, readdirSync, statSync, renameSync, writeFileSync } from 'fs'; import { join, resolve, basename } from 'path'; import { tmpdir } from 'os'; import { get as httpsGet } from 'https'; import { pipeline } from 'stream/promises'; const ROOT = resolve(import.meta.dirname, '..'); const REPO = 'davidvkimball/astro-modular'; // Files and directories that belong to the USER and must be preserved const USER_PATHS = [ 'src/content', // All user content + .obsidian vault (plugin data.json files) 'public/profile.jpg', 'public/profile.png', 'public/profile.webp', 'public/favicon.png', 'public/favicon-dark.png', 'public/favicon-light.png', 'public/open-graph.png', '.env', '.env.local', '.env.production', ]; // Directories that should never be touched const SKIP_PATHS = [ 'node_modules', '.git', '.astro', '.netlify', ]; // ── Helpers ───────────────────────────────────────────────────────────────── function log(msg) { console.log(` ${msg}`); } function logStep(msg) { console.log(`\n> ${msg}`); } function logError(msg) { console.error(`\n ERROR: ${msg}`); } function getCurrentVersion() { const versionFile = join(ROOT, 'VERSION'); if (existsSync(versionFile)) { return readFileSync(versionFile, 'utf-8').trim(); } const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')); return pkg.version; } async function fetchJSON(url) { return new Promise((resolve, reject) => { httpsGet(url, { headers: { 'User-Agent': 'astro-modular-updater' } }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { return fetchJSON(res.headers.location).then(resolve, reject); } let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { resolve(JSON.parse(data)); } catch { reject(new Error(`Invalid JSON from ${url}`)); } }); res.on('error', reject); }).on('error', reject); }); } async function downloadFile(url, dest) { return new Promise((resolve, reject) => { httpsGet(url, { headers: { 'User-Agent': 'astro-modular-updater' } }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { return downloadFile(res.headers.location, dest).then(resolve, reject); } if (res.statusCode !== 200) { return reject(new Error(`Download failed: HTTP ${res.statusCode}`)); } const ws = createWriteStream(dest); pipeline(res, ws).then(resolve, reject); }).on('error', reject); }); } function copyIfExists(src, dest) { if (!existsSync(src)) return false; const stat = statSync(src); if (stat.isDirectory()) { cpSync(src, dest, { recursive: true }); } else { const dir = join(dest, '..'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); cpSync(src, dest); } return true; } // ── Main ──────────────────────────────────────────────────────────────────── async function main() { console.log('\nAstro Modular Updater'); console.log('===================='); // 1. Get current version const currentVersion = getCurrentVersion(); log(`Current version: ${currentVersion}`); // 2. Check latest release logStep('Checking for updates...'); let release; try { release = await fetchJSON(`https://api.github.com/repos/${REPO}/releases/latest`); } catch (err) { logError(`Could not reach GitHub: ${err.message}`); process.exit(1); } const latestVersion = release.tag_name.replace(/^v/, ''); log(`Latest version: ${latestVersion}`); if (currentVersion === latestVersion) { log('Already up to date!'); process.exit(0); } // 3. Download release archive logStep(`Downloading v${latestVersion}...`); const isWindows = process.platform === 'win32'; const archiveUrl = isWindows ? release.zipball_url : release.tarball_url; const tempDir = join(tmpdir(), `astro-modular-update-${Date.now()}`); mkdirSync(tempDir, { recursive: true }); const archivePath = join(tempDir, isWindows ? 'release.zip' : 'release.tar.gz'); try { await downloadFile(archiveUrl, archivePath); } catch (err) { logError(`Download failed: ${err.message}`); rmSync(tempDir, { recursive: true, force: true }); process.exit(1); } log(`Downloaded to temp directory.`); // 4. Extract archive logStep('Extracting...'); const extractDir = join(tempDir, 'extracted'); mkdirSync(extractDir, { recursive: true }); try { if (isWindows) { execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force"`, { stdio: 'pipe' }); } else { execSync(`tar -xzf "${archivePath}" -C "${extractDir}"`, { stdio: 'pipe' }); } } catch (err) { logError(`Extraction failed: ${err.message}`); rmSync(tempDir, { recursive: true, force: true }); process.exit(1); } // GitHub tarballs extract into a directory like "owner-repo-hash/" const extractedContents = readdirSync(extractDir); if (extractedContents.length !== 1) { logError('Unexpected archive structure.'); rmSync(tempDir, { recursive: true, force: true }); process.exit(1); } const sourceDir = join(extractDir, extractedContents[0]); log('Extracted successfully.'); // 5. Back up user files logStep('Backing up user content...'); const backupDir = join(tempDir, 'backup'); mkdirSync(backupDir, { recursive: true }); let backedUp = 0; for (const userPath of USER_PATHS) { const src = join(ROOT, userPath); const dest = join(backupDir, userPath); if (copyIfExists(src, dest)) { log(` Backed up: ${userPath}`); backedUp++; } } // Also back up any user-added public/ files not in the new release const publicDir = join(ROOT, 'public'); const newPublicDir = join(sourceDir, 'public'); if (existsSync(publicDir)) { for (const file of readdirSync(publicDir)) { const fullPath = join(publicDir, file); if (statSync(fullPath).isFile()) { const alreadyBacked = USER_PATHS.some(p => p === `public/${file}`); if (!alreadyBacked) { // Check if this file does NOT exist in the new release (user-added) if (!existsSync(join(newPublicDir, file))) { const dest = join(backupDir, 'public', file); mkdirSync(join(backupDir, 'public'), { recursive: true }); cpSync(fullPath, dest); log(` Backed up (user asset): public/${file}`); backedUp++; } } } } } log(`${backedUp} item(s) backed up.`); // 6. Replace framework files logStep('Updating framework files...'); // Get list of all paths in the new release (excluding user/skip paths) function listPaths(dir, base = '') { const results = []; for (const entry of readdirSync(dir)) { const rel = base ? `${base}/${entry}` : entry; const full = join(dir, entry); // Skip paths we should never touch if (SKIP_PATHS.includes(rel)) continue; // Skip user content paths (we'll restore from backup) if (USER_PATHS.includes(rel)) continue; if (statSync(full).isDirectory()) { // If it's a user directory root, skip recursing if (USER_PATHS.some(p => p.startsWith(rel + '/'))) { // Only skip exact user-path prefixes like src/content if (USER_PATHS.includes(rel)) continue; results.push(...listPaths(full, rel)); } else { results.push(...listPaths(full, rel)); } } else { results.push(rel); } } return results; } const newFiles = listPaths(sourceDir); let updated = 0; for (const relPath of newFiles) { const src = join(sourceDir, relPath); const dest = join(ROOT, relPath); const destDir = join(dest, '..'); if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true }); cpSync(src, dest); updated++; } log(`${updated} framework file(s) updated.`); // 7. Restore user files from backup logStep('Restoring user content...'); let restored = 0; for (const userPath of USER_PATHS) { const src = join(backupDir, userPath); const dest = join(ROOT, userPath); if (copyIfExists(src, dest)) { log(` Restored: ${userPath}`); restored++; } } // Restore user-added public/ files const backupPublicDir = join(backupDir, 'public'); if (existsSync(backupPublicDir)) { for (const file of readdirSync(backupPublicDir)) { const alreadyRestored = USER_PATHS.some(p => p === `public/${file}`); if (!alreadyRestored) { cpSync(join(backupPublicDir, file), join(ROOT, 'public', file)); log(` Restored (user asset): public/${file}`); restored++; } } } log(`${restored} item(s) restored.`); // 8. Install dependencies logStep('Installing dependencies...'); try { execSync('pnpm install', { cwd: ROOT, stdio: 'inherit' }); } catch { logError('pnpm install failed. You may need to run it manually.'); } // 9. Clean up temp directory rmSync(tempDir, { recursive: true, force: true }); // 10. Done console.log('\n===================================='); console.log(` Updated to v${latestVersion}!`); console.log('===================================='); console.log('\n Next steps:'); console.log(' 1. Open your vault in Obsidian'); console.log(' 2. Go to Astro Modular Settings > Advanced tab'); console.log(' 3. Click "Apply to config.ts" under "Apply all settings"'); console.log(' to write your saved settings to the new config.ts'); console.log(' 4. Run `pnpm dev` to verify everything works'); console.log(''); } main().catch(err => { logError(err.message); process.exit(1); });