setapp-cli
Version:
Command-line interface for installing Setapp applications
245 lines (243 loc) • 9.67 kB
JavaScript
// @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}`);
}