UNPKG

setapp-cli

Version:

Command-line interface for installing Setapp applications

245 lines (243 loc) 9.67 kB
#!/usr/bin/env bun // @bun // bin/setapp.ts import fs from "fs"; import { execSync } from "child_process"; import path from "path"; import crypto from "crypto"; var args = process.argv.slice(2); var command = args[0]; var appIdentifiers = []; var nameFlag = false; var parallelFlag = false; var nameIndex = args.indexOf("--name"); if (nameIndex !== -1) { nameFlag = true; args.splice(nameIndex, 1); } var parallelIndex = args.indexOf("--parallel"); if (parallelIndex !== -1) { parallelFlag = true; args.splice(parallelIndex, 1); } if (command) { appIdentifiers = args.slice(1); } var colors = { reset: "\x1B[0m", bright: "\x1B[1m", dim: "\x1B[2m", green: "\x1B[32m", yellow: "\x1B[33m", blue: "\x1B[34m", cyan: "\x1B[36m", red: "\x1B[31m" }; if (!command || command === "install" && appIdentifiers.length === 0) { console.log(`${colors.bright}${colors.cyan}Setapp CLI${colors.reset}`); console.log(`${colors.dim}A command-line tool for installing Setapp applications${colors.reset} `); console.log(`${colors.bright}Usage:${colors.reset}`); console.log(` ${colors.green}setapp install <ids...> ${colors.reset}- Install apps by their IDs`); console.log(` ${colors.green}setapp install --name <names...> ${colors.reset}- Install apps by their names`); console.log(` ${colors.green}setapp install --parallel <ids...>${colors.reset}- Install apps concurrently`); console.log(` ${colors.green}setapp list ${colors.reset}- List all available apps`); process.exit(1); } var cacheDir = path.join(process.env.HOME || "~", ".cache", "setapp-cli"); var cacheFile = path.join(cacheDir, "store-api-cache.json"); var json; if (!fs.existsSync(cacheDir)) { fs.mkdirSync(cacheDir, { recursive: true }); } var useCache = false; if (fs.existsSync(cacheFile)) { try { const cacheData = JSON.parse(fs.readFileSync(cacheFile, "utf-8")); const now = Date.now(); if (cacheData.expiry && cacheData.expiry > now) { console.log(`${colors.dim}Using cached API data...${colors.reset}`); json = cacheData.data; useCache = true; } } catch (error) { console.log(`${colors.dim}Cache file invalid, fetching fresh data...${colors.reset}`); } } if (!useCache) { console.log(`${colors.dim}Fetching data from Setapp API...${colors.reset}`); const response = await fetch("https://store.setapp.com/store/api/v8/en"); json = await response.json(); let maxAge = 14400; const cacheControl = response.headers.get("cache-control"); const expires = response.headers.get("expires"); if (cacheControl) { const maxAgeMatch = cacheControl.match(/max-age=(\d+)/); if (maxAgeMatch && maxAgeMatch[1]) { maxAge = parseInt(maxAgeMatch[1], 10); } } let expiryTime = Date.now() + maxAge * 1000; if (expires) { const expiresDate = new Date(expires).getTime(); if (!isNaN(expiresDate)) { expiryTime = Math.min(expiryTime, expiresDate); } } const cacheData = { data: json, expiry: expiryTime, etag: response.headers.get("etag"), lastModified: response.headers.get("last-modified") }; fs.writeFileSync(cacheFile, JSON.stringify(cacheData)); } var typedJson = json; var appArchiveMap = new Map; var appNameMap = new Map; for (const vendor of typedJson.data.relationships.vendors.data) { for (const app of vendor.relationships.applications.data) { const appId = app.id; const appName = app.attributes.name; const versions = app.relationships.versions.data; if (versions.length > 0) { const archiveUrl = versions[0].attributes.archive_url; appArchiveMap.set(appId, { url: archiveUrl, name: appName }); appNameMap.set(appName.toLowerCase(), appId); } } } var setappDir = "/Applications/Setapp"; try { if (!fs.existsSync(setappDir)) { console.log(`${colors.yellow}Creating Setapp directory at ${setappDir}${colors.reset}`); try { execSync(`sudo mkdir -p "${setappDir}"`); } catch (error) { console.error(`${colors.red}Error creating directory: ${error}${colors.reset}`); process.exit(1); } } } catch (error) { console.error(`${colors.red}Error checking directory: ${error}${colors.reset}`); console.error(`${colors.red}This script requires permission to access /Applications${colors.reset}`); process.exit(1); } if (command === "install") { const appsToInstall = []; for (const identifier of appIdentifiers) { const appId = Number(identifier); let app; if (!isNaN(appId) && !nameFlag) { app = appArchiveMap.get(appId); if (!app) { console.log(`${colors.red}App with ID ${appId} not found.${colors.reset}`); continue; } appsToInstall.push({ ...app, id: appId }); } else if (nameFlag) { const id = appNameMap.get(identifier.toLowerCase()); if (!id) { console.log(`${colors.red}App with name "${identifier}" not found.${colors.reset}`); continue; } app = appArchiveMap.get(id); if (app) { appsToInstall.push({ ...app, id }); } } else { console.log(`${colors.red}Invalid app ID: ${identifier}. Use --name flag to search by name.${colors.reset}`); } } if (appsToInstall.length === 0) { console.log(`${colors.red}No valid apps to install.${colors.reset}`); process.exit(1); } console.log(` ${colors.bright}${colors.cyan}Installing the following Setapp applications:${colors.reset}`); console.log(`${colors.dim}${"\u2500".repeat(50)}${colors.reset}`); for (const app of appsToInstall) { console.log(`${colors.bright}${colors.cyan}${app.name}${colors.reset} ${colors.dim}(ID: ${app.id})${colors.reset}`); } console.log(`${colors.dim}${"\u2500".repeat(50)}${colors.reset} `); async function installApp(app) { try { const appNamePattern = `${setappDir}/${app.name}*.app`; const existingApps = execSync(`find "${setappDir}" -maxdepth 1 -name "${app.name}*.app" 2>/dev/null || true`).toString().trim(); if (existingApps) { console.log(`${colors.yellow}\u26A0\uFE0F ${app.name} already exists in ${setappDir}, skipping...${colors.reset}`); return { success: true, name: app.name, skipped: true }; } const randomId = crypto.randomBytes(8).toString("hex"); const tempDir = `/tmp/setapp_${randomId}_${app.id}`; const tempFile = `${tempDir}.zip`; console.log(`${colors.yellow}\u2B07\uFE0F Downloading ${app.name}...${colors.reset}`); await new Promise((resolve, reject) => { const progressFlag = parallelFlag ? "" : "--progress-bar"; const curl = execSync(`curl -L "${app.url}" -o "${tempFile}" ${progressFlag}`, { stdio: "inherit" }); resolve(); }); execSync(`mkdir -p "${tempDir}"`); execSync(`unzip -q "${tempFile}" -d "${tempDir}"`); const appFiles = execSync(`find "${tempDir}" -name "*.app" -maxdepth 1`).toString().trim().split(` `); if (appFiles.length === 0) { throw new Error("No .app package found in the downloaded archive"); } for (const appFile of appFiles) { if (appFile) { const appName = appFile.split("/").pop(); const destPath = `${setappDir}/${appName}`; if (fs.existsSync(destPath)) { console.log(`${colors.yellow}\u26A0\uFE0F ${appName} already exists in ${setappDir}, skipping...${colors.reset}`); } else { execSync(`sudo mv "${appFile}" "${destPath}"`); console.log(`${colors.green}\u2705 Installed ${appName} to ${setappDir}${colors.reset}`); } } } execSync(`rm -rf "${tempDir}" "${tempFile}"`); return { success: true, name: app.name }; } catch (error) { console.error(`${colors.red}\u274C Error installing ${app.name}: ${error}${colors.reset}`); console.log(`${colors.dim}${"\u2500".repeat(50)}${colors.reset}`); return { success: false, name: app.name, error }; } } let results; if (parallelFlag) { const installPromises = appsToInstall.map((app) => installApp(app)); results = await Promise.all(installPromises); } else { results = []; for (const app of appsToInstall) { const result = await installApp(app); results.push(result); } } const successful = results.filter((r) => r.success).length; const skipped = results.filter((r) => r.skipped).length; console.log(` ${colors.bright}${colors.cyan}Installation summary:${colors.reset}`); console.log(`${colors.green}\u2705 Successfully installed: ${successful - skipped}/${appsToInstall.length}${colors.reset}`); if (skipped > 0) { console.log(`${colors.yellow}\u26A0\uFE0F Skipped (already installed): ${skipped}${colors.reset}`); } if (successful < appsToInstall.length) { console.log(`${colors.red}\u274C Failed: ${appsToInstall.length - successful}${colors.reset}`); } } else if (command === "list") { console.log(` ${colors.bright}${colors.cyan}Available applications:${colors.reset}`); console.log(`${colors.dim}${"\u2500".repeat(50)}${colors.reset}`); const sortedApps = Array.from(appArchiveMap.entries()).sort((a, b) => a[1].name.localeCompare(b[1].name)).map(([id, app]) => ({ id, name: app.name })); for (const app of sortedApps) { console.log(`${colors.green}\u2022${colors.reset} ${colors.bright}${app.name}${colors.reset} ${colors.dim}(ID: ${app.id})${colors.reset}`); } console.log(` ${colors.dim}Total: ${sortedApps.length} applications${colors.reset}`); } else { console.log(`${colors.red}Unknown command: ${command}${colors.reset}`); console.log(`${colors.yellow}Available commands: install, list${colors.reset}`); }