#!/usr/bin/env node /* * Uploads the latest signed release artifacts in releases// via FTP/FTPS/SFTP using curl. * Reads configuration from .env (auto-loaded) or process.env. * Env vars: * FTP_PROTOCOL=ftp|ftps|sftp (default: ftp) * FTP_HOST=example.com (required) * FTP_PORT=21 (optional) * FTP_USER=username (required) * FTP_PASS=password (required) * FTP_REMOTE_DIR=/remote/path (required) */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const root = path.join(__dirname, '..'); // Auto-load .env if present (() => { try { const envPath = path.join(root, '.env'); if (fs.existsSync(envPath)) { const content = fs.readFileSync(envPath, 'utf8'); for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith('#')) continue; const cleaned = line.startsWith('export ') ? line.slice('export '.length).trim() : line; const eq = cleaned.indexOf('='); if (eq === -1) continue; const key = cleaned.slice(0, eq).trim(); let val = cleaned.slice(eq + 1).trim(); if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1); } if (!(key in process.env)) process.env[key] = val; } console.log('Loaded environment from .env'); } } catch (e) { console.warn('Warning: Failed to load .env:', e.message); } })(); function mask(str) { if (!str) return str; return '***'; } function run(cmd, secrets = []) { let shown = cmd; for (const s of secrets) { if (s) shown = shown.split(String(s)).join('***'); } console.log('> ' + shown); execSync(cmd, { stdio: 'inherit' }); } const pkg = require(path.join(root, 'package.json')); const manifest = require(path.join(root, 'manifest.json')); const version = pkg.version; if (!version) { console.error('package.json is missing version'); process.exit(1); } const artifactsDir = path.join(root, 'releases', version); if (!fs.existsSync(artifactsDir) || !fs.statSync(artifactsDir).isDirectory()) { console.error(`Artifacts directory not found: ${artifactsDir}. Run npm run release:sign first.`); process.exit(1); } const files = fs.readdirSync(artifactsDir).filter(f => fs.statSync(path.join(artifactsDir, f)).isFile()); const xpis = files.filter(f => f.toLowerCase().endsWith('.xpi')); if (xpis.length === 0) { console.error(`No .xpi artifacts found in ${artifactsDir}`); process.exit(1); } const chosenXpi = xpis.sort()[0]; if (files.length === 0) { console.error(`No files found in ${artifactsDir}`); process.exit(1); } const protocol = (process.env.FTP_PROTOCOL || 'ftp').toLowerCase(); const host = process.env.FTP_HOST; const port = process.env.FTP_PORT; const user = process.env.FTP_USER; const pass = process.env.FTP_PASS; let remoteDir = process.env.FTP_REMOTE_DIR || '/'; if (!host || !user || !pass || !remoteDir) { console.error('Missing FTP config. Required: FTP_HOST, FTP_USER, FTP_PASS, FTP_REMOTE_DIR'); process.exit(1); } // Normalize remoteDir if (!remoteDir.startsWith('/')) remoteDir = '/' + remoteDir; if (remoteDir.endsWith('/')) remoteDir = remoteDir.slice(0, -1); // Construct base URL for FTP upload target const baseUrl = `${protocol}://${host}${port ? `:${port}` : ''}${remoteDir}`; // Prepare or update self-hosted updates.json before upload function ensureUpdatesJson() { try { const updatesPath = path.join(root, 'releases', 'updates.json'); const examplePath = path.join(root, 'releases', 'updates.example.json'); let data; if (fs.existsSync(updatesPath)) { data = JSON.parse(fs.readFileSync(updatesPath, 'utf8')); } else if (fs.existsSync(examplePath)) { data = JSON.parse(fs.readFileSync(examplePath, 'utf8')); } else { data = { addons: {} }; } const addonId = manifest?.applications?.gecko?.id; const updateUrl = manifest?.applications?.gecko?.update_url; if (!addonId || !updateUrl) { console.warn('Missing add-on id or update_url in manifest; skipping updates.json generation'); return; } // Derive public base directory from update_url (strip trailing filename) let updatesBaseDir = updateUrl.replace(/\/?[^/]*$/, ''); // Build public link to the uploaded XPI (artifact is placed directly under the base dir) const publicLink = `${updatesBaseDir}/${encodeURIComponent(chosenXpi)}`; if (!data.addons) data.addons = {}; if (!data.addons[addonId]) data.addons[addonId] = { updates: [] }; const entry = { version: String(version), update_link: publicLink }; // Try to preserve existing strict_min_version if present in latest entry const existingUpdates = data.addons[addonId].updates || []; let strictMin = '91.0'; for (const u of existingUpdates) { if (u?.applications?.gecko?.strict_min_version) { strictMin = u.applications.gecko.strict_min_version; break; } } entry.applications = { gecko: { strict_min_version: strictMin } }; // Replace or append the entry for this version const idx = existingUpdates.findIndex(u => String(u.version) === String(version)); if (idx >= 0) existingUpdates[idx] = entry; else existingUpdates.push(entry); data.addons[addonId].updates = existingUpdates; fs.writeFileSync(updatesPath, JSON.stringify(data, null, 2) + '\n', 'utf8'); console.log(`Prepared releases/updates.json for version ${version}`); } catch (e) { console.warn('Warning: Failed to update releases/updates.json:', e.message); } } ensureUpdatesJson(); console.log(`Uploading ${files.length} file(s) from ${artifactsDir} to ${protocol}://${host}${port ? `:${port}` : ''}${remoteDir}/`); for (const file of files) { const localPath = path.join(artifactsDir, file); const url = `${baseUrl}/${encodeURIComponent(file)}`; // --ftp-create-dirs ensures remote dirs are created; --fail fails on server errors. const cmd = [ 'curl', '--fail', '--ftp-create-dirs', `--user`, `${user}:${pass}`, '--upload-file', JSON.stringify(localPath), JSON.stringify(url), ].join(' '); run(cmd, [user, pass]); } // Upload a copy of the latest XPI under a stable filename for static linking try { const latestAlias = 'archive-org-link-grabber-latest.xpi'; const localLatestSrc = path.join(artifactsDir, chosenXpi); const latestUrl = `${baseUrl}/${latestAlias}`; console.log(`Uploading latest alias ${latestAlias} -> ${chosenXpi}`); const latestCmd = [ 'curl', '--fail', '--ftp-create-dirs', `--user`, `${user}:${pass}`, '--upload-file', JSON.stringify(localLatestSrc), JSON.stringify(latestUrl), ].join(' '); run(latestCmd, [user, pass]); } catch (e) { console.warn('Warning: Failed to upload latest alias:', e.message); } // Also upload updates.json (self-hosted updates manifest) alongside artifacts const updatesPath = path.join(root, 'releases', 'updates.json'); if (fs.existsSync(updatesPath)) { const updatesUrl = `${baseUrl}/updates.json`; console.log(`Uploading updates.json to ${updatesUrl}`); const cmd = [ 'curl', '--fail', '--ftp-create-dirs', `--user`, `${user}:${pass}`, '--upload-file', JSON.stringify(updatesPath), JSON.stringify(updatesUrl), ].join(' '); run(cmd, [user, pass]); } else { console.warn('Warning: releases/updates.json not found; skipping upload.'); } console.log('Upload complete.'); // After upload, commit generated artifacts to the repo so changes are tracked. try { const { spawnSync } = require('child_process'); function runGit(args, opts = {}) { const res = spawnSync('git', args, { stdio: 'inherit', cwd: root, ...opts }); if (res.error) throw res.error; return res.status || 0; } function runGitOut(args, opts = {}) { const res = spawnSync('git', args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', cwd: root, ...opts }); if (res.error) throw res.error; return { code: res.status || 0, out: (res.stdout || '').trim(), err: (res.stderr || '').trim() }; } console.log('Staging release artifacts for commit…'); // Stage updates.json and the current version folder; icons may have changed earlier, include them too. runGit(['add', 'releases/updates.json']); runGit(['add', '-f', path.join('releases', String(version))]); runGit(['add', 'icons/icon-*.png']); const { out: staged } = runGitOut(['diff', '--cached', '--name-only']); if (!staged) { console.log('✓ Nothing to commit. Repository already up to date.'); } else { const msg = `chore(release): publish v${version} artifacts`; console.log(`Committing: ${msg}`); const codeCommit = runGit(['commit', '-m', msg]); if (codeCommit !== 0) { console.warn('Warning: git commit exited with non-zero status.'); } else { // Optional push if requested const doPush = String(process.env.GIT_PUSH || '').toLowerCase() === 'true'; if (doPush) { console.log('Pushing commit to upstream…'); const { code: upCode } = runGitOut(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']); if (upCode === 0) { runGit(['push']); } else { const { out: branch } = runGitOut(['rev-parse', '--abbrev-ref', 'HEAD']); runGit(['push', '-u', 'origin', branch]); } } else { console.log('✓ Commit created. Skipping git push (enable with GIT_PUSH=true).'); } } } } catch (e) { console.warn('Warning: Failed to create release commit:', e.message); }